Files
kis_bot/kis_trader/backtest/optuna_web_jobs.py
Your Name 0780b2cdd0 feat: Enhance Optuna integration and logging for backtesting framework
Changes:
- Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies.
- Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests.
- Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes.
- Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework.

Impact:
- These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
2026-08-21 19:05:23 +09:00

2298 lines
85 KiB
Python

#!/usr/bin/env python3
"""
optuna_web_jobs.py — 백테 웹용 Optuna 잡 (subprocess + 디스크 상태)
HTTP 타임아웃과 분리: start 는 즉시 job_id 반환, status 폴링으로 진행률/결과.
apply-best 는 절대 자동 적용하지 않음.
※ 자식 프로세스: Popen 후 반드시 wait(reaper) — 안 하면 종료 후 좀비(Z)로 남아
웹이 "이미 실행 중" 으로 막힘.
"""
from __future__ import annotations
import json
import os
import re
import shlex
import signal
import subprocess
import threading
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
ROOT = Path(__file__).resolve().parents[2]
JOBS_DIR = ROOT / "logs" / "optuna_web_jobs"
RESULTS_DIR = ROOT / "kis_trader" / "backtest" / "results"
PY = ROOT / ".venv" / "bin" / "python"
_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)."""
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 _normalize_breakout_sl_modes(raw: Any) -> List[str]:
"""웹/CLI: fixed, atr. 빈값이면 fixed 1개."""
from kis_trader.backtest.optuna_breakout_tpe_space import (
normalize_tpe_breakout_sl_mode,
)
items: List[str] = []
if raw is None or raw is False:
seq: List[Any] = []
elif isinstance(raw, str):
seq = [x for x in raw.replace(",", " ").split() if x.strip()]
elif isinstance(raw, (list, tuple)):
seq = list(raw)
else:
seq = [raw]
for x in seq:
sm = normalize_tpe_breakout_sl_mode(x)
if sm not in items:
items.append(sm)
return items or ["fixed"]
def _normalize_tail_entry_modes(raw: Any) -> List[str]:
"""웹/CLI: align, limit_atr. 빈값이면 align 1개(기존 TPE와 동일)."""
from kis_trader.backtest.optuna_tail_tpe_space import normalize_tpe_tail_entry_mode
items: List[str] = []
if raw is None or raw is False:
seq: List[Any] = []
elif isinstance(raw, str):
seq = [x for x in raw.replace(",", " ").split() if x.strip()]
elif isinstance(raw, (list, tuple)):
seq = list(raw)
else:
seq = [raw]
for x in seq:
em = normalize_tpe_tail_entry_mode(x)
if em not in items:
items.append(em)
return items or ["align"]
def _now_iso() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
def _ensure_dirs() -> None:
JOBS_DIR.mkdir(parents=True, exist_ok=True)
(ROOT / "logs").mkdir(parents=True, exist_ok=True)
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
def _job_path(job_id: str) -> Path:
return JOBS_DIR / f"{job_id}.json"
def save_job(meta: Dict[str, Any]) -> None:
_ensure_dirs()
jid = str(meta.get("job_id") or "")
if not jid:
raise ValueError("job_id required")
path = _job_path(jid)
tmp = path.with_suffix(".tmp")
tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
tmp.replace(path)
def load_job(job_id: str) -> Optional[Dict[str, Any]]:
path = _job_path(job_id)
if not path.is_file():
return None
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def _job_sort_ts(meta: Dict[str, Any], sort: str = "started") -> float:
"""
최근 잡 정렬 키. ※ 파일 mtime 금지 — refresh_job_status 가 폴링마다 save 해서
mtime 순이면 순서가 계속 뒤바뀜.
sort=started → started_ts (없으면 started_at)
sort=finished → finished_at (미종료는 맨 위, started_ts 보조)
"""
st = float(meta.get("started_ts") or 0)
if not st:
sa = str(meta.get("started_at") or "")[:19]
if sa:
try:
st = datetime.strptime(sa, "%Y-%m-%dT%H:%M:%S").timestamp()
except Exception:
st = 0.0
sort = str(sort or "started").strip().lower()
if sort != "finished":
return st
ft = float(meta.get("finished_ts") or 0)
if ft:
return ft
fa = str(meta.get("finished_at") or "")[:19]
if not fa:
# 실행 중/미종료 → 종료순에서도 최상단
return st + 1e15
try:
return datetime.strptime(fa, "%Y-%m-%dT%H:%M:%S").timestamp()
except Exception:
return st
def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]:
_ensure_dirs()
out: List[Dict[str, Any]] = []
for p in JOBS_DIR.glob("*.json"):
try:
out.append(json.loads(p.read_text(encoding="utf-8")))
except Exception:
continue
out.sort(key=lambda m: _job_sort_ts(m, sort), reverse=True)
return out[: max(1, int(limit))]
def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]:
"""잡 목록 테이블용 슬림 행 — 후처리 JSON·브리핑·로그테일 제외."""
m = dict(meta or {})
_attach_study_result_flags(m)
prog = m.get("progress") if isinstance(m.get("progress"), dict) else {}
post = m.get("postprocess") if isinstance(m.get("postprocess"), dict) else {}
return {
"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")),
"started_at": m.get("started_at"),
"finished_at": m.get("finished_at"),
"started_ts": m.get("started_ts"),
"finished_ts": m.get("finished_ts"),
"progress": {
"trials_done": prog.get("trials_done"),
"trials_total": prog.get("trials_total"),
"pct": prog.get("pct"),
},
"postprocess": {
"pct": post.get("pct"),
"ready": post.get("ready"),
},
"join_cmd": m.get("join_cmd") or "",
"web_cmd": m.get("web_cmd") or m.get("cmd") or "",
"join_study": m.get("join_study") or m.get("active_study_name") or m.get("study_name") or "",
"join_hint": m.get("join_hint") or "",
}
def _job_needs_status_refresh(meta: Dict[str, Any]) -> bool:
"""목록용: 이미 끝난 잡은 JSON 재파싱·study 로드를 생략."""
st = str((meta or {}).get("status") or "").strip().lower()
if st == "running" or _pid_alive((meta or {}).get("pid")):
return True
if st in ("done", "error", "stopped") and (meta or {}).get("finished_at"):
return False
return True
def _try_reap_child(pid: int) -> None:
"""웹이 부모인 좀비면 waitpid 로 회수. 아니면 ChildProcessError → 무시."""
try:
os.waitpid(int(pid), os.WNOHANG)
except (ChildProcessError, OSError, ValueError):
pass
def _pid_alive(pid: Optional[int]) -> bool:
"""프로세스가 실제로 살아 있으면 True. 좀비(Z)는 회수 후 False."""
if not pid or int(pid) <= 0:
return False
try:
os.kill(int(pid), 0)
except OSError:
return False
# Linux: /proc/<pid>/stat 상태 Z = 좀비 (부모 wait 안 함 → kill 0 은 성공)
try:
raw = Path("/proc/%d/stat" % int(pid)).read_text(encoding="utf-8", errors="replace")
rp = raw.rfind(")")
if rp >= 0 and rp + 2 < len(raw):
state = raw[rp + 2 : rp + 3]
if state == "Z":
_try_reap_child(int(pid))
return False
except Exception:
pass
return True
def _spawn_job_reaper(
proc: subprocess.Popen,
job_id: str,
log_f: Any,
) -> None:
"""
근본: Popen 자식을 wait 해서 좀비 방지 + 종료 시 job JSON 즉시 done/error 확정.
웹 프로세스가 부모로 남는 한 이 스레드가 필수.
"""
def _run() -> None:
rc: Optional[int] = None
try:
rc = int(proc.wait())
except Exception:
try:
rc = int(proc.poll()) if proc.poll() is not None else None
except Exception:
rc = None
try:
if log_f is not None and hasattr(log_f, "closed") and not log_f.closed:
log_f.flush()
log_f.close()
except Exception:
pass
try:
meta = load_job(job_id)
if not meta:
return
meta["exit_code"] = rc
if rc not in (None, 0) and not meta.get("error"):
meta["error"] = "process exit_code=%s" % rc
save_job(meta)
refresh_job_status(meta)
except Exception:
pass
threading.Thread(
target=_run,
name="optuna-reap-%s" % job_id,
daemon=True,
).start()
def _tail_text(path: Optional[str], n: int = 40) -> str:
if not path:
return ""
p = Path(path)
if not p.is_file():
return ""
try:
# 큰 로그: 끝부분만
data = p.read_bytes()
if len(data) > 200_000:
data = data[-200_000:]
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()
return "\n".join(lines[-n:])
except Exception:
return ""
_POST_PROG_RE = re.compile(
r"OPTUNA_POST_PROGRESS\s+pct=(?P<pct>[0-9.]+)\s+step=(?P<step>\d+)\s+"
r"total=(?P<total>\d+)\s+stage=(?P<stage>\S+)\s+axis=(?P<ad>\d+)/(?P<at>\d+)"
r"(?P<detail>.*)$"
)
def _parse_postprocess_progress(log_path: Optional[str]) -> Dict[str, Any]:
"""후처리 한 줄 진행. 없으면 구로그 휴리스틱."""
out: Dict[str, Any] = {
"pct": 0.0,
"step": 0,
"total": 0,
"stage": "",
"axis_done": 0,
"axis_total": 0,
"detail": "",
"ready": False,
"hint": "",
}
if not log_path:
return out
text = _tail_text(log_path, 250) or ""
last = None
for mm in _POST_PROG_RE.finditer(text):
last = mm
if last:
d = last.groupdict()
try:
out["pct"] = float(d.get("pct") or 0)
except (TypeError, ValueError):
out["pct"] = 0.0
out["step"] = int(d.get("step") or 0)
out["total"] = int(d.get("total") or 0)
out["stage"] = str(d.get("stage") or "")
out["axis_done"] = int(d.get("ad") or 0)
out["axis_total"] = int(d.get("at") or 0)
det = str(d.get("detail") or "").strip()
out["detail"] = det
if out["stage"] == "done" or "상세가능" in det:
out["ready"] = True
out["pct"] = 100.0
out["hint"] = "후처리 끝 · 「상세」 가능"
else:
ax = ""
if out["axis_total"]:
ax = f" · 축 {out['axis_done']}/{out['axis_total']}"
out["hint"] = (
f"후처리 {out['step']}/{out['total']} · {out['stage']}{ax} · 「상세」는 끝난 뒤"
)
return out
g = re.findall(r"\[후처리\] gated#(\d+)", text)
if g:
out["stage"] = "gated#" + g[-1]
out["pct"] = min(90.0, float(g[-1]) * 12.0)
out["hint"] = "후처리 중 · 「상세」는 끝난 뒤"
if "[후처리 TopN]" in text or "상세가능" in text:
out["ready"] = True
out["pct"] = 100.0
out["stage"] = out.get("stage") or "done"
out["hint"] = "후처리 끝 · 「상세」 가능"
return out
def _parse_result_paths_from_log(log_path: str) -> Dict[str, Any]:
tail = _tail_text(log_path, 800) # 다중 전략일 경우 수백 줄 위에 있을 수 있음
rj_list = []
bm_list = []
for m in re.finditer(r"OPTUNA_RESULT_JSON=(.+)", tail):
rj_list.append(m.group(1).strip())
for m in re.finditer(r"OPTUNA_BRIEFING_MD=(.+)", tail):
bm_list.append(m.group(1).strip())
rj_list = _uniq_keep(rj_list)
bm_list = _uniq_keep(bm_list)
best = _pick_result_json_with_rows(rj_list) or (rj_list[-1] if rj_list else None)
return {
"result_json": best,
"result_jsons": rj_list,
"briefing_md": bm_list[-1] if bm_list else None,
"briefing_mds": bm_list,
}
def _uniq_keep(seq: List[str]) -> List[str]:
seen = set()
out: List[str] = []
for x in seq:
s = str(x or "").strip()
if not s or s in seen:
continue
seen.add(s)
out.append(s)
return out
def _json_n_results(path: str) -> int:
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except Exception:
return -1
n = data.get("n_results_all")
if n is not None:
try:
return int(n)
except (TypeError, ValueError):
pass
return len(list(data.get("results_all") or data.get("results") or []))
def _pick_result_json_with_rows(paths: List[str]) -> Optional[str]:
"""순차 2스터디: 마지막 JSON이 0건이면 후보가 있는 쪽을 미리보기로 고른다."""
scored = []
for p in paths:
if p and Path(p).is_file():
scored.append((_json_n_results(p), p))
if not scored:
return None
scored.sort(key=lambda x: x[0])
return scored[-1][1]
# us_momentum 을 momentum 보다 앞에 — [us_momentum] 이 momentum 으로 잘리지 않게
_SEQ_START_RE = re.compile(
r"\[(?P<strat>us_momentum|momentum|tail|breakout|scalp)"
r"(?:/(?P<extra>[a-z0-9_]+))?\] START"
r"(?:[^\n]*study=(?P<study>[^\s]+))?"
)
def _seq_step_catalog(meta: Dict[str, Any]) -> List[Dict[str, str]]:
"""순차 한 칸 = 전략(+꼬리 진입/+돌파 손절). 스크립트 run_one 과 동일 순서."""
picked = list(meta.get("strategies") or [])
if not picked:
raw = str(meta.get("strategy") or "")
picked = [s.strip() for s in raw.split(",") if s.strip()]
tail_ems = list(meta.get("tail_entry_modes") or ["align"])
bo_sms = list(meta.get("breakout_sl_modes") or ["fixed"])
out: List[Dict[str, str]] = []
for s in picked:
s = str(s or "").strip().lower()
if s == "tail":
for em in tail_ems:
out.append({"strategy": "tail", "extra": str(em or "align")})
elif s == "breakout":
for sm in bo_sms:
out.append({"strategy": "breakout", "extra": str(sm or "fixed")})
elif s:
out.append({"strategy": s, "extra": ""})
return out
def _read_seq_active_sidecar(path: Optional[str]) -> Dict[str, str]:
"""run_one 이 덮어쓰는 잡별 현재 study (전역 latest.study 보다 우선)."""
out: Dict[str, str] = {}
if not path:
return out
p = Path(path)
if not p.is_file():
return out
try:
for line in p.read_text(encoding="utf-8").splitlines():
if "=" not in line:
continue
k, _, v = line.partition("=")
k = k.strip().lower()
v = v.strip()
if k in ("strategy", "study", "entry_mode", "sl_mode", "extra"):
out[k] = v
except Exception:
return {}
extra = out.get("extra") or out.get("entry_mode") or out.get("sl_mode") or ""
if extra and "extra" not in out:
out["extra"] = extra
return out
def _last_seq_start_from_log(log_path: Optional[str]) -> Dict[str, str]:
"""이 잡 마스터 로그에서 마지막 [strat] START study=… (끝 80줄만 보면 1번만 남음)."""
if not log_path:
return {}
p = Path(log_path)
if not p.is_file():
return {}
try:
data = p.read_bytes()
if len(data) > 400_000:
data = data[-400_000:]
text = data.decode("utf-8", errors="replace")
except Exception:
return {}
hits = list(_SEQ_START_RE.finditer(text))
if not hits:
return {}
m = hits[-1]
extra = str(m.group("extra") or "").strip()
return {
"strategy": str(m.group("strat") or "").strip().lower(),
"extra": extra,
"study": str(m.group("study") or "").strip(),
}
def _quote_cmd(argv: List[str]) -> str:
return " ".join(shlex.quote(str(x)) for x in argv)
def _ps_quote(s: str) -> str:
"""PowerShell 인자. 공백·특수문자면 큰따옴표 + ` 이스케이프."""
t = str(s)
if t == "":
return '""'
if re.search(r'[\s"\'$`&|;<>()]', t):
return '"' + t.replace("`", "``").replace('"', '`"') + '"'
return t
def _quote_ps(argv: List[str]) -> str:
return " ".join(_ps_quote(str(x)) for x in argv)
def _join_argv_for_study(
meta: Dict[str, Any],
*,
strategy: str,
study: str,
extra: str = "",
py_bin: str = "python3",
) -> List[str]:
"""다른 PC용 — 상대경로. --trials 는 추가분. py_bin=python3|python."""
strat = str(strategy or "").strip().lower()
mode = str(meta.get("mode") or "tpe").strip() or "tpe"
trials = str(int(meta.get("trials") or 200))
start = str(meta.get("start") or "")
end = str(meta.get("end") or "")
hist = str(meta.get("universe_history_source") or "kiwoom").strip() or "kiwoom"
sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl"
argv = [
py_bin, "-u", "kis_trader/backtest/param_search_optuna.py",
"--strategy", strat,
"--mode", mode,
"--start", start,
"--end", end,
"--trials", trials,
"--min_trades", "1",
"--min_win_rate", "0",
"--min_pf", "0",
"--orderbook-filter", "off",
"--no-progress",
"--study-name", study,
"--sort-by", sort_by,
"--universe-history-source", hist,
]
st_goal = 0
try:
st_goal = int(meta.get("study_trials") or 0)
except (TypeError, ValueError):
st_goal = 0
leftover = 0
try:
leftover = int(meta.get("leftover_trials") or 0)
except (TypeError, ValueError):
leftover = 0
if st_goal <= 0:
try:
from kis_trader.backtest.optuna_study_store import flags_for_web
fl = flags_for_web(study)
st_goal = int(fl.get("study_trials") or 0)
leftover = int(fl.get("leftover_trials") or leftover)
except Exception:
pass
if st_goal > 0:
argv.extend(["--study-trials", str(st_goal)])
if leftover > 0:
# --trials 는 이 프로세스 추가분 = 남은 횟수
try:
idx = argv.index("--trials")
argv[idx + 1] = str(leftover)
except (ValueError, IndexError):
argv.extend(["--trials", str(leftover)])
extra = str(extra or "").strip()
if strat == "tail":
ems = list(meta.get("tail_entry_modes") or ["align"])
argv.extend(["--entry-mode", extra or str(ems[0] if ems else "align")])
if strat == "breakout":
sms = list(meta.get("breakout_sl_modes") or ["fixed"])
argv.extend(["--sl-mode", extra or str(sms[0] if sms else "fixed")])
for flag, key in (
("--candle-source", "candle_source"),
("--tick-source", "tick_source"),
("--ob-source", "ob_source"),
):
val = str(meta.get(key) or "").strip()
if val:
argv.extend([flag, val])
if strat == "us_momentum":
sym = str(meta.get("symbol") or "").strip().upper()
if sym:
argv.extend(["--symbol", sym])
return argv
def _ps_join_script(argv: List[str]) -> str:
"""Win11 PowerShell 한 덩어리. 레포 루트에서 실행."""
from kis_trader.backtest.optuna_common import mariadb_creds
host = str(mariadb_creds().get("host") or "192.168.0.141")
body = _quote_ps(argv)
return (
"# 레포 루트로 이동한 뒤 붙여넣기 (git 커밋 = 웹 VM 과 동일)\n"
"$env:PYTHONUNBUFFERED = '1'\n"
"$env:PYTHONPATH = (Get-Location).Path\n"
f"$env:DB_HOST = '{host}'\n"
f"{body}\n"
)
def build_optuna_join_payload(meta: Dict[str, Any]) -> Dict[str, Any]:
"""웹 실행 명령 + 다른 PC에서 같은 study 에 붙는 python 명령."""
m = meta or {}
kind = str(m.get("kind") or "")
is_seq = kind in ("seq", "seq4")
strat = str(m.get("current_strategy") or "").strip().lower()
if not strat:
raw = str(m.get("strategy") or "")
if "," in raw or raw in ("all", "seq"):
picked = list(m.get("strategies") or [])
strat = str(picked[0] or "").strip().lower() if picked else ""
else:
strat = raw.strip().lower()
extra = str(
m.get("current_sl_mode") or m.get("current_entry_mode") or ""
).strip()
study = str(m.get("active_study_name") or "").strip()
if not is_seq:
study = study or str(m.get("study_name") or "").strip()
if study.startswith("seq_"):
study = str(m.get("active_study_name") or "").strip()
web_argv = m.get("cmd_argv")
if isinstance(web_argv, list) and web_argv:
web_cmd = _quote_cmd([str(x) for x in web_argv])
else:
web_cmd = str(m.get("cmd") or "").strip()
hints = [
"레포 루트 · 웹과 같은 git 커밋 · MariaDB 141/kis_optuna.",
"PowerShell 은 python (python3 아님). --trials 는 이 PC 추가분, --study-trials 는 스터디 총 완료 목표.",
"자잘한 VM 10대는 틱 RAM 이 10번 복제됩니다. Win PC 1대 + 기존 VM 이 현실적입니다.",
]
if is_seq:
hints.append(
"순차 웹 bash 를 그대로 돌리면 새 study. 아래 python 만 복사."
)
join_cmd = ""
join_cmd_ps = ""
if strat and study:
argv_sh = _join_argv_for_study(
m, strategy=strat, study=study, extra=extra, py_bin="python3"
)
argv_ps = _join_argv_for_study(
m, strategy=strat, study=study, extra=extra, py_bin="python"
)
join_cmd = _quote_cmd(argv_sh)
join_cmd_ps = _ps_join_script(argv_ps)
elif is_seq:
hints.append("현재 study 가 없으면 첫 전략 START 후 다시 여세요.")
join_all: List[Dict[str, str]] = []
log_path = str(m.get("log_path") or "")
if is_seq and log_path:
try:
data = Path(log_path).read_bytes()
if len(data) > 400_000:
data = data[-400_000:]
text = data.decode("utf-8", errors="replace")
seen = set()
for hit in _SEQ_START_RE.finditer(text):
st = str(hit.group("strat") or "").strip().lower()
ex = str(hit.group("extra") or "").strip()
sy = str(hit.group("study") or "").strip()
if not st or not sy or sy in seen:
continue
seen.add(sy)
a_sh = _join_argv_for_study(
m, strategy=st, study=sy, extra=ex, py_bin="python3"
)
a_ps = _join_argv_for_study(
m, strategy=st, study=sy, extra=ex, py_bin="python"
)
join_all.append({
"strategy": st,
"extra": ex,
"study": sy,
"cmd": _quote_cmd(a_sh),
"cmd_ps": _ps_join_script(a_ps),
})
except Exception:
join_all = []
return {
"web_cmd": web_cmd,
"join_cmd": join_cmd,
"join_cmd_ps": join_cmd_ps,
"join_hint": "\n".join(hints),
"join_study": study,
"join_cmds_all": join_all,
}
def _apply_seq_active(m: Dict[str, Any], info: Dict[str, str]) -> None:
strat = str(info.get("strategy") or "").strip().lower()
extra = str(info.get("extra") or info.get("entry_mode") or info.get("sl_mode") or "").strip()
study = str(info.get("study") or "").strip()
if strat:
m["current_strategy"] = strat
if extra:
if strat == "breakout":
m["current_entry_mode"] = extra
m["current_sl_mode"] = extra
else:
m["current_entry_mode"] = extra
if study:
m["active_study_name"] = study
def _study_progress(study_name: str, trials_total: int) -> Dict[str, Any]:
"""Optuna MariaDB study 기준 진행률 + best trial 실측 지표.
best_value(score)만 보여주면 의미가 안 보이므로,
best trial user_attrs 의 WR/PnL/PF/MDD/trades 도 함께 반환.
"""
out = {
"trials_done": 0,
"trials_total": int(trials_total or 0),
"pct": 0.0,
"best_value": None,
"best_trial": None,
"best_win_rate": None,
"best_pnl": None,
"best_pf": None,
"best_mdd": None,
"best_trades": None,
"study_ok": False,
}
if not study_name:
return out
try:
import optuna
from kis_trader.backtest.optuna_common import resolve_optuna_storage_url
storage = resolve_optuna_storage_url()
study = optuna.load_study(study_name=study_name, storage=storage)
n = len(study.trials)
out["trials_done"] = int(n)
tot = max(1, int(trials_total or n or 1))
out["trials_total"] = tot
out["pct"] = round(min(100.0, 100.0 * n / tot), 1)
try:
bt = study.best_trial
if bt is not None:
out["best_value"] = float(study.best_value)
out["best_trial"] = int(bt.number)
ua = bt.user_attrs or {}
if ua.get("win_rate") is not None:
out["best_win_rate"] = float(ua.get("win_rate") or 0)
if ua.get("total_pnl") is not None:
out["best_pnl"] = float(ua.get("total_pnl") or 0)
if ua.get("pf") is not None:
out["best_pf"] = float(ua.get("pf") or 0)
if ua.get("mdd") is not None:
out["best_mdd"] = float(ua.get("mdd") or 0)
if ua.get("total_trades") is not None:
out["best_trades"] = int(ua.get("total_trades") or 0)
except Exception:
out["best_value"] = None
out["study_ok"] = True
except Exception as exc:
msg = str(exc)
# 스터디 create 전(캔들/틱 로드) load_study 실패는 오류가 아님
low = msg.lower()
if "does not exist" in low or "not found" in low:
out["error"] = None
else:
out["error"] = msg[:200]
return out
def _row_metrics(
row: Optional[Dict[str, Any]],
*,
label: str,
source: str,
data: Optional[Dict[str, Any]] = None,
) -> Optional[Dict[str, Any]]:
if not row:
return None
out = {
"label": label,
"source": source,
"optuna_trial_number": row.get("optuna_trial_number"),
"total_pnl": row.get("total_pnl"),
"total_trades": row.get("total_trades"),
"win_rate": row.get("win_rate"),
"pf": row.get("pf"),
"score": row.get("score"),
}
# 일별 안정성 (신규 Optuna JSON)
for k in (
"stability_score", "n_losing_days", "n_active_days",
"worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std",
"daily_pnl",
):
if row.get(k) is not None:
out[k] = row.get(k)
if data is not None:
try:
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
of = overfit_risk_pct_for_row(data, row)
out["overfit_risk_pct"] = of.get("overfit_risk_pct")
out["overfit_verdict"] = of.get("verdict")
out["overfit_verdict_ui"] = of.get("verdict_ui")
except Exception:
pass
return out
def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]:
"""완료 JSON → TopN learn/gated/stable · 행별 과적합% · vs_best."""
if not path or not Path(path).is_file():
return None
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except Exception:
return None
return _summarize_result_data(data, path=path)
def _summarize_result_data(
data: Optional[Dict[str, Any]],
path: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
if not isinstance(data, dict):
return None
from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
top_n = resolve_post_top_n(10)
gated = list(data.get("results_gated") or [])
from kis_trader.backtest.optuna_common import resolve_results_stable
stable, stable_gates_resolved = resolve_results_stable(data, top_n=top_n)
allr = list(data.get("results") or data.get("results_all") or [])
learn = allr[0] if allr else None
gate0 = gated[0] if gated else None
stab0 = stable[0] if stable else None
top = gate0 or learn
mc = data.get("mode_combo") or {}
mc_bt = mc.get("backtest") or {}
vs = mc.get("vs_best") or {}
compare_rows: List[Dict[str, Any]] = []
r_learn = _row_metrics(learn, label="학습1위(results)", source="learn", data=data)
if r_learn:
compare_rows.append(r_learn)
r_gate = _row_metrics(gate0, label="사후합격1위(gated)", source="gated", data=data)
if r_gate:
compare_rows.append(r_gate)
r_stab = _row_metrics(stab0, label="안정1위(stable)", source="stable", data=data)
if r_stab:
compare_rows.append(r_stab)
if mc_bt.get("ok") or mc_bt.get("total_pnl") is not None:
compare_rows.append({
"label": "mode_combo 실측",
"source": "mode",
"optuna_trial_number": None,
"total_pnl": mc_bt.get("total_pnl"),
"total_trades": mc_bt.get("total_trades"),
"win_rate": mc_bt.get("win_rate"),
"pf": mc_bt.get("pf"),
"score": None,
})
top5 = []
for i, row in enumerate(gated[:top_n], start=1):
m = _row_metrics(row, label=f"gated #{i}", source="gated", data=data)
if m:
m["rank"] = i
top5.append(m)
top5_learn: List[Dict[str, Any]] = []
for i, row in enumerate(allr[:top_n], start=1):
m = _row_metrics(row, label=f"learn #{i}", source="learn", data=data)
if m:
m["rank"] = i
top5_learn.append(m)
top5_stable: List[Dict[str, Any]] = []
for i, row in enumerate(stable[:top_n], start=1):
m = _row_metrics(row, label=f"stable #{i}", source="stable", data=data)
if m:
m["rank"] = i
top5_stable.append(m)
briefing = None
bp = str(path).replace(".json", ".briefing.md")
if Path(bp).is_file():
briefing = bp
elif data.get("briefing_md_path"):
briefing = data.get("briefing_md_path")
# 다단트레일 추천 (JSON에 없으면 재계산 — 구결과 미리보기용)
trail_rec = data.get("daily_trail_recommend")
if not isinstance(trail_rec, dict):
try:
from kis_trader.backtest.optuna_daily_trail_recommend import (
recommend_from_optuna_out_data,
)
trail_rec = recommend_from_optuna_out_data(data)
except Exception:
trail_rec = None
# 과적합·임계값 분포 (JSON에 없어도 웹에서 즉시 계산 — 구결과·진행중 완료 공용)
overfit = data.get("overfit_diagnostics")
if not isinstance(overfit, dict) or overfit.get("overfit_risk_pct") is None:
try:
from kis_trader.backtest.optuna_common import build_optuna_overfit_diagnostics
overfit = build_optuna_overfit_diagnostics(data)
except Exception:
overfit = None
post_topn = data.get("postprocess_topn")
# 웹 새로고침에서 가짜 light_skip 앵커를 만들지 않음.
# (중간 JSON + run_ob=False attach → 「구JSON」오인)
if not isinstance(post_topn, dict) or not post_topn.get("postprocess_by_anchor"):
post_topn = None
if isinstance(post_topn, dict) and post_topn.get("postprocess_by_anchor"):
try:
from kis_trader.backtest.optuna_postprocess_topn import (
ensure_stable_postprocess_on_payload,
)
ensure_stable_postprocess_on_payload(data)
post_topn = data.get("postprocess_topn") or post_topn
except Exception:
pass
apply_overfit_pct = None
apply_overfit_verdict = None
apply_overfit_verdict_ui = None
if isinstance(post_topn, dict):
apply_overfit_pct = post_topn.get("apply_overfit_pct")
apply_overfit_verdict = post_topn.get("apply_overfit_verdict")
apply_overfit_verdict_ui = post_topn.get("apply_overfit_verdict_ui")
if apply_overfit_pct is None and isinstance(overfit, dict):
apply_overfit_pct = overfit.get("overfit_risk_pct")
apply_overfit_verdict = overfit.get("verdict")
return {
"strategy": data.get("strategy"),
"mode": data.get("mode"),
"start": data.get("start"),
"end": data.get("end"),
"n_gated": len(gated),
"n_stable": len(stable),
"n_all": len(allr),
"optuna_best_trial_number": data.get("optuna_best_trial_number"),
"stable_gates": stable_gates_resolved or data.get("stable_gates"),
"mode_combo_summary": {
"ok": bool(mc_bt.get("ok") or mc_bt.get("total_pnl") is not None),
"total_pnl": mc_bt.get("total_pnl"),
"total_trades": mc_bt.get("total_trades"),
"win_rate": mc_bt.get("win_rate"),
"pf": mc_bt.get("pf"),
"note": mc.get("note"),
"method": mc.get("method"),
"top_n": mc.get("top_n"),
"pool_size": mc.get("pool_size"),
"vs_best": vs if vs else None,
"has_params": bool(mc.get("params")),
},
"top": _row_metrics(
top,
label="적용후보(gated우선)",
source="gated" if gate0 else "learn",
data=data,
),
"compare_rows": compare_rows,
"vs_best": vs if vs else None,
"top5_gated": top5,
"top5_learn": top5_learn,
"top5_stable": top5_stable,
"mode_combo_note": mc.get("note"),
"daily_trail_recommend": (
data.get("daily_trail_recommend")
if isinstance(data.get("daily_trail_recommend"), dict)
else trail_rec
),
# 후처리 추천 (탐색 trial 아님) — 웹 Optuna 탭 표용.
"orderbook_recommend": data.get("orderbook_recommend")
if isinstance(data.get("orderbook_recommend"), dict)
else (mc.get("orderbook_recommend") if isinstance(mc.get("orderbook_recommend"), dict) else None),
"whipsaw_recommend": data.get("whipsaw_recommend")
if isinstance(data.get("whipsaw_recommend"), dict)
else (mc.get("whipsaw_recommend") if isinstance(mc.get("whipsaw_recommend"), dict) else None),
"postprocess_topn": post_topn if isinstance(post_topn, dict) else None,
"apply_overfit_pct": apply_overfit_pct,
"apply_overfit_verdict": apply_overfit_verdict,
"apply_overfit_verdict_ui": apply_overfit_verdict_ui,
"overfit_diagnostics": overfit,
"apply_ready": bool(gate0) and float(gate0.get("total_pnl") or 0) > 0,
"apply_stable_ready": bool(stab0) and float(stab0.get("total_pnl") or 0) > 0,
"briefing_md": briefing,
}
def _strategy_from_filename(name: str) -> str:
n = str(name or "").lower()
for s in ("us_momentum", "momentum", "tail", "breakout", "scalp"):
if n.startswith(f"optuna_{s}_"):
return s
return ""
def _resolve_single_strategy(
meta: Optional[Dict[str, Any]],
data: Optional[Dict[str, Any]],
path: Optional[str],
) -> str:
"""잡.strategy 가 'momentum,tail' 묶음이면 JSON/파일명이 진실."""
js = str((data or {}).get("strategy") or "").strip().lower()
if js in _STRATS:
return js
fn = _strategy_from_filename(Path(str(path or "")).name)
if fn:
return fn
raw = str((meta or {}).get("strategy") or "").strip().lower()
if raw in _STRATS:
return raw
return ""
def _pool_for_optuna_source(data: Dict[str, Any], src: str) -> List[Dict[str, Any]]:
"""gated / learn(results) / stable(비면 학습풀 재구성) 후보 풀."""
s = str(src or "gated").strip().lower()
if s == "stable":
from kis_trader.backtest.optuna_common import resolve_results_stable
from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n
pool, _ = resolve_results_stable(data, top_n=resolve_post_top_n(10))
return list(pool or [])
if s == "gated":
return list(data.get("results_gated") or [])
return list(data.get("results") or data.get("results_all") or [])
def get_candidate_detail(
*,
job_id: Optional[str] = None,
result_json: Optional[str] = None,
source: str = "gated",
rank: int = 1,
hist_src: str = "",
candle_source: str = "",
tick_source: str = "",
ob_source: str = "",
) -> Dict[str, Any]:
"""보기용: gated/learn/mode 후보 메트릭 + params 미리보기."""
path = result_json
meta = None
if job_id:
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(f"job not found: {job_id}")
path = path or meta.get("result_json")
data, path = _load_out_data_for_job(meta, path)
src = str(source or "gated").strip().lower()
rank = max(1, int(rank or 1))
params: Dict[str, Any] = {}
metrics: Dict[str, Any] = {}
if src == "mode":
mc = data.get("mode_combo") or {}
params = dict(mc.get("params") or {})
bt = mc.get("backtest") or {}
metrics = {
"label": "mode_combo",
"optuna_trial_number": None,
"total_pnl": bt.get("total_pnl"),
"total_trades": bt.get("total_trades"),
"win_rate": bt.get("win_rate"),
"pf": bt.get("pf"),
}
else:
pool = _pool_for_optuna_source(data, src)
if not pool:
raise RuntimeError(f"{src} 결과 없음")
if rank > len(pool):
raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
item = pool[rank - 1]
try:
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
params = merge_param_search_apply_source(item, data)
except Exception:
params = dict(item.get("merged_params") or item.get("params") or {})
metrics = {
"label": f"{src} #{rank}",
"optuna_trial_number": item.get("optuna_trial_number"),
"total_pnl": item.get("total_pnl"),
"total_trades": item.get("total_trades"),
"win_rate": item.get("win_rate"),
"pf": item.get("pf"),
"score": item.get("score"),
"stability_score": item.get("stability_score"),
"n_losing_days": item.get("n_losing_days"),
"worst_day_pnl": item.get("worst_day_pnl"),
"daily_pnl": item.get("daily_pnl"),
}
try:
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
of = overfit_risk_pct_for_row(data, item)
metrics["overfit_risk_pct"] = of.get("overfit_risk_pct")
metrics["overfit_verdict_ui"] = of.get("verdict_ui")
except Exception:
pass
# 보기: 전체 파라미터 (키 정렬). 예전 [:40] 잘림 → ratchet/rsi 등이 “없는 것처럼” 보임
preview = {k: params[k] for k in sorted(params.keys(), key=lambda x: str(x))}
return {
"ok": True,
"source": src,
"rank": rank,
"metrics": metrics,
"params_preview": preview,
"params_full": preview,
"params_count": len(params),
"result_json": str(path),
"strategy": _resolve_single_strategy(meta, data, path) or data.get("strategy"),
"start": data.get("start"),
"end": data.get("end"),
}
def apply_optuna_result(
*,
job_id: Optional[str] = None,
result_json: Optional[str] = None,
source: str = "gated",
rank: int = 1,
allow_non_positive_pnl: bool = False,
symbol: Optional[str] = None,
exchange: Optional[str] = None,
stock_group: Optional[str] = None,
upto: str = "base",
) -> Dict[str, Any]:
"""
Optuna JSON 후보 → 전략별 apply_params_to_db.
source: gated | stable | learn | mode
rank: gated/stable/learn 1-based
upto/combo: base|e|x|s|ex|es|xs|exs|whipsaw|trail (+ 구 entry/exit/stop·000~111)
8방=진입×익절×손절. whipsaw=111+휩쏘. trail=합의 트레일만(타점 미적용).
symbol: us_momentum 종목 cfg 적용 시 (없으면 job/JSON 메타 또는 전역)
"""
path = result_json
meta = None
if job_id:
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(f"job not found: {job_id}")
if meta.get("status") != "done":
raise RuntimeError(f"job status={meta.get('status')} — 완료 후에만 적용")
path = path or meta.get("result_json")
data, path = _load_out_data_for_job(meta, path)
strat = _resolve_single_strategy(meta, data, path)
if strat not in _STRATS:
raise ValueError(f"전략 불명: {strat or (meta or {}).get('strategy')}")
src = str(source or "gated").strip().lower()
rank = max(1, int(rank or 1))
upto_s = str(upto or "base").strip().lower() or "base"
_allowed = (
"base", "entry", "exit", "stop", "whipsaw", "trail",
"e", "x", "s", "ex", "es", "xs", "exs",
"000", "100", "010", "001", "110", "101", "011", "111",
)
if upto_s not in _allowed:
raise ValueError(
"upto/combo 는 base|e|x|s|ex|es|xs|exs|whipsaw|trail|000~111 "
"(구 entry/exit/stop 별칭 포함) 만"
)
item: Optional[Dict[str, Any]] = None
merged: Dict[str, Any] = {}
if src == "mode":
mc = data.get("mode_combo") or {}
merged = dict(mc.get("params") or {})
bt = mc.get("backtest") or {}
pnl = float(bt.get("total_pnl") or 0)
metrics = {
"total_pnl": bt.get("total_pnl"),
"total_trades": bt.get("total_trades"),
"win_rate": bt.get("win_rate"),
"pf": bt.get("pf"),
"optuna_trial_number": None,
}
if not merged:
raise RuntimeError("mode_combo.params 없음")
else:
pool = _pool_for_optuna_source(data, src)
if not pool:
raise RuntimeError(f"{src} 결과 없음")
if rank > len(pool):
raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
item = pool[rank - 1]
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
merged = merge_param_search_apply_source(item, data)
pnl = float(item.get("total_pnl") or 0)
metrics = {
"total_pnl": item.get("total_pnl"),
"total_trades": item.get("total_trades"),
"win_rate": item.get("win_rate"),
"pf": item.get("pf"),
"optuna_trial_number": item.get("optuna_trial_number"),
"stability_score": item.get("stability_score"),
"n_losing_days": item.get("n_losing_days"),
"worst_day_pnl": item.get("worst_day_pnl"),
}
if upto_s != "trail" and pnl <= 0 and not allow_non_positive_pnl:
raise RuntimeError(
f"total_pnl={pnl} ≤ 0 — DB 미적용. 강제 시 allow_non_positive_pnl=true",
)
sym = str(
symbol
or (meta or {}).get("symbol")
or data.get("symbol")
or data.get("_apply_symbol")
or "",
).strip().upper()
env_id = None
axis_patch: Dict[str, str] = {}
axis_notes: List[str] = []
trail_apply: Dict[str, Any] = {"applied": False}
if upto_s == "trail":
if sym:
trail_apply = {"applied": False, "reason": f"stock_cfg:{sym}"}
else:
try:
from kis_trader.backtest.optuna_daily_trail_recommend import (
apply_daily_trail_recommend_from_optuna_json,
recommend_from_optuna_out_data,
)
if not data.get("daily_trail_recommend"):
data["daily_trail_recommend"] = recommend_from_optuna_out_data(data)
trail_apply = apply_daily_trail_recommend_from_optuna_json(
str(path), strategy=strat,
)
except Exception as exc:
trail_apply = {"applied": False, "error": str(exc)}
else:
if strat == "momentum":
from kis_trader.backtest.param_search_momentum import apply_params_to_db
env_id = apply_params_to_db(merged)
elif strat == "us_momentum":
from kis_trader.backtest.param_search_momentum import apply_params_to_db_us
if exchange:
merged["exchange"] = str(exchange).strip() or merged.get("exchange")
if stock_group:
merged["stock_group"] = str(stock_group).strip() or merged.get("stock_group")
env_id = apply_params_to_db_us(merged, symbol=sym)
elif strat == "breakout":
from kis_trader.backtest.param_search_breakout import apply_params_to_db
apply_params_to_db(merged)
elif strat == "scalp":
from kis_trader.backtest.param_search_scalping import apply_params_to_db
apply_params_to_db(merged)
elif strat == "tail":
from kis_trader.backtest.tail_param_search import apply_params_to_db
apply_params_to_db(merged)
else:
raise ValueError(strat)
if not sym:
from kis_trader.backtest.optuna_postprocess_topn import build_upto_env_patch
axis_patch, axis_notes = build_upto_env_patch(
data=data, source=src, rank=rank, upto=upto_s, strategy=strat,
)
if axis_patch:
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
apply_env_patch(axis_patch)
else:
axis_notes.append(f"종목cfg({sym}) — 후처리 env 전역 패치 생략")
if meta is not None:
meta["applied_at"] = _now_iso()
meta["applied_source"] = src
meta["applied_rank"] = rank
meta["applied_upto"] = upto_s
meta["applied_trial"] = metrics.get("optuna_trial_number")
meta["applied_symbol"] = sym or None
meta["daily_trail_applied"] = bool(trail_apply.get("applied"))
save_job(meta)
trail_note = ""
if trail_apply.get("applied"):
patch = trail_apply.get("patch") or {}
tiers = patch.get(
next((k for k in patch if k.endswith("_DAILY_PROFIT_TRAIL_TIERS")), ""),
"",
)
trail_note = f" · 다단트레일 합의 반영 tiers={tiers}"
elif trail_apply.get("reason") and upto_s == "trail":
trail_note = f" · 다단트레일 미반영({trail_apply.get('reason')})"
axis_note = ""
if axis_patch:
axis_note = " · 후처리키 " + ",".join(sorted(axis_patch.keys()))
elif axis_notes:
axis_note = " · " + "; ".join(axis_notes)
apply_target = f"stock_config:{sym}" if (strat == "us_momentum" and sym) else "global"
return {
"ok": True,
"strategy": strat,
"source": src,
"rank": rank,
"upto": upto_s,
"symbol": sym or None,
"apply_target": apply_target,
"env_id": env_id,
"metrics": metrics,
"result_json": str(path),
"daily_trail_apply": trail_apply,
"axis_patch": axis_patch,
"axis_notes": axis_notes,
"note": (
"TIME_* 는 session_env_patch 기본 OFF — 운영 시간창 유지"
+ ("" if upto_s == "trail" else " · 타점 적용(upto=" + upto_s + ")")
+ trail_note
+ axis_note
),
}
def register_result_json_as_job(
result_json: str,
*,
source_label: str = "cli",
) -> Dict[str, Any]:
"""
CLI/순차 스크립트가 남긴 Optuna JSON 을 웹 잡 목록에 등록.
동일 result_json 경로가 이미 있으면 갱신만 한다.
"""
_ensure_dirs()
path = Path(result_json).resolve()
if not path.is_file():
raise FileNotFoundError(str(path))
data = json.loads(path.read_text(encoding="utf-8"))
strat = str(data.get("strategy") or "").strip().lower()
if strat not in _STRATS:
for s in _STRATS:
if path.name.startswith(f"optuna_{s}_"):
strat = s
break
if strat not in _STRATS:
raise ValueError(f"전략 불명: {path.name}")
brief = path.with_suffix("").as_posix()
if brief.endswith(".json"):
brief = brief[:-5]
briefing = Path(str(path).replace(".json", ".briefing.md"))
study = str(data.get("optuna_study_name") or data.get("study_name") or "")
start = str(data.get("start") or "")
end = str(data.get("end") or "")
mode = str(data.get("mode") or "tpe")
label = strat
if strat == "tail":
em = ""
if "limit_atr" in study:
em = "limit_atr"
elif "align" in study:
em = "align"
else:
rows = list(data.get("results_all") or data.get("results") or [])
p0 = (rows[0] or {}).get("params") or {} if rows else {}
em = str(p0.get("entry_mode") or "").strip()
label = f"꼬리({em})" if em else "꼬리"
elif strat == "breakout":
sm = ""
if "_atr_" in study or study.endswith("_atr"):
sm = "atr"
elif "_fixed_" in study:
sm = "fixed"
else:
rows = list(data.get("results_all") or data.get("results") or [])
p0 = (rows[0] or {}).get("params") or {} if rows else {}
sm = str(p0.get("sl_mode") or "").strip()
label = f"돌파({sm})" if sm else "돌파"
_labels_imp = {
"momentum": "모멘텀",
"us_momentum": "해외모멘텀",
"breakout": "돌파",
"scalp": "스캘핑",
}
if strat not in ("tail", "breakout"):
label = _labels_imp.get(strat, strat)
# 안정 job_id: 파일 stem
job_id = f"import_{path.stem}"
existing = load_job(job_id)
meta: Dict[str, Any] = existing or {}
meta.update({
"job_id": job_id,
"kind": "import",
"source": source_label,
"label": label,
"strategy": strat,
"mode": mode,
"start": start,
"end": end,
"trials": int(data.get("optuna_trials_completed") or data.get("optuna_n_trials_requested") or 0),
"study_name": study,
"status": "done",
"pid": None,
"log_path": None,
"result_json": str(path),
"briefing_md": str(briefing) if briefing.is_file() else None,
"started_ts": meta.get("started_ts") or path.stat().st_mtime,
"started_at": meta.get("started_at") or _now_iso(),
"finished_at": meta.get("finished_at") or _now_iso(),
"imported_at": _now_iso(),
})
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
if briefing.is_file():
try:
meta["briefing_preview"] = briefing.read_text(encoding="utf-8")[:4000]
except Exception:
pass
save_job(meta)
return meta
def import_recent_cli_results(*, limit_per_strategy: int = 3) -> List[Dict[str, Any]]:
"""전략별 최근 Optuna JSON 을 웹 잡으로 등록 (CLI 결과 노출용)."""
_ensure_dirs()
out: List[Dict[str, Any]] = []
lim = max(1, int(limit_per_strategy or 3))
for strat in _STRATS:
files = sorted(
RESULTS_DIR.glob(f"optuna_{strat}_tpe_*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)[:lim]
for p in files:
try:
out.append(register_result_json_as_job(str(p), source_label="cli"))
except Exception:
continue
return out
def _child_jobs_from_jsons(paths: List[str]) -> List[Dict[str, Any]]:
"""순차 묶음의 전략별 JSON → 잡 목록에 따로 등록 (보기/적용은 여기)."""
out: List[Dict[str, Any]] = []
for p in paths:
if not p or not Path(str(p)).is_file():
continue
try:
ch = register_result_json_as_job(str(p), source_label="seq")
except Exception:
continue
sm = ch.get("result_summary") or {}
out.append({
"job_id": ch.get("job_id"),
"label": ch.get("label") or ch.get("strategy"),
"strategy": ch.get("strategy"),
"n_all": sm.get("n_all") if sm.get("n_all") is not None else _json_n_results(str(p)),
"n_gated": sm.get("n_gated"),
})
return out
def refresh_job_status(meta: Dict[str, Any]) -> Dict[str, Any]:
"""pid/로그/study 로 status·progress 갱신 후 저장."""
m = dict(meta)
pid = m.get("pid")
alive = _pid_alive(pid)
# 중간저장 JSON(OPTUNA_RESULT_JSON)만 보고 완료 처리하면 후처리(호가) 중에
# 「구JSON」이 뜬다. PID 살아 있으면 무조건 진행 중.
log_path = str(m.get("log_path") or "")
paths = _parse_result_paths_from_log(log_path) if log_path else {}
if paths.get("result_json"):
m["result_json"] = paths["result_json"]
m["result_jsons"] = paths.get("result_jsons", [])
notes = []
for p in m["result_jsons"]:
notes.append({"path": p, "n_all": _json_n_results(p)})
m["result_json_notes"] = notes
if m.get("kind") in ("seq", "seq4"):
m["child_jobs"] = _child_jobs_from_jsons(m["result_jsons"])
if paths.get("briefing_md"):
m["briefing_md"] = paths["briefing_md"]
m["briefing_mds"] = paths.get("briefing_mds", [])
prog = _study_progress(str(m.get("study_name") or ""), int(m.get("trials") or 0))
# 순차(seq/seq4): 마스터 로그는 START/DONE만 찍힘 → trial 로그·study는 전략별 파일
active_log = log_path
if m.get("kind") in ("seq4", "seq") and log_path:
# 1) 이 잡 전용 sidecar (run_one 시작 시 덮어씀) 2) 이 잡 로그의 마지막 START
# 전역 optuna_*_tpe_latest.study 는 이전 전략에 남을 수 있어 쓰지 않음
side_info = _read_seq_active_sidecar(str(m.get("seq_active_file") or ""))
log_info = _last_seq_start_from_log(log_path)
if side_info.get("study") or side_info.get("strategy"):
_apply_seq_active(m, side_info)
elif log_info.get("study") or log_info.get("strategy"):
_apply_seq_active(m, log_info)
cs = str(m.get("current_strategy") or "").strip().lower()
if cs:
lp = ROOT / "logs" / f"optuna_{cs}_tpe_latest.logpath"
try:
if lp.is_file():
val = lp.read_text(encoding="utf-8").strip()
if val:
m["active_log_path"] = val
except Exception:
pass
if m.get("active_study_name"):
prog = _study_progress(
str(m["active_study_name"]), int(m.get("trials") or 0)
)
catalog = _seq_step_catalog(m)
if catalog:
prog["seq_steps"] = len(catalog)
cur = str(m.get("current_strategy") or "").strip().lower()
extra = str(
m.get("current_sl_mode") or m.get("current_entry_mode") or ""
).strip()
step_i = 0
for i, row in enumerate(catalog, start=1):
if row["strategy"] != cur:
continue
if row["extra"] and extra and row["extra"] != extra:
continue
step_i = i
if step_i:
prog["seq_step"] = step_i
if m.get("active_log_path") and Path(str(m["active_log_path"])).is_file():
active_log = str(m["active_log_path"])
master_blob = (_tail_text(log_path, 40) or "")
if "ALL DONE" in master_blob:
m["status"] = "done"
alive = False
if alive:
m["status"] = "running"
m["finished_at"] = None
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 m.get("kind") in ("seq4", "seq") and m.get("status") != "done":
tail = _tail_text(log_path, 20)
if "ALL DONE" in (tail or ""):
m["status"] = "done"
else:
m["status"] = "error"
m["error"] = m.get("error") or "process ended without ALL DONE"
else:
strat = str(m.get("strategy") or "")
if strat and strat not in ("all", "seq"):
globs = [
f"optuna_{strat}_{m.get('mode') or 'tpe'}_*.json",
f"optuna_{strat}_*.json",
]
if strat == "us_momentum":
globs.append("optuna_momentum_tpe_*.json")
globs.append("optuna_momentum_*.json")
cands = []
for g in globs:
cands.extend(RESULTS_DIR.glob(g))
cands = sorted(
{p.resolve(): p for p in cands}.values(),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if cands and cands[0].stat().st_mtime >= float(m.get("started_ts") or 0) - 5:
m["result_json"] = str(cands[0])
brief = Path(str(cands[0]).replace(".json", ".briefing.md"))
if brief.is_file():
m["briefing_md"] = str(brief)
m["status"] = "done"
else:
m["status"] = "error"
m["error"] = m.get("error") or "process ended (no result json)"
else:
m["status"] = "error"
m["error"] = m.get("error") or "process ended"
if m["status"] in ("done", "error") and not m.get("finished_at"):
m["finished_at"] = _now_iso()
m["finished_ts"] = time.time()
elif m["status"] in ("done", "error") and not m.get("finished_ts"):
fa = str(m.get("finished_at") or "")[:19]
try:
m["finished_ts"] = datetime.strptime(fa, "%Y-%m-%dT%H:%M:%S").timestamp()
except Exception:
m["finished_ts"] = float(m.get("started_ts") or time.time())
if m.get("status") == "done" and not alive:
# 순차: 1번 스터디 200/200 이어도 프로세스가 살아 있으면 아직 다음 전략
prog["pct"] = 100.0
if prog["trials_total"] and prog["trials_done"] < prog["trials_total"]:
prog["trials_done"] = prog["trials_total"]
m["progress"] = prog
m["pid_alive"] = alive
# seq: 웹 미리보기는 전략별 trial 로그 (마스터는 START만 있어 “안 올라가는” 것처럼 보임)
m["log_tail"] = _tail_text(active_log, 25)
if active_log and active_log != log_path:
m["display_log_path"] = active_log
else:
m["display_log_path"] = log_path or None
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
_attach_study_result_flags(m)
if m.get("briefing_md") and Path(str(m["briefing_md"])).is_file():
try:
m["briefing_preview"] = Path(str(m["briefing_md"])).read_text(encoding="utf-8")[:4000]
except Exception:
m["briefing_preview"] = None
rerun = m.get("postprocess_rerun") if isinstance(m.get("postprocess_rerun"), dict) else {}
pp_log = str((rerun or {}).get("log_path") or "") if str((rerun or {}).get("status") or "") == "running" else ""
post = _parse_postprocess_progress(pp_log or active_log)
topn = ((m.get("result_summary") or {}).get("postprocess_topn") or {}) if isinstance(m.get("result_summary"), dict) else {}
if isinstance(topn, dict) and topn.get("postprocess_by_anchor") and topn.get("run_ob_whipsaw"):
if not alive and str((rerun or {}).get("status") or "") != "running":
post["ready"] = True
post["pct"] = 100.0
from kis_trader.backtest.optuna_postprocess_topn import ob_8way_web_hint
miss8 = ob_8way_web_hint(topn)
post["hint"] = (
("후처리 끝 · 8방 미산출(%s) · 「상세」 가능" % miss8)
if miss8 else "후처리 끝 · 「상세」 가능"
)
post["stage"] = post.get("stage") or "done"
trials_tot = int(prog.get("trials_total") or 0)
trials_done = int(prog.get("trials_done") or 0)
is_seq = m.get("kind") in ("seq4", "seq")
if is_seq and alive:
# 앞 전략 trial 이 가득 차도 다음 전략이 남음 → 후처리 페이즈로 올리지 않음
trial_finished = False
else:
trial_finished = bool(trials_tot and trials_done >= trials_tot)
rerun_run = str((rerun or {}).get("status") or "") == "running"
if rerun_run:
m["phase"] = "postprocess"
if not post.get("hint"):
post["hint"] = "후처리 재실행 중 · 「상세」는 끝난 뒤"
elif alive and trial_finished and not post.get("ready"):
m["phase"] = "postprocess"
if not post.get("stage"):
post["stage"] = "wait"
post["hint"] = "학습 끝 · 후처리 시작 대기 · 「상세」는 아직"
elif alive:
m["phase"] = "trials"
if not post.get("hint"):
post["hint"] = "학습 trial 중 · 후처리는 그 다음"
else:
m["phase"] = str(m.get("status") or "idle")
if post.get("ready") and not post.get("hint"):
post["hint"] = "후처리 끝 · 「상세」 가능"
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
save_job(m)
m.update(build_optuna_join_payload(m))
return m
def find_running_jobs() -> List[Dict[str, Any]]:
out: List[Dict[str, Any]] = []
for meta in list_jobs(40):
if meta.get("status") == "running" or _pid_alive(meta.get("pid")):
refreshed = refresh_job_status(meta)
if refreshed.get("status") == "running":
out.append(refreshed)
return out
def any_optuna_python_running() -> Optional[Dict[str, Any]]:
"""웹 외 CLI nohup 도 상단바에 힌트용."""
try:
r = subprocess.run(
["pgrep", "-af", "param_search_optuna.py|run_optuna_4strat_tpe_seq.sh"],
capture_output=True,
text=True,
timeout=3,
)
lines = []
for ln in (r.stdout or "").splitlines():
if "extglob" in ln or "pgrep" in ln:
continue
if "param_search_optuna.py" in ln or "run_optuna_4strat_tpe_seq.sh" in ln:
lines.append(ln)
if not lines:
return None
return {"external": True, "cmdline": lines[0][:240], "count": len(lines)}
except Exception:
return None
def _normalize_strategies(
strategy: Optional[str] = None,
strategies: Optional[Any] = None,
) -> List[str]:
"""
웹 체크박스 / 레거시 strategy=all · 단일 문자열 → 전략 리스트.
국내4 기본(all): momentum tail breakout scalp (해외는 명시 체크 시에만).
"""
order = ("momentum", "us_momentum", "tail", "breakout", "scalp")
raw: List[str] = []
if strategies is not None:
if isinstance(strategies, str):
raw = re.split(r"[\s,]+", strategies.strip())
elif isinstance(strategies, (list, tuple)):
raw = [str(x) for x in strategies]
if not raw:
s = str(strategy or "").strip().lower()
if not s:
raise ValueError("strategy/strategies 필요")
if s in ("all", "seq", "seq4", "kr4"):
# 레거시 '4전략' = 국내 4만 (해외 자동 포함 안 함)
return ["momentum", "tail", "breakout", "scalp"]
raw = re.split(r"[\s,]+", s)
seen = set()
out: List[str] = []
for x in raw:
k = str(x or "").strip().lower()
if not k or k in seen:
continue
if k not in _STRATS:
raise ValueError(f"unknown strategy={k} (허용: {_STRATS})")
seen.add(k)
out.append(k)
if not out:
raise ValueError("전략을 1개 이상 선택하세요")
# 표시·실행 순서는 고정 순서(체크 순 혼선 방지)
return [k for k in order if k in seen]
def start_optuna_job(
*,
strategy: Optional[str] = None,
strategies: Optional[Any] = None,
start: str,
end: str,
trials: int = 200,
mode: str = "tpe",
symbol: Optional[str] = None,
universe_history_source: Optional[str] = None,
candle_source: Optional[str] = None,
tick_source: Optional[str] = None,
ob_source: Optional[str] = None,
entry_modes: Optional[Any] = None,
sl_modes: Optional[Any] = None,
study_trials: Optional[int] = None,
study_name_override: Optional[str] = None,
) -> Dict[str, Any]:
"""
subprocess 로 Optuna 시작. apply-best 없음.
전략 2개 이상 → scripts/run_optuna_4strat_tpe_seq.sh + STRATEGIES=
(레거시 strategy='all' → 국내 4순차, 해외 미포함)
symbol: us_momentum 종목 cfg Optuna (1종목 유니버스). 순차잡과 병행 불가.
universe_history_source: kiwoom|ls (저장 후보 이력 테이블).
candle_source: ''|kis|kiwoom — CANDLE_SOURCE / --candle-source (실매 읽기쌍과 동일).
entry_modes: 꼬리 TPE 고정 진입 align|limit_atr. 둘 다=순차 2스터디(한 스터디에 섞지 않음).
sl_modes: 돌파 TPE 고정 손절 fixed|atr. 둘 다=순차 2스터디.
"""
_ensure_dirs()
running = find_running_jobs()
if running:
raise RuntimeError(
f"이미 실행 중 job={running[0].get('job_id')} "
f"({running[0].get('strategy')}). 끝난 뒤 다시 시작하세요."
)
ext = any_optuna_python_running()
if ext:
raise RuntimeError(
"CLI/다른 Optuna 프로세스가 이미 실행 중입니다. "
"끝난 뒤 웹에서 시작하세요. (" + str(ext.get("cmdline") or "")[:120] + ")"
)
picked = _normalize_strategies(strategy=strategy, strategies=strategies)
mode = str(mode or "tpe").strip().lower() or "tpe"
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()
end = str(end or "").strip()
if not start or not end:
raise ValueError("start/end 필요")
sym = str(symbol or "").strip().upper()
if sym:
if len(picked) != 1 or picked[0] != "us_momentum":
raise ValueError("종목 Optuna(--symbol)는 us_momentum 단독만 가능")
candle_source = str(candle_source or "").strip().lower() or None
if candle_source and candle_source not in ("kis", "kiwoom"):
raise ValueError("candle_source 는 kis|kiwoom|빈값만")
tick_source = str(tick_source or "").strip().lower() or None
ob_source = str(ob_source or "").strip().lower() or None
from kis_trader.backtest.universe_history_source import (
resolve_backtest_universe_history_source,
)
from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day
hist_src = resolve_backtest_universe_history_source(universe_history_source)
start = clamp_to_prev_kr_trading_day(start)
end = clamp_to_prev_kr_trading_day(end)
if start > end:
start, end = end, start
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
started_ts = time.time()
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["BACKTEST_UNIVERSE_HISTORY_SOURCE"] = hist_src
_labels = {
"momentum": "모멘텀",
"us_momentum": "해외모멘텀",
"tail": "꼬리",
"breakout": "돌파",
"scalp": "스캘핑",
}
tail_ems = _normalize_tail_entry_modes(entry_modes)
if "tail" not in picked:
tail_ems = ["align"]
tail_dual = "tail" in picked and len(tail_ems) >= 2
bo_sms = _normalize_breakout_sl_modes(sl_modes)
if "breakout" not in picked:
bo_sms = ["fixed"]
bo_dual = "breakout" in picked and len(bo_sms) >= 2
use_seq = len(picked) >= 2 or tail_dual or bo_dual
seq_active = None
if use_seq:
job_id = f"opt_{ts}_seq"
log_path = ROOT / "logs" / f"optuna_web_seq_{ts}.log"
study_name = f"seq_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
cmd = [
"bash",
str(ROOT / "scripts" / "run_optuna_4strat_tpe_seq.sh"),
]
seq_active = ROOT / "logs" / f"{job_id}_seq_active.txt"
env["START"] = start
env["END"] = end
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["MODE"] = mode
env["MIN_WIN_RATE"] = "0"
env["MIN_PF"] = "0"
env["MIN_TRADES"] = "1"
env["STRATEGIES"] = " ".join(picked)
env["UNIVERSE_HISTORY_SOURCE"] = hist_src
env["TAIL_OPTUNA_ENTRY_MODES"] = " ".join(tail_ems if "tail" in picked else ["align"])
env["BREAKOUT_OPTUNA_SL_MODES"] = " ".join(bo_sms if "breakout" in picked else ["fixed"])
if candle_source:
env["CANDLE_SOURCE"] = candle_source
if tick_source:
env["TICK_SOURCE"] = tick_source
if ob_source:
env["OB_SOURCE"] = ob_source
kind = "seq"
_lab = "+".join(_labels.get(s, s) for s in picked)
if "tail" in picked and tail_ems:
_lab = _lab.replace("꼬리", "꼬리(" + "+".join(tail_ems) + ")")
if "breakout" in picked and bo_sms:
_lab = _lab.replace("돌파", "돌파(" + "+".join(bo_sms) + ")")
label = "순차(" + _lab + ")"
strat_field = ",".join(picked)
else:
strat = picked[0]
job_id = f"opt_{ts}_{strat[:4]}"
if sym and strat == "us_momentum":
study_name = (
f"usmom_{sym}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
)
log_path = ROOT / "logs" / f"optuna_web_usmom_{sym}_{ts}.log"
label = f"해외모멘텀·종목 {sym}"
elif strat == "tail":
_em = tail_ems[0]
study_name = (
f"{strat}_{_em}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
)
log_path = ROOT / "logs" / f"optuna_web_{strat}_{_em}_{ts}.log"
label = f"꼬리({_em})"
elif strat == "breakout":
_sm = bo_sms[0]
study_name = (
f"{strat}_{_sm}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
)
log_path = ROOT / "logs" / f"optuna_web_{strat}_{_sm}_{ts}.log"
label = f"돌파({_sm})"
else:
study_name = f"{strat}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
log_path = ROOT / "logs" / f"optuna_web_{strat}_{ts}.log"
label = _labels.get(strat, strat)
sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl"
cmd = [
str(PY if PY.is_file() else "python3"),
"-u",
str(ROOT / "kis_trader" / "backtest" / "param_search_optuna.py"),
"--strategy", strat,
"--mode", mode,
"--start", start,
"--end", end,
"--trials", str(trials),
"--min_trades", "1",
"--min_win_rate", "0",
"--min_pf", "0",
"--orderbook-filter", "off",
"--no-progress",
"--study-name", study_name,
"--sort-by", sort_by,
"--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":
cmd.extend(["--entry-mode", tail_ems[0]])
if strat == "breakout":
cmd.extend(["--sl-mode", bo_sms[0]])
if candle_source:
cmd.extend(["--candle-source", candle_source])
env["CANDLE_SOURCE"] = candle_source
if tick_source:
cmd.extend(["--tick-source", tick_source])
env["TICK_SOURCE"] = tick_source
if ob_source:
cmd.extend(["--ob-source", ob_source])
env["OB_SOURCE"] = ob_source
if sym and strat == "us_momentum":
cmd.extend(["--symbol", sym])
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")
env["OPTUNA_WEB_JOB_ID"] = job_id
# start_new_session: 세션 분리. 부모 wait 필수(reaper) — 없으면 좀비(Z).
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
meta = {
"job_id": job_id,
"kind": kind,
"label": label,
"strategy": strat_field,
"strategies": picked,
"mode": mode,
"start": start,
"end": end,
"trials": trials,
"study_trials": st_goal,
"study_name": study_name,
"seq_active_file": str(seq_active) if use_seq else None,
"symbol": sym or None,
"universe_history_source": hist_src,
"candle_source": candle_source,
"tick_source": tick_source,
"ob_source": ob_source,
"tail_entry_modes": tail_ems if "tail" in picked else None,
"breakout_sl_modes": bo_sms if "breakout" in picked else None,
"log_path": str(log_path),
"pid": int(proc.pid),
"status": "running",
"started_at": _now_iso(),
"started_ts": started_ts,
"finished_at": None,
"finished_ts": None,
"result_json": None,
"briefing_md": None,
"error": None,
"apply_best": False,
"cmd_argv": [str(x) for x in cmd],
"cmd": _quote_cmd([str(x) for x in cmd]),
}
save_job(meta)
_spawn_job_reaper(proc, job_id, log_f)
# latest pointer
(ROOT / "logs" / "optuna_web_latest_job.txt").write_text(job_id + "\n", encoding="utf-8")
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]:
"""완료 잡의 result JSON 에 축별 후처리를 백그라운드로 다시 붙인다. 실매 DB 미적용."""
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(f"job not found: {job_id}")
if meta.get("status") != "done":
raise RuntimeError("완료된 Optuna 잡만 후처리 재실행 가능")
running = find_running_jobs()
if running:
raise RuntimeError(
f"이미 실행 중 job={running[0].get('job_id')} — 끝난 뒤 후처리 재실행"
)
pp = dict(meta.get("postprocess_rerun") or {})
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 없음")
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
log_path = ROOT / "logs" / f"optuna_postprocess_rerun_{job_id}_{ts}.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
import sys as _sys
py_bin = str(PY) if PY.is_file() else _sys.executable
cmd = [
py_bin, "-u",
str(ROOT / "kis_trader" / "backtest" / "optuna_rerun_postprocess.py"),
"--result-json", path,
]
log_f = open(log_path, "w", encoding="utf-8")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
env["PYTHONPATH"] = str(ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
proc = subprocess.Popen(
cmd,
cwd=str(ROOT),
env=env,
stdout=log_f,
stderr=subprocess.STDOUT,
start_new_session=True,
)
meta["postprocess_rerun"] = {
"status": "running",
"pid": int(proc.pid),
"log_path": str(log_path),
"started_at": _now_iso(),
"result_json": path,
}
save_job(meta)
def _pp_reaper() -> None:
rc: Optional[int] = None
try:
rc = int(proc.wait())
except Exception:
rc = int(proc.poll()) if proc.poll() is not None else None
try:
if log_f is not None and hasattr(log_f, "closed") and not log_f.closed:
log_f.flush()
log_f.close()
except Exception:
pass
try:
m2 = load_job(job_id)
if not m2:
return
info = dict(m2.get("postprocess_rerun") or {})
info["status"] = "done" if rc == 0 else "error"
info["exit_code"] = rc
info["finished_at"] = _now_iso()
if rc not in (None, 0):
info["error"] = "exit_code=%s" % rc
m2["postprocess_rerun"] = info
save_job(m2)
refresh_job_status(m2)
except Exception:
pass
threading.Thread(target=_pp_reaper, daemon=True).start()
return {
"ok": True,
"job_id": job_id,
"log_path": str(log_path),
"pid": int(proc.pid),
"note": "후처리 재실행 중. 캔들/틱 DB 재사용. 끝나면 JSON에 축별 추천이 저장됩니다.",
}
def stop_optuna_job(job_id: str) -> Dict[str, Any]:
"""프로세스 그룹 kill (선택). 결과는 보장하지 않음."""
meta = load_job(job_id)
if not meta:
raise FileNotFoundError(job_id)
pid = int(meta.get("pid") or 0)
if pid and _pid_alive(pid):
try:
os.killpg(pid, signal.SIGTERM)
except Exception:
try:
os.kill(pid, signal.SIGTERM)
except Exception as exc:
meta["error"] = str(exc)
meta["status"] = "error"
meta["error"] = meta.get("error") or "stopped by user"
meta["finished_at"] = _now_iso()
save_job(meta)
return refresh_job_status(meta)