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

377 lines
14 KiB
Python
Executable File

#!/usr/bin/env python3
"""
꼬리(SHORT) 웹백테와 동일 엔진 1회 — CLI 잡 (타임아웃 없음).
# 현재 DB 파라미터 · 저장 이력 유니버스 · 틱 ON(DB/env 기본)
python3 -u scripts/run_tail_backtest_cli.py \\
--start 2026-07-20 --end 2026-07-22
# Optuna/폼 JSON 덮어쓰기 (flat params dict)
python3 -u scripts/run_tail_backtest_cli.py \\
--start 2026-07-22 --end 2026-07-22 \\
--params-json /path/to/params.json
# 백그라운드
nohup python3 -u scripts/run_tail_backtest_cli.py \\
--start 2026-07-20 --end 2026-07-22 \\
--out-dir kis_trader/backtest/results \\
> logs/tail_bt_cli_YYYYMMDD_HHMMSS.log 2>&1 &
tail -f logs/tail_bt_cli_....log
결과 JSON: {out_dir}/tail_bt_cli_{start}_{end}_{ts}.json
DB 미저장. Optuna 탐색 아님.
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from database import TradeDB
from kis_trader.backtest import tail_backtest_common as tbc
from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row
from kis_trader.engine import tail_engine as te
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("tail_bt_cli")
def _write_progress(
path: Optional[str],
*,
pct: float,
phase: str,
message: str = "",
step: Optional[int] = None,
total: Optional[int] = None,
) -> 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(),
}
if step is not None:
payload["step"] = int(step)
if total is not None:
payload["total"] = int(total)
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 _load_params_json(path: Optional[str]) -> Dict[str, Any]:
if not path:
return {}
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"params-json 없음: {p}")
data = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("params-json 은 object 여야 함")
# Optuna 결과 파일 형태면 params / mode_combo.params 우선
if "params" in data and isinstance(data["params"], dict):
return dict(data["params"])
mc = data.get("mode_combo")
if isinstance(mc, dict) and isinstance(mc.get("params"), dict):
return dict(mc["params"])
# gated row 덤프
if "merged_params" in data and isinstance(data["merged_params"], dict):
return dict(data["merged_params"])
return dict(data)
def _bool_arg(val: Optional[str], default: bool) -> bool:
if val is None or str(val).strip() == "":
return bool(default)
return str(val).strip().lower() in ("1", "true", "t", "y", "yes", "on")
def main() -> int:
ap = argparse.ArgumentParser(description="꼬리 백테 CLI 1회 (웹 동일 엔진)")
ap.add_argument("--start", required=True, help="YYYY-MM-DD")
ap.add_argument("--end", required=True, help="YYYY-MM-DD")
ap.add_argument("--timeframe", type=int, default=3, choices=[3, 5, 15, 60])
ap.add_argument(
"--universe",
choices=["history", "all"],
default="history",
help="history=후보 이력(키움/LS) / all=ws_candles 전 종목",
)
ap.add_argument(
"--universe-history-source",
default="",
choices=["", "kiwoom", "ls"],
help="이력 테이블: kiwoom(기본) | ls",
)
ap.add_argument("--params-json", default="", help="덮어쓸 params JSON 경로")
ap.add_argument("--out-dir", default="", help="결과 JSON 디렉터리 (기본 results/)")
ap.add_argument("--job-id", default="", help="잡 ID (로그/파일명용, 비우면 자동)")
ap.add_argument(
"--tick-db",
default="",
help="1/0/빈칸 — 빈칸이면 DB/env 기본 (backtest_use_tick_db)",
)
ap.add_argument(
"--tick-exit",
default="",
help="1/0/빈칸 — 빈칸이면 DB/env 기본 (backtest_use_tick_exit)",
)
ap.add_argument(
"--orderbook-filter",
choices=["off", "on", "auto"],
default="off",
help="호가필터 (웹/Optuna와 동일 의미, 기본 off)",
)
ap.add_argument(
"--env-timeline",
action="store_true",
help="그 시각 DB env 타임라인 재현 (웹 체크와 동일 계열)",
)
ap.add_argument(
"--progress-file",
default="",
help="진행률 JSON 경로 (웹 폴링용)",
)
args = ap.parse_args()
prog_file = (args.progress_file or "").strip() or None
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"tail_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"
logger.info("🚀 tail BT CLI 시작 job=%s | %s~%s tf=%s univ=%s", job_id, start, end, args.timeframe, args.universe)
t0 = time.time()
_write_progress(prog_file, pct=3, phase="init", message="초기화")
db = TradeDB()
_write_progress(prog_file, pct=8, phase="db", message="DB·파라미터")
base = te.get_tail_defaults_from_db(db)
r = load_portfolio_env_row(db)
fee_rate, sell_tax, slot_from_fee = tbc.fee_and_slot_from_env_row(r)
portfolio = tbc.resolve_tail_portfolio_params(
r, base, slot_money=slot_from_fee, max_stocks=None, total_budget_krw=None,
)
params = dict(base)
tbc.merge_tail_portfolio_into_params(params, portfolio)
params["capital"] = float(r.get("BACKTEST_CAPITAL") or params.get("capital") or 100_000_000.0)
params["timeframe"] = int(args.timeframe)
overrides = _load_params_json(args.params_json or None)
if overrides:
# Optuna UI/% 혼재 키는 엔진이 쓰는 키만 병합 (그대로 update — 웹 폼반영과 동일 가정)
params.update(overrides)
logger.info("📌 params-json 병합 %d", len(overrides))
from kis_trader.engine.tail_tick_replay import (
tail_backtest_use_tick_db,
tail_backtest_use_tick_exit,
)
if str(args.tick_db).strip() != "":
params["backtest_use_tick_db"] = _bool_arg(args.tick_db, True)
else:
params.setdefault("backtest_use_tick_db", tail_backtest_use_tick_db(params))
if str(args.tick_exit).strip() != "":
params["backtest_use_tick_exit"] = _bool_arg(args.tick_exit, True)
else:
params.setdefault("backtest_use_tick_exit", tail_backtest_use_tick_exit(params))
# 절대규칙: CLI 백테도 OHLC 폴백으로 숫자 변조 금지
params["backtest_tick_fallback_ohlc"] = False
ob_mode = (args.orderbook_filter or "off").strip().lower()
if ob_mode == "off":
params["_orderbook_filter_enabled"] = False
elif ob_mode == "on":
params["_orderbook_filter_enabled"] = True
# 다단트레일 — 실매 SHORT_DAILY_PROFIT_* (apply_daily_profit_halt_sim 이 env/params 추종)
params["_daily_profit_strategy_id"] = "SHORT"
params.setdefault("_backtest_daily_profit_trail", None) # None → DB ENABLED 추종
if args.env_timeline:
params["_backtest_env_timeline"] = True
start_key, end_key, start_ymd, end_ymd = tbc.date_keys(start, end)
use_hist = args.universe == "history"
hist_src = (args.universe_history_source or "").strip() or None
universe_by_slot, universe_source, universe_history_slots, scan_iv = (
tbc.resolve_tail_universe(
start_ymd, end_ymd, use_saved_history=use_hist, history_source=hist_src,
)
)
params["scan_interval_min"] = scan_iv
logger.info(
"📌 유니버스=%s slots=%s | tick_db=%s tick_exit=%s",
universe_source,
universe_history_slots,
params.get("backtest_use_tick_db"),
params.get("backtest_use_tick_exit"),
)
rsi_period = int(params.get("rsi_period") or 14)
_write_progress(prog_file, pct=15, phase="candles", message="분봉 로드")
candles_by_code, n_bars, _ = tbc.load_tail_candles_by_code(
db, start_key, end_key, int(args.timeframe), rsi_period=rsi_period,
)
if not candles_by_code:
logger.error("❌ 봉 없음 — 종료")
_write_progress(prog_file, pct=100, phase="error", message="봉 없음")
return 1
logger.info("📌 봉 로드 codes=%d bars≈%s", len(candles_by_code), n_bars)
_write_progress(prog_file, pct=35, phase="candles_done", message=f"{len(candles_by_code)}")
if params.get("backtest_use_tick_db") or params.get("backtest_use_tick_exit"):
_write_progress(prog_file, pct=40, phase="ticks", message="틱 로드·리플레이 준비")
if prog_file:
params["_bt_progress_file"] = prog_file
params["_bt_progress_base_pct"] = 45
params["_bt_progress_span_pct"] = 50
slot_money = float(portfolio["slot_money"])
max_stocks = int(portfolio["max_stocks"])
total_budget = float(portfolio["total_budget_krw"])
# params-json 이 포트 키를 덮었으면 그쪽 우선
if overrides.get("slot_money") is not None:
slot_money = float(overrides["slot_money"])
if overrides.get("short_max_buy_amount") is not None and overrides.get("slot_money") is None:
slot_money = float(overrides["short_max_buy_amount"])
if overrides.get("max_stocks") is not None:
max_stocks = int(overrides["max_stocks"])
if overrides.get("total_budget_krw") is not None:
total_budget = float(overrides["total_budget_krw"])
meta: Dict[str, Any] = {
"start_key": start_key,
"end_key": end_key,
"job_id": job_id,
"universe_source": universe_source,
"universe_history_slots": universe_history_slots,
"db": db, # 틱 청산·진입 로드용 (없으면 OHLC만 → 실매와 괴리)
}
_write_progress(prog_file, pct=45, phase="engine", message="엔진 실행")
trades = tbc.run_tail_backtest_web_aligned(
candles_by_code,
params,
universe_by_slot,
slot_money=slot_money,
fee_rate=fee_rate,
sell_tax=sell_tax,
max_stocks=max_stocks,
total_budget_krw=total_budget,
meta_out=meta,
)
# 가상거래 종목명 (웹 _enrich_trades_with_names 와 동일 소스)
try:
from kis_trader.utils.stock_name import resolve_stock_display_name
for t in trades:
c = str(t.get("code") or "").strip()
if not c:
continue
if t.get("name"):
continue
t["name"] = resolve_stock_display_name(db, c, fallback=c)
except Exception as exc:
logger.debug("종목명 enrich 스킵: %s", exc)
_write_progress(prog_file, pct=96, phase="summarize", message="요약")
period_days = max(
1,
(datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1,
)
summary = tbc.summarize_tail_trades(
trades,
total_budget_krw=total_budget,
period_days=period_days,
)
elapsed = time.time() - t0
out = {
"ok": True,
"job_id": job_id,
"kind": "tail_bt_cli",
"strategy": "tail",
"start": start,
"end": end,
"timeframe": int(args.timeframe),
"universe": args.universe,
"universe_source": universe_source,
"elapsed_sec": round(elapsed, 1),
"summary": summary,
"n_trades": len(trades),
"trades": trades,
"meta": {
k: meta[k]
for k in meta
if k not in ("engine_params",)
},
"params_used": {
k: params.get(k)
for k in sorted(params.keys())
if not str(k).startswith("_") or k in (
"_orderbook_filter_enabled",
"_backtest_daily_profit_trail",
"_daily_profit_strategy_id",
)
},
"note": "웹 /api/backtest/tail 동일 엔진 · 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={len(trades)} PnL={summary.get('total_pnl')}",
)
logger.info(
"✅ 완료 %.1fs | trades=%s WR=%s%% PnL=%s | %s",
elapsed,
summary.get("total_trades") or summary.get("n_trades") or len(trades),
summary.get("win_rate"),
summary.get("total_pnl"),
out_json,
)
print(
json.dumps(
{
"ok": True,
"job_id": job_id,
"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)