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.
257 lines
8.7 KiB
Python
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)
|