#!/usr/bin/env python3 """ 전략 백테 CLI 웹 잡 (꼬리·스캘핑·돌파·모멘텀 공용). - subprocess + start_new_session + reaper(wait) → 좀비(Z) 방지 - 전역 1잡만 running (전략 간 충돌·API 폭주 방지) - progress.json + job meta 디스크 저장 → 탭 이동·새로고침에도 폴링 가능 """ from __future__ import annotations import json import os import signal import subprocess import threading import time from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional ROOT = Path(__file__).resolve().parents[2] JOBS_DIR = ROOT / "logs" / "bt_web_jobs" RESULTS_DIR = ROOT / "kis_trader" / "backtest" / "results" PY = ROOT / ".venv" / "bin" / "python" CLI = ROOT / "scripts" / "run_strategy_backtest_cli.py" TAIL_CLI = ROOT / "scripts" / "run_tail_backtest_cli.py" STRATEGIES = ("tail", "scalp", "breakout", "momentum") LABELS = { "tail": "꼬리 백테", "scalp": "스캘핑 백테", "breakout": "돌파 백테", "momentum": "모멘텀 백테", } 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 _progress_path(job_id: str) -> Path: return JOBS_DIR / f"{job_id}.progress.json" def save_job(meta: Dict[str, Any]) -> None: _ensure_dirs() jid = str(meta.get("job_id") or "") if not jid: return p = _job_path(jid) tmp = p.with_suffix(".tmp") tmp.write_text(json.dumps(meta, ensure_ascii=False, indent=2, default=str), encoding="utf-8") tmp.replace(p) def _legacy_job_path(job_id: str) -> Path: return ROOT / "logs" / "tail_bt_web_jobs" / f"{job_id}.json" def load_job(job_id: str) -> Optional[Dict[str, Any]]: for p in (_job_path(job_id), _legacy_job_path(job_id)): if not p.is_file(): continue try: return json.loads(p.read_text(encoding="utf-8")) except Exception: continue return None def _try_reap_child(pid: int) -> None: try: os.waitpid(int(pid), os.WNOHANG) except (ChildProcessError, OSError, ValueError): pass def _pid_alive(pid: int) -> bool: """살아 있으면 True. 좀비(Z)는 wait 후 False.""" if pid <= 0: return False try: os.kill(pid, 0) except OSError: return False 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 read_progress(job_id: str) -> Dict[str, Any]: for p in ( _progress_path(job_id), ROOT / "logs" / "tail_bt_web_jobs" / f"{job_id}.progress.json", ): if not p.is_file(): continue try: return json.loads(p.read_text(encoding="utf-8")) except Exception: continue return {"pct": 0, "phase": "init", "message": ""} def refresh_job(job_id: str) -> Optional[Dict[str, Any]]: m = load_job(job_id) if not m: return None pid = int(m.get("pid") or 0) prog = read_progress(job_id) m["progress"] = prog m["pid_alive"] = _pid_alive(pid) st = str(m.get("status") or "") if st == "running": if not m["pid_alive"]: rj = m.get("result_json") if rj and Path(str(rj)).is_file(): m["status"] = "done" m["finished_at"] = m.get("finished_at") or _now_iso() try: data = json.loads(Path(str(rj)).read_text(encoding="utf-8")) m["result_summary"] = data.get("summary") or {} m["progress"] = { "pct": 100, "phase": "done", "message": "완료", "step": prog.get("step"), "total": prog.get("total"), } except Exception: m["progress"] = {"pct": 100, "phase": "done", "message": "완료"} else: err = "" logp = m.get("log_path") if logp and Path(str(logp)).is_file(): try: tail = Path(str(logp)).read_text(encoding="utf-8", errors="replace")[-2000:] if "Traceback" in tail or "실패" in tail: err = tail.strip().splitlines()[-1][:200] except Exception: pass m["status"] = "error" m["error"] = err or "프로세스 종료·결과 없음" m["finished_at"] = _now_iso() m["progress"] = { "pct": int(prog.get("pct") or 0), "phase": "error", "message": m["error"], } save_job(m) return m def list_jobs(limit: int = 30) -> List[Dict[str, Any]]: _ensure_dirs() # 구버전 tail_bt_web_jobs 도 함께 보여 줌 dirs = [JOBS_DIR, ROOT / "logs" / "tail_bt_web_jobs"] seen = set() rows: List[Dict[str, Any]] = [] paths: List[Path] = [] for d in dirs: if d.is_dir(): paths.extend(d.glob("*.json")) for p in sorted(paths, key=lambda x: x.stat().st_mtime, reverse=True): if p.name.endswith(".progress.json") or p.name.endswith(".tmp") or ".progress." in p.name: continue try: m = json.loads(p.read_text(encoding="utf-8")) except Exception: continue if not isinstance(m, dict) or not m.get("job_id"): continue jid = str(m["job_id"]) if jid in seen: continue seen.add(jid) # 새 디렉터리 우선 refresh if (_job_path(jid)).is_file(): rows.append(refresh_job(jid) or m) else: # legacy tail path — 최소 필드만 m["progress"] = m.get("progress") or {} m["pid_alive"] = _pid_alive(int(m.get("pid") or 0)) rows.append(m) if len(rows) >= limit: break return rows def find_running() -> List[Dict[str, Any]]: out = [] for m in list_jobs(limit=80): if m.get("status") == "running" and m.get("pid_alive"): out.append(m) return out def _spawn_reaper(proc: subprocess.Popen, job_id: str, log_f: Any) -> None: 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 meta.get("status") == "running": # result 있으면 done 우선 rj = meta.get("result_json") if not (rj and Path(str(rj)).is_file()): meta["error"] = meta.get("error") or f"process exit_code={rc}" save_job(meta) refresh_job(job_id) except Exception: pass threading.Thread(target=_run, name=f"bt-reap-{job_id}", daemon=True).start() def start_bt_job( *, strategy: str, start: str, end: str, timeframe: Optional[int] = None, universe: str = "history", universe_history_source: Optional[str] = None, tick_db: Optional[bool] = None, tick_exit: Optional[bool] = None, orderbook_filter: str = "auto", params_json: Optional[str] = None, params_override: Optional[Dict[str, Any]] = None, env_timeline: bool = False, ) -> Dict[str, Any]: _ensure_dirs() strat = str(strategy or "").strip().lower() if strat not in STRATEGIES: raise ValueError(f"strategy 는 {STRATEGIES} 중 하나") running = find_running() if running: r0 = running[0] raise RuntimeError( f"이미 실행 중: {r0.get('label') or r0.get('strategy')} job={r0.get('job_id')}. " "끝난 뒤 다시 시작하거나 중지하세요." ) if not PY.is_file(): raise RuntimeError(f"python 없음: {PY}") if not CLI.is_file() and not TAIL_CLI.is_file(): raise RuntimeError(f"CLI 없음: {CLI}") from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day start = clamp_to_prev_kr_trading_day(str(start or "").strip()) end = clamp_to_prev_kr_trading_day(str(end or "").strip()) if start > end: start, end = end, start if not start or not end: raise ValueError("start/end 필요") univ = str(universe or "history").strip().lower() if univ not in ("history", "all", "sim"): univ = "history" hist_src = str(universe_history_source or "").strip().lower() if hist_src not in ("", "kiwoom", "ls"): hist_src = "" ts = datetime.now().strftime("%Y%m%d_%H%M%S") job_id = f"{strat}_bt_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" log_path = ROOT / "logs" / f"{job_id}.log" result_json = RESULTS_DIR / f"{job_id}.json" progress_file = _progress_path(job_id) params_json_path = str(params_json or "").strip() or None if params_override and isinstance(params_override, dict) and params_override: pj_dir = ROOT / "logs" / "bt_job_params" pj_dir.mkdir(parents=True, exist_ok=True) pj = pj_dir / f"{job_id}_params.json" pj.write_text(json.dumps(params_override, ensure_ascii=False), encoding="utf-8") params_json_path = str(pj) # tail 은 세밀 progress 있는 전용 CLI 우선 if strat == "tail" and TAIL_CLI.is_file(): cmd = [ str(PY), "-u", str(TAIL_CLI), "--start", start, "--end", end, "--timeframe", str(int(timeframe or 3)), "--universe", "history" if univ == "sim" else univ, "--job-id", job_id, "--out-dir", str(RESULTS_DIR), "--progress-file", str(progress_file), "--orderbook-filter", str(orderbook_filter or "auto"), ] if tick_db is not None: cmd.extend(["--tick-db", "1" if tick_db else "0"]) if tick_exit is not None: cmd.extend(["--tick-exit", "1" if tick_exit else "0"]) if params_json_path: cmd.extend(["--params-json", str(params_json_path)]) if env_timeline: cmd.append("--env-timeline") if hist_src: cmd.extend(["--universe-history-source", hist_src]) else: if not CLI.is_file(): raise RuntimeError(f"CLI 없음: {CLI}") cmd = [ str(PY), "-u", str(CLI), "--strategy", strat, "--start", start, "--end", end, "--universe", univ, "--job-id", job_id, "--out-dir", str(RESULTS_DIR), "--progress-file", str(progress_file), "--orderbook-filter", str(orderbook_filter or "auto"), ] if timeframe is not None: cmd.extend(["--timeframe", str(int(timeframe))]) if tick_db is not None: cmd.extend(["--tick-db", "1" if tick_db else "0"]) if params_json_path: cmd.extend(["--params-json", str(params_json_path)]) if env_timeline: cmd.append("--env-timeline") if hist_src: cmd.extend(["--universe-history-source", hist_src]) progress_file.write_text( json.dumps({"pct": 1, "phase": "starting", "message": "시작"}, ensure_ascii=False), encoding="utf-8", ) log_f = open(log_path, "w", encoding="utf-8") proc = subprocess.Popen( cmd, cwd=str(ROOT), stdout=log_f, stderr=subprocess.STDOUT, start_new_session=True, ) meta = { "job_id": job_id, "kind": "strategy_bt_cli", "label": LABELS.get(strat, strat), "strategy": strat, "start": start, "end": end, "timeframe": int(timeframe) if timeframe is not None else None, "universe": univ, "universe_history_source": hist_src or "kiwoom", "status": "running", "pid": proc.pid, "log_path": str(log_path), "result_json": str(result_json), "progress_file": str(progress_file), "started_at": _now_iso(), "started_ts": time.time(), "cmd": " ".join(cmd), "progress": {"pct": 1, "phase": "starting", "message": "시작"}, } save_job(meta) _spawn_reaper(proc, job_id, log_f) return meta def stop_bt_job(job_id: str) -> Dict[str, Any]: m = load_job(job_id) if not m: # legacy tail dir legacy = ROOT / "logs" / "tail_bt_web_jobs" / f"{job_id}.json" if legacy.is_file(): try: m = json.loads(legacy.read_text(encoding="utf-8")) except Exception: m = None if not m: raise RuntimeError("job 없음") pid = int(m.get("pid") or 0) if pid > 0 and _pid_alive(pid): try: os.killpg(pid, signal.SIGTERM) except Exception: try: os.kill(pid, signal.SIGTERM) except Exception: pass # 짧게 대기 후 강제 time.sleep(0.4) if _pid_alive(pid): try: os.killpg(pid, signal.SIGKILL) except Exception: try: os.kill(pid, signal.SIGKILL) except Exception: pass _try_reap_child(pid) m["status"] = "error" m["error"] = "사용자 중지" m["finished_at"] = _now_iso() m["progress"] = { "pct": int((m.get("progress") or {}).get("pct") or 0), "phase": "stopped", "message": "중지", } # 새 경로에 저장 (legacy 잡도 새 폴더로 옮김) save_job(m) return m # ── 하위호환 별칭 (tail_bt_web_jobs) ───────────────────────────────────────── def start_tail_bt_job(**kwargs: Any) -> Dict[str, Any]: kwargs = dict(kwargs) kwargs["strategy"] = "tail" return start_bt_job(**kwargs) def stop_tail_bt_job(job_id: str) -> Dict[str, Any]: return stop_bt_job(job_id)