한투 호가 = 2번째 앱키 전용 키 없거나 start 실패 시 메인에 H0STASP0 안 붙임. 운영설정 WS_ORDERBOOK_SAVE_KIS 빨간 danger. LS RAM 합집합 후보∪보유∪영구∪grace. sync_targets와 split reconcile 둘 다. 틱 DB 영구 게이트는 그대로. 분봉 쓰레기 → 다음 소스 봉 통째 그 분 틱 0건이거나 전부 봉끝 대비 LIVE_FEED_FALLBACK_MAX_AGE_SEC 초과면 구멍. 메인 WS → 2차 → LS → REST → rollup. CANDLE_GARBAGE_FALLBACK 기본 true. 파일: feed_fallback.py(신규), ws_manager.py, kis_ws.py, candle_series.py, bt_candle_source.py, live_config_schema.py, database.py, 스모크, MD 2개. 같은 ws_manager/database/kis_ws/live_config에는 직전 커밋 이후 쌓여 있던 시세 폴백·ENV 키 정리도 같이 들어갔습니다. 파일 단위로 나눌 수 없어서입니다.
1736 lines
63 KiB
Python
1736 lines
63 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 signal
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
JOBS_DIR = ROOT / "logs" / "optuna_web_jobs"
|
|
RESULTS_DIR = ROOT / "kis_trader" / "backtest" / "results"
|
|
PY = ROOT / ".venv" / "bin" / "python"
|
|
|
|
_STRATS = ("momentum", "us_momentum", "tail", "breakout", "scalp")
|
|
|
|
|
|
def _normalize_breakout_sl_modes(raw: Any) -> List[str]:
|
|
"""웹/CLI: fixed, atr. 빈값이면 fixed 1개."""
|
|
from kis_trader.backtest.optuna_breakout_tpe_space import (
|
|
normalize_tpe_breakout_sl_mode,
|
|
)
|
|
|
|
items: List[str] = []
|
|
if raw is None or raw is False:
|
|
seq: List[Any] = []
|
|
elif isinstance(raw, str):
|
|
seq = [x for x in raw.replace(",", " ").split() if x.strip()]
|
|
elif isinstance(raw, (list, tuple)):
|
|
seq = list(raw)
|
|
else:
|
|
seq = [raw]
|
|
for x in seq:
|
|
sm = normalize_tpe_breakout_sl_mode(x)
|
|
if sm not in items:
|
|
items.append(sm)
|
|
return items or ["fixed"]
|
|
|
|
|
|
def _normalize_tail_entry_modes(raw: Any) -> List[str]:
|
|
"""웹/CLI: align, limit_atr. 빈값이면 align 1개(기존 TPE와 동일)."""
|
|
from kis_trader.backtest.optuna_tail_tpe_space import normalize_tpe_tail_entry_mode
|
|
|
|
items: List[str] = []
|
|
if raw is None or raw is False:
|
|
seq: List[Any] = []
|
|
elif isinstance(raw, str):
|
|
seq = [x for x in raw.replace(",", " ").split() if x.strip()]
|
|
elif isinstance(raw, (list, tuple)):
|
|
seq = list(raw)
|
|
else:
|
|
seq = [raw]
|
|
for x in seq:
|
|
em = normalize_tpe_tail_entry_mode(x)
|
|
if em not in items:
|
|
items.append(em)
|
|
return items or ["align"]
|
|
|
|
|
|
def _now_iso() -> str:
|
|
return datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
def _ensure_dirs() -> None:
|
|
JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
|
(ROOT / "logs").mkdir(parents=True, exist_ok=True)
|
|
RESULTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _job_path(job_id: str) -> Path:
|
|
return JOBS_DIR / f"{job_id}.json"
|
|
|
|
|
|
def save_job(meta: Dict[str, Any]) -> None:
|
|
_ensure_dirs()
|
|
jid = str(meta.get("job_id") or "")
|
|
if not jid:
|
|
raise ValueError("job_id required")
|
|
path = _job_path(jid)
|
|
tmp = path.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
tmp.replace(path)
|
|
|
|
|
|
def load_job(job_id: str) -> Optional[Dict[str, Any]]:
|
|
path = _job_path(job_id)
|
|
if not path.is_file():
|
|
return None
|
|
try:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _job_sort_ts(meta: Dict[str, Any], sort: str = "started") -> float:
|
|
"""
|
|
최근 잡 정렬 키. ※ 파일 mtime 금지 — refresh_job_status 가 폴링마다 save 해서
|
|
mtime 순이면 순서가 계속 뒤바뀜.
|
|
sort=started → started_ts (없으면 started_at)
|
|
sort=finished → finished_at (미종료는 맨 위, started_ts 보조)
|
|
"""
|
|
st = float(meta.get("started_ts") or 0)
|
|
if not st:
|
|
sa = str(meta.get("started_at") or "")[:19]
|
|
if sa:
|
|
try:
|
|
st = datetime.strptime(sa, "%Y-%m-%dT%H:%M:%S").timestamp()
|
|
except Exception:
|
|
st = 0.0
|
|
sort = str(sort or "started").strip().lower()
|
|
if sort != "finished":
|
|
return st
|
|
ft = float(meta.get("finished_ts") or 0)
|
|
if ft:
|
|
return ft
|
|
fa = str(meta.get("finished_at") or "")[:19]
|
|
if not fa:
|
|
# 실행 중/미종료 → 종료순에서도 최상단
|
|
return st + 1e15
|
|
try:
|
|
return datetime.strptime(fa, "%Y-%m-%dT%H:%M:%S").timestamp()
|
|
except Exception:
|
|
return st
|
|
|
|
|
|
def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]:
|
|
_ensure_dirs()
|
|
out: List[Dict[str, Any]] = []
|
|
for p in JOBS_DIR.glob("*.json"):
|
|
try:
|
|
out.append(json.loads(p.read_text(encoding="utf-8")))
|
|
except Exception:
|
|
continue
|
|
out.sort(key=lambda m: _job_sort_ts(m, sort), reverse=True)
|
|
return out[: max(1, int(limit))]
|
|
|
|
|
|
def job_list_row(meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""잡 목록 테이블용 슬림 행 — 후처리 JSON·브리핑·로그테일 제외."""
|
|
m = meta or {}
|
|
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"),
|
|
"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"),
|
|
},
|
|
}
|
|
|
|
|
|
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]
|
|
|
|
|
|
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:
|
|
out["error"] = str(exc)[:200]
|
|
return out
|
|
|
|
|
|
def _row_metrics(
|
|
row: Optional[Dict[str, Any]],
|
|
*,
|
|
label: str,
|
|
source: str,
|
|
data: Optional[Dict[str, Any]] = None,
|
|
) -> Optional[Dict[str, Any]]:
|
|
if not row:
|
|
return None
|
|
out = {
|
|
"label": label,
|
|
"source": source,
|
|
"optuna_trial_number": row.get("optuna_trial_number"),
|
|
"total_pnl": row.get("total_pnl"),
|
|
"total_trades": row.get("total_trades"),
|
|
"win_rate": row.get("win_rate"),
|
|
"pf": row.get("pf"),
|
|
"score": row.get("score"),
|
|
}
|
|
# 일별 안정성 (신규 Optuna JSON)
|
|
for k in (
|
|
"stability_score", "n_losing_days", "n_active_days",
|
|
"worst_day_pnl", "best_day_pnl", "daily_pnl_mean", "daily_pnl_std",
|
|
"daily_pnl",
|
|
):
|
|
if row.get(k) is not None:
|
|
out[k] = row.get(k)
|
|
if data is not None:
|
|
try:
|
|
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
|
|
of = overfit_risk_pct_for_row(data, row)
|
|
out["overfit_risk_pct"] = of.get("overfit_risk_pct")
|
|
out["overfit_verdict"] = of.get("verdict")
|
|
out["overfit_verdict_ui"] = of.get("verdict_ui")
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
def _summarize_result_json(path: Optional[str]) -> Optional[Dict[str, Any]]:
|
|
"""완료 JSON → Top5 learn/gated/stable · 행별 과적합% · vs_best."""
|
|
if not path or not Path(path).is_file():
|
|
return None
|
|
try:
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return None
|
|
gated = list(data.get("results_gated") or [])
|
|
stable = list(data.get("results_stable") or [])
|
|
allr = list(data.get("results") or data.get("results_all") or [])
|
|
learn = allr[0] if allr else None
|
|
gate0 = gated[0] if gated else None
|
|
stab0 = stable[0] if stable else None
|
|
top = gate0 or learn
|
|
|
|
mc = data.get("mode_combo") or {}
|
|
mc_bt = mc.get("backtest") or {}
|
|
vs = mc.get("vs_best") or {}
|
|
|
|
compare_rows: List[Dict[str, Any]] = []
|
|
r_learn = _row_metrics(learn, label="학습1위(results)", source="learn", data=data)
|
|
if r_learn:
|
|
compare_rows.append(r_learn)
|
|
r_gate = _row_metrics(gate0, label="사후합격1위(gated)", source="gated", data=data)
|
|
if r_gate:
|
|
compare_rows.append(r_gate)
|
|
r_stab = _row_metrics(stab0, label="안정1위(stable)", source="stable", data=data)
|
|
if r_stab:
|
|
compare_rows.append(r_stab)
|
|
if mc_bt.get("ok") or mc_bt.get("total_pnl") is not None:
|
|
compare_rows.append({
|
|
"label": "mode_combo 실측",
|
|
"source": "mode",
|
|
"optuna_trial_number": None,
|
|
"total_pnl": mc_bt.get("total_pnl"),
|
|
"total_trades": mc_bt.get("total_trades"),
|
|
"win_rate": mc_bt.get("win_rate"),
|
|
"pf": mc_bt.get("pf"),
|
|
"score": None,
|
|
})
|
|
|
|
top5 = []
|
|
for i, row in enumerate(gated[:5], start=1):
|
|
m = _row_metrics(row, label=f"gated #{i}", source="gated", data=data)
|
|
if m:
|
|
m["rank"] = i
|
|
top5.append(m)
|
|
|
|
top5_learn: List[Dict[str, Any]] = []
|
|
for i, row in enumerate(allr[:5], start=1):
|
|
m = _row_metrics(row, label=f"learn #{i}", source="learn", data=data)
|
|
if m:
|
|
m["rank"] = i
|
|
top5_learn.append(m)
|
|
|
|
top5_stable: List[Dict[str, Any]] = []
|
|
for i, row in enumerate(stable[:5], start=1):
|
|
m = _row_metrics(row, label=f"stable #{i}", source="stable", data=data)
|
|
if m:
|
|
m["rank"] = i
|
|
top5_stable.append(m)
|
|
|
|
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": data.get("stable_gates"),
|
|
"top": _row_metrics(
|
|
top,
|
|
label="적용후보(gated우선)",
|
|
source="gated" if gate0 else "learn",
|
|
data=data,
|
|
),
|
|
"compare_rows": compare_rows,
|
|
"vs_best": vs if vs else None,
|
|
"top5_gated": top5,
|
|
"top5_learn": top5_learn,
|
|
"top5_stable": top5_stable,
|
|
"mode_combo_note": mc.get("note"),
|
|
"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 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")
|
|
if not path or not Path(path).is_file():
|
|
raise FileNotFoundError("result_json 없음")
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
src = str(source or "gated").strip().lower()
|
|
rank = max(1, int(rank or 1))
|
|
params: Dict[str, Any] = {}
|
|
metrics: Dict[str, Any] = {}
|
|
|
|
if src == "mode":
|
|
mc = data.get("mode_combo") or {}
|
|
params = dict(mc.get("params") or {})
|
|
bt = mc.get("backtest") or {}
|
|
metrics = {
|
|
"label": "mode_combo",
|
|
"optuna_trial_number": None,
|
|
"total_pnl": bt.get("total_pnl"),
|
|
"total_trades": bt.get("total_trades"),
|
|
"win_rate": bt.get("win_rate"),
|
|
"pf": bt.get("pf"),
|
|
}
|
|
else:
|
|
if src == "stable":
|
|
pool = data.get("results_stable")
|
|
elif src == "gated":
|
|
pool = data.get("results_gated")
|
|
else:
|
|
pool = data.get("results") or []
|
|
pool = list(pool or [])
|
|
if not pool:
|
|
raise RuntimeError(f"{src} 결과 없음")
|
|
if rank > len(pool):
|
|
raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
|
|
item = pool[rank - 1]
|
|
try:
|
|
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
|
|
params = merge_param_search_apply_source(item, data)
|
|
except Exception:
|
|
params = dict(item.get("merged_params") or item.get("params") or {})
|
|
metrics = {
|
|
"label": f"{src} #{rank}",
|
|
"optuna_trial_number": item.get("optuna_trial_number"),
|
|
"total_pnl": item.get("total_pnl"),
|
|
"total_trades": item.get("total_trades"),
|
|
"win_rate": item.get("win_rate"),
|
|
"pf": item.get("pf"),
|
|
"score": item.get("score"),
|
|
"stability_score": item.get("stability_score"),
|
|
"n_losing_days": item.get("n_losing_days"),
|
|
"worst_day_pnl": item.get("worst_day_pnl"),
|
|
"daily_pnl": item.get("daily_pnl"),
|
|
}
|
|
try:
|
|
from kis_trader.backtest.optuna_common import overfit_risk_pct_for_row
|
|
of = overfit_risk_pct_for_row(data, item)
|
|
metrics["overfit_risk_pct"] = of.get("overfit_risk_pct")
|
|
metrics["overfit_verdict_ui"] = of.get("verdict_ui")
|
|
except Exception:
|
|
pass
|
|
|
|
# 보기: 전체 파라미터 (키 정렬). 예전 [:40] 잘림 → ratchet/rsi 등이 “없는 것처럼” 보임
|
|
preview = {k: params[k] for k in sorted(params.keys(), key=lambda x: str(x))}
|
|
return {
|
|
"ok": True,
|
|
"source": src,
|
|
"rank": rank,
|
|
"metrics": metrics,
|
|
"params_preview": preview,
|
|
"params_full": preview,
|
|
"params_count": len(params),
|
|
"result_json": str(path),
|
|
"strategy": _resolve_single_strategy(meta, data, path) or data.get("strategy"),
|
|
"start": data.get("start"),
|
|
"end": data.get("end"),
|
|
}
|
|
|
|
|
|
def apply_optuna_result(
|
|
*,
|
|
job_id: Optional[str] = None,
|
|
result_json: Optional[str] = None,
|
|
source: str = "gated",
|
|
rank: int = 1,
|
|
allow_non_positive_pnl: bool = False,
|
|
symbol: Optional[str] = None,
|
|
exchange: Optional[str] = None,
|
|
stock_group: Optional[str] = None,
|
|
upto: str = "base",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Optuna JSON 후보 → 전략별 apply_params_to_db.
|
|
source: gated | stable | learn | mode
|
|
rank: gated/stable/learn 1-based
|
|
upto/combo: base|e|x|s|ex|es|xs|exs|whipsaw|trail (+ 구 entry/exit/stop·000~111)
|
|
8방=진입×익절×손절. whipsaw=111+휩쏘. trail=합의 트레일만(타점 미적용).
|
|
symbol: us_momentum 종목 cfg 적용 시 (없으면 job/JSON 메타 또는 전역)
|
|
"""
|
|
path = result_json
|
|
meta = None
|
|
if job_id:
|
|
meta = load_job(job_id)
|
|
if not meta:
|
|
raise FileNotFoundError(f"job not found: {job_id}")
|
|
if meta.get("status") != "done":
|
|
raise RuntimeError(f"job status={meta.get('status')} — 완료 후에만 적용")
|
|
path = path or meta.get("result_json")
|
|
if not path or not Path(path).is_file():
|
|
raise FileNotFoundError("result_json 없음")
|
|
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
strat = _resolve_single_strategy(meta, data, path)
|
|
if strat not in _STRATS:
|
|
raise ValueError(f"전략 불명: {strat or (meta or {}).get('strategy')}")
|
|
|
|
src = str(source or "gated").strip().lower()
|
|
rank = max(1, int(rank or 1))
|
|
upto_s = str(upto or "base").strip().lower() or "base"
|
|
_allowed = (
|
|
"base", "entry", "exit", "stop", "whipsaw", "trail",
|
|
"e", "x", "s", "ex", "es", "xs", "exs",
|
|
"000", "100", "010", "001", "110", "101", "011", "111",
|
|
)
|
|
if upto_s not in _allowed:
|
|
raise ValueError(
|
|
"upto/combo 는 base|e|x|s|ex|es|xs|exs|whipsaw|trail|000~111 "
|
|
"(구 entry/exit/stop 별칭 포함) 만"
|
|
)
|
|
item: Optional[Dict[str, Any]] = None
|
|
merged: Dict[str, Any] = {}
|
|
|
|
if src == "mode":
|
|
mc = data.get("mode_combo") or {}
|
|
merged = dict(mc.get("params") or {})
|
|
bt = mc.get("backtest") or {}
|
|
pnl = float(bt.get("total_pnl") or 0)
|
|
metrics = {
|
|
"total_pnl": bt.get("total_pnl"),
|
|
"total_trades": bt.get("total_trades"),
|
|
"win_rate": bt.get("win_rate"),
|
|
"pf": bt.get("pf"),
|
|
"optuna_trial_number": None,
|
|
}
|
|
if not merged:
|
|
raise RuntimeError("mode_combo.params 없음")
|
|
else:
|
|
if src == "stable":
|
|
pool = data.get("results_stable")
|
|
elif src == "gated":
|
|
pool = data.get("results_gated")
|
|
else:
|
|
pool = data.get("results") or []
|
|
pool = list(pool or [])
|
|
if not pool:
|
|
raise RuntimeError(f"{src} 결과 없음")
|
|
if rank > len(pool):
|
|
raise RuntimeError(f"rank {rank} 범위 초과 (1~{len(pool)})")
|
|
item = pool[rank - 1]
|
|
from kis_trader.backtest.backtest_portfolio_common import merge_param_search_apply_source
|
|
merged = merge_param_search_apply_source(item, data)
|
|
pnl = float(item.get("total_pnl") or 0)
|
|
metrics = {
|
|
"total_pnl": item.get("total_pnl"),
|
|
"total_trades": item.get("total_trades"),
|
|
"win_rate": item.get("win_rate"),
|
|
"pf": item.get("pf"),
|
|
"optuna_trial_number": item.get("optuna_trial_number"),
|
|
"stability_score": item.get("stability_score"),
|
|
"n_losing_days": item.get("n_losing_days"),
|
|
"worst_day_pnl": item.get("worst_day_pnl"),
|
|
}
|
|
|
|
if upto_s != "trail" and pnl <= 0 and not allow_non_positive_pnl:
|
|
raise RuntimeError(
|
|
f"total_pnl={pnl} ≤ 0 — DB 미적용. 강제 시 allow_non_positive_pnl=true",
|
|
)
|
|
|
|
sym = str(
|
|
symbol
|
|
or (meta or {}).get("symbol")
|
|
or data.get("symbol")
|
|
or data.get("_apply_symbol")
|
|
or "",
|
|
).strip().upper()
|
|
|
|
env_id = None
|
|
axis_patch: Dict[str, str] = {}
|
|
axis_notes: List[str] = []
|
|
trail_apply: Dict[str, Any] = {"applied": False}
|
|
|
|
if upto_s == "trail":
|
|
if sym:
|
|
trail_apply = {"applied": False, "reason": f"stock_cfg:{sym}"}
|
|
else:
|
|
try:
|
|
from kis_trader.backtest.optuna_daily_trail_recommend import (
|
|
apply_daily_trail_recommend_from_optuna_json,
|
|
recommend_from_optuna_out_data,
|
|
)
|
|
if not data.get("daily_trail_recommend"):
|
|
data["daily_trail_recommend"] = recommend_from_optuna_out_data(data)
|
|
trail_apply = apply_daily_trail_recommend_from_optuna_json(
|
|
str(path), strategy=strat,
|
|
)
|
|
except Exception as exc:
|
|
trail_apply = {"applied": False, "error": str(exc)}
|
|
else:
|
|
if strat == "momentum":
|
|
from kis_trader.backtest.param_search_momentum import apply_params_to_db
|
|
env_id = apply_params_to_db(merged)
|
|
elif strat == "us_momentum":
|
|
from kis_trader.backtest.param_search_momentum import apply_params_to_db_us
|
|
if exchange:
|
|
merged["exchange"] = str(exchange).strip() or merged.get("exchange")
|
|
if stock_group:
|
|
merged["stock_group"] = str(stock_group).strip() or merged.get("stock_group")
|
|
env_id = apply_params_to_db_us(merged, symbol=sym)
|
|
elif strat == "breakout":
|
|
from kis_trader.backtest.param_search_breakout import apply_params_to_db
|
|
apply_params_to_db(merged)
|
|
elif strat == "scalp":
|
|
from kis_trader.backtest.param_search_scalping import apply_params_to_db
|
|
apply_params_to_db(merged)
|
|
elif strat == "tail":
|
|
from kis_trader.backtest.tail_param_search import apply_params_to_db
|
|
apply_params_to_db(merged)
|
|
else:
|
|
raise ValueError(strat)
|
|
|
|
if not sym:
|
|
from kis_trader.backtest.optuna_postprocess_topn import build_upto_env_patch
|
|
axis_patch, axis_notes = build_upto_env_patch(
|
|
data=data, source=src, rank=rank, upto=upto_s, strategy=strat,
|
|
)
|
|
if axis_patch:
|
|
from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch
|
|
apply_env_patch(axis_patch)
|
|
else:
|
|
axis_notes.append(f"종목cfg({sym}) — 후처리 env 전역 패치 생략")
|
|
|
|
if meta is not None:
|
|
meta["applied_at"] = _now_iso()
|
|
meta["applied_source"] = src
|
|
meta["applied_rank"] = rank
|
|
meta["applied_upto"] = upto_s
|
|
meta["applied_trial"] = metrics.get("optuna_trial_number")
|
|
meta["applied_symbol"] = sym or None
|
|
meta["daily_trail_applied"] = bool(trail_apply.get("applied"))
|
|
save_job(meta)
|
|
|
|
trail_note = ""
|
|
if trail_apply.get("applied"):
|
|
patch = trail_apply.get("patch") or {}
|
|
tiers = patch.get(
|
|
next((k for k in patch if k.endswith("_DAILY_PROFIT_TRAIL_TIERS")), ""),
|
|
"",
|
|
)
|
|
trail_note = f" · 다단트레일 합의 반영 tiers={tiers}"
|
|
elif trail_apply.get("reason") and upto_s == "trail":
|
|
trail_note = f" · 다단트레일 미반영({trail_apply.get('reason')})"
|
|
|
|
axis_note = ""
|
|
if axis_patch:
|
|
axis_note = " · 후처리키 " + ",".join(sorted(axis_patch.keys()))
|
|
elif axis_notes:
|
|
axis_note = " · " + "; ".join(axis_notes)
|
|
|
|
apply_target = f"stock_config:{sym}" if (strat == "us_momentum" and sym) else "global"
|
|
return {
|
|
"ok": True,
|
|
"strategy": strat,
|
|
"source": src,
|
|
"rank": rank,
|
|
"upto": upto_s,
|
|
"symbol": sym or None,
|
|
"apply_target": apply_target,
|
|
"env_id": env_id,
|
|
"metrics": metrics,
|
|
"result_json": str(path),
|
|
"daily_trail_apply": trail_apply,
|
|
"axis_patch": axis_patch,
|
|
"axis_notes": axis_notes,
|
|
"note": (
|
|
"TIME_* 는 session_env_patch 기본 OFF — 운영 시간창 유지"
|
|
+ ("" if upto_s == "trail" else " · 타점 적용(upto=" + upto_s + ")")
|
|
+ trail_note
|
|
+ axis_note
|
|
),
|
|
}
|
|
|
|
|
|
def register_result_json_as_job(
|
|
result_json: str,
|
|
*,
|
|
source_label: str = "cli",
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
CLI/순차 스크립트가 남긴 Optuna JSON 을 웹 잡 목록에 등록.
|
|
동일 result_json 경로가 이미 있으면 갱신만 한다.
|
|
"""
|
|
_ensure_dirs()
|
|
path = Path(result_json).resolve()
|
|
if not path.is_file():
|
|
raise FileNotFoundError(str(path))
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
strat = str(data.get("strategy") or "").strip().lower()
|
|
if strat not in _STRATS:
|
|
for s in _STRATS:
|
|
if path.name.startswith(f"optuna_{s}_"):
|
|
strat = s
|
|
break
|
|
if strat not in _STRATS:
|
|
raise ValueError(f"전략 불명: {path.name}")
|
|
|
|
brief = path.with_suffix("").as_posix()
|
|
if brief.endswith(".json"):
|
|
brief = brief[:-5]
|
|
briefing = Path(str(path).replace(".json", ".briefing.md"))
|
|
study = str(data.get("optuna_study_name") or data.get("study_name") or "")
|
|
start = str(data.get("start") or "")
|
|
end = str(data.get("end") or "")
|
|
mode = str(data.get("mode") or "tpe")
|
|
label = strat
|
|
if strat == "tail":
|
|
em = ""
|
|
if "limit_atr" in study:
|
|
em = "limit_atr"
|
|
elif "align" in study:
|
|
em = "align"
|
|
else:
|
|
rows = list(data.get("results_all") or data.get("results") or [])
|
|
p0 = (rows[0] or {}).get("params") or {} if rows else {}
|
|
em = str(p0.get("entry_mode") or "").strip()
|
|
label = f"꼬리({em})" if em else "꼬리"
|
|
elif strat == "breakout":
|
|
sm = ""
|
|
if "_atr_" in study or study.endswith("_atr"):
|
|
sm = "atr"
|
|
elif "_fixed_" in study:
|
|
sm = "fixed"
|
|
else:
|
|
rows = list(data.get("results_all") or data.get("results") or [])
|
|
p0 = (rows[0] or {}).get("params") or {} if rows else {}
|
|
sm = str(p0.get("sl_mode") or "").strip()
|
|
label = f"돌파({sm})" if sm else "돌파"
|
|
_labels_imp = {
|
|
"momentum": "모멘텀",
|
|
"us_momentum": "해외모멘텀",
|
|
"breakout": "돌파",
|
|
"scalp": "스캘핑",
|
|
}
|
|
if strat not in ("tail", "breakout"):
|
|
label = _labels_imp.get(strat, strat)
|
|
# 안정 job_id: 파일 stem
|
|
job_id = f"import_{path.stem}"
|
|
|
|
existing = load_job(job_id)
|
|
meta: Dict[str, Any] = existing or {}
|
|
meta.update({
|
|
"job_id": job_id,
|
|
"kind": "import",
|
|
"source": source_label,
|
|
"label": label,
|
|
"strategy": strat,
|
|
"mode": mode,
|
|
"start": start,
|
|
"end": end,
|
|
"trials": int(data.get("optuna_trials_completed") or data.get("optuna_n_trials_requested") or 0),
|
|
"study_name": study,
|
|
"status": "done",
|
|
"pid": None,
|
|
"log_path": None,
|
|
"result_json": str(path),
|
|
"briefing_md": str(briefing) if briefing.is_file() else None,
|
|
"started_ts": meta.get("started_ts") or path.stat().st_mtime,
|
|
"started_at": meta.get("started_at") or _now_iso(),
|
|
"finished_at": meta.get("finished_at") or _now_iso(),
|
|
"imported_at": _now_iso(),
|
|
})
|
|
meta["result_summary"] = _summarize_result_json(str(path))
|
|
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:
|
|
# master + 전략 로그에 START 줄이 흩어질 수 있어 둘 다 스캔
|
|
master_blob = (_tail_text(log_path, 80) or "") + "\n"
|
|
try:
|
|
side = ROOT / "logs" / "optuna_4strat_tpe_latest_master.logpath"
|
|
if side.is_file():
|
|
master_file = Path(side.read_text(encoding="utf-8").strip())
|
|
if master_file.is_file():
|
|
master_blob += _tail_text(str(master_file), 80) or ""
|
|
except Exception:
|
|
pass
|
|
# 로그: [tail] START 또는 [tail/align] START study=...
|
|
hits = re.findall(
|
|
r"\[(momentum|us_momentum|tail|breakout|scalp)(?:/([a-z0-9_]+))?\] START(?:[^\n]*study=([^\s]+))?",
|
|
master_blob,
|
|
)
|
|
if hits:
|
|
strat_h, em_h, study_h = hits[-1]
|
|
m["current_strategy"] = strat_h
|
|
if em_h:
|
|
m["current_entry_mode"] = em_h
|
|
if study_h:
|
|
m["active_study_name"] = study_h.strip()
|
|
prog = _study_progress(
|
|
m["active_study_name"], int(m.get("trials") or 0)
|
|
)
|
|
cs = str(m.get("current_strategy") or "").strip().lower()
|
|
if cs:
|
|
# 전략별 로그/study 사이드카 (스크립트가 갱신)
|
|
for side_name, key in (
|
|
(f"optuna_{cs}_tpe_latest.logpath", "active_log_path"),
|
|
(f"optuna_{cs}_tpe_latest.study", "active_study_name"),
|
|
):
|
|
sp = ROOT / "logs" / side_name
|
|
try:
|
|
if sp.is_file():
|
|
val = sp.read_text(encoding="utf-8").strip()
|
|
if val:
|
|
m[key] = val
|
|
except Exception:
|
|
pass
|
|
if m.get("active_study_name") and not prog.get("study_ok"):
|
|
prog = _study_progress(
|
|
str(m["active_study_name"]), int(m.get("trials") or 0)
|
|
)
|
|
if m.get("active_log_path") and Path(str(m["active_log_path"])).is_file():
|
|
active_log = str(m["active_log_path"])
|
|
if "ALL DONE" in master_blob:
|
|
m["status"] = "done"
|
|
alive = False
|
|
|
|
if alive:
|
|
m["status"] = "running"
|
|
m["finished_at"] = None
|
|
elif not alive:
|
|
# 프로세스 종료
|
|
if m.get("result_json") and Path(str(m["result_json"])).is_file():
|
|
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":
|
|
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
|
|
m["result_summary"] = _summarize_result_json(m.get("result_json"))
|
|
if m.get("briefing_md") and Path(str(m["briefing_md"])).is_file():
|
|
try:
|
|
m["briefing_preview"] = Path(str(m["briefing_md"])).read_text(encoding="utf-8")[:4000]
|
|
except Exception:
|
|
m["briefing_preview"] = None
|
|
|
|
rerun = m.get("postprocess_rerun") if isinstance(m.get("postprocess_rerun"), dict) else {}
|
|
pp_log = str((rerun or {}).get("log_path") or "") if str((rerun or {}).get("status") or "") == "running" else ""
|
|
post = _parse_postprocess_progress(pp_log or active_log)
|
|
topn = ((m.get("result_summary") or {}).get("postprocess_topn") or {}) if isinstance(m.get("result_summary"), dict) else {}
|
|
if isinstance(topn, dict) and topn.get("postprocess_by_anchor") and topn.get("run_ob_whipsaw"):
|
|
if not alive and str((rerun or {}).get("status") or "") != "running":
|
|
post["ready"] = True
|
|
post["pct"] = 100.0
|
|
post["hint"] = "후처리 끝 · 「상세」 가능"
|
|
post["stage"] = post.get("stage") or "done"
|
|
trials_tot = int(prog.get("trials_total") or 0)
|
|
trials_done = int(prog.get("trials_done") or 0)
|
|
trial_finished = bool(trials_tot and trials_done >= trials_tot)
|
|
rerun_run = str((rerun or {}).get("status") or "") == "running"
|
|
if rerun_run:
|
|
m["phase"] = "postprocess"
|
|
if not post.get("hint"):
|
|
post["hint"] = "후처리 재실행 중 · 「상세」는 끝난 뒤"
|
|
elif alive and trial_finished and not post.get("ready"):
|
|
m["phase"] = "postprocess"
|
|
if not post.get("stage"):
|
|
post["stage"] = "wait"
|
|
post["hint"] = "학습 끝 · 후처리 시작 대기 · 「상세」는 아직"
|
|
elif alive:
|
|
m["phase"] = "trials"
|
|
if not post.get("hint"):
|
|
post["hint"] = "학습 trial 중 · 후처리는 그 다음"
|
|
else:
|
|
m["phase"] = str(m.get("status") or "idle")
|
|
if post.get("ready") and not post.get("hint"):
|
|
post["hint"] = "후처리 끝 · 「상세」 가능"
|
|
m["postprocess"] = post
|
|
|
|
save_job(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,
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
subprocess 로 Optuna 시작. apply-best 없음.
|
|
전략 2개 이상 → scripts/run_optuna_4strat_tpe_seq.sh + STRATEGIES=
|
|
(레거시 strategy='all' → 국내 4순차, 해외 미포함)
|
|
symbol: us_momentum 종목 cfg Optuna (1종목 유니버스). 순차잡과 병행 불가.
|
|
universe_history_source: kiwoom|ls (저장 후보 이력 테이블).
|
|
candle_source: ''|kis|kiwoom — CANDLE_SOURCE / --candle-source (실매 읽기쌍과 동일).
|
|
entry_modes: 꼬리 TPE 고정 진입 align|limit_atr. 둘 다=순차 2스터디(한 스터디에 섞지 않음).
|
|
sl_modes: 돌파 TPE 고정 손절 fixed|atr. 둘 다=순차 2스터디.
|
|
"""
|
|
_ensure_dirs()
|
|
running = find_running_jobs()
|
|
if running:
|
|
raise RuntimeError(
|
|
f"이미 실행 중 job={running[0].get('job_id')} "
|
|
f"({running[0].get('strategy')}). 끝난 뒤 다시 시작하세요."
|
|
)
|
|
ext = any_optuna_python_running()
|
|
if ext:
|
|
raise RuntimeError(
|
|
"CLI/다른 Optuna 프로세스가 이미 실행 중입니다. "
|
|
"끝난 뒤 웹에서 시작하세요. (" + str(ext.get("cmdline") or "")[:120] + ")"
|
|
)
|
|
|
|
picked = _normalize_strategies(strategy=strategy, strategies=strategies)
|
|
mode = str(mode or "tpe").strip().lower() or "tpe"
|
|
trials = max(1, min(2000, int(trials or 200)))
|
|
start = str(start or "").strip()
|
|
end = str(end or "").strip()
|
|
if not start or not end:
|
|
raise ValueError("start/end 필요")
|
|
sym = str(symbol or "").strip().upper()
|
|
if sym:
|
|
if len(picked) != 1 or picked[0] != "us_momentum":
|
|
raise ValueError("종목 Optuna(--symbol)는 us_momentum 단독만 가능")
|
|
candle_source = str(candle_source or "").strip().lower() or None
|
|
if candle_source and candle_source not in ("kis", "kiwoom"):
|
|
raise ValueError("candle_source 는 kis|kiwoom|빈값만")
|
|
tick_source = str(tick_source or "").strip().lower() or None
|
|
ob_source = str(ob_source or "").strip().lower() or None
|
|
|
|
from kis_trader.backtest.universe_history_source import (
|
|
resolve_backtest_universe_history_source,
|
|
)
|
|
from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day
|
|
|
|
hist_src = resolve_backtest_universe_history_source(universe_history_source)
|
|
start = clamp_to_prev_kr_trading_day(start)
|
|
end = clamp_to_prev_kr_trading_day(end)
|
|
if start > end:
|
|
start, end = end, start
|
|
|
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
started_ts = time.time()
|
|
|
|
env = os.environ.copy()
|
|
env["PYTHONUNBUFFERED"] = "1"
|
|
env["BACKTEST_UNIVERSE_HISTORY_SOURCE"] = hist_src
|
|
|
|
_labels = {
|
|
"momentum": "모멘텀",
|
|
"us_momentum": "해외모멘텀",
|
|
"tail": "꼬리",
|
|
"breakout": "돌파",
|
|
"scalp": "스캘핑",
|
|
}
|
|
|
|
tail_ems = _normalize_tail_entry_modes(entry_modes)
|
|
if "tail" not in picked:
|
|
tail_ems = ["align"]
|
|
tail_dual = "tail" in picked and len(tail_ems) >= 2
|
|
bo_sms = _normalize_breakout_sl_modes(sl_modes)
|
|
if "breakout" not in picked:
|
|
bo_sms = ["fixed"]
|
|
bo_dual = "breakout" in picked and len(bo_sms) >= 2
|
|
use_seq = len(picked) >= 2 or tail_dual or bo_dual
|
|
|
|
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"),
|
|
]
|
|
env["START"] = start
|
|
env["END"] = end
|
|
env["TRIALS"] = str(trials)
|
|
env["MODE"] = mode
|
|
env["MIN_WIN_RATE"] = "0"
|
|
env["MIN_PF"] = "0"
|
|
env["MIN_TRADES"] = "1"
|
|
env["STRATEGIES"] = " ".join(picked)
|
|
env["UNIVERSE_HISTORY_SOURCE"] = hist_src
|
|
env["TAIL_OPTUNA_ENTRY_MODES"] = " ".join(tail_ems if "tail" in picked else ["align"])
|
|
env["BREAKOUT_OPTUNA_SL_MODES"] = " ".join(bo_sms if "breakout" in picked else ["fixed"])
|
|
if candle_source:
|
|
env["CANDLE_SOURCE"] = candle_source
|
|
if tick_source:
|
|
env["TICK_SOURCE"] = tick_source
|
|
if ob_source:
|
|
env["OB_SOURCE"] = ob_source
|
|
kind = "seq"
|
|
_lab = "+".join(_labels.get(s, s) for s in picked)
|
|
if "tail" in picked and tail_ems:
|
|
_lab = _lab.replace("꼬리", "꼬리(" + "+".join(tail_ems) + ")")
|
|
if "breakout" in picked and bo_sms:
|
|
_lab = _lab.replace("돌파", "돌파(" + "+".join(bo_sms) + ")")
|
|
label = "순차(" + _lab + ")"
|
|
strat_field = ",".join(picked)
|
|
else:
|
|
strat = picked[0]
|
|
job_id = f"opt_{ts}_{strat[:4]}"
|
|
if sym and strat == "us_momentum":
|
|
study_name = (
|
|
f"usmom_{sym}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
|
|
)
|
|
log_path = ROOT / "logs" / f"optuna_web_usmom_{sym}_{ts}.log"
|
|
label = f"해외모멘텀·종목 {sym}"
|
|
elif strat == "tail":
|
|
_em = tail_ems[0]
|
|
study_name = (
|
|
f"{strat}_{_em}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
|
|
)
|
|
log_path = ROOT / "logs" / f"optuna_web_{strat}_{_em}_{ts}.log"
|
|
label = f"꼬리({_em})"
|
|
elif strat == "breakout":
|
|
_sm = bo_sms[0]
|
|
study_name = (
|
|
f"{strat}_{_sm}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
|
|
)
|
|
log_path = ROOT / "logs" / f"optuna_web_{strat}_{_sm}_{ts}.log"
|
|
label = f"돌파({_sm})"
|
|
else:
|
|
study_name = f"{strat}_{mode}_{start.replace('-', '')}_{end.replace('-', '')}_{ts}"
|
|
log_path = ROOT / "logs" / f"optuna_web_{strat}_{ts}.log"
|
|
label = _labels.get(strat, strat)
|
|
sort_by = "score" if strat in ("momentum", "us_momentum", "scalp") else "pnl"
|
|
cmd = [
|
|
str(PY if PY.is_file() else "python3"),
|
|
"-u",
|
|
str(ROOT / "kis_trader" / "backtest" / "param_search_optuna.py"),
|
|
"--strategy", strat,
|
|
"--mode", mode,
|
|
"--start", start,
|
|
"--end", end,
|
|
"--trials", str(trials),
|
|
"--min_trades", "1",
|
|
"--min_win_rate", "0",
|
|
"--min_pf", "0",
|
|
"--orderbook-filter", "off",
|
|
"--no-progress",
|
|
"--study-name", study_name,
|
|
"--sort-by", sort_by,
|
|
"--universe-history-source", hist_src,
|
|
]
|
|
if 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
|
|
|
|
log_f = open(log_path, "w", encoding="utf-8")
|
|
# 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_name": study_name,
|
|
"symbol": sym or None,
|
|
"universe_history_source": hist_src,
|
|
"candle_source": candle_source,
|
|
"tick_source": tick_source,
|
|
"ob_source": ob_source,
|
|
"tail_entry_modes": tail_ems if "tail" in picked else None,
|
|
"breakout_sl_modes": bo_sms if "breakout" in picked else None,
|
|
"log_path": str(log_path),
|
|
"pid": int(proc.pid),
|
|
"status": "running",
|
|
"started_at": _now_iso(),
|
|
"started_ts": started_ts,
|
|
"finished_at": None,
|
|
"finished_ts": None,
|
|
"result_json": None,
|
|
"briefing_md": None,
|
|
"error": None,
|
|
"apply_best": False,
|
|
"cmd": " ".join(cmd)[:500],
|
|
}
|
|
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 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("이미 이 잡 후처리 재실행 중")
|
|
path = str(meta.get("result_json") or "")
|
|
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)
|