- optuna_web_jobs.py: - 과거 JSON 로그에 use_rust 필드가 없는 경우 꼬리잡기 외에는 Python 엔진으로 폴백 처리되도록 수정 - mode_refine 작업 생성 시 특정 조건에서 label 포맷팅 에러(None)로 인해 UI에서 라벨이 소실되는 버그 수정 - legacy JSON에 대비하여 whipsaw 관련 속성명(whipsaw_filter_enabled 등)의 호환성 확보 - register_result_json_as_job 시 use_rust 필드 저장 보장 - backtest_web.py: - 완료된 작업의 경우에도 period_info 파싱/복구 로직을 추가하여 요약(summary)이 정상 렌더링 되도록 폴백 보완 - 참고: 일부 레거시 데이터와의 호환성 충돌 등 미처 파악하지 못한 엣지 케이스 오류가 아직 남아있을 수 있음
4066 lines
154 KiB
Python
4066 lines
154 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
optuna_web_jobs.py — 백테 웹용 Optuna 잡 (subprocess + 디스크 상태)
|
||
|
||
HTTP 타임아웃과 분리: start 는 즉시 job_id 반환, status 폴링으로 진행률/결과.
|
||
apply-best 는 절대 자동 적용하지 않음.
|
||
|
||
※ 자식 프로세스: Popen 후 반드시 wait(reaper) — 안 하면 종료 후 좀비(Z)로 남아
|
||
웹이 "이미 실행 중" 으로 막힘.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
import shlex
|
||
import signal
|
||
import subprocess
|
||
import threading
|
||
import time
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, 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/<pid>/stat 상태 Z = 좀비 (부모 wait 안 함 → kill 0 은 성공)
|
||
try:
|
||
raw = Path("/proc/%d/stat" % int(pid)).read_text(encoding="utf-8", errors="replace")
|
||
rp = raw.rfind(")")
|
||
if rp >= 0 and rp + 2 < len(raw):
|
||
state = raw[rp + 2 : rp + 3]
|
||
if state == "Z":
|
||
_try_reap_child(int(pid))
|
||
return False
|
||
except Exception:
|
||
pass
|
||
return True
|
||
|
||
|
||
def _spawn_job_reaper(
|
||
proc: subprocess.Popen,
|
||
job_id: str,
|
||
log_f: Any,
|
||
) -> None:
|
||
"""
|
||
근본: Popen 자식을 wait 해서 좀비 방지 + 종료 시 job JSON 즉시 done/error 확정.
|
||
웹 프로세스가 부모로 남는 한 이 스레드가 필수.
|
||
"""
|
||
|
||
def _run() -> None:
|
||
rc: Optional[int] = None
|
||
try:
|
||
rc = int(proc.wait())
|
||
except Exception:
|
||
try:
|
||
rc = int(proc.poll()) if proc.poll() is not None else None
|
||
except Exception:
|
||
rc = None
|
||
try:
|
||
if log_f is not None and hasattr(log_f, "closed") and not log_f.closed:
|
||
log_f.flush()
|
||
log_f.close()
|
||
except Exception:
|
||
pass
|
||
try:
|
||
meta = load_job(job_id)
|
||
if not meta:
|
||
return
|
||
meta["exit_code"] = rc
|
||
if rc not in (None, 0) and not meta.get("error"):
|
||
meta["error"] = "process exit_code=%s" % rc
|
||
save_job(meta)
|
||
refresh_job_status(meta)
|
||
except Exception:
|
||
pass
|
||
|
||
threading.Thread(
|
||
target=_run,
|
||
name="optuna-reap-%s" % job_id,
|
||
daemon=True,
|
||
).start()
|
||
|
||
|
||
def _tail_text(path: Optional[str], n: int = 40) -> str:
|
||
if not path:
|
||
return ""
|
||
p = Path(path)
|
||
if not p.is_file():
|
||
return ""
|
||
try:
|
||
# 큰 로그: 끝부분만
|
||
data = p.read_bytes()
|
||
if len(data) > 200_000:
|
||
data = data[-200_000:]
|
||
text = data.decode("utf-8", errors="replace")
|
||
lines = text.splitlines()
|
||
return "\n".join(lines[-n:])
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
_POST_PROG_RE = re.compile(
|
||
r"OPTUNA_POST_PROGRESS\s+pct=(?P<pct>[0-9.]+)\s+step=(?P<step>\d+)\s+"
|
||
r"total=(?P<total>\d+)\s+stage=(?P<stage>\S+)\s+axis=(?P<ad>\d+)/(?P<at>\d+)"
|
||
r"(?P<detail>.*)$"
|
||
)
|
||
|
||
|
||
def _parse_postprocess_progress(log_path: Optional[str]) -> Dict[str, Any]:
|
||
"""후처리 한 줄 진행. 없으면 구로그 휴리스틱."""
|
||
out: Dict[str, Any] = {
|
||
"pct": 0.0,
|
||
"step": 0,
|
||
"total": 0,
|
||
"stage": "",
|
||
"axis_done": 0,
|
||
"axis_total": 0,
|
||
"detail": "",
|
||
"ready": False,
|
||
"hint": "",
|
||
}
|
||
if not log_path:
|
||
return out
|
||
text = _tail_text(log_path, 250) or ""
|
||
last = None
|
||
for mm in _POST_PROG_RE.finditer(text):
|
||
last = mm
|
||
if last:
|
||
d = last.groupdict()
|
||
try:
|
||
out["pct"] = float(d.get("pct") or 0)
|
||
except (TypeError, ValueError):
|
||
out["pct"] = 0.0
|
||
out["step"] = int(d.get("step") or 0)
|
||
out["total"] = int(d.get("total") or 0)
|
||
out["stage"] = str(d.get("stage") or "")
|
||
out["axis_done"] = int(d.get("ad") or 0)
|
||
out["axis_total"] = int(d.get("at") or 0)
|
||
det = str(d.get("detail") or "").strip()
|
||
out["detail"] = det
|
||
if out["stage"] == "done" or "상세가능" in det:
|
||
out["ready"] = True
|
||
out["pct"] = 100.0
|
||
out["hint"] = "후처리 끝 · 「상세」 가능"
|
||
else:
|
||
ax = ""
|
||
if out["axis_total"]:
|
||
ax = f" · 축 {out['axis_done']}/{out['axis_total']}"
|
||
out["hint"] = (
|
||
f"후처리 {out['step']}/{out['total']} · {out['stage']}{ax} · 「상세」는 끝난 뒤"
|
||
)
|
||
return out
|
||
g = re.findall(r"\[후처리\] gated#(\d+)", text)
|
||
if g:
|
||
out["stage"] = "gated#" + g[-1]
|
||
out["pct"] = min(90.0, float(g[-1]) * 12.0)
|
||
out["hint"] = "후처리 중 · 「상세」는 끝난 뒤"
|
||
if "[후처리 TopN]" in text or "상세가능" in text:
|
||
out["ready"] = True
|
||
out["pct"] = 100.0
|
||
out["stage"] = out.get("stage") or "done"
|
||
out["hint"] = "후처리 끝 · 「상세」 가능"
|
||
return out
|
||
|
||
|
||
def _parse_result_paths_from_log(log_path: str) -> Dict[str, Any]:
|
||
tail = _tail_text(log_path, 800) # 다중 전략일 경우 수백 줄 위에 있을 수 있음
|
||
rj_list = []
|
||
bm_list = []
|
||
|
||
for m in re.finditer(r"OPTUNA_RESULT_JSON=(.+)", tail):
|
||
rj_list.append(m.group(1).strip())
|
||
for m in re.finditer(r"OPTUNA_BRIEFING_MD=(.+)", tail):
|
||
bm_list.append(m.group(1).strip())
|
||
rj_list = _uniq_keep(rj_list)
|
||
bm_list = _uniq_keep(bm_list)
|
||
best = _pick_result_json_with_rows(rj_list) or (rj_list[-1] if rj_list else None)
|
||
return {
|
||
"result_json": best,
|
||
"result_jsons": rj_list,
|
||
"briefing_md": bm_list[-1] if bm_list else None,
|
||
"briefing_mds": bm_list,
|
||
}
|
||
|
||
|
||
def _uniq_keep(seq: List[str]) -> List[str]:
|
||
seen = set()
|
||
out: List[str] = []
|
||
for x in seq:
|
||
s = str(x or "").strip()
|
||
if not s or s in seen:
|
||
continue
|
||
seen.add(s)
|
||
out.append(s)
|
||
return out
|
||
|
||
|
||
def _json_n_results(path: str) -> int:
|
||
try:
|
||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return -1
|
||
n = data.get("n_results_all")
|
||
if n is not None:
|
||
try:
|
||
return int(n)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
return len(list(data.get("results_all") or data.get("results") or []))
|
||
|
||
|
||
def _pick_result_json_with_rows(paths: List[str]) -> Optional[str]:
|
||
"""순차 2스터디: 마지막 JSON이 0건이면 후보가 있는 쪽을 미리보기로 고른다."""
|
||
scored = []
|
||
for p in paths:
|
||
if p and Path(p).is_file():
|
||
scored.append((_json_n_results(p), p))
|
||
if not scored:
|
||
return None
|
||
scored.sort(key=lambda x: x[0])
|
||
return scored[-1][1]
|
||
|
||
|
||
# us_momentum 을 momentum 보다 앞에 — [us_momentum] 이 momentum 으로 잘리지 않게
|
||
_SEQ_START_RE = re.compile(
|
||
r"\[(?P<strat>us_momentum|momentum|tail|breakout|scalp)"
|
||
r"(?:/(?P<extra>[a-z0-9_]+))?\] START"
|
||
r"(?:[^\n]*study=(?P<study>[^\s]+))?"
|
||
)
|
||
|
||
|
||
def _seq_step_catalog(meta: Dict[str, Any]) -> List[Dict[str, str]]:
|
||
"""순차 한 칸 = 전략(+꼬리 진입/+돌파 손절×호가). 스크립트 run_one 과 동일 순서."""
|
||
from kis_trader.backtest.optuna_breakout_tpe_space import breakout_tpe_study_extra
|
||
|
||
picked = list(meta.get("strategies") or [])
|
||
if not picked:
|
||
raw = str(meta.get("strategy") or "")
|
||
picked = [s.strip() for s in raw.split(",") if s.strip()]
|
||
tail_ems = list(meta.get("tail_entry_modes") or ["align"])
|
||
bo_sms = list(meta.get("breakout_sl_modes") or ["fixed"])
|
||
bo_oms = list(meta.get("breakout_ob_modes") or ["off"])
|
||
out: List[Dict[str, str]] = []
|
||
for s in picked:
|
||
s = str(s or "").strip().lower()
|
||
if s == "tail":
|
||
for em in tail_ems:
|
||
out.append({"strategy": "tail", "extra": str(em or "align")})
|
||
elif s == "breakout":
|
||
for sm in bo_sms:
|
||
for om in bo_oms:
|
||
out.append({
|
||
"strategy": "breakout",
|
||
"extra": breakout_tpe_study_extra(sm, om),
|
||
})
|
||
elif s:
|
||
out.append({"strategy": s, "extra": ""})
|
||
return out
|
||
|
||
|
||
def _read_seq_active_sidecar(path: Optional[str]) -> Dict[str, str]:
|
||
"""run_one 이 덮어쓰는 잡별 현재 study (전역 latest.study 보다 우선)."""
|
||
out: Dict[str, str] = {}
|
||
if not path:
|
||
return out
|
||
p = Path(path)
|
||
if not p.is_file():
|
||
return out
|
||
try:
|
||
for line in p.read_text(encoding="utf-8").splitlines():
|
||
if "=" not in line:
|
||
continue
|
||
k, _, v = line.partition("=")
|
||
k = k.strip().lower()
|
||
v = v.strip()
|
||
if k in ("strategy", "study", "entry_mode", "sl_mode", "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)
|