#!/usr/bin/env python3 """ kis_trader/backtest/param_search.py — 스캘핑 백테스트 파라미터 자동 탐색 (Grid Search) ====================================================================================== 실행: cd /home/hoon/kis_bot python3 kis_trader/backtest/param_search.py # 또는 패키지 모듈로 python3 -m kis_trader.backtest.param_search --mode coarse --apply-ai 옵션: --start 시작일 (기본: 오늘-7일) --end 종료일 (기본: 오늘) --mode 탐색 모드: coarse(기본) / fine / full / wide wide = 급등·최소가격·방어ON/OFF 등 방어축을 넓게(조합 수 큼) --top 상위 N개 출력 (기본: 5000) --min_trades 최소 거래 건수 필터 (기본: 1) --apply 결과 N위 조합을 DB에 자동 적용 (기본: False, 지정하면 N 생략 시 1) --apply-ai Gemini 로 수익·승률 기준 조합 하나 선택해서 DB 자동 적용 설명: 웹 API를 거치지 않고 scalping_engine 을 직접 호출하여 속도를 극대화했습니다. 피뢰침 방지(high_chase_thr)·급등주 필터(max_daily_chg)·방어필터 ON/OFF 도 그리드에 포함됩니다. 위치 이관 (2026-04 기준): backtest_scalping/param_search.py → kis_trader/backtest/param_search.py - ROOT = kis_bot 프로젝트 루트 (__file__ 기준 3단계 위) - 결과 저장: kis_trader/backtest/results/ (구 backtest_scalping/results 는 그대로 유지) - scalping_engine / database.TradeDB 는 ROOT 에서 그대로 임포트 """ import sys, os, json, time, argparse, signal import heapq from datetime import datetime, timedelta from itertools import product from concurrent.futures import ProcessPoolExecutor, as_completed from typing import Optional, List, Dict, Any, Tuple MIN_WIN_RATE_DEFAULT = 52.0 # ───────────────────────────────────────────────────────────────────────────── # 멀티프로세싱 안전장치 — 부모(마스터)가 죽으면 워커도 자동으로 함께 종료 # ───────────────────────────────────────────────────────────────────────────── # Linux 의 `PR_SET_PDEATHSIG` 를 이용해 "부모가 죽으면 이 시그널을 내게 보내라" # 고 커널에 요청. Ctrl+C / SIGTERM / SIGKILL 어느 경로로 부모가 사라져도 워커가 # 고아(PPID=1) 로 남지 않는다. 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 # 비 Linux 환경이거나 prctl 불가 시 무시 (치명적이지 않음) # 워커 프로세스에서는 부모의 KeyboardInterrupt 에 간섭하지 않도록 SIGINT 무시. # (부모가 시그널을 받아 shutdown 하는 동안 워커가 중간에 예외로 죽으면 # 결과 수집 로직이 혼잡해짐) try: signal.signal(signal.SIGINT, signal.SIG_IGN) except Exception: pass # kis_bot 루트를 경로에 추가 (스크립트·패키지 실행 모두 대응) 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) import logging logging.getLogger("TradeDB").setLevel(logging.WARNING) # 반복 초기화 로그 억제 from database import TradeDB import scalping_engine as se # ────────────────────────────────────────────────────────────────────────────── # 스캘핑 기본값 = 엔진에서 DB 로드 (백테스트 API와 동일 단일 소스, 실매매와 동기화) # ────────────────────────────────────────────────────────────────────────────── def _fixed_defaults(): """엔진 get_scalping_defaults_from_db() 사용. UI/파람서치용으로 %/비율 변환.""" _d = se.get_scalping_defaults_from_db() return { "rsi_period": _d["rsi_period"], "rsi_overbought": _d.get("rsi_overbought", 75.0), # 과열 차단 (그리드 탐색 제외, 고정값) "slot_money": _d["slot_money"], "vol_mult": _d["vol_mult"], "trail_trigger": _d["trail_trigger"] * 100, # % 단위 "trail_stop": _d["trail_stop"] * 100, # % 단위 "cooldown_min": _d["cooldown_min"], "time_start_hm": _d["time_start_hm"], "time_end_hm": _d["time_end_hm"], "max_daily": _d["max_daily"], "fee_rate": _d["fee_rate"] * 100, # % 단위 "sell_tax": _d["sell_tax"] * 100, # % 단위 # 방어로직 "high_chase_thr": _d["high_chase_thr"], # 비율 (0.96) "max_daily_chg": _d["max_daily_chg"], # % 단위 "min_price": _d["min_price"], "max_loss_krw": _d["max_loss_krw"], "min_margin": _d["min_margin"] * 100, # % 단위 "use_defense_filters": _d.get("use_defense_filters", True), } # RSI_OVERSOLD별 JSON 저장 개수 (한 RSI에 치중되지 않도록 균등 분배) PER_RSI_JSON = 20 # ────────────────────────────────────────────────────────────────────────────── # 결과 디렉터리 (신규 위치 우선, 구 경로도 계속 조회 가능) # ────────────────────────────────────────────────────────────────────────────── def _results_dir_for_write() -> str: """새로 저장할 결과는 kis_trader/backtest/results/ 에 둔다.""" d = os.path.join(HERE, "results") os.makedirs(d, exist_ok=True) return d def _results_dirs_for_read() -> List[str]: """읽기용 디렉터리: 신규 → 구 순서.""" return [ os.path.join(HERE, "results"), os.path.join(ROOT, "backtest_scalping", "results"), ] def _latest_json(prefix: str) -> Optional[str]: """읽기용 디렉터리에서 prefix 로 시작하는 가장 최근 JSON 경로 반환.""" best_path, best_mtime = None, -1.0 for d in _results_dirs_for_read(): if not os.path.isdir(d): continue for f in os.listdir(d): if not (f.startswith(prefix) and f.endswith(".json")): continue p = os.path.join(d, f) try: m = os.path.getmtime(p) except OSError: continue if m > best_mtime: best_mtime = m best_path = p return best_path # ────────────────────────────────────────────────────────────────────────────── # 파라미터 그리드 (min_price 등은 DB 앵커 기반 — 고정 1값만 쓰지 않음) # ────────────────────────────────────────────────────────────────────────────── def _min_price_grid(): """DB get_scalping_defaults 의 min_price 를 포함한 최소가격 스윕 (저·중·고가 후보).""" mp = int(_fixed_defaults()["min_price"]) return sorted(set([500, 1000, max(500, mp), min(mp + 5000, 200000), 50000, 100000])) def _scalp_grids(): """탐색 모드별 그리드. 호출 시점 DB 기본값으로 min_price 티어가 잡힘.""" mp_list = _min_price_grid() # fine: 조합 폭주 완화 — min_price 는 앵커·고가 두 티어 + 전 구간 5만원 fd_mp = int(_fixed_defaults()["min_price"]) min_price_fine = sorted(set([1000, fd_mp, 50000])) return { # 빠른 1차 — 트레일·손익·RSI 핵심 축 + 방어 ON/OFF. # ⚠️ trail_trigger=0 포함 = "트레일 OFF" 경로. 이 경로에서는 tp_pct 가 # 실제 상한선으로 작동하므로 tp 값 탐색이 의미 있어진다. # 조합 수: 6 × 3 × 4 × 3 × 3 × 2 × 2 × 1 × 1 × 2 × 1 × 1 = 5,184 "coarse": { "rsi_oversold": [15, 18, 21, 24, 26, 28], # 저·중·고 RSI 대표 "sl_pct": [0.8, 1.2, 1.5], # 보수·중·공격 "tp_pct": [1.5, 2.5, 3.5, 5.0], # 트레일 OFF 시 상한선 "drop_rate": [1.0, 1.5, 2.0], # 얕은·중·깊은 눌림 "trail_trigger": [0, 0.5, 1.0], # 0 = 트레일 OFF (tp_pct 의미 있음) "trail_stop": [0.3, 0.5], # 추적 폭 (trigger=0 이면 무의미하지만 무해) "high_chase_thr": [0.96, 1.0], # 피뢰침 보수 / OFF "max_daily_chg": [28.0], # 상승장 대응 (coarse 에서는 고정) "min_price": [1000], # coarse 에서는 저가 고정 "use_defense_filters": [True, False], # 방어 ON / OFF "max_loss_krw": [200000], # 중간값 고정 (wide 에서 세분화) "min_drop_pct_for_loss_cut": [0.015], # 1.5% 고정 (wide 에서 세분화) }, # 세밀 2차 (트레일·쿨다운 포함 + 최소가격·방어) "fine": { "rsi_oversold": [15, 17, 19, 21, 25], "sl_pct": [0.8, 1.0, 1.2, 1.5], "tp_pct": [1.5, 2.0, 2.5, 3.0, 3.5], "drop_rate": [1.0, 1.5, 2.0], "trail_trigger": [0, 0.5, 0.8], "trail_stop": [0.3, 0.4, 0.5], "cooldown_min": [5, 10], "high_chase_thr": [0.96, 0.98], "max_daily_chg": [15.0, 20.0, 25.0], "min_price": min_price_fine, "use_defense_filters": [True, False], "max_loss_krw": [150000, 200000, 300000], "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], }, # 전체 탐색 (매우 오래 걸림) "full": { "rsi_oversold": [15, 17, 20, 25], "sl_pct": [0.8, 1.0, 1.2, 1.5, 2.0], "tp_pct": [1.5, 2.0, 2.5, 3.0, 4.0], "drop_rate": [0.8, 1.0, 1.5, 2.0, 2.5], "trail_trigger": [0, 0.5, 0.8, 1.0], "trail_stop": [0.3, 0.4, 0.5, 0.8], "cooldown_min": [5, 10], "high_chase_thr": [0.96, 0.98, 1.0], "max_daily_chg": [15.0, 20.0, 30.0], "min_price": mp_list, "use_defense_filters": [True, False], "max_loss_krw": [100000, 200000, 300000], "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02, 0.025], }, # 방어축 광범위 (RSI·슬립은 coarse 보다 약간 줄이고 급등·min_price·방어를 넓게) "wide": { "rsi_oversold": [15, 17, 19, 21, 23, 25, 28], "sl_pct": [0.8, 1.0, 1.2, 1.5], "tp_pct": [1.5, 2.0, 2.5, 3.0], "drop_rate": [1.0, 1.5, 2.0, 2.5], "high_chase_thr": [0.96, 0.98, 1.0], "max_daily_chg": [8.0, 12.0, 16.0, 20.0, 25.0, 30.0, 40.0], "min_price": mp_list, "use_defense_filters": [True, False], "max_loss_krw": [100000, 200000, 300000], "min_drop_pct_for_loss_cut": [0.01, 0.015, 0.02], }, } def _get_scalp_field_map(): """스캘핑 파라미터 → env_config 컬럼 매핑 (apply / db_snapshot 공용). 웹·봇과 동일 키 저장.""" return { "rsi_oversold": ("SCALP_RSI_OVERSOLD", lambda v: str(int(v))), "rsi_overbought": ("SCALP_RSI_OVERBOUGHT", lambda v: str(int(v))), "sl_pct": ("SCALP_STOP_LOSS_PCT", lambda v: str(float(v) / 100)), "tp_pct": ("SCALP_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)), "drop_rate": ("SCALP_MIN_DROP_RATE", lambda v: str(float(v) / 100)), "trail_trigger": ("SCALP_ATR_UP_MULT", lambda v: str(float(v))), "trail_stop": ("SCALP_ATR_DOWN_MULT", lambda v: str(float(v))), "cooldown_min": ("SCALP_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)), # 스캘핑 전용 방어로직 (꼬리잡기와 값 분리) "high_chase_thr": ("SCALP_HIGH_PRICE_CHASE_THRESHOLD", lambda v: str(float(v))), "max_daily_chg": ("SCALP_MAX_DAILY_CHANGE_PCT", lambda v: str(float(v))), "min_price": ("SCALP_MIN_PRICE", lambda v: str(int(float(v)))), "max_loss_krw": ("SCALP_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))), "min_drop_pct_for_loss_cut": ("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT", lambda v: str(round(float(v)*100, 2)) if float(v) < 1 else str(round(float(v), 2))), # % (1.5) "min_margin": ("SCALP_MIN_PROFIT_PCT", lambda v: str(float(v))), # % 단위 (0.2 등) "use_defense_filters": ("SCALP_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"), } def _params_to_db_snapshot(params: dict) -> dict: """그리드 params(표시 단위) → env_config 컬럼명:값 문자열 dict.""" field_map = _get_scalp_field_map() return { db_col: fmt(params[param_k]) for param_k, (db_col, fmt) in field_map.items() if param_k in params } def _apply_from_latest_json(rank: int): """최근 search_*.json에서 rank번째(1-based) 항목의 merged_params를 DB에 적용.""" latest_path = _latest_json("search_") if not latest_path: print("⚠️ search_*.json 파일이 없습니다 (신규/구 경로 모두).") return with open(latest_path, "r", encoding="utf-8") as f: data = json.load(f) top = data.get("top") or [] if rank < 1 or rank > len(top): print(f"⚠️ 순번 {rank}이(가) 유효하지 않습니다. (1~{len(top)})") return item = top[rank - 1] merged = item.get("merged_params") if not merged: merged = item.get("params") if not merged: print("⚠️ 해당 항목에 merged_params/params가 없습니다.") return if item.get("total_pnl", 0) <= 0: print(f"⚠️ {rank}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.") return print(f"📂 {latest_path} 에서 {rank}번째 적용합니다.") _apply_to_db(merged) def _ui_to_engine_params(ui_params: dict) -> dict: """UI 표시용(% 등) → 엔진용 비율 단위. 워커에서 공통 사용.""" engine_params = dict(ui_params) engine_params["sl_pct"] = ui_params["sl_pct"] / 100 engine_params["tp_pct"] = ui_params["tp_pct"] / 100 engine_params["drop_rate"] = ui_params["drop_rate"] / 100 engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100 engine_params["trail_stop"] = ui_params["trail_stop"] / 100 engine_params["fee_rate"] = ui_params["fee_rate"] / 100 engine_params["sell_tax"] = ui_params["sell_tax"] / 100 engine_params["min_margin"] = ui_params.get("min_margin", 0.2) / 100 if "use_defense_filters" in ui_params: engine_params["use_defense_filters"] = bool(ui_params["use_defense_filters"]) # 👇 [핵심] 손절 퍼센트에 맞춰 1회 투자금(slot_money) 자동 계산 (봇과 동일 공식) # 5억 고정이면 수수료만으로 손절컷 걸려 좋은 조합(RSI 17 등)이 버려짐 → max_loss_krw/sl_pct 로 보정 max_loss = engine_params.get("max_loss_krw", 200000) if engine_params["sl_pct"] > 0 and max_loss > 0: engine_params["slot_money"] = max_loss / engine_params["sl_pct"] return engine_params def _evaluate_scalp_chunk( param_chunk: List[Dict[str, Any]], base_fixed: Dict[str, Any], keys: List[str], codes_candles: Dict[str, List[Dict]], min_trades: int, min_win_rate: float, top_n: int, universe_by_slot: Optional[Dict[str, List[str]]] = None, ) -> List[Tuple[float, float, int, Dict]]: """워커: 청크 내 조합 평가 후 승률→수익 순 상위 top_n개만 힙으로 반환. universe_by_slot이 있으면 실매매와 동일하게 5분마다 후보 종목만 매수 검사.""" local_heap: List[Tuple[float, float, int, Dict]] = [] for combo in param_chunk: ui_params = dict(base_fixed) ui_params.update(combo) engine_params = _ui_to_engine_params(ui_params) trades = se.run_scalping_backtest( codes_candles, engine_params, universe_by_slot=universe_by_slot ) total_trades = len(trades) if total_trades < min_trades: continue total_pnl = sum(t["pnl"] for t in trades) wins = sum(1 for t in trades if t["pnl"] > 0) win_rate = (wins / total_trades * 100) if total_trades else 0 win_pnl = sum(t["pnl"] for t in trades if t["pnl"] > 0) loss_pnl = sum(t["pnl"] for t in trades if t["pnl"] < 0) pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0 avg_hold = (sum(t["hold_min"] for t in trades) / total_trades) if total_trades else 0 peak, mdd, cum = 0.0, 0.0, 0.0 for t in trades: cum += t["pnl"] if cum > peak: peak = cum dd = peak - cum if dd > mdd: mdd = dd # merged_params = 실제 엔진에 넘긴 값 반영 (slot_money는 손절%에 맞춰 계산된 값 저장) merged = dict(ui_params) if "slot_money" in engine_params: merged["slot_money"] = engine_params["slot_money"] result_pkg = { "params": {k: ui_params[k] for k in keys}, "total_pnl": int(total_pnl), "win_rate": round(win_rate, 2), "total_trades": total_trades, "pf": round(pf, 2), "avg_hold": round(avg_hold, 1), "mdd": round(mdd), "merged_params": merged, } 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 _load_candles_for_search(start: str, end: str, rsi_period: int) -> dict: """엔진 직접 타격을 위해 DB에서 캔들을 한 번만 메모리에 로드합니다.""" db = TradeDB() codes_candles = {} try: start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" codes_raw = db.conn.execute( "SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 " "AND candle_time >= %s AND candle_time <= %s ORDER BY code", [start_key, end_key] ).fetchall() codes = [r["code"] for r in codes_raw] for code in codes: rows = db.conn.execute( "SELECT candle_time, open, high, low, close, volume " "FROM ws_candles " "WHERE timeframe=1 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 codes_candles[code] = [dict(r) for r in rows] finally: db.close() return codes_candles def run_search(start: str, end: str, mode: str, top_n: int, min_trades: int, min_win_rate: float, apply_rank: Optional[int], from_file_only: bool, sort_by: str = "pnl", use_fallback_universe: bool = False) -> bool: """탐색 실행. 결과가 있어서 JSON 저장까지 했으면 True, 조건 만족 조합 없이 조기 return 시 False.""" if from_file_only and apply_rank is not None and apply_rank >= 1: _apply_from_latest_json(apply_rank) return True grid = _scalp_grids()[mode] keys = list(grid.keys()) combos = list(product(*[grid[k] for k in keys])) total = len(combos) print(f"\n[{mode.upper()} 모드] 탐색 조합: {total:,}개 | 기간: {start} ~ {end}") print(f"📌 1위 정렬 기준: {'총손익 최대 (수익 나는 조합 우선)' if sort_by == 'pnl' else '승률 최대'}") print("=" * 70) results = [] t0 = time.time() FIXED_DEFAULTS = _fixed_defaults() # 캔들 데이터를 메모리에 1회 로드 (워커에 전달) print("⏳ DB에서 캔들 데이터를 메모리로 불러오는 중...") codes_candles = _load_candles_for_search(start, end, FIXED_DEFAULTS.get("rsi_period", 3)) print(f"✅ 데이터 로드 완료: {len(codes_candles)}종목") # 유니버스: --fallback-universe 이면 이력 무시하고 시뮬레이션만 사용 (조합별 거래 수 확대) # 신봇 기준: # * 실매매는 10초 REST 폴링 + 변동 tick 마다 초단위 event_time 으로 저장. # * 백테스트는 TradeDBExt.get_universe_by_candle_time("SCALP", ...) 로 # 1분 캔들 시각 키를 가진 dict 로 받아 엔진에 그대로 주입. # * fallback 시뮬레이션은 1분봉 근사 점수 기반 5분 버킷팅 (과거 호환). universe_by_slot = None fallback_sim_interval = 5 # --fallback-universe 전용 시뮬레이션 버킷 (분) start_ymd = start.replace("-", "") if start else "" end_ymd = end.replace("-", "") if end else "" if use_fallback_universe: print("📌 [유니버스] --fallback-universe: 저장 이력 무시 → 시뮬레이션 유니버스 (조합별 거래 수 확대)") elif start_ymd and end_ymd: try: # 신봇 이력 (초단위 event_time) → 1분 캔들 시각으로 리샘플링 from kis_trader.database.db_manager import get_db as _get_ext_db # type: ignore _ext = _get_ext_db() history = _ext.get_universe_by_candle_time( strategy_id="SCALP", 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}종목 (SCALP)" ) print( "📌 [유니버스] 이력만 쓰면 매수 기회 적어 거래 0~1건 나올 수 있음. " "조합 많을 때는 --fallback-universe 권장." ) else: universe_by_slot = None except Exception as _e: logging.getLogger("param_search").debug( "신봇 유니버스 이력 조회 스킵: %s", _e, ) universe_by_slot = None # 엔진이 쓸 슬롯 단위 결정: # * 신봇 이력 경로 → 1분봉 키(=passthrough) # * 시뮬 fallback → fallback_sim_interval 분 버킷 engine_scan_interval_min = 1 if universe_by_slot is None: universe_top_n = int(os.environ.get("UPDATE_UNIVERSE_TOP_N", "20")) universe_min_score = float(os.environ.get("UPDATE_UNIVERSE_MIN_SCORE", "4.0")) universe_by_slot = se.build_universe_simulation( codes_candles, top_n=universe_top_n, min_score=universe_min_score, scan_interval_min=fallback_sim_interval, ) engine_scan_interval_min = fallback_sim_interval n_slots = len(universe_by_slot) avg_per_slot = sum(len(c) for c in universe_by_slot.values()) / max(1, n_slots) print( f"✅ 유니버스: 시뮬레이션 사용 (이력 없음) | " f"{fallback_sim_interval}분 슬롯 {n_slots}개 · 슬롯당 평균 {avg_per_slot:.1f}종목" ) print("📌 [유니버스] 서치는 '시뮬레이션 유니버스' 기준입니다. (신봇 이력 없음)") # 엔진에 슬롯 단위 주입 (engine._slot_key 가 이 값으로 캔들 시각 정규화) FIXED_DEFAULTS["scan_interval_min"] = engine_scan_interval_min # 조합을 딕셔너리 리스트로 변환 후 청크 분할 dict_combos = [dict(zip(keys, c)) for c in combos] 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 // (max_workers * 4))) chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)] print(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers} ≈ 80%%) | 청크: {len(chunks):,}개") start_time = time.time() global_heap: List[Tuple[float, float, Tuple[int, int], Dict]] = [] # initializer=_worker_init 로 워커에게 PR_SET_PDEATHSIG 설정 # → 부모가 SIGKILL·SIGTERM 어느 경로로 죽어도 워커가 고아로 남지 않음. with ProcessPoolExecutor(max_workers=max_workers, initializer=_worker_init) as executor: futures = { executor.submit( _evaluate_scalp_chunk, chunk, FIXED_DEFAULTS, keys, codes_candles, min_trades, min_win_rate, top_n, universe_by_slot ): chunk for chunk in chunks } combos_per_chunk = chunk_size print(f"⏳ 청크 처리 중… (청크당 최대 {combos_per_chunk:,}개 조합, 완료되는 대로 진행률·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 # sort_by pnl: 1위 = 총손익 최대 (-pnl 로 넣어서 min-heap에서 수익 큰 게 뒤로) # sort_by win_rate: 1위 = 승률 최대 (기존) 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 # 누적 소요 시간을 처리된 청크 개수로 나누어 1청크당 평균 처리 시간 도출 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: print(line, flush=True) if use_carriage_return: print(flush=True) # 줄바꿈으로 진행률 줄 마무리 elapsed = time.time() - start_time if not global_heap: print("⚠️ 조건을 만족하는 조합이 없습니다. (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 필요 results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))] if sort_by == "win_rate": results.reverse() # RSI_OVERSOLD 기준으로 균등 분배 → JSON에 RSI별 PER_RSI_JSON개씩 (한 RSI에 치중 방지) if "rsi_oversold" in keys and results: rsi_vals = sorted(set(r["params"]["rsi_oversold"] for r in results)) by_rsi: Dict[Any, List[Dict]] = {} for r in results: v = r["params"]["rsi_oversold"] if v not in by_rsi: by_rsi[v] = [] if len(by_rsi[v]) < PER_RSI_JSON: by_rsi[v].append(r) results = [] for v in rsi_vals: results.extend(by_rsi.get(v, [])) # 손익 순 유지, 동점이면 RSI 낮은 쪽(과매도 강함) 우선 results.sort(key=lambda r: (-r["total_pnl"], r["params"]["rsi_oversold"], -r["win_rate"])) print(f"✅ RSI별 상위 {PER_RSI_JSON}개씩 보장 → {len(results)}건 (총 {len(rsi_vals)}가지 RSI)") # 승률 min_win_rate 이상만 우선; 없으면 차악 filtered = [r for r in results if r["win_rate"] >= min_win_rate] if filtered: results = filtered order_msg = "수익→승률 순" if sort_by == "pnl" else "승률→수익 순" print(f"✅ 승률 {min_win_rate}% 이상 {len(results)}건 중 {order_msg} 상위 사용") else: print(f"⚠️ 승률 {min_win_rate}% 이상 없음 → 차악(상위) 적용") # 손익 마이너스인 조합 제외 (수익 나는 것만 표시·저장) profitable = [r for r in results if r["total_pnl"] > 0] if profitable: results = profitable print(f"✅ 총손익 플러스만 사용: {len(results)}건 (손실 조합 제외)") else: print(f"⚠️ 수익 나는 조합 없음 → 손실 최소 순으로 표시") print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results)}건") # ── 결과 출력 ── hdr_keys = [k for k in keys] col_w = max(len(k) for k in hdr_keys) + 2 order_label = "수익" if sort_by == "pnl" else "승률" print(f"\n{'='*90}") print(f" 🏆 {order_label} TOP {min(top_n, len(results))} (투자금 1,000,000원 기준)") print(f"{'='*90}") # 헤더 hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys) print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유':>6}") print("-" * (len(hdr) + 55)) for r in results[:top_n]: p = r["params"] row = " ".join( (f"{str(p[k]):>{col_w}}" if isinstance(p[k], bool) else f"{p[k]:>{col_w}.4g}") for k in hdr_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']:>5.1f}분") best = results[0] bp = best["params"] print(f""" ╔══════════════════════════════════════════╗ ║ 🏆 1위 최적 파라미터 ║ ╠══════════════════════════════════════════╣""") _disp_map = { "rsi_oversold": "SCALP_RSI_OVERSOLD", "rsi_overbought": "SCALP_RSI_OVERBOUGHT", "sl_pct": "SCALP_STOP_LOSS_PCT (÷100)", "tp_pct": "SCALP_TAKE_PROFIT_PCT(÷100)", "drop_rate": "SCALP_MIN_DROP_RATE (÷100)", "trail_trigger": "SCALP_ATR_UP_MULT (트레일발동%)", "trail_stop": "SCALP_ATR_DOWN_MULT(트레일추적%)", "cooldown_min": "SCALP_COOLDOWN_SEC(÷60=분)", "high_chase_thr": "HIGH_PRICE_CHASE_THRESHOLD", "max_daily_chg": "MAX_DAILY_CHANGE_PCT", "max_loss_krw": "MAX_LOSS_PER_TRADE_KRW(원)", "min_price": "SCALP_MIN_PRICE(원)", "use_defense_filters": "SCALP_USE_DEFENSE_FILTERS", } for k, v in bp.items(): label = _disp_map.get(k, k) print(f"║ {label:<30s} : {v!s:>6} ║") print(f"""╠══════════════════════════════════════════╣ ║ 총 손익 : {best['total_pnl']:>+12,.0f} 원 ║ ║ 승률 : {best['win_rate']:>6.1f}% ║ ║ 총 거래 : {best['total_trades']:>5} 건 ║ ║ Profit Factor : {best['pf']:>5.2f} ║ ║ 평균 보유 : {best['avg_hold']:>5.1f} 분 ║ ║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║ ╚══════════════════════════════════════════╝""") # JSON 저장용: RSI별 200개씩 모은 전체 리스트 (콘솔 TOP은 여전히 top_n만 출력) top_list = [] for idx, r in enumerate(results): p = r["params"] merged = r.get("merged_params", p) top_list.append({ "rank": idx + 1, "params": p, "merged_params": merged, "db_snapshot": _params_to_db_snapshot(merged), "total_pnl": r["total_pnl"], "win_rate": r["win_rate"], "total_trades": r["total_trades"], "pf": r["pf"], "avg_hold": r["avg_hold"], "mdd": r["mdd"], }) # ── JSON 결과 저장 (kis_trader/backtest/results/) ── # 권한 문제 대비: 쓰기 실패 시 사용자 홈(~/.kis_bot_search_results/)으로 폴백. out_dir = _results_dir_for_write() ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(out_dir, f"search_{mode}_{ts}.json") payload = { "mode": mode, "start": start, "end": end, "min_win_rate": min_win_rate, "top": top_list, } try: with open(out_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) print(f"\n💾 결과 저장: {out_path}") except (PermissionError, OSError) as _e: fallback_dir = os.path.join(os.path.expanduser("~"), ".kis_bot_search_results") os.makedirs(fallback_dir, exist_ok=True) out_path = os.path.join(fallback_dir, f"search_{mode}_{ts}.json") with open(out_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) print(f"\n⚠️ 기본 경로({out_dir}) 쓰기 실패({type(_e).__name__}). 폴백 저장: {out_path}") print(f" 권한 복구: sudo chown -R $USER:$USER {out_dir}") # ── DB 자동 적용 (만족 조건: 총손익 > 0. 미충족 시 기존 설정 유지) ── if apply_rank is not None and apply_rank >= 1 and apply_rank <= len(top_list): cand = top_list[apply_rank - 1] if cand.get("total_pnl", 0) <= 0: print(f"⚠️ {apply_rank}번째 결과는 총손익 ≤ 0 (조건 미충족). DB 미적용. 기존 설정 유지.") else: _apply_to_db(cand["merged_params"]) print(f"✅ {apply_rank}번째 결과 적용 완료") return True def _apply_to_db(best_params: dict): """1위 파라미터를 env_config DB에 자동 반영.""" field_map = _get_scalp_field_map() sets = [] vals = [] for param_k, val in best_params.items(): if param_k not in field_map: continue db_col, fmt = field_map[param_k] sets.append(f"{db_col} = %s") vals.append(fmt(val)) if not sets: print("DB 적용할 파라미터가 없습니다.") return db = TradeDB() db.conn.execute(f"UPDATE env_config SET {', '.join(sets)}", vals) db.conn.commit() db.close() print("\n✅ DB env_config 자동 적용 완료:") for param_k, val in best_params.items(): if param_k in field_map: db_col, fmt = field_map[param_k] print(f" {db_col:<30s} = {fmt(val)}") # ────────────────────────────────────────────────────────────────────────────── # CLI 진입점 # ────────────────────────────────────────────────────────────────────────────── def main(): today = datetime.now().strftime("%Y-%m-%d") week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d") parser = argparse.ArgumentParser(description="스캘핑 백테스트 파라미터 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", "wide"], help="탐색 모드: coarse / fine / full / wide(방어·min_price 광범위)") parser.add_argument("--top", default=5000, type=int, help="상위 N개 출력·JSON 저장 (기본 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="승률 하한 (%%). 이 이상만 손익순 1위") parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N", help="N번째 결과를 DB에 적용 (기본 1). --from-file 시 최근 JSON에서 적용") parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께 사용 시, 최근 결과 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="저장 이력 무시, 시뮬레이션 유니버스만 사용. 조합 많을 때 거래 수 확대용") 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_scalp() 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( start = args.start, end = args.end, mode = args.mode, top_n = args.top, min_trades = args.min_trades, min_win_rate = args.min_win_rate, apply_rank = args.apply, from_file_only = args.from_file, sort_by = args.sort_by, use_fallback_universe = args.fallback_universe, ) except KeyboardInterrupt as e: # ProcessPoolExecutor 의 `with ... as executor:` 블록이 __exit__ 시 # shutdown(wait=True) 을 호출하고, 워커들은 PR_SET_PDEATHSIG 로 이미 # SIGTERM 을 받은 상태라 빠르게 정리된다. print(f"\n⛔ {e} — 미완료 결과 없이 종료합니다.", flush=True) sys.exit(130) # 130 = SIGINT 관례 exit code # 탐색에서 조건 만족 조합이 있었을 때만 방금 저장된 JSON으로 AI 적용 (없으면 기존 설정 유지) if args.apply_ai and had_results: import param_apply_ai param_apply_ai.apply_ai_scalp() elif args.apply_ai and not had_results: print("📌 이번 탐색에서 조건 만족 조합 없음 → apply_ai 스킵. DB 미적용. 기존 설정 유지.") if __name__ == "__main__": main()