Files
kis_trader/scripts/run_strategy_backtest_cli.py
2026-07-30 18:05:07 +09:00

241 lines
8.1 KiB
Python

#!/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="웹엔진 실행중")
try:
with bw.app.test_request_context(f"{path}?{qs}"):
resp = fn()
if isinstance(resp, tuple):
resp = resp[0]
data = resp.get_json(silent=True) if hasattr(resp, "get_json") else None
if not isinstance(data, dict):
raise RuntimeError(f"응답 JSON 아님: {type(resp)}")
if data.get("error") and not data.get("summary") and not data.get("ok", True):
raise RuntimeError(str(data.get("error")))
finally:
stop_hb.set()
try:
hb.join(timeout=1.0)
except Exception:
pass
elapsed = time.time() - t0
summary = data.get("summary") or {}
trades = data.get("trades") or []
out = dict(data)
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)