#!/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 import os # 2026-09-06: 강제 세팅 제거 (docs/정합성.md §9). DB env 로만 관리. # - CANDLE_GARBAGE_FALLBACK 기본 True (wall-clock recv_ts 기준 실매 정합) # - BACKTEST_USE_RUST 는 명시적으로 켤 때만 (--use-rust 등) 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)