#!/usr/bin/env python3 """ holding_param_search.py — 홀딩 전략 파라미터 자동 탐색 (종목별 Grid Search) ============================================================================= 실행: cd /home/hoon/kis_bot python3 backtest_scalping/holding_param_search.py 옵션: --code 종목코드 (미지정 시 관심종목 전체) --start 시작일 (기본: 2년 전) --end 종료일 (기본: 오늘) --mode coarse(기본) / fine / full --top 상위 N개 출력 (기본: 10) --apply 1위 파라미터를 DB에 자동 적용 예시: python3 backtest_scalping/holding_param_search.py --code 005930 --apply python3 backtest_scalping/holding_param_search.py --mode fine --apply """ import sys, os, json, time, argparse from datetime import datetime, timedelta from itertools import product as iproduct import logging logging.getLogger("TradeDB").setLevel(logging.WARNING) ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, ROOT) from database import TradeDB import holding_bot as hb # ────────────────────────────────────────────────────────────────────────────── # 그리드 정의 # ────────────────────────────────────────────────────────────────────────────── GRIDS = { # 빠른 1차 탐색 (~270 조합) "coarse": { "rsi_buy1": [40, 35, 30], "rsi_buy2": [35, 30, 25], "rsi_buy3": [30, 25, 20], "take_profit_pct": [3.0, 4.0, 5.0, 6.0, 8.0], "stop_loss_pct": [7.0, 10.0, 15.0], }, # 세밀 2차 탐색 (~2,400 조합) "fine": { "rsi_buy1": [45, 40, 35, 30], "rsi_buy2": [40, 35, 30, 25], "rsi_buy3": [35, 30, 25, 20], "take_profit_pct": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0], "stop_loss_pct": [5.0, 7.0, 10.0, 15.0, 20.0], "rsi_sell": [65, 70, 75], }, # 전체 탐색 (~10,000+ 조합) "full": { "rsi_buy1": [50, 45, 40, 35, 30], "rsi_buy2": [45, 40, 35, 30, 25], "rsi_buy3": [40, 35, 30, 25, 20, 15], "take_profit_pct": [2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0], "stop_loss_pct": [5.0, 7.0, 10.0, 15.0, 20.0], "rsi_sell": [60, 65, 70, 75, 80], "rsi_period": [7, 14, 21], }, } FIXED_DEFAULTS = { "rsi_sell": 70, "rsi_period": 14, "buy1_ratio": 30, "buy2_ratio": 30, "buy3_ratio": 40, "slot_money": 3_000_000, } def run_search(code: str, name: str, candles, mode: str, top_n: int, min_trades: int, apply_best: bool, db): grid = GRIDS[mode] keys = list(grid.keys()) combos = list(iproduct(*[grid[k] for k in keys])) valid = [(zip(keys, v)) for v in combos] results = [] base_cfg = dict(hb.DEFAULT_STOCK_CONFIG) base_cfg.update(FIXED_DEFAULTS) t0 = time.time() total = len(combos) print(f"\n[{code}] {name} | {mode.upper()} | 조합: {total:,}개") print("=" * 60) for i, vals in enumerate(combos): cfg = dict(base_cfg) cfg.update(dict(zip(keys, vals))) # rsi_buy 순서 보정 if cfg["rsi_buy1"] <= cfg["rsi_buy2"] or cfg["rsi_buy2"] <= cfg["rsi_buy3"]: continue res = hb.run_backtest(candles, cfg) s = res.get("summary", {}) if s.get("total_trades", 0) < min_trades: continue results.append({ "params": {k: cfg[k] for k in keys}, "total_pnl": s["total_pnl"], "win_rate": s["win_rate"], "total_trades": s["total_trades"], "pf": s["profit_factor"], "avg_hold": s["avg_hold_days"], "mdd": s["max_drawdown"], }) elapsed = time.time() - t0 print(f"완료: {elapsed:.1f}초 | 유효: {len(results)}건") if not results: print("⚠️ 유효 결과 없음. 기간을 늘리거나 min_trades를 낮추세요.") return results.sort(key=lambda x: x["total_pnl"], reverse=True) col_w = max(len(k) for k in keys) + 2 hdr = " ".join(f"{k:>{col_w}}" for k in keys) print(f"\n{'='*60}") print(f" TOP {min(top_n, len(results))}") print(f"{'='*60}") print(f"{hdr} | {'손익':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유(일)':>8}") print("-" * (len(hdr) + 50)) for r in results[:top_n]: p = r["params"] row = " ".join(f"{p[k]:>{col_w}.4g}" for k in keys) print(f"{row} | {r['total_pnl']:>+12,.0f} {r['win_rate']:>5.1f}% " f"{r['total_trades']:>5} {r['pf']:>5.2f} {r['avg_hold']:>7.1f}일") best = results[0] bp = best["params"] print(f""" ╔═══════════════════════════════════════════╗ ║ 🏆 [{code}] {name[:10]} 최적 파라미터 ╠═══════════════════════════════════════════╣""") for k, v in bp.items(): print(f"║ {k:<22s} : {v!s:>8} ║") print(f"""╠═══════════════════════════════════════════╣ ║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║ ║ 승률 : {best['win_rate']:>6.1f}% ║ ║ 거래 : {best['total_trades']:>5} 건 ║ ║ PF : {best['pf']:>5.2f} ║ ║ 보유(일) : {best['avg_hold']:>5.1f} 일 ║ ║ MDD : {best['mdd']:>12,.0f} 원 ║ ╚═══════════════════════════════════════════╝""") # 결과 저장 out_dir = os.path.join(ROOT, "backtest_scalping", "results") os.makedirs(out_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(out_dir, f"holding_{code}_{mode}_{ts}.json") with open(out_path, "w", encoding="utf-8") as f: json.dump({"code": code, "name": name, "mode": mode, "top": results[:top_n]}, f, ensure_ascii=False, indent=2) print(f"\n💾 결과 저장: {out_path}") if apply_best: hb.set_stock_config(db, code, name, bp) print(f"✅ DB 파라미터 적용 완료 ({code})") def main(): today = datetime.now().strftime("%Y-%m-%d") two_ago = (datetime.now() - timedelta(days=730)).strftime("%Y-%m-%d") parser = argparse.ArgumentParser(description="홀딩 전략 파라미터 Grid Search") parser.add_argument("--code", default="", help="종목코드 (공백=관심종목 전체)") parser.add_argument("--start", default=two_ago, help="시작일") parser.add_argument("--end", default=today, help="종료일") parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full"]) parser.add_argument("--top", default=10, type=int) parser.add_argument("--min_trades", default=2, type=int) parser.add_argument("--apply", action="store_true") args = parser.parse_args() db = TradeDB() hb.ensure_holding_tables(db) items = hb.load_watchlist() if args.code: items = [i for i in items if i["code"] == args.code] if not items: print(f"종목을 찾을 수 없습니다: {args.code}") db.close() return for item in items: code = item["code"] name = item["name"] candles = hb.get_stored_candles(db, code, args.start, args.end) if len(candles) < 20: print(f"⚠️ [{code}] {name}: 봉 부족 ({len(candles)}개) → 스킵. 먼저 캔들을 수집하세요.") print(f" 실행: python3 holding_bot.py fetch --start {args.start} --end {args.end}") continue run_search(code, name, candles, args.mode, args.top, args.min_trades, args.apply, db) db.close() if __name__ == "__main__": main()