- 프론트엔드 UI 업데이트 (backtest.html, backtest.js) 엔진 라디오 버튼 통합 관련 반영 - Rust 플러그인(kis_rust_core) 및 컴파일 소스코드 추가 - CLI 백테스트 스크립트 수정 및 최신화 - 기타 스크래치 테스트 스크립트, 로그 요약 마크다운(.md) 등 누락 파일 일괄 반영 - 추가적으로 아직 발견되지 않은 엣지 케이스나 렌더링 오류가 포함되어 있을 가능성이 있음
270 lines
9.3 KiB
Python
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)
|