Files
kis_trader/scripts/run_strategy_backtest_cli.py
Your Name cb7e5037a0 feat: Enhance trading system with new e_min_chg_pct parameter and related logic
Changes:
- Introduced the `e_min_chg_pct` parameter to define the minimum price change percentage compared to the previous day's close, enhancing the momentum trading strategy.
- Updated various functions and classes to incorporate this new parameter, ensuring it is utilized in both backtesting and live trading scenarios.
- Improved documentation and comments to clarify the purpose and usage of the new parameter across the codebase.

Impact:
- This addition allows for more precise control over trading conditions, potentially increasing the effectiveness of the momentum strategy while maintaining system integrity and performance.
2026-08-01 16:19:24 +09:00

257 lines
8.7 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="웹엔진 실행중")
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)