Files
kis_trader/scripts/run_strategy_backtest_cli.py
Your Name e1ac8d119b chore: 작업 중 발생한 부수적 변경 사항 및 누락된 파일 전체 커밋
- 프론트엔드 UI 업데이트 (backtest.html, backtest.js) 엔진 라디오 버튼 통합 관련 반영
- Rust 플러그인(kis_rust_core) 및 컴파일 소스코드 추가
- CLI 백테스트 스크립트 수정 및 최신화
- 기타 스크래치 테스트 스크립트, 로그 요약 마크다운(.md) 등 누락 파일 일괄 반영
- 추가적으로 아직 발견되지 않은 엣지 케이스나 렌더링 오류가 포함되어 있을 가능성이 있음
2026-09-06 17:04:50 +09:00

270 lines
9.3 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="auto")
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"
# Optuna 등에서 넘어온 1회성 폼 파라미터(params_override JSON)를 쿼리에 병합
if args.params_json:
try:
with open(args.params_json, "r", encoding="utf-8") as f:
pj = json.load(f)
if isinstance(pj, dict):
for k, v in pj.items():
if k not in q and v is not None and str(v) != "":
q[k] = str(v)
except Exception as e:
logger.warning("params_json 로드 실패: %s", e)
qs = urlencode({k: str(v) for k, v in q.items() if v is not None and str(v) != ""})
logger.info("🔍 [파라미터 병합 완료] 쿼리스트링: %s", qs)
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)