#!/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, Tuple 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: kind = str(m.get("kind") or "") st = str(m.get("status") or "").strip().lower() rs = m.get("refine_state") if isinstance(m.get("refine_state"), dict) else {} prog = m.get("progress") if isinstance(m.get("progress"), dict) else {} refine_phase = str(rs.get("phase") or prog.get("refine_phase") or "").strip().lower() # 1·2차 refine 진행 중 — 1차 study 「목표 도달」을 상태에 붙이지 않음 if kind == "mode_refine" and st == "running" and refine_phase in ("phase1", "phase2"): done = prog.get("trials_done") tot = prog.get("trials_total") or m.get("trials") tag = "1차" if refine_phase == "phase1" else "2차" if done is not None and tot: m["leftover_note"] = f"{tag} TPE 진행 {done}/{tot}" else: m["leftover_note"] = f"{tag} TPE 진행 중" m["can_continue"] = False m["can_confirm"] = False if refine_phase == "phase2": try: st_goal = int(m.get("study_trials") or 0) except (TypeError, ValueError): st_goal = 0 if st_goal > 0 and done is not None: m["n_complete"] = int(done) m["study_trials"] = st_goal return 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", "mode_refine") 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 filter_redundant_refine1_jobs(jobs: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """2차 refine import 가 있으면 동기간 1차 import 는 목록에서 숨김 (중간산출).""" refine2_keys: set = set() for j in jobs: sn = str(j.get("study_name") or "") if "refine2" in sn: refine2_keys.add(( str(j.get("strategy") or ""), str(j.get("start") or "")[:10], str(j.get("end") or "")[:10], )) if not refine2_keys: return jobs out: List[Dict[str, Any]] = [] for j in jobs: sn = str(j.get("study_name") or "") if "refine1" in sn and str(j.get("kind") or "") == "import": key = ( str(j.get("strategy") or ""), str(j.get("start") or "")[:10], str(j.get("end") or "")[:10], ) if key in refine2_keys: continue out.append(j) return out def list_jobs(limit: int = 30, sort: str = "started") -> List[Dict[str, Any]]: _ensure_dirs() paths = list(JOBS_DIR.glob("*.json")) paths.sort(key=lambda x: x.stat().st_mtime, reverse=True) out: List[Dict[str, Any]] = [] # 파일이 많을 때 전체 파싱을 막기 위해 최근 100개(limit 30 기준 넉넉히)만 메모리에 올림 for p in paths[:100]: 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) out = filter_redundant_refine1_jobs(out) 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) if not isinstance(m.get("period_info"), dict): try: m["period_info"] = _build_period_info(m) except Exception: pass prog = m.get("progress") if isinstance(m.get("progress"), dict) else {} post = m.get("postprocess") if isinstance(m.get("postprocess"), dict) else {} out = { "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"), "refine_phase": prog.get("refine_phase"), "label": prog.get("label"), }, "postprocess": { "pct": post.get("pct"), "ready": post.get("ready"), }, "join_cmd": m.get("join_cmd") or "", "join_cmd_ps": m.get("join_cmd_ps") or "", "web_cmd": m.get("web_cmd") or m.get("cmd") or "", "web_cmd_full": m.get("web_cmd_full") or "", "mode": m.get("mode"), "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 "", "join_cmds_all": m.get("join_cmds_all") or [], "seq_refine_cmds": m.get("seq_refine_cmds") or [], "period_info": m.get("period_info") if isinstance(m.get("period_info"), dict) else None, "kind": m.get("kind"), "study_name": m.get("study_name") or m.get("active_study_name") or "", "study_short": m.get("study_short") or _study_short_note( str(m.get("study_name") or m.get("active_study_name") or "") ), "source": m.get("source"), "sort_by": m.get("sort_by"), "symbol": m.get("symbol"), "universe_history_source": m.get("universe_history_source"), "candle_source": m.get("candle_source"), "tick_source": m.get("tick_source"), "ob_source": m.get("ob_source"), "tail_entry_modes": m.get("tail_entry_modes"), "breakout_sl_modes": m.get("breakout_sl_modes"), "breakout_ob_modes": m.get("breakout_ob_modes"), "tpe_includes_orderbook": (m.get("result_summary") or {}).get("tpe_includes_orderbook"), } use_rust = m.get("use_rust") if use_rust is True: out["engine_label"] = "Rust⚡" elif use_rust is False: out["engine_label"] = "Python🐢" else: # 과거 작업(use_rust 필드가 없는 JSON)을 위한 폴백 로직 is_tail = "tail" in (m.get("strategy") or "") if is_tail: out["engine_label"] = "Rust⚡" else: out["engine_label"] = "Python🐢" return out 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//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[0-9.]+)\s+step=(?P\d+)\s+" r"total=(?P\d+)\s+stage=(?P\S+)\s+axis=(?P\d+)/(?P\d+)" r"(?P.*)$" ) 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"\[(?Pus_momentum|momentum|tail|breakout|scalp)" r"(?:/(?P[a-z0-9_]+))?\] START" r"(?:[^\n]*study=(?P[^\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", "ob_mode", "extra", "refine_state_path"): 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" from kis_trader.backtest.optuna_common import ( normalize_optuna_sort_by, resolve_optuna_min_trades, ) sort_by = normalize_optuna_sort_by(meta.get("sort_by") or "score", web=True) _mt = resolve_optuna_min_trades(start, end, strat) argv = [ py_bin, "-u", "kis_trader/backtest/param_search_optuna.py", "--strategy", strat, "--mode", mode, "--start", start, "--end", end, "--trials", trials, "--min_trades", str(int(_mt["min_trades"])), "--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 _refine_runner_argv( meta: Dict[str, Any], *, strategy: str, job_id: str, entry_mode: Optional[str] = None, sl_mode: Optional[str] = None, ob_mode: Optional[str] = None, skip_phase1: bool = False, phase1_json: Optional[str] = None, phase1_study: Optional[str] = None, py_bin: str = ".venv/bin/python", ) -> List[str]: """다른 PC — optuna_mode_refine_runner.py (1·2차 연쇄). 상대경로.""" from kis_trader.backtest.optuna_common import ( normalize_optuna_sort_by, resolve_optuna_min_trades, ) strat = str(strategy or "").strip().lower() mode = str(meta.get("mode") or "tpe").strip() or "tpe" start = str(meta.get("start") or "") end = str(meta.get("end") or "") trials = str(int(meta.get("trials") or 200)) hist = str(meta.get("universe_history_source") or "kiwoom").strip() or "kiwoom" sort_by = normalize_optuna_sort_by(meta.get("sort_by") or "score", web=True) _mt = resolve_optuna_min_trades(start, end, strat) argv = [ py_bin, "-u", "kis_trader/backtest/optuna_mode_refine_runner.py", "--job-id", str(job_id), "--strategy", strat, "--mode", mode, "--start", start, "--end", end, "--trials", trials, "--sort-by", sort_by, "--min-trades", str(int(_mt["min_trades"])), "--universe-history-source", hist, ] try: st_goal = int(meta.get("study_trials") or 0) except (TypeError, ValueError): st_goal = 0 if st_goal > 0: argv.extend(["--study-trials", str(st_goal)]) if strat == "tail" and entry_mode: argv.extend(["--entry-mode", str(entry_mode)]) if strat == "breakout": argv.extend([ "--sl-mode", str(sl_mode or "fixed"), "--ob-mode", str(ob_mode or "off"), ]) sym = str(meta.get("symbol") or "").strip().upper() if sym and strat == "us_momentum": argv.extend(["--symbol", sym]) 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 skip_phase1: if phase1_study: argv.extend(["--skip-phase1", "--phase1-study", str(phase1_study)]) elif phase1_json: argv.extend(["--skip-phase1", "--phase1-json", str(phase1_json)]) return argv def _seq_env_export_lines(meta: Dict[str, Any]) -> List[str]: """순차 bash 재현용 env (레포 루트 기준).""" 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() and s.strip() not in ("seq", "all")] 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"]) pairs: List[Tuple[str, str]] = [ ("START", str(meta.get("start") or "")), ("END", str(meta.get("end") or "")), ("TRIALS", str(int(meta.get("trials") or 200))), ("MODE", str(meta.get("mode") or "tpe")), ("SORT_BY", str(meta.get("sort_by") or "score")), ("UNIVERSE_HISTORY_SOURCE", str(meta.get("universe_history_source") or "kiwoom")), ("STRATEGIES", " ".join(picked)), ("TAIL_OPTUNA_ENTRY_MODES", " ".join(tail_ems)), ("BREAKOUT_OPTUNA_SL_MODES", " ".join(bo_sms)), ("BREAKOUT_OPTUNA_OB_MODES", " ".join(bo_oms)), ] try: st = int(meta.get("study_trials") or 0) except (TypeError, ValueError): st = 0 if st > 0: pairs.append(("STUDY_TRIALS", str(st))) jid = str(meta.get("job_id") or "").strip() if jid: pairs.append(("OPTUNA_SEQ_JOB_ID", jid)) for ek, mk in ( ("CANDLE_SOURCE", "candle_source"), ("TICK_SOURCE", "tick_source"), ("OB_SOURCE", "ob_source"), ): v = str(meta.get(mk) or "").strip() if v: pairs.append((ek, v)) lines: List[str] = [] for k, v in pairs: if v: lines.append(f"export {k}={shlex.quote(v)}") return lines def _build_web_cmd_full(meta: Dict[str, Any]) -> str: kind = str(meta.get("kind") or "") if kind == "mode_refine": strat = str(meta.get("strategy") or "").split(",")[0].strip().lower() jid = str(meta.get("job_id") or "manual_refine") tail_ems = list(meta.get("tail_entry_modes") or []) bo_sms = list(meta.get("breakout_sl_modes") or ["fixed"]) bo_oms = list(meta.get("breakout_ob_modes") or ["off"]) em = tail_ems[0] if strat == "tail" and tail_ems else None sm = bo_sms[0] if strat == "breakout" else None om = bo_oms[0] if strat == "breakout" else None argv = _refine_runner_argv( meta, strategy=strat, job_id=jid, entry_mode=em, sl_mode=sm, ob_mode=om, py_bin=".venv/bin/python", ) return "# 레포 루트 (1·2차 단일)\n" + _quote_cmd(argv) if kind not in ("seq", "seq4"): web_argv = meta.get("cmd_argv") if isinstance(web_argv, list) and web_argv: return "# 레포 루트\n" + _quote_cmd([str(x) for x in web_argv]) return str(meta.get("cmd") or "").strip() env_lines = _seq_env_export_lines(meta) body = "\n".join(env_lines) + "\nbash scripts/run_optuna_4strat_tpe_seq.sh" return "# 레포 루트 (순차 1·2차 전체 — 이 VM과 동일 설정)\n" + body def _parse_seq_phase1_studies_from_log(log_path: str) -> List[str]: """마스터/refine 로그 순서대로 OPTUNA_PHASE1_STUDY= 수집.""" if not log_path or not Path(log_path).is_file(): return [] try: data = Path(log_path).read_bytes() if len(data) > 800_000: data = data[-800_000:] text = data.decode("utf-8", errors="replace") except Exception: return [] out: List[str] = [] seen: set = set() for m in re.finditer(r"OPTUNA_PHASE1_STUDY=(\S+)", text): sy = str(m.group(1) or "").strip() if sy and sy not in seen: seen.add(sy) out.append(sy) return out def _parse_seq_refine_states_from_log(log_path: str) -> List[Dict[str, Any]]: """마스터 로그 STATE= 경로 순서 → refine_state 내용.""" if not log_path or not Path(log_path).is_file(): return [] try: data = Path(log_path).read_bytes() if len(data) > 800_000: data = data[-800_000:] text = data.decode("utf-8", errors="replace") except Exception: return [] out: List[Dict[str, Any]] = [] seen: set = set() for m in re.finditer(r"STATE=(\S+refine_state\.json)", text): sp = str(m.group(1) or "").strip() if not sp or sp in seen: continue seen.add(sp) p = Path(sp) if not p.is_file(): p = ROOT / sp row: Dict[str, Any] = {"state_path": str(p)} if p.is_file(): try: st = json.loads(p.read_text(encoding="utf-8")) if isinstance(st, dict): row.update(st) except Exception: pass out.append(row) return out def _resolve_step_phase1( meta: Dict[str, Any], *, step_job_id: str, step_i: int, state_row: Optional[Dict[str, Any]] = None, log_phase1_studies: Optional[List[str]] = None, log_result_jsons: Optional[List[str]] = None, ) -> Dict[str, Any]: """1차 study/JSON — DB·state·로그·job_id 조회.""" from kis_trader.backtest.optuna_study_store import ( load_phase1_study_for_job, phase1_payload_ready, ) st = state_row if isinstance(state_row, dict) else {} p1_study = str(st.get("phase1_study") or "").strip() p1_json = str(st.get("phase1_json") or "").strip() idx = max(0, int(step_i) - 1) log_studies = list(log_phase1_studies or []) log_jsons = list(log_result_jsons or []) if not p1_study and idx < len(log_studies): p1_study = str(log_studies[idx] or "").strip() if not p1_study and p1_json: p1_study = str(_phase1_study_from_result_json(p1_json) or "").strip() if not p1_study: p1_study = str(load_phase1_study_for_job(step_job_id) or "").strip() if not p1_json and idx < len(log_jsons): p1_json = str(log_jsons[idx] or "").strip() saved = list(meta.get("seq_refine_steps") or []) if not p1_study and idx < len(saved): p1_study = str((saved[idx] or {}).get("phase1_study") or "").strip() db_ok = bool(p1_study and phase1_payload_ready(p1_study)) file_ok = bool(p1_json and Path(p1_json).is_file()) return { "phase1_study": p1_study or None, "phase1_json": p1_json or None, "phase1_db": db_ok, "done": db_ok or file_ok, } def _parse_seq_result_jsons_from_log(log_path: str) -> List[str]: if not log_path or not Path(log_path).is_file(): return [] try: data = Path(log_path).read_bytes() if len(data) > 800_000: data = data[-800_000:] text = data.decode("utf-8", errors="replace") except Exception: return [] out: List[str] = [] for m in re.finditer(r"OPTUNA_RESULT_JSON=(\S+)", text): p = str(m.group(1) or "").strip() if not p: continue if out and out[-1] == p: continue out.append(p) return out def _phase1_study_from_result_json(json_path: str) -> Optional[str]: """2차 결과 JSON → 1차 study_name (refine2→refine1 치환).""" path = str(json_path or "").strip() 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 if not isinstance(data, dict): return None sn = str(data.get("optuna_study_name") or data.get("study_name") or "").strip() if not sn: return None if "refine2" in sn: return sn.replace("refine2", "refine1", 1) if "refine1" in sn: return sn return None def _step_refine_params(meta: Dict[str, Any], row: Dict[str, str]) -> Tuple[str, Optional[str], Optional[str], Optional[str]]: strat = str(row.get("strategy") or "").strip().lower() extra = str(row.get("extra") or "").strip() em = sm = om = None if strat == "tail": em = extra or "align" elif 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") return strat, em, sm, om def _build_seq_refine_cmds(meta: Dict[str, Any]) -> List[Dict[str, Any]]: """순차·단일 1·2차 — PC별 병렬용 refine runner 명령 목록.""" kind = str(meta.get("kind") or "") catalog = _seq_step_catalog(meta) if kind == "mode_refine" and not catalog: strat = str(meta.get("strategy") or "").split(",")[0].strip().lower() if strat: catalog = [{"strategy": strat, "extra": ""}] if strat == "tail": ems = list(meta.get("tail_entry_modes") or ["align"]) catalog = [{"strategy": "tail", "extra": str(ems[0] if ems else "align")}] elif strat == "breakout": from kis_trader.backtest.optuna_breakout_tpe_space import breakout_tpe_study_extra sms = list(meta.get("breakout_sl_modes") or ["fixed"]) oms = list(meta.get("breakout_ob_modes") or ["off"]) catalog = [{ "strategy": "breakout", "extra": breakout_tpe_study_extra(sms[0], oms[0]), }] if not catalog: return [] base_jid = str(meta.get("job_id") or "manual").replace(" ", "_") log_path = str(meta.get("log_path") or "") done_jsons = _parse_seq_result_jsons_from_log(log_path) log_p1_studies = _parse_seq_phase1_studies_from_log(log_path) state_rows = _parse_seq_refine_states_from_log(log_path) rows: List[Dict[str, Any]] = [] for i, row in enumerate(catalog, start=1): strat, em, sm, om = _step_refine_params(meta, row) extra = str(row.get("extra") or "") lab_parts = [strat] if extra: lab_parts.append(extra) label = "/".join(lab_parts) slug = re.sub(r"[^a-z0-9_]+", "_", f"{strat}_{extra or 'base'}").strip("_")[:32] step_jid = f"{base_jid}_{slug}_{i}" state_row = state_rows[i - 1] if i - 1 < len(state_rows) else None p1 = _resolve_step_phase1( meta, step_job_id=step_jid, step_i=i, state_row=state_row, log_phase1_studies=log_p1_studies, log_result_jsons=done_jsons, ) p1_study = str(p1.get("phase1_study") or "").strip() p1_json = str(p1.get("phase1_json") or "").strip() p1_db = bool(p1.get("phase1_db")) p1_done = bool(p1.get("done")) argv_full = _refine_runner_argv( meta, strategy=strat, job_id=step_jid, entry_mode=em, sl_mode=sm, ob_mode=om, py_bin=".venv/bin/python", ) argv_ps = _refine_runner_argv( meta, strategy=strat, job_id=step_jid, entry_mode=em, sl_mode=sm, ob_mode=om, py_bin="python", ) phase2_note = "" cmd_p2 = "" cmd_p2_ps = "" if p1_study and p1_db: phase2_note = f"1차 DB OK · --phase1-study {p1_study}" elif p1_study: phase2_note = f"1차 study · --phase1-study {p1_study} (DB payload 확인)" elif p1_json and Path(p1_json).is_file(): phase2_note = f"1차 JSON(로컬): {p1_json}" else: phase2_note = ( "1차 완료 후 --phase1-study (MariaDB payload · DB_HOST=141 공유)" ) p1_study = "PHASE1_STUDY_NAME_HERE" argv_p2 = _refine_runner_argv( meta, strategy=strat, job_id=step_jid + "_p2only", entry_mode=em, sl_mode=sm, ob_mode=om, skip_phase1=True, phase1_study=p1_study if p1_study else None, phase1_json=p1_json if (not p1_study and p1_json) else None, py_bin=".venv/bin/python", ) argv_p2_ps = _refine_runner_argv( meta, strategy=strat, job_id=step_jid + "_p2only", entry_mode=em, sl_mode=sm, ob_mode=om, skip_phase1=True, phase1_study=p1_study if p1_study else None, phase1_json=p1_json if (not p1_study and p1_json) else None, py_bin="python", ) cmd_p2 = _quote_cmd(argv_p2) cmd_p2_ps = _ps_join_script(argv_p2_ps) rows.append({ "step": i, "label": label, "strategy": strat, "extra": extra, "job_id": step_jid, "cmd": _quote_cmd(argv_full), "cmd_ps": _ps_join_script(argv_ps), "cmd_phase2": cmd_p2, "cmd_phase2_ps": cmd_p2_ps, "phase1_study": p1_study or None, "phase1_json": p1_json or None, "phase1_db": p1_db, "phase2_note": phase2_note, "done": p1_done, }) return rows 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() web_cmd_full = _build_web_cmd_full(m) seq_refine_cmds = _build_seq_refine_cmds(m) hints = [ "레포 루트 · git 커밋 동일 · MariaDB(kis_optuna) 공유 필수.", "병렬: 「전략별 1·2차」를 PC마다 1줄씩 — RAM 분산. study 이름은 runner가 PC마다 새로 붙임(각 PC 독립 1→2).", "같은 study에 trial 추가: 아래 join(param_search). Optuna trial은 DB에 쌓임.", "2차만: --phase1-study (MariaDB payload_json). 파일 scp 불필요 · DB_HOST=141.", "순차 전체 재실행: web_cmd_full (env 포함 bash).", ] 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 or kind == "mode_refine": hints.append("현재 study 없음 — 첫 스텝 START 후 join 갱신.") join_all: List[Dict[str, str]] = [] refine_state = m.get("refine_state") if isinstance(m.get("refine_state"), dict) else {} for sk, label in ( ("phase1_study", "1차"), ("phase2_study", "2차"), ): sy = str(refine_state.get(sk) or "").strip() if not sy or not strat: continue a_sh = _join_argv_for_study( m, strategy=strat, study=sy, extra=extra, py_bin=".venv/bin/python", ) a_ps = _join_argv_for_study( m, strategy=strat, study=sy, extra=extra, py_bin="python", ) join_all.append({ "strategy": strat, "extra": extra or label, "study": sy, "cmd": _quote_cmd(a_sh), "cmd_ps": _ps_join_script(a_ps), }) log_path = str(m.get("log_path") or "") if is_seq and log_path and not join_all: 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 = 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, "web_cmd_full": web_cmd_full, "join_cmd": join_cmd, "join_cmd_ps": join_cmd_ps, "join_hint": "\n".join(hints), "join_study": study, "join_cmds_all": join_all, "seq_refine_cmds": seq_refine_cmds, } 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 _parse_study_name_from_log(log_path: str) -> str: """refine/param 로그 CMD 줄에서 --study-name 추출.""" p = Path(str(log_path or "")) if not p.is_file(): return "" try: head = p.read_text(encoding="utf-8", errors="replace")[:4000] except Exception: return "" m = re.search(r"--study-name\s+(\S+)", head) return str(m.group(1) or "").strip() if m else "" def _resolve_refine_active_study(st: Dict[str, Any]) -> str: """refine_state → 현재 단계 study (phase2_study 없으면 derive).""" phase = str(st.get("phase") or "").strip().lower() p1 = str(st.get("phase1_study") or "").strip() p2 = str(st.get("phase2_study") or "").strip() if phase == "phase2": if p2: return p2 if "refine1" in p1: return p1.replace("refine1", "refine2", 1) p2_log = str(st.get("phase2_log") or "") if p2_log: sy = _parse_study_name_from_log(p2_log) if sy: return sy return p1 def _trial_to_result_row(trial: Any) -> Optional[Dict[str, Any]]: """Optuna trial → results 행 (mode pool·밴드 근접용).""" ua = dict(getattr(trial, "user_attrs", None) or {}) if ua.get("total_pnl") is None: return None raw = ua.get("merged_json") or ua.get("params_json") or "{}" try: merged = json.loads(str(raw)) except Exception: merged = dict(getattr(trial, "params", None) or {}) if not isinstance(merged, dict): merged = dict(getattr(trial, "params", None) or {}) params = dict(getattr(trial, "params", None) or {}) try: from kis_trader.backtest.optuna_common import optuna_score_fields_from_trial score_fields = optuna_score_fields_from_trial(trial) except Exception: score_fields = {} row: Dict[str, Any] = { "params": params, "merged_params": merged, "total_trades": ua.get("total_trades"), "win_rate": ua.get("win_rate"), "total_pnl": ua.get("total_pnl"), "pf": ua.get("pf"), "mdd": ua.get("mdd"), "optuna_trial_number": getattr(trial, "number", None), **score_fields, } try: from kis_trader.backtest.optuna_common import stability_fields_from_trial_attrs row.update(stability_fields_from_trial_attrs(trial)) except Exception: pass return row def _live_mode_top3( study_name: str, *, start: str, end: str, grid_keys: Optional[List[str]] = None, n: int = 3, ) -> Optional[Dict[str, Any]]: """진행 중 study — PnL 양수 pool 밴드 근접 mode Top3 + 일평균.""" name = str(study_name or "").strip() if not name: return None try: import optuna from kis_trader.backtest.optuna_common import resolve_optuna_storage_url from kis_trader.backtest.optuna_mode_combo import build_results_mode_consensus_tier storage = resolve_optuna_storage_url() study = optuna.load_study(study_name=name, storage=storage) complete = optuna.trial.TrialState.COMPLETE results: List[Dict[str, Any]] = [] for tr in list(study.trials or []): if getattr(tr, "state", None) != complete: continue row = _trial_to_result_row(tr) if row is not None: results.append(row) if not results: return None data = {"start": start, "end": end, "results": results, "grid_keys": list(grid_keys or [])} mode_rows, mode_meta = build_results_mode_consensus_tier( results, top_n=max(1, int(n)), grid_keys=grid_keys, data=data, ) top3: List[Dict[str, Any]] = [] for i, row in enumerate(mode_rows[: max(1, int(n))], start=1): _annotate_row_period_daily(row, start=start, end=end) met = _row_metrics(row, label=f"mode #{i}", source="consensus", data=data) if not met: continue for k in ("consensus_match_pct", "consensus_match_n", "consensus_match_of", "consensus_band_err"): if row.get(k) is not None: met[k] = row.get(k) top3.append(met) if not top3: return None ps = str(start or "").strip()[:10] pe = str(end or "").strip()[:10] if not (ps and pe): ps, pe = _period_from_study(name) return { "mode_top3": top3, "mode_pool_size": mode_meta.get("mode_pool_size"), "mode_band_axes": mode_meta.get("band_axes"), "period_range": _fmt_period_range(ps, pe), } except Exception: return None def _breakout_import_label(study: str, data: Dict[str, Any]) -> str: """CLI import 잡 — refine1/2·sl_mode 구분 라벨.""" sy = str(study or "") if "refine2" in sy: return "돌파·2차TPE" if "refine1" in sy: return "돌파·1차TPE" sm = "" if "_atr_" in sy or sy.endswith("_atr"): sm = "atr" elif "_fixed_" in sy: 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() return f"돌파({sm})" if sm else "돌파" def _study_short_note(study: str) -> str: sy = str(study or "").strip() if not sy: return "" if "refine2" in sy: return "refine2" if "refine1" in sy: return "refine1" if len(sy) <= 36: return sy return "…" + sy[-34:] def _trial_to_learn_row(trial: Any) -> Dict[str, Any]: ua = dict(getattr(trial, "user_attrs", None) or {}) params: Dict[str, Any] = {} raw = ua.get("params_json") if raw: try: params = json.loads(str(raw)) except Exception: params = {} return { "optuna_trial_number": getattr(trial, "number", None), "total_pnl": ua.get("total_pnl"), "win_rate": ua.get("win_rate"), "pf": ua.get("pf"), "total_trades": ua.get("total_trades"), "score": getattr(trial, "value", None), "params": params, } def _annotate_row_period_daily(row: Dict[str, Any], *, start: str, end: str) -> None: from kis_trader.utils.kr_trading_day import count_kr_trading_days try: n_days = count_kr_trading_days(str(start or ""), str(end or "")) except Exception: n_days = 1 n_days = max(1, int(n_days or 1)) try: pnl = float(row.get("total_pnl") or 0) except (TypeError, ValueError): pnl = 0.0 row["n_period_trading_days"] = n_days row["period_daily_avg_pnl"] = round(pnl / float(n_days), 2) def _period_from_study(study_name: str) -> tuple: """MariaDB optuna_study_result / payload_json 에서 study 백테 기간.""" name = str(study_name or "").strip() if not name: return "", "" try: from kis_trader.backtest.optuna_study_store import load_payload_dict, load_row row = load_row(name) if row: s = str(row.get("start_date") or "").strip()[:10] e = str(row.get("end_date") or "").strip()[:10] if s and e: return s, e data = load_payload_dict(name) if data: s = str(data.get("start") or "").strip()[:10] e = str(data.get("end") or "").strip()[:10] if s and e: return s, e except Exception: pass return "", "" def _fmt_period_range(start: str, end: str) -> str: s = str(start or "").strip()[:10] e = str(end or "").strip()[:10] if s and e: return f"{s}~{e}" if s: return s return "—" def _period_slot(label: str, start: str, end: str, study: str = "") -> Dict[str, str]: s = str(start or "").strip()[:10] e = str(end or "").strip()[:10] return { "label": label, "start": s, "end": e, "range": _fmt_period_range(s, e), "study": str(study or "").strip(), } def _infer_refine_study_pair(study_name: str) -> Tuple[str, str]: """refine1/refine2 study 이름 쌍 추론 (import·완료 잡용).""" sn = str(study_name or "").strip() if not sn: return "", "" if "refine2" in sn: p2 = sn p1 = sn.replace("refine2", "refine1", 1) return p1, p2 if "refine1" in sn: p1 = sn p2 = sn.replace("refine1", "refine2", 1) return p1, p2 return "", "" def _build_period_info(meta: Dict[str, Any]) -> Dict[str, Any]: """잡(메인) · 1·2차 refine · 활성 study 백테 기간 — UI 강조용.""" master_s = str(meta.get("start") or "").strip()[:10] master_e = str(meta.get("end") or "").strip()[:10] kind = str(meta.get("kind") or "") rs = meta.get("refine_state") if isinstance(meta.get("refine_state"), dict) else {} out: Dict[str, Any] = { "master": _period_slot("잡(메인)", master_s, master_e), "phase1": _period_slot("1차 TPE", "", "", ""), "phase2": _period_slot("2차 TPE", "", "", ""), "active": _period_slot("활성 study", "", "", ""), "has_refine": False, "same_all": True, "mismatch": False, "mismatch_notes": [], } if kind == "mode_refine" or rs: out["has_refine"] = True p1_study = str(rs.get("phase1_study") or "").strip() p2_study = str(rs.get("phase2_study") or "").strip() phase = str(rs.get("phase") or "").strip().lower() if phase == "phase2" and not p2_study: cand = _resolve_refine_active_study(rs) if cand and cand != p1_study: p2_study = cand p1_s = str(rs.get("start") or master_s)[:10] p1_e = str(rs.get("end") or master_e)[:10] db_s, db_e = _period_from_study(p1_study) if db_s and db_e: p1_s, p1_e = db_s, db_e out["phase1"] = _period_slot("1차 TPE", p1_s, p1_e, p1_study) p2_s, p2_e = p1_s, p1_e if p2_study: db2_s, db2_e = _period_from_study(p2_study) if db2_s and db2_e: p2_s, p2_e = db2_s, db2_e out["phase2"] = _period_slot("2차 TPE", p2_s, p2_e, p2_study) act_study = str(meta.get("active_study_name") or _resolve_refine_active_study(rs)) act_s, act_e = master_s, master_e if act_study: ds, de = _period_from_study(act_study) if ds and de: act_s, act_e = ds, de elif phase == "phase2": act_s, act_e = p2_s, p2_e elif phase == "phase1": act_s, act_e = p1_s, p1_e out["active"] = _period_slot("활성 study", act_s, act_e, act_study) else: act_study = str(meta.get("active_study_name") or meta.get("study_name") or "").strip() p1_study, p2_study = _infer_refine_study_pair(act_study) if not p1_study and meta.get("result_json"): p1_from_json = _phase1_study_from_result_json(str(meta.get("result_json") or "")) if p1_from_json: p1_study = str(p1_from_json).strip() if "refine1" in p1_study: p2_study = p1_study.replace("refine1", "refine2", 1) if p2_study and "refine2" in act_study: out["has_refine"] = True p_s, p_e = master_s, master_e ds, de = _period_from_study(act_study) if ds and de: p_s, p_e = ds, de elif master_s and master_e: p_s, p_e = master_s, master_e out["phase1"] = _period_slot("1차 TPE", p_s, p_e, p1_study) out["phase2"] = _period_slot("2차 TPE", p_s, p_e, p2_study or act_study) out["active"] = _period_slot("2차 study", p_s, p_e, act_study) elif act_study: act_s, act_e = master_s, master_e ds, de = _period_from_study(act_study) if ds and de: act_s, act_e = ds, de out["active"] = _period_slot("study", act_s, act_e, act_study) notes: List[str] = [] if out["has_refine"]: p1 = out["phase1"] p2 = out["phase2"] if p1.get("start") and (p1["start"], p1["end"]) != (master_s, master_e): notes.append("1차≠잡") if p2.get("study") and (p2["start"], p2["end"]) != (master_s, master_e): notes.append("2차≠잡") if p2.get("study") and p1.get("start") and (p2["start"], p2["end"]) != (p1["start"], p1["end"]): notes.append("1차≠2차") ranges = { (master_s, master_e), } if out["phase1"].get("start"): ranges.add((out["phase1"]["start"], out["phase1"]["end"])) if out["phase2"].get("study") and out["phase2"].get("start"): ranges.add((out["phase2"]["start"], out["phase2"]["end"])) ranges = {r for r in ranges if r[0] and r[1]} out["same_all"] = len(ranges) <= 1 out["mismatch"] = bool(notes) or len(ranges) > 1 out["mismatch_notes"] = notes if out["has_refine"] and out["same_all"] and master_s and master_e: out["refine_note"] = ( "1·2차 TPE 모두 동일 백테 기간(거래일 합산 아님 · 1차=넓은 탐색 → 2차=밴드 축소 재탐색)" ) else: out["refine_note"] = "" return out def _live_study_top3( study_name: str, *, start: str, end: str, n: int = 3, label_prefix: str = "learn", ) -> Optional[Dict[str, Any]]: """진행 중 study — COMPLETE trial TopN (분포·일평균 미리보기).""" name = str(study_name or "").strip() if not name: return None try: import optuna from kis_trader.backtest.optuna_common import resolve_optuna_storage_url from kis_trader.backtest.optuna_study_store import count_study_states storage = resolve_optuna_storage_url() study = optuna.load_study(study_name=name, storage=storage) complete = optuna.trial.TrialState.COMPLETE ok = [ t for t in list(study.trials or []) if getattr(t, "state", None) == complete and getattr(t, "value", None) is not None ] ok.sort(key=lambda t: float(t.value), reverse=True) top3: List[Dict[str, Any]] = [] for i, tr in enumerate(ok[: max(1, int(n))], start=1): row = _trial_to_learn_row(tr) _annotate_row_period_daily(row, start=start, end=end) met = _row_metrics(row, label=f"{label_prefix} #{i}", source="learn") if met: top3.append(met) n_c, n_r, n_f = count_study_states(study) ps = str(start or "").strip()[:10] pe = str(end or "").strip()[:10] if not (ps and pe): ps, pe = _period_from_study(name) return { "study_name": name, "top3_learn": top3, "n_complete": n_c, "n_finished": n_f, "n_running": n_r, "period_start": ps, "period_end": pe, "period_range": _fmt_period_range(ps, pe), } except Exception: return None 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_filter_enabled")) if whip_on is None: whip_on = _as_bool_opt(p.get("whipsaw_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", "period_daily_avg_pnl", "period_daily_avg_pct", "n_period_trading_days", ): 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_common import ( annotate_optuna_period_daily_avg, resolve_results_stable, resolve_results_mode_consensus, ) from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n annotate_optuna_period_daily_avg(data) top_n = resolve_post_top_n(10) gated = list(data.get("results_gated") or []) stable, stable_gates_resolved = resolve_results_stable(data, top_n=top_n) mode_consensus, mode_consensus_meta = resolve_results_mode_consensus(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: _annotate_row_period_daily( mode_row, start=str(data.get("start") or ""), end=str(data.get("end") or ""), ) try: budget = float( data.get("total_budget_krw") or data.get("total_budget") or 0, ) except (TypeError, ValueError): budget = 0.0 n_days = int(mode_row.get("n_period_trading_days") or 1) pnl_mc = float(mode_row.get("total_pnl") or 0) if budget > 0 and n_days > 0: mode_row["period_daily_avg_pct"] = round( pnl_mc / budget * 100.0 / float(n_days), 3, ) 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) top5_consensus: List[Dict[str, Any]] = [] for i, row in enumerate(mode_consensus[:top_n], start=1): m = _row_metrics(row, label=f"mode #{i}", source="consensus", data=data) if m: m["rank"] = i if row.get("consensus_match_pct") is not None: m["consensus_match_pct"] = row.get("consensus_match_pct") m["consensus_match_n"] = row.get("consensus_match_n") m["consensus_match_of"] = row.get("consensus_match_of") top5_consensus.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_trading_days": data.get("n_trading_days"), "min_trades": data.get("min_trades"), "min_trades_per_day": data.get("min_trades_per_day"), "n_gated": len(gated), "n_stable": len(stable), "n_consensus": len(mode_consensus), "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"), "pool_kind": mc.get("pool_kind"), "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_consensus": top5_consensus, "mode_consensus_meta": mode_consensus_meta or data.get("mode_consensus_meta"), "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 []) if s in ("consensus", "mode_consensus", "results_mode", "mode_top"): from kis_trader.backtest.optuna_common import resolve_results_mode_consensus from kis_trader.backtest.optuna_postprocess_topn import resolve_post_top_n pool, _ = resolve_results_mode_consensus(data, top_n=resolve_post_top_n(10)) return list(pool or []) return list(data.get("results") or data.get("results_all") or []) def _overlay_trial_ob_whip_on_ui(ui: Dict[str, Any], params: Dict[str, Any]) -> None: """Optuna trial 호가·휩쏘 — DB snap 기본값 덮어쓰기 (백테탭 폼·RO 정합).""" p = params if isinstance(params, dict) else {} ob = p.get("_orderbook_filter_enabled") if ob is None: ob = p.get("ob_filter_enabled") if ob is not None: ui["ob_filter_enabled"] = bool(ob) pg = p.get("_program_filter_enabled") if pg is None: pg = p.get("pg_filter_enabled") if pg is not None: ui["pg_filter_enabled"] = bool(pg) whip = p.get("whipsaw_filter_enabled") if whip is None: whip = p.get("whipsaw_enabled") # Legacy fallback for old JSONs if whip is not None: ui["whipsaw_filter_enabled"] = bool(whip) for k in ( "max_spread_pct", "min_bid_ask_ratio", "ob_min_bid_ask_ratio", "ask_max_mult", "ob_ask_max_mult", "whipsaw_filter_enabled", "whipsaw_subbar_sec", "whipsaw_lookback_sec", "whipsaw_dip_pct", "whipsaw_recovery_tol_pct", ): if p.get(k) is not None: ui[k] = p[k] def optuna_engine_params_to_web_ui(strategy: str, params: Dict[str, Any]) -> Dict[str, Any]: """Optuna merged/engine params → 웹 백테 폼 키 (꼬리 등 엔진↔UI 이름 변환).""" strat = str(strategy or "").strip().lower() p = dict(params or {}) if not p: return {} if strat == "tail": from backtest_web import _tail_engine_dict_to_ui ui = _tail_engine_dict_to_ui(p, snap={}) _overlay_trial_ob_whip_on_ui(ui, p) return ui if strat == "scalp": ui = dict(p) if ui.get("ob_filter_enabled") is None and p.get("_orderbook_filter_enabled") is not None: ui["ob_filter_enabled"] = bool(p.get("_orderbook_filter_enabled")) _overlay_trial_ob_whip_on_ui(ui, p) return ui if strat == "breakout": ui = dict(p) if ui.get("ob_filter_enabled") is None and p.get("_orderbook_filter_enabled") is not None: ui["ob_filter_enabled"] = bool(p.get("_orderbook_filter_enabled")) if ui.get("pg_filter_enabled") is None and p.get("_program_filter_enabled") is not None: ui["pg_filter_enabled"] = bool(p.get("_program_filter_enabled")) if ui.get("shoulder_min_high_pct") is None and p.get("shoulder_min_high") is not None: ui["shoulder_min_high_pct"] = p.get("shoulder_min_high") _overlay_trial_ob_whip_on_ui(ui, p) return ui ui = dict(p) _overlay_trial_ob_whip_on_ui(ui, p) return ui 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))} strat = _resolve_single_strategy(meta, data, path) or data.get("strategy") params_ui = optuna_engine_params_to_web_ui(str(strat or ""), params) return { "ok": True, "source": src, "rank": rank, "metrics": metrics, "params_preview": preview, "params_full": preview, "params_ui": params_ui, "params_count": len(params), "result_json": str(path), "strategy": strat, "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": label = _breakout_import_label(study, data) _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, "sort_by": data.get("sort_by") or meta.get("sort_by"), "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(), "use_rust": data.get("use_rust") if data.get("use_rust") is not None else ("tail" in strat), }) 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 _apply_refine_state_progress( m: Dict[str, Any], prog: Dict[str, Any], state_path: Path, *, alive: bool, seq_step: bool = False, ) -> Optional[str]: """1·2차 refine_state.json → 진행률·활성 study·로그. seq_step=True 면 잡 status 는 건드리지 않음.""" if not state_path.is_file(): return None active_log: Optional[str] = None try: st = json.loads(state_path.read_text(encoding="utf-8")) m["refine_state"] = st phase = str(st.get("phase") or "") prog["refine_phase"] = phase strat = str(st.get("strategy") or m.get("current_strategy") or "") if strat: m["current_strategy"] = strat if phase == "phase1": prog["label"] = f"{strat or 'Optuna'} · 1차 TPE(넓은 Grid)" elif phase == "phase2": prog["label"] = f"{strat or 'Optuna'} · 2차 TPE(밴드 축소)" elif phase == "done": prog["label"] = f"{strat or 'Optuna'} · 1·2차 완료" active_study = _resolve_refine_active_study(st) if phase in ("phase1", "phase2") else str(st.get("phase2_study") or st.get("phase1_study") or "") if phase == "phase2" and not active_study: active_study = str(st.get("phase2_study") or st.get("phase1_study") or "") elif phase == "phase1": active_study = str(st.get("phase1_study") or active_study or "") phase_trials = int(m.get("trials") or 0) if phase == "phase2" and st.get("phase2_trials"): try: phase_trials = int(st.get("phase2_trials") or phase_trials) except (TypeError, ValueError): pass if active_study: phase_prog = _study_progress(active_study, phase_trials) for k in ( "trials_done", "trials_total", "pct", "best_value", "best_trial", "best_win_rate", "best_pnl", "best_pf", "best_mdd", "best_trades", "study_ok", "error", ): if k in phase_prog and phase_prog.get(k) is not None: prog[k] = phase_prog[k] m["active_study_name"] = active_study if st.get("result_json") and not seq_step: m["result_json"] = st["result_json"] p2_log = st.get("phase2_log") p1_log = st.get("phase1_log") if phase == "phase2" and p2_log and Path(str(p2_log)).is_file(): active_log = str(p2_log) elif p1_log and Path(str(p1_log)).is_file(): active_log = str(p1_log) if not alive and not seq_step: if phase == "done": m["status"] = "done" elif phase == "error": m["status"] = "error" m["error"] = st.get("error") or m.get("error") except Exception: return active_log return active_log 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) refine_sp = str(side_info.get("refine_state_path") or "").strip() if refine_sp: m["refine_state_path"] = refine_sp rl = _apply_refine_state_progress( m, prog, Path(refine_sp), alive=alive, seq_step=True, ) if rl: active_log = rl m["active_log_path"] = rl if not refine_sp: 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 active_log = val except Exception: pass if not refine_sp and 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 m.get("kind") == "mode_refine": state_path = Path(str(m.get("refine_state_path") or "")) if not state_path.is_file() and m.get("job_id"): state_path = ROOT / "logs" / f"{m.get('job_id')}_refine_state.json" rl = _apply_refine_state_progress( m, prog, state_path, alive=alive, seq_step=False, ) if rl: active_log = rl m["period_info"] = _build_period_info(m) if alive: rs = m.get("refine_state") if isinstance(m.get("refine_state"), dict) else {} phase = str(rs.get("phase") or prog.get("refine_phase") or "") act = str(m.get("active_study_name") or _resolve_refine_active_study(rs)) pi = m.get("period_info") if isinstance(m.get("period_info"), dict) else {} p1_slot = pi.get("phase1") if isinstance(pi.get("phase1"), dict) else {} p2_slot = pi.get("phase2") if isinstance(pi.get("phase2"), dict) else {} start = str(m.get("start") or "") end = str(m.get("end") or "") live: Dict[str, Any] = {"refine_phase": phase} if act and phase in ("phase1", "phase2"): cur_start = p2_slot.get("start") if phase == "phase2" else p1_slot.get("start") or start cur_end = p2_slot.get("end") if phase == "phase2" else p1_slot.get("end") or end cur = _live_study_top3( act, start=cur_start, end=cur_end, n=5, label_prefix="2차" if phase == "phase2" else "1차", ) if cur: live.update(cur) if phase == "phase2": p1s = str(rs.get("phase1_study") or "").strip() if p1s: p1live = _live_study_top3( p1s, start=p1_slot.get("start") or start, end=p1_slot.get("end") or end, n=5, label_prefix="1차", ) if p1live: live["phase1_top3"] = p1live.get("top3_learn") or [] live["phase1_period_range"] = p1live.get("period_range") or "" gkeys: List[str] = [] try: rj = m.get("result_json") if rj and Path(str(rj)).is_file(): jd = json.loads(Path(str(rj)).read_text(encoding="utf-8")) gkeys = list(jd.get("grid_keys") or []) except Exception: gkeys = [] if act and phase in ("phase1", "phase2"): cur_start = p2_slot.get("start") if phase == "phase2" else p1_slot.get("start") or start cur_end = p2_slot.get("end") if phase == "phase2" else p1_slot.get("end") or end mode_live = _live_mode_top3( act, start=cur_start, end=cur_end, grid_keys=gkeys or None, n=5, ) if mode_live: live["mode_top3"] = mode_live.get("mode_top3") or [] live["mode_pool_size"] = mode_live.get("mode_pool_size") live["mode_period_range"] = mode_live.get("period_range") or "" if live.get("top3_learn") or live.get("phase1_top3") or live.get("mode_top3"): m["live_summary"] = live else: m.pop("live_summary", None) elif alive and m.get("active_study_name"): act = str(m.get("active_study_name") or "") start = str(m.get("start") or "") end = str(m.get("end") or "") gkeys = [] try: rj = m.get("result_json") if rj and Path(str(rj)).is_file(): jd = json.loads(Path(str(rj)).read_text(encoding="utf-8")) gkeys = list(jd.get("grid_keys") or []) except Exception: pass learn = _live_study_top3(act, start=start, end=end, n=5, label_prefix="learn") mode_live = _live_mode_top3(act, start=start, end=end, grid_keys=gkeys or None, n=5) live2: Dict[str, Any] = {} if learn: live2.update(learn) if mode_live: live2["mode_top3"] = mode_live.get("mode_top3") or [] live2["mode_pool_size"] = mode_live.get("mode_pool_size") live2["mode_period_range"] = mode_live.get("period_range") or "" if live2.get("top3_learn") or live2.get("mode_top3"): m["live_summary"] = live2 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") in ("done", "error") and not alive: # 진행 중 learn/mode Top3 스냅샷 — 완료 후에는 gated·mode Top3(결과 JSON)만 표시 m.pop("live_summary", None) 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("kind") == "import" and m.get("result_json"): try: rpath = Path(str(m["result_json"])) if rpath.is_file(): idata = json.loads(rpath.read_text(encoding="utf-8")) study = str(idata.get("optuna_study_name") or m.get("study_name") or "") if study: m["study_name"] = study m["study_short"] = _study_short_note(study) strat = str(m.get("strategy") or idata.get("strategy") or "").lower() if strat == "breakout": m["label"] = _breakout_import_label(study, idata) bp = Path(str(rpath).replace(".json", ".briefing.md")) need_brief = not bp.is_file() if not need_brief: try: need_brief = "최종 선택 후보" not in bp.read_text(encoding="utf-8") except Exception: need_brief = True if need_brief: from kis_trader.backtest.optuna_briefing import write_briefing_for_json write_briefing_for_json(str(rpath)) m["briefing_md"] = str(bp) except Exception: pass m["study_short"] = m.get("study_short") or _study_short_note( str(m.get("study_name") or m.get("active_study_name") or "") ) 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 m["period_info"] = _build_period_info(m) save_job(m) try: if str(m.get("kind") or "") in ("seq", "seq4", "mode_refine"): m["seq_refine_steps"] = [ { "step": c.get("step"), "label": c.get("label"), "job_id": c.get("job_id"), "phase1_study": c.get("phase1_study"), "phase1_db": c.get("phase1_db"), "done": c.get("done"), } for c in _build_seq_refine_cmds(m) ] save_job(m) except Exception: pass 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|optuna_mode_refine_runner.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 "optuna_mode_refine_runner.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", sort_by: Optional[str] = None, 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, use_rust: bool = False, ) -> 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() refine_state_path: Optional[str] = None 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 from kis_trader.backtest.optuna_common import ( normalize_optuna_sort_by, resolve_optuna_min_trades, ) _mt_info = resolve_optuna_min_trades(start, end, picked[0] if len(picked) == 1 else None) min_trades = int(_mt_info["min_trades"]) sort_by = normalize_optuna_sort_by(sort_by or "score", web=True) 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 # 사후 report 게이트 거래수 = 탐색과 동일(기간 자동) env["PARAM_SEARCH_OPTUNA_REPORT_MIN_TRADES"] = str(min_trades) env["SORT_BY"] = sort_by if use_rust: env["BACKTEST_USE_RUST"] = "1" _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) env["OPTUNA_SEQ_JOB_ID"] = job_id if sort_by: env["SORT_BY"] = sort_by 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"] = str(min_trades) 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 = "순차1·2차(" + _lab + ")" strat_field = ",".join(picked) else: strat = picked[0] 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) refine_state_path = None if reuse_study: # 이어 돌리기: 단일 study (1·2차 연쇄 아님) job_id = f"opt_{ts}_{strat[:4]}" 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", str(min_trades), "--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" else: # 단일 전략 기본: 1차(넓은 Grid) → 2차(밴드 축소) 자동 연쇄 job_id = f"opt_{ts}_refine_{strat[:4]}" log_path = ROOT / "logs" / f"optuna_web_refine_{ts}.log" study_name = ( f"{strat}_{mode}_refine2_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" ) _base_label = label if label else _labels.get(strat, strat) label = f"1·2차TPE·{_base_label}" refine_state_path = str(ROOT / "logs" / f"{job_id}_refine_state.json") cmd = [ str(PY if PY.is_file() else "python3"), "-u", str(ROOT / "kis_trader" / "backtest" / "optuna_mode_refine_runner.py"), "--job-id", job_id, "--strategy", strat, "--mode", mode, "--start", start, "--end", end, "--trials", str(trials), "--sort-by", sort_by, "--min-trades", str(min_trades), "--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], "--ob-mode", bo_oms[0]]) if sym and strat == "us_momentum": cmd.extend(["--symbol", sym]) 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 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) kind = "mode_refine" 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, "sort_by": sort_by, "start": start, "end": end, "trials": trials, "min_trades": min_trades, "n_trading_days": int(_mt_info.get("n_trading_days") or 0), "min_trades_per_day": int(_mt_info.get("min_trades_per_day") or 0), "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, "refine_state_path": refine_state_path if kind == "mode_refine" else None, "use_rust": use_rust, "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", "mode_refine"): raise RuntimeError("순차·1·2차 잡은 이어 돌리기 불가 — 2차 study 또는 전략별 보기 잡에서 하세요") 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", "mode_refine"): raise RuntimeError("순차·1·2차 잡은 확정 불가 — 전략별 보기 잡에서 하세요") 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)