#!/usr/bin/env python3 """ 전략 백테 CLI 1회 — 웹 API 와 동일 핸들러 (Flask test_request_context). python3 -u scripts/run_strategy_backtest_cli.py \\ --strategy scalp|breakout|momentum|tail \\ --start 2026-07-20 --end 2026-07-22 꼬리(tail)는 세밀 progress 가 필요하면 scripts/run_tail_backtest_cli.py 를 쓰세요. 이 스크립트는 스캘핑·돌파·모멘텀(+ tail 폴백)용. DB 미저장. Optuna 아님. """ from __future__ import annotations import argparse import json import logging import sys import threading import time from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional from urllib.parse import urlencode ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from kis_trader.utils.kr_trading_day import clamp_to_prev_kr_trading_day logging.basicConfig( level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", datefmt="%H:%M:%S", ) logger = logging.getLogger("strategy_bt_cli") _ROUTE = { "scalp": ("/api/backtest/scalping", "api_backtest_scalping"), "breakout": ("/api/backtest/breakout", "api_backtest_breakout"), "momentum": ("/api/backtest/momentum", "api_backtest_momentum"), "tail": ("/api/backtest/tail", "api_backtest_tail"), } def _write_progress( path: Optional[str], *, pct: float, phase: str, message: str = "", ) -> None: if not path: return try: payload = { "pct": int(max(0, min(100, round(float(pct))))), "phase": str(phase or ""), "message": str(message or ""), "ts": time.time(), } p = Path(path) p.parent.mkdir(parents=True, exist_ok=True) tmp = p.with_suffix(".tmp") tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") tmp.replace(p) except Exception: pass def _bool_arg(val: Optional[str], default: Optional[bool] = None) -> Optional[bool]: if val is None or str(val).strip() == "": return default return str(val).strip().lower() in ("1", "true", "t", "y", "yes", "on") def _heartbeat(prog_file: Optional[str], stop_evt: threading.Event) -> None: """엔진 중 조악 progress (웹 핸들러는 내부 progress 없음).""" pct = 30.0 while not stop_evt.wait(2.0): pct = min(88.0, pct + 2.5) _write_progress(prog_file, pct=pct, phase="engine", message="웹엔진 실행중") def main() -> int: ap = argparse.ArgumentParser(description="전략 백테 CLI (웹 동일 핸들러)") ap.add_argument( "--strategy", required=True, choices=sorted(_ROUTE.keys()), help="scalp|breakout|momentum|tail", ) ap.add_argument("--start", required=True) ap.add_argument("--end", required=True) ap.add_argument("--timeframe", type=int, default=0, help="꼬리만 사용(3/5/15/60), 0=기본") ap.add_argument( "--universe", default="history", help="history|all|sim (전략별 웹 파서 따름)", ) ap.add_argument( "--universe-history-source", default="", choices=["", "kiwoom", "ls"], help="이력 테이블: kiwoom(기본) | ls (BACKTEST_UNIVERSE_HISTORY_SOURCE)", ) ap.add_argument("--job-id", default="") ap.add_argument("--out-dir", default="") ap.add_argument("--progress-file", default="") ap.add_argument("--orderbook-filter", choices=["off", "on", "auto"], default="off") ap.add_argument("--tick-db", default="", help="1/0/빈칸") ap.add_argument("--env-timeline", action="store_true") ap.add_argument("--params-json", default="", help="미사용(예약) — 웹폼 저장 후 DB 반영 권장") args = ap.parse_args() strat = args.strategy.strip().lower() start = clamp_to_prev_kr_trading_day(str(args.start).strip()) end = clamp_to_prev_kr_trading_day(str(args.end).strip()) if start > end: start, end = end, start ts = datetime.now().strftime("%Y%m%d_%H%M%S") job_id = (args.job_id or f"{strat}_bt_cli_{start.replace('-', '')}_{end.replace('-', '')}_{ts}").strip() out_dir = Path(args.out_dir) if args.out_dir else (ROOT / "kis_trader" / "backtest" / "results") out_dir.mkdir(parents=True, exist_ok=True) out_json = out_dir / f"{job_id}.json" prog_file = (args.progress_file or "").strip() or None logger.info( "🚀 %s BT CLI 시작 job=%s | %s~%s univ=%s hist_src=%s", strat, job_id, start, end, args.universe, args.universe_history_source or "env/kiwoom", ) t0 = time.time() _write_progress(prog_file, pct=5, phase="init", message="초기화") q: Dict[str, Any] = { "start": start, "end": end, "universe": args.universe or "history", } if (args.universe_history_source or "").strip(): q["universe_history_source"] = str(args.universe_history_source).strip() if args.timeframe and int(args.timeframe) > 0: q["timeframe"] = int(args.timeframe) if args.env_timeline: q["env_timeline"] = "1" tick = _bool_arg(args.tick_db, None) if tick is not None: q["backtest_use_tick_db"] = "1" if tick else "0" ob = (args.orderbook_filter or "off").strip().lower() if ob == "off": q["ob_filter"] = "0" elif ob == "on": q["ob_filter"] = "1" qs = urlencode({k: str(v) for k, v in q.items() if v is not None and str(v) != ""}) path, fn_name = _ROUTE[strat] _write_progress(prog_file, pct=20, phase="import", message="웹모듈 로드") import backtest_web as bw # noqa: WPS433 — CLI 전용, 웹과 동일 핸들러 fn = getattr(bw, fn_name, None) if not callable(fn): logger.error("핸들러 없음: %s", fn_name) _write_progress(prog_file, pct=100, phase="error", message=f"핸들러 없음 {fn_name}") return 1 stop_hb = threading.Event() hb = threading.Thread(target=_heartbeat, args=(prog_file, stop_hb), daemon=True) hb.start() _write_progress(prog_file, pct=30, phase="engine", message="웹엔진 실행중") data = None http_status = 200 try: with bw.app.test_request_context(f"{path}?{qs}"): resp = fn() if isinstance(resp, tuple): # Flask: (jsonify(...), 500) — 본문만 취하면 실패를 성공으로 오인함 http_status = int(resp[1]) if len(resp) > 1 else 200 resp = resp[0] elif hasattr(resp, "status_code"): try: http_status = int(resp.status_code) except (TypeError, ValueError): http_status = 200 data = resp.get_json(silent=True) if hasattr(resp, "get_json") else None if not isinstance(data, dict): raise RuntimeError(f"응답 JSON 아님: {type(resp)}") # 핸들러가 jsonify({"error": ...}, 500) 을 주면 summary 없이 error 만 옴 if http_status >= 400 or ( data.get("error") and not data.get("summary") ): raise RuntimeError(str(data.get("error") or f"HTTP {http_status}")) finally: stop_hb.set() try: hb.join(timeout=1.0) except Exception: pass if not isinstance(data, dict): raise RuntimeError("백테 응답 없음") elapsed = time.time() - t0 summary = data.get("summary") or {} trades = data.get("trades") or [] out = dict(data) out.pop("error", None) out["ok"] = True out["job_id"] = job_id out["kind"] = "strategy_bt_cli" out["strategy"] = strat out["start"] = start out["end"] = end out["elapsed_sec"] = round(elapsed, 1) out["n_trades"] = len(trades) if isinstance(trades, list) else int(summary.get("total_trades") or 0) out["note"] = f"웹 {path} 동일 핸들러 · DB 미저장 · Optuna 아님" out_json.write_text(json.dumps(out, ensure_ascii=False, indent=2, default=str), encoding="utf-8") _write_progress( prog_file, pct=100, phase="done", message=f"완료 trades={out['n_trades']} PnL={summary.get('total_pnl')}", ) logger.info( "✅ 완료 %.1fs | trades=%s WR=%s PnL=%s | %s", elapsed, out["n_trades"], summary.get("win_rate"), summary.get("total_pnl"), out_json, ) print( json.dumps( { "ok": True, "job_id": job_id, "strategy": strat, "result_json": str(out_json), "summary": summary, "elapsed_sec": round(elapsed, 1), }, ensure_ascii=False, ) ) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: logger.exception("실패: %s", exc) raise SystemExit(1)