377 lines
14 KiB
Python
Executable File
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)
|