Files
kis_bot/backtest_scalping/holding_param_search.py
2026-03-17 12:33:30 +09:00

211 lines
8.3 KiB
Python

#!/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()