#!/usr/bin/env python3 """ 모멘텀 1위 파라미터 CLI 진단 백테스트 (웹 타임아웃 우회용 · 보존 스크립트) 목적 - 웹 모멘텀 백테가 5일치에서 타임아웃 → 같은 엔진(run_momentum_backtest_web_aligned)을 CLI 로 직접 돌려, 일자별 손익 + 필터 OFF/ON 비교를 한 화면에서 본다. - "웹 입력값(UI) ↔ 엔진 파라미터" 매핑을 나란히 출력해, trail_pct 같은 값이 중간에 깨지지 않는지 사람이 눈으로 검증한다. 사용 .venv/bin/python diag_momentum_backtest.py # 최신 JSON 1위, 5일 .venv/bin/python diag_momentum_backtest.py --rank 1 --start 2026-06-22 --end 2026-06-26 .venv/bin/python diag_momentum_backtest.py --json <경로> 주의 - 웹과 동일하게 universe=history(MOMENTUM 저장 이력) 사용. - 틱재생은 코드 기본값(OFF) 그대로 → 웹·파람서치 동일 1분봉 OHLC 경로. """ import sys, os, json, glob, argparse, logging from datetime import datetime logging.getLogger("TradeDB").setLevel(logging.WARNING) from database import TradeDB from kis_trader.backtest import momentum_backtest_common as mbc def _latest_json() -> str: pats = [ os.path.join("kis_trader", "backtest", "results", "search_momentum_*.json"), os.path.join(os.path.expanduser("~"), ".kis_bot_search_results", "search_momentum_*.json"), ] found = [] for p in pats: found.extend(glob.glob(p)) if not found: sys.exit("❌ search_momentum_*.json 을 찾을 수 없습니다. --json 으로 직접 지정하세요.") return max(found, key=os.path.getmtime) def main(): ap = argparse.ArgumentParser(description="모멘텀 1위 CLI 진단 백테스트") ap.add_argument("--json", default=None, help="param_search 결과 JSON (미지정 시 최신)") ap.add_argument("--rank", type=int, default=1, help="JSON 내 순위 (기본 1위)") ap.add_argument("--start", default=None, help="시작일 YYYY-MM-DD (미지정 시 JSON 기간)") ap.add_argument("--end", default=None, help="종료일 YYYY-MM-DD") args = ap.parse_args() json_path = args.json or _latest_json() with open(json_path, encoding="utf-8") as f: data = json.load(f) start = args.start or data.get("start") end = args.end or data.get("end") top = data.get("top") or [] if args.rank < 1 or args.rank > len(top): sys.exit(f"❌ rank {args.rank} 없음 (JSON 보유 {len(top)}개)") entry = top[args.rank - 1] ui = entry.get("params") or {} # 웹 폼에 입력될 값(그리드 축) merged = dict(entry.get("merged_params") or {}) # 엔진에 그대로 들어간 값 slot = float(merged.get("slot_money") or data.get("slot_money") or 300000.0) max_stocks = int(merged.get("max_stocks") or data.get("max_stocks") or 20) budget = float(merged.get("total_budget_krw") or data.get("total_budget_krw") or 6000000.0) fee_rate = float(merged.get("fee_rate") or 0.00015) sell_tax = float(merged.get("sell_tax") or 0.0018) # merged 의 fee/tax 가 퍼센트(0.015)로 저장된 옛 JSON 방어 → 비율로 정규화 if fee_rate > 0.01: fee_rate /= 100.0 if sell_tax > 0.05: sell_tax /= 100.0 print("=" * 78) print(f"📂 JSON : {json_path}") print(f"🏅 순위 : {args.rank}위 | 기간: {start} ~ {end}") print(f"💰 JSON기준: 손익 {entry.get('total_pnl', 0):+,.0f}원 | " f"승률 {entry.get('win_rate', 0):.1f}% | 거래 {entry.get('total_trades', 0)} | " f"PF {entry.get('pf', 0):.2f} (파람서치 필터 OFF)") print("=" * 78) # ── 웹 입력(UI) ↔ 엔진 파라미터 비교 (값 깨짐 검증) ── print("\n[웹 입력값 ↔ 엔진 파라미터 매핑]") cmp_keys = [ ("trail_pct", "trail_pct"), ("trail_arm_pct", "trail_arm_pct"), ("sl_pct", "sl_pct"), ("tp_pct", "tp_pct"), ("tp_max_pct", "tp_max_pct"), ("shoulder_min_high", "shoulder_min_high"), ("shoulder_cut_pct", "shoulder_cut_pct"), ("mom_rsi_min", "mom_rsi_min"), ("mom_vol_mult", "mom_vol_mult"), ("pattern_breakout", "pattern_breakout"), ("pattern_pullback", "pattern_pullback"), ("use_ema_filter", "use_ema_filter"), ("max_spread_pct", "max_spread_pct"), ] print(f" {'필드':22s} {'UI(폼)':>12s} {'엔진(merged)':>12s}") for uk, mk in cmp_keys: uv = ui.get(uk, "-") mv = merged.get(mk, "-") print(f" {uk:22s} {str(uv):>12s} {str(mv):>12s}") # ── 데이터 로딩 (param_search 와 동일 소스) ── print("\n⏳ 캔들 로딩...") from kis_trader.backtest.param_search_momentum import _load_candles_for_search rsi_period = int(merged.get("rsi_period", 3)) codes_candles = _load_candles_for_search(start, end, rsi_period) print(f" 종목 {len(codes_candles):,}") print("⏳ 유니버스(MOMENTUM 저장 이력)...") hist, src, n_bins, _iv, timing = mbc.resolve_momentum_universe( start.replace("-", ""), end.replace("-", ""), use_saved_history=True, strategy_id="MOMENTUM", ) print(f" src={src} bins={n_bins} timing={timing}") period_days = max(1, (datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1) start_key = start.replace("-", "") + "0000" end_key = end.replace("-", "") + "2359" # merged_params 는 UI 스케일(sl_pct=1.5 = 1.5%). 엔진은 비율(0.015)을 기대하므로 # param-search·웹과 동일하게 _ui_to_engine_params 로 변환한 뒤 엔진에 넣는다. from kis_trader.backtest.param_search_momentum import _ui_to_engine_params def run(label, ob_on, pg_on): ep = _ui_to_engine_params(dict(merged)) ep["slot_money"] = slot ep["max_stocks"] = max_stocks ep["total_budget_krw"] = budget ep["portfolio_mode"] = True ep["scan_interval_min"] = 1 ep["_orderbook_filter_enabled"] = ob_on ep["_program_filter_enabled"] = pg_on if ob_on or pg_on: ep["backtest_use_kiwoom_body_snapshot"] = True ep["_backtest_use_kiwoom_body"] = True db = TradeDB() meta = {"db": db, "start_key": start_key, "end_key": end_key} try: trades = mbc.run_momentum_backtest_web_aligned( codes_candles, ep, hist, slot_money=slot, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=max_stocks, total_budget_krw=budget, meta_out=meta, ) finally: db.close() st = mbc.summarize_momentum_trades( trades, total_budget_krw=budget, period_days=period_days, ) print(f"\n── {label} ──") print(f" 총손익 {st['total_pnl']:+,.0f}원 | 승률 {st['win_rate']:.1f}% | " f"거래 {st['total_trades']} | PF {st.get('pf', 0):.2f}") daily = {} for t in trades: day = str(t.get("buy_time") or t.get("entry_time") or "")[:8] d = daily.setdefault(day, [0, 0.0]) d[0] += 1 d[1] += float(t.get("pnl") or 0) for day in sorted(daily): n, p = daily[day] print(f" {day}: {n:3d}거래 {p:+,.0f}원") return st print("\n" + "=" * 78) print("백테 실행 (웹과 동일 엔진 · 1분봉 OHLC)") print("=" * 78) run("① 필터 OFF (파람서치 1위 재현)", False, False) run("② 호가필터 ON (웹 체크박스 호가만)", True, False) run("③ 호가+프로그램 ON (웹 기본 체크 그대로)", True, True) print("\n✅ 완료") if __name__ == "__main__": main()