- Optuna web jobs/TPE/apply snapshot·틱로더 정합, jobs limit·감사로그 - 백테 UI 호가모드·후보 적용 흐름, feed_collect_stats API/탭 - 가설검증·교차검증 룰, 4전략 스모크·OB slot41 진단 스크립트 Co-authored-by: Cursor <cursoragent@cursor.com>
2712 lines
101 KiB
Python
2712 lines
101 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"
|
||
# Optuna 웹/API 적용 감사 — 한 줄 JSON (역추적: source=mode|gated · tp/sl)
|
||
APPLY_AUDIT_PATH = ROOT / "logs" / "optuna_apply_audit.jsonl"
|
||
PY = ROOT / ".venv" / "bin" / "python"
|
||
|
||
_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")
|
||
|
||
|
||
def _pct_keys_from_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""적용 로그용 — 익절/손절 등 UI% 축만 짧게."""
|
||
if not isinstance(params, dict):
|
||
return {}
|
||
out: Dict[str, Any] = {}
|
||
for k in (
|
||
"tp_pct", "sl_pct", "tp_max_pct", "drop_rate",
|
||
"trail_pct", "trail_arm_pct", "trail_trigger", "trail_stop",
|
||
"shoulder_min_high", "shoulder_min_high_pct",
|
||
"shoulder_cut_pct",
|
||
):
|
||
if k in params and params[k] is not None:
|
||
out[k] = params[k]
|
||
return out
|
||
|
||
|
||
def record_optuna_apply_audit(
|
||
*,
|
||
ok: bool,
|
||
strategy: str = "",
|
||
source: str = "",
|
||
rank: int = 0,
|
||
upto: str = "",
|
||
trial: Any = None,
|
||
job_id: Optional[str] = None,
|
||
study_name: str = "",
|
||
result_json: str = "",
|
||
params: Optional[Dict[str, Any]] = None,
|
||
metrics: Optional[Dict[str, Any]] = None,
|
||
error: str = "",
|
||
note: str = "",
|
||
) -> None:
|
||
"""적용 성공/실패를 JSONL + 표준 로그에 남김 (HTTP access body 없음 보완)."""
|
||
import logging
|
||
|
||
lg = logging.getLogger("optuna_apply")
|
||
row = {
|
||
"ts": _now_iso(),
|
||
"ok": bool(ok),
|
||
"strategy": str(strategy or ""),
|
||
"source": str(source or ""),
|
||
"rank": int(rank or 0),
|
||
"upto": str(upto or ""),
|
||
"trial": trial,
|
||
"job_id": job_id or None,
|
||
"study_name": str(study_name or "") or None,
|
||
"result_json": str(result_json or "") or None,
|
||
"params": _pct_keys_from_params(params),
|
||
"metrics": {
|
||
k: (metrics or {}).get(k)
|
||
for k in (
|
||
"total_pnl", "total_trades", "win_rate", "pf",
|
||
"optuna_trial_number",
|
||
)
|
||
if metrics and k in metrics
|
||
} or None,
|
||
"error": (error or "")[:500] or None,
|
||
"note": (note or "")[:300] or None,
|
||
}
|
||
try:
|
||
APPLY_AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
with APPLY_AUDIT_PATH.open("a", encoding="utf-8") as f:
|
||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
except Exception as exc:
|
||
lg.warning("optuna apply audit file write failed: %s", exc)
|
||
if ok:
|
||
lg.info(
|
||
"OPTUNA_APPLY ok strat=%s source=%s rank=%s upto=%s trial=%s params=%s job=%s",
|
||
row["strategy"], row["source"], row["rank"], row["upto"],
|
||
row["trial"], row["params"], row["job_id"],
|
||
)
|
||
else:
|
||
lg.warning(
|
||
"OPTUNA_APPLY fail strat=%s source=%s rank=%s err=%s job=%s",
|
||
row["strategy"], row["source"], row["rank"], row["error"], row["job_id"],
|
||
)
|
||
|
||
|
||
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_breakout_ob_modes(raw: Any) -> List[str]:
|
||
"""웹/CLI: off, on. 빈값이면 off 1개(호가OFF 스터디)."""
|
||
from kis_trader.backtest.optuna_breakout_tpe_space import (
|
||
normalize_tpe_breakout_ob_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:
|
||
om = normalize_tpe_breakout_ob_mode(x)
|
||
if om not in items:
|
||
items.append(om)
|
||
return items or ["off"]
|
||
|
||
|
||
def _parse_breakout_seq_extra(extra: str) -> Tuple[str, str]:
|
||
"""순차 extra → (sl_mode, ob_mode). 예: fixed_ob_off / atr_ob_on / fixed(구형)."""
|
||
from kis_trader.backtest.optuna_breakout_tpe_space import (
|
||
normalize_tpe_breakout_ob_mode,
|
||
normalize_tpe_breakout_sl_mode,
|
||
)
|
||
|
||
e = str(extra or "").strip().lower()
|
||
if "_ob_" in e:
|
||
sm, _, om = e.partition("_ob_")
|
||
return normalize_tpe_breakout_sl_mode(sm), normalize_tpe_breakout_ob_mode(om)
|
||
return normalize_tpe_breakout_sl_mode(e or "fixed"), "off"
|
||
|
||
|
||
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 과 동일 순서."""
|
||
from kis_trader.backtest.optuna_breakout_tpe_space import breakout_tpe_study_extra
|
||
|
||
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"])
|
||
bo_oms = list(meta.get("breakout_ob_modes") or ["off"])
|
||
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:
|
||
for om in bo_oms:
|
||
out.append({
|
||
"strategy": "breakout",
|
||
"extra": breakout_tpe_study_extra(sm, om),
|
||
})
|
||
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 는 추가분(leftover). py_bin=python3|python|venv경로."""
|
||
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
|
||
leftover = 0
|
||
# 항상 DB 플래그로 leftover 재계산 (meta 캐시가 오래된 200을 붙이지 않게)
|
||
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 0)
|
||
except Exception:
|
||
try:
|
||
st_goal = int(meta.get("study_trials") or 0)
|
||
except (TypeError, ValueError):
|
||
st_goal = 0
|
||
try:
|
||
leftover = int(meta.get("leftover_trials") or 0)
|
||
except (TypeError, ValueError):
|
||
leftover = 0
|
||
if st_goal > 0:
|
||
argv.extend(["--study-trials", str(st_goal)])
|
||
# 목표 도달이면 trials=0 (명령 복사해도 추가 연타 금지)
|
||
try:
|
||
idx = argv.index("--trials")
|
||
argv[idx + 1] = str(max(0, leftover))
|
||
except (ValueError, IndexError):
|
||
argv.extend(["--trials", str(max(0, 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":
|
||
if extra:
|
||
sm, om = _parse_breakout_seq_extra(extra)
|
||
else:
|
||
sms = list(meta.get("breakout_sl_modes") or ["fixed"])
|
||
oms = list(meta.get("breakout_ob_modes") or ["off"])
|
||
sm = str(sms[0] if sms else "fixed")
|
||
om = str(oms[0] if oms else "off")
|
||
argv.extend(["--sl-mode", sm])
|
||
# 베이스 argv 의 --orderbook-filter off 를 스터디 스위치로 덮어씀
|
||
if "--orderbook-filter" in argv:
|
||
_i = argv.index("--orderbook-filter")
|
||
if _i + 1 < len(argv):
|
||
argv[_i + 1] = om
|
||
else:
|
||
argv.extend(["--orderbook-filter", om])
|
||
else:
|
||
argv.extend(["--orderbook-filter", om])
|
||
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 한 덩어리. 레포 루트에서 실행 · .venv python 고정."""
|
||
from kis_trader.backtest.optuna_common import mariadb_creds
|
||
|
||
host = str(mariadb_creds().get("host") or "192.168.0.141")
|
||
body_argv = list(argv or [])
|
||
if body_argv and str(body_argv[0]).strip().lower() in ("python", "python3"):
|
||
body_argv[0] = r".\.venv\Scripts\python.exe"
|
||
body = _quote_ps(body_argv)
|
||
return (
|
||
"# 레포 루트로 이동한 뒤 붙여넣기 (git 커밋 = 웹 VM 과 동일)\n"
|
||
"# --trials = 남은 횟수(leftover). study-trials 목표 도달이면 0.\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: .\\.venv\\Scripts\\python.exe 사용. --trials=이 PC 추가분(남은 횟수), --study-trials=스터디 총 목표.",
|
||
"목표가 이미 찼으면 --trials 0 (추가 연타 없음). 자잘한 VM 다수보다 Win PC 1대가 현실적.",
|
||
]
|
||
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=".venv/bin/python",
|
||
)
|
||
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=".venv/bin/python",
|
||
)
|
||
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 _as_bool_opt(v: Any) -> Optional[bool]:
|
||
if v is None or v == "":
|
||
return None
|
||
if isinstance(v, bool):
|
||
return v
|
||
s = str(v).strip().lower()
|
||
if s in ("1", "true", "yes", "on"):
|
||
return True
|
||
if s in ("0", "false", "no", "off"):
|
||
return False
|
||
return None
|
||
|
||
|
||
def _ob_whip_ui_from_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||
"""TopN 표용 — 본 TPE trial 호가·익절·손절·휩쏘 요약 (사후 8방과 무관).
|
||
|
||
상세 보기 없이 적용값을 고를 수 있게 ON/OFF + 핵심 수치를 전부 내려준다.
|
||
"""
|
||
p = params if isinstance(params, dict) else {}
|
||
ob_on = _as_bool_opt(p.get("_orderbook_filter_enabled"))
|
||
if ob_on is None:
|
||
ob_on = _as_bool_opt(p.get("ob_filter_enabled"))
|
||
whip_on = _as_bool_opt(p.get("whipsaw_enabled"))
|
||
if whip_on is None:
|
||
whip_on = _as_bool_opt(p.get("whipsaw_filter_enabled"))
|
||
|
||
def _f(key: str) -> Optional[float]:
|
||
v = p.get(key)
|
||
if v is None or v == "":
|
||
return None
|
||
try:
|
||
return float(v)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
def _i(key: str) -> Optional[int]:
|
||
v = _f(key)
|
||
if v is None:
|
||
return None
|
||
try:
|
||
return int(round(v))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
spread = _f("max_spread_pct")
|
||
if spread is None:
|
||
spread = _f("orderbook_max_spread_pct")
|
||
ratio = _f("min_bid_ask_ratio")
|
||
if ratio is None:
|
||
ratio = _f("orderbook_min_bid_ask_ratio")
|
||
ask = _f("ask_max_mult")
|
||
if ask is None:
|
||
ask = _f("orderbook_entry_ask_max_mult")
|
||
|
||
tp = _f("tp_pct")
|
||
if tp is None:
|
||
tp = _f("take_profit_pct")
|
||
tp_max = _f("tp_max_pct")
|
||
if tp_max is None:
|
||
tp_max = _f("take_profit_max_pct")
|
||
sl = _f("sl_pct")
|
||
if sl is None:
|
||
sl = _f("stop_loss_pct")
|
||
|
||
whip_sub = _i("whipsaw_subbar_sec")
|
||
whip_lb = _i("whipsaw_lookback_sec")
|
||
whip_dip = _f("whipsaw_dip_pct")
|
||
whip_tol = _f("whipsaw_recovery_tol_pct")
|
||
|
||
lines: List[str] = []
|
||
if ob_on is True:
|
||
ob_bits = ["호가ON"]
|
||
if spread is not None:
|
||
ob_bits.append(f"spr{spread:.1f}")
|
||
if ratio is not None:
|
||
ob_bits.append(f"r{ratio:.2f}")
|
||
if ask is not None:
|
||
ob_bits.append(f"ask×{ask:.0f}")
|
||
lines.append(" ".join(ob_bits))
|
||
elif ob_on is False:
|
||
lines.append("호가OFF")
|
||
|
||
exit_bits: List[str] = []
|
||
if tp is not None:
|
||
exit_bits.append(f"익절{tp:.1f}%")
|
||
if tp_max is not None:
|
||
exit_bits.append(f"상한{tp_max:.1f}%")
|
||
if sl is not None:
|
||
exit_bits.append(f"손절{sl:.1f}%")
|
||
if exit_bits:
|
||
lines.append(" ".join(exit_bits))
|
||
|
||
if whip_on is True:
|
||
w_bits = ["휩쏘ON"]
|
||
if whip_sub is not None:
|
||
w_bits.append(f"sub{whip_sub}s")
|
||
if whip_lb is not None:
|
||
w_bits.append(f"lb{whip_lb}s")
|
||
if whip_dip is not None:
|
||
# 저장값 0.007 → 화면 0.70% (비율→퍼센트)
|
||
dip_pct = whip_dip * 100.0 if whip_dip < 0.5 else whip_dip
|
||
w_bits.append(f"dip{dip_pct:.2f}%")
|
||
if whip_tol is not None:
|
||
tol_pct = whip_tol * 100.0 if whip_tol < 0.5 else whip_tol
|
||
w_bits.append(f"tol{tol_pct:.2f}%")
|
||
lines.append(" ".join(w_bits))
|
||
elif whip_on is False and (ob_on is not None or exit_bits):
|
||
lines.append("휩쏘OFF")
|
||
|
||
return {
|
||
"ob_on": ob_on,
|
||
"whip_on": whip_on,
|
||
"ob_spread": spread,
|
||
"ob_ratio": ratio,
|
||
"ob_ask": ask,
|
||
"tp_pct": tp,
|
||
"tp_max_pct": tp_max,
|
||
"sl_pct": sl,
|
||
"whip_subbar_sec": whip_sub,
|
||
"whip_lookback_sec": whip_lb,
|
||
"whip_dip_pct": whip_dip,
|
||
"whip_recovery_tol_pct": whip_tol,
|
||
"ob_summary": " · ".join(lines) if lines else None,
|
||
"ob_summary_lines": lines,
|
||
}
|
||
|
||
|
||
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
|
||
try:
|
||
prm = row.get("merged_params") or row.get("params") or {}
|
||
out.update(_ob_whip_ui_from_params(prm if isinstance(prm, dict) else {}))
|
||
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)
|
||
top5_mode: List[Dict[str, Any]] = []
|
||
if mc_bt.get("ok") or mc_bt.get("total_pnl") is not None:
|
||
# mode_combo 는 trial 1개가 아니라 축최빈 조립 1회 실측 → 표는 사후합격과 동일 컬럼(1행).
|
||
stab_score = mc_bt.get("stability_score")
|
||
n_lose = mc_bt.get("n_losing_days")
|
||
worst_d = mc_bt.get("worst_day_pnl")
|
||
best_d = mc_bt.get("best_day_pnl")
|
||
daily = mc_bt.get("daily_pnl")
|
||
# 구 JSON: _bt_summary 가 안정필드를 버려 — 저장 _trades 로 재계산
|
||
if stab_score is None and (mc_bt.get("_trades") or daily):
|
||
try:
|
||
from kis_trader.backtest.optuna_common import compute_daily_stability_metrics
|
||
fills = list(mc_bt.get("_trades") or [])
|
||
if fills:
|
||
sm = compute_daily_stability_metrics(fills)
|
||
stab_score = sm.get("stability_score")
|
||
n_lose = sm.get("n_losing_days")
|
||
worst_d = sm.get("worst_day_pnl")
|
||
best_d = sm.get("best_day_pnl")
|
||
daily = sm.get("daily_pnl")
|
||
except Exception:
|
||
pass
|
||
mode_row = {
|
||
"label": "mode_combo 실측",
|
||
"source": "mode",
|
||
"optuna_trial_number": None, # 축최빈 조립 — trial 번호 없음
|
||
"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": mc_bt.get("score"),
|
||
"stability_score": stab_score,
|
||
"n_losing_days": n_lose,
|
||
"worst_day_pnl": worst_d,
|
||
"best_day_pnl": best_d,
|
||
"daily_pnl": daily,
|
||
"rank": 1,
|
||
}
|
||
try:
|
||
mode_row.update(_ob_whip_ui_from_params(mc.get("params") or {}))
|
||
except Exception:
|
||
pass
|
||
try:
|
||
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
|
||
of = overfit_risk_pct_for_row(data, mode_row)
|
||
mode_row["overfit_risk_pct"] = of.get("overfit_risk_pct")
|
||
mode_row["overfit_verdict"] = of.get("verdict")
|
||
mode_row["overfit_verdict_ui"] = of.get("verdict_ui")
|
||
except Exception:
|
||
pass
|
||
compare_rows.append(dict(mode_row))
|
||
top5_mode.append(mode_row)
|
||
|
||
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,
|
||
"top5_mode": top5_mode,
|
||
# 본 TPE 호가축 여부 · 사후8방 생략 안내
|
||
"tpe_includes_orderbook": (
|
||
"_orderbook_filter_enabled" in list(data.get("grid_keys") or [])
|
||
or "max_spread_pct" in list(data.get("grid_keys") or [])
|
||
),
|
||
"post_run_ob_whipsaw": (
|
||
bool(post_topn.get("run_ob_whipsaw"))
|
||
if isinstance(post_topn, dict) else None
|
||
),
|
||
"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|000~111
|
||
+ 구 entry/exit/stop · 방별 휩쏘는 `e+whip` / `100+whipsaw` 등.
|
||
8방=진입×익절×손절. whipsaw=111+휩쏘(스캘핑은 base+휩쏘). 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"
|
||
# full/as_scored/trial = Top10「적용」: 그 trial 점수에 쓰인 타점·익절·손절·호가·휩쏘 전부
|
||
# (구 base=타점만+호가OFF 후처리 덮어쓰기 — TPE 호가축 ON일 때 호가가 꺼지는 원인)
|
||
_FULL_UPTO = frozenset({"full", "as_scored", "trial", "all"})
|
||
_allowed = (
|
||
"base", "entry", "exit", "stop", "whipsaw", "trail",
|
||
"e", "x", "s", "ex", "es", "xs", "exs",
|
||
"000", "100", "010", "001", "110", "101", "011", "111",
|
||
"full", "as_scored", "trial", "all",
|
||
)
|
||
# 방별 휩쏘: e+whip / base+whipsaw / 100+whip …
|
||
_chk = upto_s
|
||
for _suf in ("+whipsaw", "+whip", "|whip"):
|
||
if _chk.endswith(_suf):
|
||
_chk = _chk[: -len(_suf)]
|
||
break
|
||
if _chk not in _allowed:
|
||
raise ValueError(
|
||
"upto/combo 는 full|base|e|x|s|ex|es|xs|exs|whipsaw|trail|000~111 "
|
||
"(full=줄전체 · 구 entry/exit/stop · +whip 방별휩쏘 포함) 만"
|
||
)
|
||
_apply_full_trial = upto_s in _FULL_UPTO
|
||
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 _apply_full_trial:
|
||
# trial params 그대로 — 8방 후처리로 호가OFF 덮어쓰지 않음
|
||
axis_notes.append(
|
||
"upto=full · 본 trial 호가/휩쏘 ON·OFF·수치 유지(사후8방 미적용)"
|
||
)
|
||
elif 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)
|
||
|
||
# 적용 직후 웹 폼·백테가 옛 RAM env 를 쓰지 않도록 (insert 경로와 이중 보장)
|
||
try:
|
||
from kis_trader.utils.env import invalidate_merged_env_cache
|
||
invalidate_merged_env_cache()
|
||
except Exception:
|
||
pass
|
||
|
||
apply_target = f"stock_config:{sym}" if (strat == "us_momentum" and sym) else "global"
|
||
note = (
|
||
"TIME_* 는 session_env_patch 기본 OFF — 운영 시간창 유지"
|
||
+ (
|
||
""
|
||
if upto_s == "trail"
|
||
else (
|
||
" · 줄전체 적용(타점·익절·손절·호가·휩쏘)"
|
||
if _apply_full_trial
|
||
else " · 타점/후처리 적용(upto=" + upto_s + ")"
|
||
)
|
||
)
|
||
+ trail_note
|
||
+ axis_note
|
||
+ " · 웹 폼은 DB로 다시 채움(새로고침 불필요)"
|
||
)
|
||
study_nm = str(
|
||
(meta or {}).get("study_name")
|
||
or data.get("optuna_study_name")
|
||
or data.get("study_name")
|
||
or "",
|
||
)
|
||
record_optuna_apply_audit(
|
||
ok=True,
|
||
strategy=strat,
|
||
source=src,
|
||
rank=rank,
|
||
upto=upto_s,
|
||
trial=metrics.get("optuna_trial_number"),
|
||
job_id=(meta or {}).get("job_id") if meta else job_id,
|
||
study_name=study_nm,
|
||
result_json=str(path or ""),
|
||
params=merged if upto_s != "trail" else None,
|
||
metrics=metrics,
|
||
note=note,
|
||
)
|
||
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,
|
||
"reload_forms": True,
|
||
"note": 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 {}
|
||
# 본 TPE 호가축 ON → 사후 8방 기본 OFF. 웹「후처리」바는 8방 TPE 전용으로만 길게 보이게.
|
||
try:
|
||
from kis_trader.backtest.optuna_postprocess_topn import _run_ob_whipsaw_full
|
||
expect_ob_post = bool(_run_ob_whipsaw_full())
|
||
except Exception:
|
||
expect_ob_post = True
|
||
post["expect_ob"] = bool(expect_ob_post)
|
||
if isinstance(topn, dict) and topn.get("postprocess_by_anchor") is not None:
|
||
ran_ob = bool(topn.get("run_ob_whipsaw"))
|
||
if ran_ob:
|
||
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"
|
||
else:
|
||
# A: 사후호가방 생략 — 바를「후처리」로 붙잡지 않음
|
||
if not alive:
|
||
post["ready"] = True
|
||
post["pct"] = 100.0
|
||
post["stage"] = "skipped_ob"
|
||
post["hint"] = "본TPE 호가포함 · 사후호가방 생략 · 「상세」가능"
|
||
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"):
|
||
if expect_ob_post:
|
||
m["phase"] = "postprocess"
|
||
if not post.get("stage"):
|
||
post["stage"] = "wait"
|
||
post["hint"] = "학습 끝 · 후처리 시작 대기 · 「상세」는 아직"
|
||
else:
|
||
# mode_combo·JSON 저장만 — 호가8방 TPE 아님
|
||
m["phase"] = "finalize"
|
||
post["stage"] = post.get("stage") or "finalize"
|
||
if not post.get("hint"):
|
||
post["hint"] = "학습 끝 · JSON 저장 중 (사후호가방 생략)"
|
||
elif alive:
|
||
m["phase"] = "trials"
|
||
if not post.get("hint"):
|
||
post["hint"] = "학습 trial 중 · 후처리는 그 다음" if expect_ob_post else "학습 trial 중 (호가=본TPE)"
|
||
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,
|
||
ob_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. 둘 다=순차(호가와 곱).
|
||
ob_modes: 돌파 TPE 호가 스터디 스위치 off|on. 손절×호가 최대 4순차(한 스터디에 안 섞음).
|
||
"""
|
||
_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_oms = _normalize_breakout_ob_modes(ob_modes)
|
||
if "breakout" not in picked:
|
||
bo_oms = ["off"]
|
||
bo_steps = (len(bo_sms) * len(bo_oms)) if "breakout" in picked else 0
|
||
bo_multi = bo_steps >= 2
|
||
use_seq = len(picked) >= 2 or tail_dual or bo_multi
|
||
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"])
|
||
env["BREAKOUT_OPTUNA_OB_MODES"] = " ".join(bo_oms if "breakout" in picked else ["off"])
|
||
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:
|
||
_bo_bits = [f"{sm}×{om}" for sm in bo_sms for om in bo_oms]
|
||
_lab = _lab.replace("돌파", "돌파(" + "+".join(_bo_bits) + ")")
|
||
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":
|
||
from kis_trader.backtest.optuna_breakout_tpe_space import breakout_tpe_study_extra
|
||
_extra = breakout_tpe_study_extra(bo_sms[0], bo_oms[0])
|
||
study_name = (
|
||
f"{strat}_{_extra}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
|
||
)
|
||
log_path = ROOT / "logs" / f"optuna_web_{strat}_{_extra}_{ts}.log"
|
||
label = f"돌파({_extra})"
|
||
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", (bo_oms[0] if strat == "breakout" else "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,
|
||
"breakout_ob_modes": bo_oms 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"),
|
||
ob_modes=meta.get("breakout_ob_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)
|