#!/usr/bin/env python3 """ kis_trader/backtest/tail_param_search.py — 꼬리잡기 백테스트 파라미터 자동 탐색 (Grid Search) ============================================================================================== tail_engine 을 직접 임포트해 run_tail_backtest 호출. 기본값은 DB(env_config) 단일 소스. 실매매·백테스트·파라서치가 동일한 env 값을 사용해 결과 예측 가능. [V3 통합]: MA20, 피뢰침, ATR 동적 배수, 리스크 비율 등 고급 방어 로직 변수 탐색 추가. [성능 최적화]: 멀티프로세싱(ProcessPool) 및 Heapq 기반 메모리 최적화 적용. 실행: cd /home/hoon/kis_bot python3 kis_trader/backtest/tail_param_search.py # 또는 python3 -m kis_trader.backtest.tail_param_search --mode full --apply 옵션: --start 시작일 (기본: 오늘-7일) --end 종료일 (기본: 오늘) --mode 탐색 모드: coarse / fine / full / massive (기본: coarse) --top 상위 N개 출력·JSON 저장 (기본: 5000) --min_trades 최소 거래 건수 필터 (기본: 1) --min_win_rate 승률 하한 (기본: 45.0). --apply [N] 1위(또는 N위) 결과를 DB에 적용. N 생략 시 1. --from-file --apply N 과 함께 사용 시, 최근 결과 JSON에서 N번째 적용 (탐색 생략). 위치 이관 (2026-04 기준): backtest_scalping/tail_param_search.py → kis_trader/backtest/tail_param_search.py - ROOT = kis_bot 프로젝트 루트 (__file__ 기준 3단계 위) - 결과 저장: kis_trader/backtest/results/*.json - tail_param_result.json: kis_trader/backtest/tail_param_result.json (구 경로 backtest_scalping/tail_param_result.json 도 읽기 fallback) """ import sys import os import time import json import signal import logging import argparse import itertools import heapq from datetime import datetime, timedelta from concurrent.futures import ProcessPoolExecutor, as_completed from typing import List, Dict, Any, Tuple, Optional # ───────────────────────────────────────────────────────────────────────────── # 멀티프로세싱 안전장치 — 부모(마스터)가 죽으면 워커도 자동으로 함께 종료 # ───────────────────────────────────────────────────────────────────────────── # Linux `PR_SET_PDEATHSIG` 로 부모 종료 시 워커도 SIGTERM 을 받도록 설정. # (Ctrl+C / kill 어느 경로든 워커가 고아 프로세스로 남지 않게 함) def _worker_init() -> None: """ProcessPoolExecutor initializer — 워커 생성 직후 한 번 호출.""" try: if sys.platform.startswith("linux"): import ctypes PR_SET_PDEATHSIG = 1 libc = ctypes.CDLL("libc.so.6", use_errno=True) libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM, 0, 0, 0) except Exception: pass # 부모가 SIGINT 처리를 하는 동안 워커는 중간 예외로 죽지 않도록 SIGINT 무시. try: signal.signal(signal.SIGINT, signal.SIG_IGN) except Exception: pass # 프로젝트 루트 경로 추가 (database, tail_engine 등 임포트용) HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) if ROOT not in sys.path: sys.path.insert(0, ROOT) if HERE not in sys.path: sys.path.insert(0, HERE) from database import TradeDB import tail_engine as te logging.basicConfig(level=logging.INFO, format='%(message)s') logger = logging.getLogger("tail_param_search") MIN_WIN_RATE_DEFAULT = 45.0 # ───────────────────────────────────────────────────────────────────────────── # 결과 디렉터리 (신규 위치 우선, 구 경로 fallback) # ───────────────────────────────────────────────────────────────────────────── def _results_dir_for_write() -> str: d = os.path.join(HERE, "results") os.makedirs(d, exist_ok=True) return d def _tail_result_paths_for_read() -> List[str]: """tail_param_result.json 조회 후보. 신규 → 구 순서.""" return [ os.path.join(HERE, "tail_param_result.json"), os.path.join(ROOT, "backtest_scalping", "tail_param_result.json"), ] def _tail_result_path_for_write() -> str: return os.path.join(HERE, "tail_param_result.json") def _find_tail_result_json() -> Optional[str]: for p in _tail_result_paths_for_read(): if os.path.isfile(p): return p return None # ───────────────────────────────────────────────────────────────────────────── # 파라미터 그리드 정의 (V3 방어 로직 포함) # ───────────────────────────────────────────────────────────────────────────── PARAM_GRIDS = { # ───────────────────────────────────────────────────────────────────── # [COARSE] 실제 결과에 영향 주는 축만 빠르게 훑는 1일 1회 탐색용 # ───────────────────────────────────────────────────────────────────── # ⚠️ 꼬리잡기 엔진 특성: # - 실제 손절가 = entry - ATR × stop_atr_mult (sl_pct 는 미사용) # - 실제 익절가 = entry + ATR × target_atr_mult (tp_pct 는 미사용) # - sl_pct 는 포지션 "수량 사이징" 계산에만 쓰임 → 여기선 고정 # - tp_pct 는 엔진에서 아예 안 씀 → 고정 # 따라서 탐색 축은 ATR 배수(stop/target) + 어깨컷(트레일링 스탑) + 진입필터. # # 총 조합: 3×2×3×3×3×3×3×3 = 4,374 (수 분 내 완료 예상) "coarse": { # ── 진입 필터 (어떤 꼬리 모양을 살 것인가) ───────────── "min_drop_rate": [0.02, 0.03, 0.04], # 당일 낙폭(저가/시가) 최소값 "min_recovery_ratio": [0.35, 0.5], # 저가→현재 반등률 최소값 "tail_ratio_min": [1.0, 1.5, 2.0], # 아래꼬리/몸통 비율 최소값 # ── 청산 (실제 P&L 을 좌우하는 축) ──────────────────── "stop_atr_mult": [2.0, 2.5, 3.0], # 동적 손절 폭 (ATR 배수) "target_atr_mult": [5.0, 8.0, 12.0], # 동적 익절 폭 (ATR 배수) "shoulder_min_high": [0.015, 0.03, 0.05], # 어깨컷(트레일) 발동 수익 임계 — 0.05 에 가까울수록 OFF 효과 "shoulder_cut_pct": [0.02, 0.03, 0.05], # 어깨컷 추적 폭 (고점 대비 하락%) # ── 자금 관리 ───────────────────────────────────────── "max_loss_krw": [100000, 200000, 300000], # 1회 최대 손실(원) # ── 고정 (엔진 미사용 또는 사이징 전용) ─────────────── "sl_pct": [0.02], # 포지션 수량 계산 전용 (실제 손절은 ATR) "tp_pct": [0.05], # ⚠️ 꼬리잡기 엔진은 사용 안 함 "min_drop_pct_for_loss_cut": [0.015], # 금액손실컷 발동 최소 하락률 }, "fine": { "min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04], "min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6], "tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0], "tail_pct_min": [0.001, 0.002, 0.003, 0.005], "sl_pct": [0.015, 0.02, 0.025, 0.03, 0.04], "tp_pct": [0.03, 0.04, 0.05, 0.06, 0.07], "shoulder_cut_pct": [0.02, 0.025, 0.03, 0.04], "rsi_threshold": [72, 75, 78, 82], "max_loss_krw": [150000, 200000, 300000], "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], }, "full": { "min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05], "min_recovery_ratio": [0.35, 0.4, 0.5, 0.6], "tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0], "tail_pct_min": [0.001, 0.002, 0.003], "sl_pct": [0.015, 0.02, 0.025, 0.03, 0.04], "tp_pct": [0.03, 0.04, 0.05, 0.06, 0.07, 0.08], "shoulder_cut_pct": [0.02, 0.03, 0.04], "ma20_max_above": [3.0, 5.0], "max_daily_change": [15.0, 20.0, 25.0], "stop_atr_mult": [2.0, 2.5, 3.0], "target_atr_mult": [5.0, 6.0, 8.0, 10.0], "max_loss_krw": [100000, 200000, 300000], "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025], }, "massive": { "min_drop_rate": [0.015, 0.02, 0.025, 0.03, 0.04, 0.05], "min_recovery_ratio": [0.35, 0.4, 0.45, 0.5, 0.6], "tail_ratio_min": [1.0, 1.2, 1.5, 1.8, 2.0], "tail_pct_min": [0.001, 0.002, 0.003, 0.005], "sl_pct": [0.015, 0.02, 0.025, 0.03, 0.04], "tp_pct": [0.03, 0.04, 0.05, 0.06, 0.07, 0.10], "shoulder_cut_pct": [0.02, 0.025, 0.03, 0.04], "ma20_max_above": [2.0, 3.0, 5.0], "max_daily_change": [15.0, 20.0, 25.0], "stop_atr_mult": [2.0, 2.5, 3.0], "target_atr_mult": [5.0, 7.0, 9.0], "risk_pct": [0.005, 0.01, 0.02], "kelly_mult": [0.15, 0.25, 0.5], "max_loss_krw": [200000, 300000], "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], } } def evaluate_param_chunk( param_chunk: List[Dict[str, Any]], base_params: Dict[str, Any], candles_by_code: Dict[str, List[Dict]], fee_rate: float, sell_tax: float, min_trades: int, min_win_rate: float, top_n: int, universe_by_slot: Optional[Dict[str, List[str]]] = None, ) -> List[Tuple[float, Dict]]: """ 워커 프로세스에서 실행될 백테스트 평가 함수. universe_by_slot이 있으면 유니버스 히스토리(5분마다 후보)만 매수 검사. """ local_heap = [] for combo in param_chunk: test_params = dict(base_params) test_params.update(combo) trades = te.run_tail_backtest(candles_by_code, test_params, universe_by_slot=universe_by_slot) total_trades = len(trades) if total_trades < min_trades: continue wins, losses, total_pnl = 0, 0, 0.0 for t in trades: qty = t.get("qty", 1) fee = (t["entry"] + t["exit"]) * qty * fee_rate tax = t["exit"] * qty * sell_tax pnl = (t["exit"] - t["entry"]) * qty - fee - tax total_pnl += pnl if pnl > 0: wins += 1 else: losses += 1 win_rate = (wins / total_trades) * 100 if total_trades > 0 else 0 result_pkg = { "params": combo, "total_trades": total_trades, "win_rate": round(win_rate, 2), "total_pnl": int(total_pnl), "wins": wins, "losses": losses } if len(local_heap) < top_n: heapq.heappush(local_heap, (win_rate, total_pnl, id(result_pkg), result_pkg)) else: heapq.heappushpop(local_heap, (win_rate, total_pnl, id(result_pkg), result_pkg)) return local_heap def run_search(start: str, end: str, mode: str, top_n: int, min_trades: int, min_win_rate: float, sort_by: str = "pnl", use_fallback_universe: bool = False) -> bool: """탐색 실행. 결과가 있어서 JSON 저장까지 했으면 True, 조건 만족 조합 없이 조기 return 시 False.""" db = TradeDB() try: # 1. Base Environment Parameters 로드 base_params = te.get_tail_defaults_from_db(db) row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() if row: r = dict(row) fee_rate = float(r.get("FEE_RATE_PCT") or 0.015) / 100 sell_tax = float(r.get("SELL_TAX_RATE_PCT") or 0.18) / 100 base_params["capital"] = float(r.get("BACKTEST_CAPITAL") or 10000000.0) else: fee_rate = 0.015 / 100 sell_tax = 0.18 / 100 base_params["capital"] = 10000000.0 # 2. 캔들 데이터 로드 (1번만 로드하여 멀티프로세스 워커에 전달) start_key = start.replace("-", "") + "0000" end_key = end.replace("-", "") + "2359" logger.info(f"📅 데이터 로드: {start} ~ {end}") codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=3 " "AND candle_time >= %s AND candle_time <= %s", [start_key, end_key] ).fetchall() codes = [r["code"] for r in codes_raw] candles_by_code = {} total_candles = 0 rsi_period = int(base_params.get("rsi_period", 14)) for code in codes: rows = db.conn.execute( "SELECT candle_time, open, high, low, close, volume " "FROM ws_candles WHERE timeframe=3 AND code=%s " "AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 " "ORDER BY candle_time ASC", [code, start_key, end_key] ).fetchall() if len(rows) < rsi_period + 5: continue candles_by_code[code] = [dict(r) for r in rows] total_candles += len(rows) if not candles_by_code: logger.info("❌ 백테스트할 데이터가 없습니다.") return False logger.info(f"📦 종목 수: {len(candles_by_code)}개 | 총 캔들 수: {total_candles:,}개") # 2-2. 유니버스: 파람서치 시 조합별 거래 수가 너무 적으면 --fallback-universe 로 전체 종목 사용 # 신봇 기준: TradeDBExt.get_universe_by_candle_time("SHORT", ...) 로 # 초단위 event_time 이력을 1분 캔들 시각 키로 리샘플링해서 로드. start_ymd = start_key[:8] end_ymd = end_key[:8] universe_by_slot = None if use_fallback_universe: print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → 전체 종목 기준 (조합별 거래 수 확대)") else: try: from kis_trader.database.db_manager import get_db as _get_ext_db _ext = _get_ext_db() history = _ext.get_universe_by_candle_time( strategy_id="SHORT", start_ymd=start_ymd, end_ymd=end_ymd, ) if history: universe_by_slot = history n_bins = len(history) avg = sum(len(v) for v in history.values()) / max(1, n_bins) print( f"✅ 유니버스: 신봇 이력 사용 (event_time → 1분 캔들 리샘플링) | " f"{n_bins:,}분봉 · 평균 {avg:.1f}종목 (SHORT)" ) print( "📌 [유니버스] 이력만 쓰면 매수 기회가 적어 거래 0~1건 나올 수 있음. " "조합 많을 때는 --fallback-universe 권장." ) else: print("📌 [유니버스] 저장 이력 없음 → 전체 종목 기준 (시간 제한 없음)") except Exception as e: logger.debug("유니버스 이력 조회 스킵: %s", e) print("📌 [유니버스] 저장 이력 조회 실패 → 전체 종목 기준") # 엔진에 슬롯 단위 주입: 신봇 이력 → 1분봉, 이력 없음 → universe_by_slot=None 이므로 무의미. # base_params 에 명시해 두면 엔진 _slot_key 가 정확히 1분 키로 매칭. base_params["scan_interval_min"] = 1 # 3. Grid 조합 생성 grid = PARAM_GRIDS.get(mode) if not grid: logger.error(f"❌ 알 수 없는 모드: {mode}") return False keys = list(grid.keys()) values = list(grid.values()) combos = list(itertools.product(*values)) total_combos = len(combos) logger.info(f"🔍 탐색 모드: {mode.upper()} | 총 조합 수: {total_combos:,}개") logger.info(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}") dict_combos = [dict(zip(keys, combo)) for combo in combos] # 4. 멀티프로세싱을 위한 청크 분할 (메모리 및 부하 분산) n_cpu = os.cpu_count() or 4 # 스캘핑·꼬리잡기 두 서치를 동시에 돌려도 합산 80% 가 되도록 40% 로 유지. # (단독 실행 시 CPU 절반 놀지만, 하루 1회 자동 최적화 파이프라인 보호가 우선) max_workers = max(1, int(n_cpu * 0.4)) # 청크 크기 최대 300개로 제한하여 실시간 프로그레스가 자주 업데이트되도록 함 chunk_size = min(300, max(50, total_combos // (max_workers * 4))) chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)] logger.info(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers} ≈ 80%%) | 청크: {len(chunks):,}개") start_time = time.time() global_heap = [] # initializer=_worker_init 로 워커에 PR_SET_PDEATHSIG 설정 (부모 죽으면 자동 종료) with ProcessPoolExecutor(max_workers=max_workers, initializer=_worker_init) as executor: futures = { executor.submit( evaluate_param_chunk, chunk, base_params, candles_by_code, fee_rate, sell_tax, min_trades, min_win_rate, top_n, universe_by_slot ): chunk for chunk in chunks } logger.info(f"⏳ 청크 처리 중… (청크당 최대 {chunk_size:,}개 조합, 완료되는 대로 진행률·ETA 출력)") processed = 0 use_carriage_return = sys.stdout.isatty() for future in as_completed(futures): processed += 1 local_results = future.result() for i, item in enumerate(local_results): wr, pnl, _, result_pkg = item if sort_by == "pnl": unique_item = (-pnl, wr, (processed, i), result_pkg) else: unique_item = (wr, pnl, (processed, i), result_pkg) if len(global_heap) < top_n: heapq.heappush(global_heap, unique_item) else: heapq.heappushpop(global_heap, unique_item) # ── 정확한 ETA(예상 남은 시간) 계산 로직 ── progress = (processed / len(chunks)) * 100 elapsed_so_far = time.time() - start_time # 지금까지 걸린 총 시간을 완료된 청크 개수로 나누어 청크당 평균 시간 도출 avg_time_per_chunk = elapsed_so_far / processed remaining_chunks = len(chunks) - processed eta_sec = avg_time_per_chunk * remaining_chunks # 초 단위 포맷팅 (시간/분/초) eta_m, eta_s = divmod(int(eta_sec), 60) eta_h, eta_m = divmod(eta_m, 60) if eta_h > 0: eta_str = f"{eta_h}시간 {eta_m}분 {eta_s}초" elif eta_m > 0: eta_str = f"{eta_m}분 {eta_s}초" else: eta_str = f"{eta_s}초" elapsed_m, elapsed_s = divmod(int(elapsed_so_far), 60) eta_msg = f" | 경과: {elapsed_m}분 {elapsed_s}초 | 남은시간: {eta_str}" line = f"⏳ 진행률: {progress:.1f}% ({processed:,}/{len(chunks):,} 청크 완료){eta_msg}" if use_carriage_return: print(f"\r{line}", end="", flush=True) else: logger.info(line) if use_carriage_return: print(flush=True) # 줄바꿈으로 진행률 줄 마무리 elapsed = time.time() - start_time # 5. 결과 정렬 및 출력 if not global_heap: logger.info("⚠️ 조건을 만족하는 조합이 없습니다. (min_trades를 낮추거나 기간을 늘려보세요)") print("📌 DB 미적용. 기존 설정 유지.") return False # sort_by pnl → (-pnl, wr) 최소힙 → heappop 순이 이미 [best pnl, ..., worst] 이므로 reverse 금지 # sort_by win_rate → (wr, pnl) 최소힙 → heappop 순은 [low wr, ..., high wr] 이므로 reverse 필요 best_results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))] if sort_by == "win_rate": best_results.reverse() # sl_pct(손절%)별 상위 보장 → 한 값만 상위 독점 방지, 동점이면 손절 낮은 쪽(보수적) 1위 if "sl_pct" in keys and best_results: sl_vals = sorted(set(r["params"]["sl_pct"] for r in best_results)) per_sl = max(1, top_n // len(sl_vals)) by_sl = {} for r in best_results: v = r["params"]["sl_pct"] if v not in by_sl: by_sl[v] = [] if len(by_sl[v]) < per_sl: by_sl[v].append(r) best_results = [] for v in sl_vals: best_results.extend(by_sl.get(v, [])) best_results.sort(key=lambda r: (-r["total_pnl"], r["params"]["sl_pct"], -r["win_rate"])) logger.info(f"✅ 손절(sl_pct)별 상위 {per_sl}개씩 보장 → {len(best_results)}건 (동점 시 손절 낮은 쪽 1위)") filtered = [r for r in best_results if r["win_rate"] >= min_win_rate] if filtered: best_results = filtered order_msg = "수익→승률 순" if sort_by == "pnl" else "승률→수익 순" logger.info(f"✅ 승률 {min_win_rate}% 이상 {len(best_results)}건 중 {order_msg} 상위 사용") else: logger.info(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(상위) 적용") # 손익 마이너스인 조합 제외 (수익 나는 것만 표시·저장) profitable = [r for r in best_results if r["total_pnl"] > 0] if profitable: best_results = profitable logger.info(f"✅ 총손익 플러스만 사용: {len(best_results)}건 (손실 조합 제외)") else: logger.info(f"⚠️ 수익 나는 조합 없음 → 손실 최소 순으로 표시") order_label = "수익" if sort_by == "pnl" else "승률" hdr_keys = [k for k in keys if k in (best_results[0]["params"] if best_results else {})] col_w = max((len(k) for k in hdr_keys), default=6) + 2 sep_w = len(hdr_keys) * (col_w + 2) + 50 print(f"\n✅ 탐색 완료! 총 소요 시간: {elapsed:.1f}초") print(f"\n{'='*min(sep_w, 100)}") print(f" 🏆 꼬리잡기 {order_label} TOP {min(top_n, len(best_results))}") print(f"{'='*min(sep_w, 100)}") hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys) print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5}") print("-" * min(sep_w + 10, 110)) for i, res in enumerate(best_results[:top_n]): p = res["params"] row = " ".join(f"{p.get(k, ''):>{col_w}.4g}" for k in hdr_keys) pf = res.get("pf", 0) or 0 print(f"{row} | {res['total_pnl']:>+12,.0f} {res['win_rate']:>5.1f}% {res['total_trades']:>5} {pf:>5.2f}") best = best_results[0] bp = best["params"] print(f""" ╔══════════════════════════════════════════╗ ║ 🏆 1위 최적 파라미터 ║ ╠══════════════════════════════════════════╣""") for k in hdr_keys: v = bp.get(k, "") print(f"║ {k:<28s} : {str(v):>6} ║") print(f"""╠══════════════════════════════════════════╣ ║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║ ║ 승률 : {best['win_rate']:>6.1f}% ║ ║ 총 거래 : {best['total_trades']:>5} 건 ║ ║ Profit Factor : {best.get('pf', 0):>5.2f} ║ ╚══════════════════════════════════════════╝""") # 6. JSON 파일로 결과 저장 out_data = { "mode": mode, "start": start, "end": end, "min_trades": min_trades, "min_win_rate": min_win_rate, "tested_combos": total_combos, "elapsed_sec": round(elapsed, 1), "results": best_results[:top_n] } out_dir = _results_dir_for_write() ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(out_dir, f"tail_search_{mode}_{ts}.json") with open(out_path, "w", encoding="utf-8") as f: json.dump(out_data, f, indent=2, ensure_ascii=False) out_file = _tail_result_path_for_write() with open(out_file, "w", encoding="utf-8") as f: json.dump(out_data, f, indent=2, ensure_ascii=False) logger.info(f"\n💾 결과 저장: {out_path}") logger.info(f"💾 적용용 복사: {out_file}") return True except Exception as e: logger.error(f"❌ 탐색 중 오류 발생: {e}", exc_info=True) return False finally: db.close() def apply_from_json(apply_idx: int): """ 저장된 JSON 결과에서 N위 조합을 추출하여 env_config에 즉시 반영 """ out_file = _find_tail_result_json() if not out_file: logger.error( "❌ 결과를 찾을 수 없습니다. 후보 경로:\n - " + "\n - ".join(_tail_result_paths_for_read()) ) return with open(out_file, "r", encoding="utf-8") as f: data = json.load(f) results = data.get("results", []) if not results: logger.error("❌ JSON에 저장된 결과가 없습니다.") return if apply_idx < 1 or apply_idx > len(results): logger.error(f"❌ 유효하지 않은 순위입니다. (1~{len(results)} 사이 입력)") return target = results[apply_idx - 1] if target.get("total_pnl", 0) <= 0: logger.warning(f"⚠️ {apply_idx}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.") print("⚠️ 해당 순위는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.") return p = target["params"] env_map = {} if "min_drop_rate" in p: env_map["MIN_DROP_RATE"] = str(p["min_drop_rate"]) if "min_recovery_ratio" in p: env_map["MIN_RECOVERY_RATIO_SHORT"] = str(p["min_recovery_ratio"]) if "tail_ratio_min" in p: env_map["TAIL_RATIO_MIN"] = str(p["tail_ratio_min"]) if "tail_pct_min" in p: env_map["TAIL_PCT_MIN"] = str(p["tail_pct_min"]) if "sl_pct" in p: env_map["STOP_LOSS_PCT"] = str(-abs(p["sl_pct"])) if "tp_pct" in p: env_map["TAKE_PROFIT_PCT"] = str(p["tp_pct"]) if "shoulder_cut_pct" in p: env_map["SHOULDER_CUT_PCT"] = str(p["shoulder_cut_pct"]) if "rsi_threshold" in p: env_map["RSI_OVERHEAT_THRESHOLD"] = str(p["rsi_threshold"]) if "ma20_max_above" in p: env_map["MA20_MAX_ABOVE_PCT"] = str(p["ma20_max_above"]) if "max_daily_change" in p: env_map["MAX_DAILY_CHANGE_PCT"] = str(p["max_daily_change"]) if "stop_atr_mult" in p: env_map["STOP_ATR_MULTIPLIER_TAIL"] = str(p["stop_atr_mult"]) if "target_atr_mult" in p: env_map["TARGET_ATR_MULTIPLIER_TAIL"] = str(p["target_atr_mult"]) if "risk_pct" in p: env_map["RISK_PCT_PER_TRADE"] = str(p["risk_pct"]) if "kelly_mult" in p: env_map["KELLY_MULTIPLIER"] = str(p["kelly_mult"]) if "max_loss_krw" in p: env_map["MAX_LOSS_PER_TRADE_KRW"] = str(int(float(p["max_loss_krw"]))) if "min_drop_pct_for_loss_cut" in p: v = float(p["min_drop_pct_for_loss_cut"]) env_map["MIN_DROP_PCT_FOR_LOSS_CUT"] = str(round(v * 100, 2)) if v < 1 else str(round(v, 2)) db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(env_map) env_id = db.insert_env_snapshot(snap) logger.info(f"\n🚀 [자동 반영 완료] {apply_idx}위 조합을 DB에 적용했습니다. (env_id: {env_id})") logger.info(f"적용된 값: {json.dumps(env_map, indent=2)}") logger.info("실매매 봇(kis_trader/main.py TailCatchStrategy)이 다음 루프부터 이 설정을 사용하여 매매를 시작합니다.") finally: db.close() def apply_params_to_db(p: dict): """ params 딕셔너리를 DB env_config에 반영. (param_apply_ai에서 AI가 고른 조합 적용 시 호출) """ env_map = {} if "min_drop_rate" in p: env_map["MIN_DROP_RATE"] = str(p["min_drop_rate"]) if "min_recovery_ratio" in p: env_map["MIN_RECOVERY_RATIO_SHORT"] = str(p["min_recovery_ratio"]) if "tail_ratio_min" in p: env_map["TAIL_RATIO_MIN"] = str(p["tail_ratio_min"]) if "tail_pct_min" in p: env_map["TAIL_PCT_MIN"] = str(p["tail_pct_min"]) if "sl_pct" in p: env_map["STOP_LOSS_PCT"] = str(-abs(p["sl_pct"])) if "tp_pct" in p: env_map["TAKE_PROFIT_PCT"] = str(p["tp_pct"]) if "shoulder_cut_pct" in p: env_map["SHOULDER_CUT_PCT"] = str(p["shoulder_cut_pct"]) if "rsi_threshold" in p: env_map["RSI_OVERHEAT_THRESHOLD"] = str(p["rsi_threshold"]) if "ma20_max_above" in p: env_map["MA20_MAX_ABOVE_PCT"] = str(p["ma20_max_above"]) if "max_daily_change" in p: env_map["MAX_DAILY_CHANGE_PCT"] = str(p["max_daily_change"]) if "stop_atr_mult" in p: env_map["STOP_ATR_MULTIPLIER_TAIL"] = str(p["stop_atr_mult"]) if "target_atr_mult" in p: env_map["TARGET_ATR_MULTIPLIER_TAIL"] = str(p["target_atr_mult"]) if "risk_pct" in p: env_map["RISK_PCT_PER_TRADE"] = str(p["risk_pct"]) if "kelly_mult" in p: env_map["KELLY_MULTIPLIER"] = str(p["kelly_mult"]) if "max_loss_krw" in p: env_map["MAX_LOSS_PER_TRADE_KRW"] = str(int(float(p["max_loss_krw"]))) if "min_drop_pct_for_loss_cut" in p: v = float(p["min_drop_pct_for_loss_cut"]) env_map["MIN_DROP_PCT_FOR_LOSS_CUT"] = str(round(v * 100, 2)) if v < 1 else str(round(v, 2)) db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(env_map) db.insert_env_snapshot(snap) finally: db.close() def main(): today = datetime.now().strftime("%Y-%m-%d") week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") parser = argparse.ArgumentParser(description="꼬리잡기 V3 백테스트 파라미터 최적화 (Grid Search)") parser.add_argument("--start", default=week_ago, help="시작일 (YYYY-MM-DD)") parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD)") parser.add_argument("--mode", default="coarse", choices=["coarse", "fine", "full", "massive"], help="탐색 모드 (massive는 수백만 조합이므로 장시간 소요)") parser.add_argument("--top", default=5000, type=int, help="메모리에 유지·JSON 저장할 상위 N개 (기본 5000)") parser.add_argument("--min_trades", default=1, type=int, help="최소 거래 건수") parser.add_argument("--min_win_rate", default=MIN_WIN_RATE_DEFAULT, type=float, help="승률 하한 (%%). 이 이상만 출력") parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N", help="N번째 결과를 DB에 적용 (기본 1위). --from-file 과 함께 쓰면 재탐색 없이 즉시 적용") parser.add_argument("--from-file", action="store_true", help="탐색 생략하고 기존 저장된 JSON에서 적용") parser.add_argument("--sort-by", default="pnl", choices=["pnl", "win_rate"], help="1위 기준: pnl=총손익 최대(기본), win_rate=승률 최대") parser.add_argument("--fallback-universe", action="store_true", dest="fallback_universe", help="저장 이력 무시, 전체 종목으로 매수 후보 산정. 조합 많을 때 거래 수 확대용 (이력 쓰면 슬롯 적어서 0~1건만 나올 수 있음)") parser.add_argument("--apply-ai", action="store_true", dest="apply_ai", help="Gemini가 수익·승률 기준으로 하나 골라 DB 적용. --from-file 과 함께 쓰면 탐색 없이 최근 JSON만 사용; 그 외에는 탐색 완료 후 방금 생성된 JSON으로 적용") args = parser.parse_args() # 탐색 없이 최근 JSON으로만 AI 적용 (--from-file --apply-ai) if args.apply_ai and args.from_file: import param_apply_ai param_apply_ai.apply_ai_tail() return if args.from_file and args.apply is not None: apply_from_json(args.apply) return # SIGTERM 도 KeyboardInterrupt 로 전환 (systemd·운영자 kill 대응) def _sigterm_to_kbd(_sig, _frm): raise KeyboardInterrupt("SIGTERM 수신 → 워커 정리 후 종료") try: signal.signal(signal.SIGTERM, _sigterm_to_kbd) except Exception: pass try: had_results = run_search( args.start, args.end, args.mode, args.top, args.min_trades, args.min_win_rate, sort_by=args.sort_by, use_fallback_universe=args.fallback_universe, ) except KeyboardInterrupt as e: print(f"\n⛔ {e} — 미완료 결과 없이 종료합니다.", flush=True) sys.exit(130) if args.apply is not None: if _find_tail_result_json(): apply_from_json(args.apply) else: logger.warning("결과가 없어 DB 적용을 건너뜁니다.") # 탐색에서 조건 만족 조합이 있었을 때만 방금 저장된 JSON으로 AI 적용 (없으면 기존 설정 유지) if args.apply_ai and had_results: import param_apply_ai param_apply_ai.apply_ai_tail() elif args.apply_ai and not had_results: print("📌 이번 탐색에서 조건 만족 조합 없음 → apply_ai 스킵. DB 미적용. 기존 설정 유지.") if __name__ == "__main__": main()