Files
kis_bot/kis_trader/backtest/optuna_mode_refine_runner.py
Your Name 9ba9ab73b6 feat(backtest): 대대적인 Optuna 백테스트 웹 UI 및 백엔드 파이프라인 개편
- Web UI:
  - Optuna 탭 추가 및 mode_combo (최빈값 조합), 사후합격 Top 10 시각화 기능
  - 파라미터 분포(p25~p75, median, mode) 히스토그램 및 과적합(Overfit) 위험도 진단 UI 신설
  - 체크박스 렌더링 깨짐 현상을 네이티브(appearance: auto)로 강제 복구 (CSS)
  - 다단 트레일링 스탑, 꼬리 진입/돌파 손절 등 고급 조건 설정 폼 UI 고도화

- Backend (Optuna Jobs):
  - CLI 환경에서 구동된 Optuna json 결과물을 웹 대시보드로 읽어오는 import 기능 강화
  - JSON 메타데이터에 sort_by, mode, 호가 적용 여부 등 핵심 파라미터 파싱 누락 수정
  - optuna_mode_combo.py 등 최빈값 조합 및 후보군 2차 검증을 위한 신규 모듈 추가

- DB & Execution:
  - WebSocket 호가/틱 피드 수집 통계(api_feed_collect_stats) 메모리 캐시 최적화
  - KIS client 접속 키(approval_key) 등 인프라스트럭처 안정성 및 공유 관리 구조 개선
  - 테스트 및 디버깅용 briefing 마크다운 자동 생성 기능 추가
2026-09-01 02:47:51 +09:00

443 lines
15 KiB
Python

#!/usr/bin/env python3
"""
kis_trader/backtest/optuna_mode_refine_runner.py — 1·2차 TPE 연쇄 (기간=폼 start/end)
=====================================================================================
1차: 넓은 Grid TPE → JSON
2차: 1차 JSON 밴드로 Grid 축소 + 동일 기간 TPE
웹: start_optuna_job(단일) · run_optuna_4strat_tpe_seq.sh(순차) → subprocess 본 스크립트.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import subprocess
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
logger = logging.getLogger("optuna_mode_refine")
def _py() -> str:
cand = ROOT / ".venv" / "bin" / "python3"
if cand.is_file():
return str(cand)
return sys.executable
def _read_json(path: str) -> Dict[str, Any]:
return json.loads(Path(path).read_text(encoding="utf-8"))
def _load_phase1_data(
*,
strategy: str,
phase1_json: Optional[str] = None,
phase1_study: Optional[str] = None,
) -> tuple:
"""1차 narrow 입력. DB(study) 우선, 없으면 JSON 파일. (data, source_label)."""
study = str(phase1_study or "").strip()
if study:
from kis_trader.backtest.optuna_study_store import load_payload_dict, payload_has_rows
data = load_payload_dict(study)
if data and payload_has_rows(data):
logger.info("📌 1차 payload DB study=%s", study)
return data, f"db:{study}"
logger.warning("⚠️ DB payload 없음 study=%s — JSON 폴백 시도", study)
path = str(phase1_json or "").strip()
if path and Path(path).is_file():
return _read_json(path), path
if study:
found = _find_json_by_study(study, strategy)
if found and Path(found).is_file():
return _read_json(found), found
raise FileNotFoundError(
f"1차 payload 없음 (study={study or '-'}, json={path or '-'})"
)
def _persist_phase1_to_db(data: Dict[str, Any], *, job_id: str, study_name: str) -> None:
"""1차 JSON → optuna_study_result (다른 PC 2차용)."""
try:
from kis_trader.backtest.optuna_study_store import ingest_out_data
out = ingest_out_data(data, job_id=job_id, replace=False)
logger.info("📌 1차 DB 저장 study=%s ok=%s", study_name, out.get("ok"))
except Exception as exc:
logger.warning("⚠️ 1차 DB 저장 실패 study=%s: %s", study_name, exc)
def _write_state(path: Path, state: Dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
def _find_json_by_study(study_name: str, strategy: str) -> Optional[str]:
from kis_trader.backtest.tail_param_search import _results_dir_for_write
out_dir = Path(_results_dir_for_write())
if not out_dir.is_dir():
return None
cands: List[Path] = []
patterns = [
f"optuna_{strategy}_*.json",
f"optuna_{strategy[:4]}_*.json",
"optuna_*.json",
]
seen: set = set()
for pat in patterns:
for p in out_dir.glob(pat):
if str(p) in seen:
continue
seen.add(str(p))
try:
d = json.loads(p.read_text(encoding="utf-8"))
except Exception:
continue
if str(d.get("optuna_study_name") or "") == study_name:
cands.append(p)
if not cands:
return None
cands.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return str(cands[0])
def _build_optuna_cmd(
*,
strategy: str,
mode: str,
start: str,
end: str,
trials: int,
study_name: str,
sort_by: str,
min_trades: int,
hist_src: str,
entry_mode: Optional[str] = None,
sl_mode: Optional[str] = None,
ob_mode: Optional[str] = None,
symbol: Optional[str] = None,
candle_source: Optional[str] = None,
tick_source: Optional[str] = None,
ob_source: Optional[str] = None,
study_trials: Optional[int] = None,
) -> List[str]:
cmd = [
_py(), "-u",
str(ROOT / "kis_trader" / "backtest" / "param_search_optuna.py"),
"--strategy", strategy,
"--mode", mode,
"--start", start,
"--end", end,
"--trials", str(int(trials)),
"--min_trades", str(int(min_trades)),
"--min_win_rate", "0",
"--min_pf", "0",
"--no-progress",
"--study-name", study_name,
"--sort-by", sort_by,
"--universe-history-source", hist_src,
]
if strategy == "tail" and entry_mode:
cmd.extend(["--entry-mode", entry_mode])
if strategy == "breakout":
cmd.extend(["--sl-mode", sl_mode or "fixed"])
cmd.extend(["--orderbook-filter", ob_mode or "off"])
if symbol and strategy == "us_momentum":
cmd.extend(["--symbol", symbol])
if candle_source:
cmd.extend(["--candle-source", candle_source])
if tick_source:
cmd.extend(["--tick-source", tick_source])
if ob_source:
cmd.extend(["--ob-source", ob_source])
if study_trials and int(study_trials) > 0:
cmd.extend(["--study-trials", str(int(study_trials))])
return cmd
def _run_phase(
cmd: List[str],
env: Dict[str, str],
log_path: Path,
) -> int:
log_path.parent.mkdir(parents=True, exist_ok=True)
with open(log_path, "w", encoding="utf-8") as log_f:
log_f.write("CMD: %s\n\n" % " ".join(cmd))
log_f.flush()
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=log_f,
stderr=subprocess.STDOUT,
)
return int(proc.wait())
def run_mode_refine(
*,
job_id: str,
strategy: str,
mode: str,
start: str,
end: str,
trials: int,
sort_by: str,
min_trades: int,
hist_src: str,
entry_mode: Optional[str] = None,
sl_mode: Optional[str] = None,
ob_mode: Optional[str] = None,
symbol: Optional[str] = None,
candle_source: Optional[str] = None,
tick_source: Optional[str] = None,
ob_source: Optional[str] = None,
study_trials: Optional[int] = None,
skip_phase1: bool = False,
phase1_json: Optional[str] = None,
phase1_study: Optional[str] = None,
) -> Dict[str, Any]:
from kis_trader.backtest.optuna_grid_narrow import (
build_and_write_narrow_grid,
resolve_refine_phase2_trials,
)
from kis_trader.backtest.optuna_mode_combo import resolve_mode_pool_kind
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
state_path = ROOT / "logs" / f"{job_id}_refine_state.json"
narrow_path = ROOT / "logs" / f"{job_id}_narrow_grid.json"
state: Dict[str, Any] = {
"job_id": job_id,
"strategy": strategy,
"mode": mode,
"start": start,
"end": end,
"phase": "phase1",
"pool_kind": resolve_mode_pool_kind(),
}
_write_state(state_path, state)
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env.get("PYTHONPATH", ""))
env["BACKTEST_UNIVERSE_HISTORY_SOURCE"] = hist_src
env["PARAM_SEARCH_OPTUNA_REPORT_MIN_TRADES"] = str(min_trades)
env["SORT_BY"] = sort_by
env["OPTUNA_WEB_JOB_ID"] = str(job_id)
env.pop("OPTUNA_GRID_NARROW_JSON", None)
p1_study_arg = str(phase1_study or "").strip()
p1_study = f"{strategy}_{mode}_refine1_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
p1_log = ROOT / "logs" / f"optuna_refine1_{job_id}.log"
p1_json = str(phase1_json or "").strip()
if skip_phase1 and (p1_study_arg or (p1_json and Path(p1_json).is_file())):
logger.info("⏭ 1차 스킵 — study=%s json=%s", p1_study_arg or "-", p1_json or "-")
data, src = _load_phase1_data(
strategy=strategy,
phase1_json=p1_json or None,
phase1_study=p1_study_arg or None,
)
p1_study = str(
p1_study_arg
or data.get("optuna_study_name")
or data.get("study_name")
or p1_study
).strip()
state["phase1_study"] = p1_study
state["phase1_json"] = p1_json or src
state["phase1_source"] = src
state["phase1_skipped"] = True
else:
cmd1 = _build_optuna_cmd(
strategy=strategy,
mode=mode,
start=start,
end=end,
trials=trials,
study_name=p1_study,
sort_by=sort_by,
min_trades=min_trades,
hist_src=hist_src,
entry_mode=entry_mode,
sl_mode=sl_mode,
ob_mode=ob_mode,
symbol=symbol,
candle_source=candle_source,
tick_source=tick_source,
ob_source=ob_source,
study_trials=study_trials,
)
state["phase1_study"] = p1_study
state["phase1_log"] = str(p1_log)
state["phase"] = "phase1"
_write_state(state_path, state)
logger.info("▶ 1차 TPE 시작 study=%s %s~%s trials=%s", p1_study, start, end, trials)
rc1 = _run_phase(cmd1, env, p1_log)
state["phase1_exit"] = rc1
state["phase1_log"] = str(p1_log)
state["phase1_study"] = p1_study
_write_state(state_path, state)
if rc1 != 0:
state["phase"] = "error"
state["error"] = f"phase1 exit {rc1}"
_write_state(state_path, state)
return state
p1_json = _find_json_by_study(p1_study, strategy) or ""
if not p1_json or not Path(p1_json).is_file():
state["phase"] = "error"
state["error"] = "phase1 JSON not found"
_write_state(state_path, state)
return state
state["phase1_json"] = p1_json
state["phase1_study"] = p1_study
print(f"OPTUNA_PHASE1_STUDY={p1_study}", flush=True)
try:
_persist_phase1_to_db(_read_json(p1_json), job_id=job_id, study_name=p1_study)
except Exception:
pass
try:
data = _load_phase1_data(
strategy=strategy,
phase1_json=p1_json or None,
phase1_study=str(state.get("phase1_study") or p1_study_arg or "").strip() or None,
)[0]
except FileNotFoundError as exc:
state["phase"] = "error"
state["error"] = str(exc)
_write_state(state_path, state)
return state
if state.get("phase1_skipped"):
p1_study = str(state.get("phase1_study") or p1_study).strip()
state["phase1_study"] = p1_study
data["mode_pool_kind"] = resolve_mode_pool_kind()
narrow_meta = build_and_write_narrow_grid(
data, str(narrow_path), mode=mode, strategy=strategy,
)
state["narrow_grid"] = narrow_meta
state["narrow_path"] = str(narrow_path)
p2_trials = resolve_refine_phase2_trials(trials)
p2_study = f"{strategy}_{mode}_refine2_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
p2_log = ROOT / "logs" / f"optuna_refine2_{job_id}.log"
env2 = dict(env)
env2["OPTUNA_GRID_NARROW_JSON"] = str(narrow_path)
cmd2 = _build_optuna_cmd(
strategy=strategy,
mode=mode,
start=start,
end=end,
trials=p2_trials,
study_name=p2_study,
sort_by=sort_by,
min_trades=min_trades,
hist_src=hist_src,
entry_mode=entry_mode,
sl_mode=sl_mode,
ob_mode=ob_mode,
symbol=symbol,
candle_source=candle_source,
tick_source=tick_source,
ob_source=ob_source,
study_trials=study_trials,
)
logger.info(
"▶ 2차 TPE(밴드축소) study=%s trials=%s narrow=%s",
p2_study, p2_trials, narrow_path,
)
state["phase"] = "phase2"
state["phase2_study"] = p2_study
state["phase2_log"] = str(p2_log)
state["phase2_trials"] = p2_trials
_write_state(state_path, state)
rc2 = _run_phase(cmd2, env2, p2_log)
state["phase2_exit"] = rc2
state["phase2_log"] = str(p2_log)
state["phase2_study"] = p2_study
state["phase2_trials"] = p2_trials
p2_json = _find_json_by_study(p2_study, strategy) or ""
if p2_json:
state["phase2_json"] = p2_json
state["result_json"] = p2_json
state["phase"] = "done" if rc2 == 0 else "error"
if rc2 != 0:
state["error"] = f"phase2 exit {rc2}"
state["finished_at"] = datetime.now().isoformat(timespec="seconds")
_write_state(state_path, state)
return state
def main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
ap = argparse.ArgumentParser(description="Optuna 1·2차 TPE 연쇄 (밴드 축소 2차)")
ap.add_argument("--job-id", required=True)
ap.add_argument("--strategy", required=True)
ap.add_argument("--mode", default="tpe")
ap.add_argument("--start", required=True)
ap.add_argument("--end", required=True)
ap.add_argument("--trials", type=int, default=200)
ap.add_argument("--sort-by", default="score")
ap.add_argument("--min-trades", type=int, default=1)
ap.add_argument("--universe-history-source", default="kiwoom")
ap.add_argument("--entry-mode", default="")
ap.add_argument("--sl-mode", default="fixed")
ap.add_argument("--ob-mode", default="off")
ap.add_argument("--symbol", default="")
ap.add_argument("--candle-source", default="")
ap.add_argument("--tick-source", default="")
ap.add_argument("--ob-source", default="")
ap.add_argument("--study-trials", type=int, default=0)
ap.add_argument("--skip-phase1", action="store_true")
ap.add_argument("--phase1-json", default="")
ap.add_argument("--phase1-study", default="", help="1차 study — MariaDB payload (다른 PC 2차)")
args = ap.parse_args()
st = run_mode_refine(
job_id=str(args.job_id),
strategy=str(args.strategy).strip().lower(),
mode=str(args.mode).strip().lower() or "tpe",
start=str(args.start).strip(),
end=str(args.end).strip(),
trials=max(1, int(args.trials)),
sort_by=str(args.sort_by or "score"),
min_trades=max(1, int(args.min_trades)),
hist_src=str(args.universe_history_source or "kiwoom").strip().lower(),
entry_mode=str(args.entry_mode or "").strip() or None,
sl_mode=str(args.sl_mode or "fixed").strip() or "fixed",
ob_mode=str(args.ob_mode or "off").strip() or "off",
symbol=str(args.symbol or "").strip().upper() or None,
candle_source=str(args.candle_source or "").strip().lower() or None,
tick_source=str(args.tick_source or "").strip().lower() or None,
ob_source=str(args.ob_source or "").strip().lower() or None,
study_trials=int(args.study_trials) if int(args.study_trials or 0) > 0 else None,
skip_phase1=bool(args.skip_phase1),
phase1_json=str(args.phase1_json or "").strip() or None,
phase1_study=str(args.phase1_study or "").strip() or None,
)
if st.get("phase") == "done":
logger.info("✅ 1·2차 완료 result=%s", st.get("result_json"))
return 0
logger.error("❌ 1·2차 실패: %s", st.get("error"))
return 1
if __name__ == "__main__":
raise SystemExit(main())