#!/usr/bin/env python3 """ kis_trader/backtest/param_search_breakout.py — 돌파매매 백테스트 파라미터 자동 탐색 (Grid Search) ======================================================================================================== [전략 = BREAKOUT] - 키움 HTS 'breakout' 조건검색(직전봉 등락률 + 고가돌파 + 거래량 spike) 기반 추세 돌파. - 실매매 BreakoutStrategy.check_buy → ``BREAKOUT_ENTRY_MODE=intrabar`` (B, HTS E) / ``align`` (A). - 본 CLI 는 ``run_breakout_backtest`` + ``breakout_scan_buy_at_bar`` (라이브·백테 동일 진입 함수). [관련 CLI (전략별 파일 분리, 2026-05 정리)] SCALP(Reversal): kis_trader/backtest/param_search_scalping.py MOMENTUM : kis_trader/backtest/param_search_momentum.py BREAKOUT : kis_trader/backtest/param_search_breakout.py ← 이 파일 SHORT(꼬리잡기) : kis_trader/backtest/tail_param_search.py UPDOW(하락매수): kis_trader/backtest/param_search_updow.py 실행: cd /home/hoon/kis_bot python3 kis_trader/backtest/param_search_breakout.py --start 2026-04-21 --end 2026-05-08 python3 -m kis_trader.backtest.param_search_breakout --mode coarse --top 30 옵션: --start 시작일 (기본: 오늘-7일) --end 종료일 (기본: 오늘) --mode 탐색 모드: fast(기본, ~768조합) / coarse / fine / full --top 상위 N개 출력·JSON 저장 (기본: 1000) --min_trades 최소 거래 건수 필터 (기본: 1) --apply N번째 결과를 DB(BREAKOUT_* env)에 자동 적용 (총손익 > 0 일 때만) --fallback-universe 저장 이력 무시, 시뮬레이션 유니버스 사용 ⚠️ SCAN(HTS) vs TRIGGER(코드) — 운영 가이드: [HTS 널넬게] A 등락 0.3~20% 또는 삭제 · E 거래량 150~200% · F 돌파 유지 또는 완화 [코드 엄격] 그리드 vol_mult(2~5배)·prev_chg·tp/sl·time_end — ``param_search_breakout`` coarse 1축 웹/DB ``BREAKOUT_VOL_MULT`` = 그리드 1위 적용값 (HTS 300%와 별개로 더 세게 잡을 수 있음). ⚠️ 청산 순서 (돌파 전용 — 스캘핑 V4 와 다름, ``breakout.check_sell_signal_breakout_live``): EOD → **익절(1순위)** → 어깨컷(2) → 손절(3) → 트레일(4). 그리드 어깨축은 익절 미달 구간의 **트레일 보호**용(0.3%급 타이트값 아님). ⚠️ 그리드 축 오버라이드: ``BREAKOUT_GRID_COARSE_VOL_MULT=2,2.5,3,4,5`` 등. """ import sys, os, json, time, argparse, signal import heapq from datetime import datetime, timedelta from itertools import product from concurrent.futures import as_completed from typing import Optional, List, Dict, Any, Tuple 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 from kis_trader.backtest import breakout_backtest_common as bbc from kis_trader.backtest.backtest_portfolio_common import ( merge_param_search_apply_source, strip_portfolio_keys_from_apply_patch, session_env_patch, ) from kis_trader.backtest.param_search_cli_common import ( add_portfolio_cli_args, add_search_filter_cli_args, apply_session_to_fixed, combo_passes_search_filters, format_session_hm, search_json_meta, ) from kis_trader.backtest.param_search_pool import ( ParamSearchSharedPayload, ParamSearchProgressETA, assert_parent_alive, cap_combos_uniform, cap_combos_uniform_lazy, iter_pool_chunk_results, managed_process_pool, param_search_chunk_plan, param_search_worker_budget_line, try_acquire_run_lock, worker_shared_get, ) from kis_trader.backtest.breakout_tick_loader import ( load_breakout_ticks_by_code, tick_coverage_stats, ) from kis_trader.strategies.breakout import ( breakout_backtest_wants_tick_replay, breakout_entry_mode, breakout_invest_amount_krw, breakout_min_bars_required, breakout_ui_to_engine_params, normalize_breakout_max_loss_krw, resolve_breakout_skip_hts_scan_dupes, ) from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params from kis_trader.utils.env import get_env_bool, get_env_from_db, get_env_float, get_env_int # noqa: E402 def _parse_csv_floats(env_key: str, fallback: List[float]) -> List[float]: raw = str(get_env_from_db(env_key, "") or "").strip() if not raw: return list(fallback) out: List[float] = [] for part in raw.split(","): part = part.strip() if not part: continue try: out.append(float(part)) except ValueError: continue return out if out else list(fallback) def _parse_csv_ints(env_key: str, fallback: List[int]) -> List[int]: raw = str(get_env_from_db(env_key, "") or "").strip() if not raw: return list(fallback) out: List[int] = [] for part in raw.split(","): part = part.strip() if not part: continue try: out.append(int(float(part))) except ValueError: continue return out if out else list(fallback) def _parse_csv_strs(env_key: str, fallback: List[str]) -> List[str]: """콤마(,) 구분 문자열 후보 축 (예: sl_mode=fixed,atr). 빈 값이면 fallback.""" raw = str(get_env_from_db(env_key, "") or "").strip() if not raw: return list(fallback) out = [part.strip().lower() for part in raw.split(",") if part.strip()] return out if out else list(fallback) def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]: raw = get_env_from_db(env_key, "") if not raw or str(raw).strip() in ("", "None"): return list(fallback) out: List[bool] = [] for chunk in str(raw).replace("|", ",").split(","): chunk = chunk.strip().lower() if not chunk: continue out.append(chunk in ("1", "true", "t", "y", "yes", "on")) return out if out else list(fallback) def _parse_csv_tiers(env_key: str, fallback: List[str]) -> List[str]: """세미콜론(;) 구분 래칫 티어 후보 → 그리드 문자열 축. 래칫 티어 1개는 ``"gain:cut,gain:cut"`` 형태(% 단위, 예 ``"2:1.5,5:1.0"`` = +2%부터 1.5% 되돌림컷, +5%부터 1.0%). 티어 내부 구분이 콤마(,)이므로 후보(조합 축)끼리는 세미콜론(;)으로 나눈다. 빈 후보("")는 래칫 OFF(= 단일 어깨컷, 기존 동작) — 비교 기준선으로 항상 포함 권장. 예: BREAKOUT_GRID_FAST_RATCHET_TIERS=";2:1.5,5:1.0;3:1.5,6:1.0,10:0.7" """ raw = get_env_from_db(env_key, "") if raw is None or str(raw).strip() in ("", "None"): return list(fallback) out: List[str] = [part.strip() for part in str(raw).split(";")] return out if out else list(fallback) def _count_sell_reasons(trades: List[Dict[str, Any]]) -> Dict[str, int]: """청산 사유별 건수 — 익절·어깨 순위 변경 후 1위 조합 검증용.""" out: Dict[str, int] = {} for t in trades or []: k = str(t.get("sell_reason") or "unknown") out[k] = out.get(k, 0) + 1 return out def _avg_profit_rate_pct(trades: List[Dict[str, Any]]) -> float: if not trades: return 0.0 return round( sum(float(t.get("profit_rate", 0) or 0) for t in trades) / len(trades), 2, ) # ────────────────────────────────────────────────────────────────────────────── # 돌파매매 기본값 (DB 우선) # ────────────────────────────────────────────────────────────────────────────── def _bo_fixed_defaults() -> Dict[str, Any]: """env_config 에서 BREAKOUT_* 비-그리드 파라미터 기본값 로드. ※ UI/CLI 표시 단위(%)로 반환. 워커 _ui_to_engine_params 가 비율로 변환. """ db = TradeDB() try: # config_breakout + env_config 병합 — SKIP_HTS 등이 env_config 단독 SELECT 에 없음 if hasattr(db, "get_strategy_config_snapshot"): env = dict(db.get_strategy_config_snapshot("BREAKOUT") or {}) merged = db.get_merged_env_snapshot() if hasattr(db, "get_merged_env_snapshot") else {} for k, v in (merged or {}).items(): if k not in env or env.get(k) in (None, "", "None"): env[k] = v else: from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row env = load_portfolio_env_row(db) finally: db.close() def _pick(*keys, default=None, cast=float): for k in keys: v = env.get(k) if v not in (None, "", "None"): try: return cast(v) except (ValueError, TypeError): continue return default def _ratio_to_ui_pct(ratio: float) -> float: av = abs(float(ratio)) if av == 0: return 0.0 return round(av * 100, 3) if av < 0.5 else round(av, 3) return { # 진입 룰 — max_daily_chg 는 fast 그리드 축(콤보가 덮어씀). 미포함 모드 폴백용. "lookback_min": int(_pick("BREAKOUT_LOOKBACK_MIN", default=1, cast=lambda v: int(float(v)))), "vol_window": int(_pick("BREAKOUT_VOL_WIN", default=1, cast=lambda v: int(float(v)))), "vol_mult": float(_pick("BREAKOUT_VOL_MULT", default=0.0)), "min_turnover_1m_pct": float(_pick("BREAKOUT_MIN_TURNOVER_1M_PCT", default=0.05)), "max_daily_chg": float(_pick("BREAKOUT_MAX_DAILY_CHG", default=15.0)), "min_price": int(_pick("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE", default=1000, cast=lambda v: int(float(v)))), # 시간대 (골든타임 09:00~10:30 기본) "time_start_hm": int(_pick("BREAKOUT_TIME_START", default=900, cast=lambda v: int(float(v)))), "time_end_hm": int(_pick("BREAKOUT_GOLDEN_END_HM", "BREAKOUT_TIME_END", default=1030, cast=lambda v: int(float(v)))), # 청산 (UI % 단위) "trail_pct": _ratio_to_ui_pct(float(_pick("BREAKOUT_TRAIL_PCT", default=0.015))), "shoulder_min_high_pct": _ratio_to_ui_pct( float(_pick("BREAKOUT_SHOULDER_MIN_HIGH_PCT", default=0.02)), ), "shoulder_cut_pct": _ratio_to_ui_pct( float(_pick("BREAKOUT_SHOULDER_CUT_PCT", default=0.01)), ), # 슬롯/수수료/쿨다운 "slot_money": int(_pick("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT", default=2_000_000, cast=lambda v: int(float(v)))), "fee_rate": 0.015, # % "sell_tax": 0.18, # % "cooldown_min": 30, "max_daily": int(_pick("BREAKOUT_MAX_DAILY", default=1, cast=lambda v: int(float(v)))), "max_loss_krw": normalize_breakout_max_loss_krw(_pick( "BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW", default=200_000, cast=lambda v: int(float(v)), )), "entry_mode": str(_pick("BREAKOUT_ENTRY_MODE", default="intrabar") or "intrabar").strip().lower(), "intrabar_slippage_pct": float(_pick("BREAKOUT_INTRABAR_SLIPPAGE_PCT", default=0.0)), # ── ATR 동적 손절 (sl_mode 는 fast 그리드 축. 미포함 모드 폴백용 기본값) ── "sl_mode": str(_pick("BREAKOUT_SL_MODE", default="fixed", cast=str) or "fixed").strip().lower(), "atr_period": int(_pick("BREAKOUT_ATR_PERIOD", default=14, cast=lambda v: int(float(v)))), "atr_sl_mult": float(_pick("BREAKOUT_ATR_SL_MULT", default=2.0)), "atr_sl_min_pct": float(_pick("BREAKOUT_ATR_SL_MIN_PCT", default=0.8)), "atr_sl_max_pct": float(_pick("BREAKOUT_ATR_SL_MAX_PCT", default=6.0)), "eod_enabled": str(_pick("BREAKOUT_EOD_ENABLED", default="1")).strip().lower() in ( "1", "true", "t", "y", "yes", "on", ), "eod_hm": ( str(_pick("BREAKOUT_EOD_HM", default="15:15")).strip() or "15:15" ), # 가짜돌파 필터 (0=OFF) — DB 컬럼 없으면 0 "confirm_margin_pct": float(_pick("BREAKOUT_CONFIRM_MARGIN_PCT", default=0.0)), "body_min_pct": float(_pick("BREAKOUT_BODY_MIN_PCT", default=0.0)), "skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env), } # 결과 디렉터리 공유 def _results_dir_for_write() -> str: 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(os.path.expanduser("~"), ".kis_bot_search_results"), ] def _latest_json(prefix: str) -> Optional[str]: 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 def _apply_from_latest_json(rank: int) -> None: latest_path = _latest_json("search_breakout_") if not latest_path: print("⚠️ search_breakout_*.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 = merge_param_search_apply_source(item, data) 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 _breakout_grids() -> Dict[str, Dict[str, List]]: """탐색 모드별 돌파매매 그리드. 청산: EOD → 익절 → 어깨 → 손절 → 트레일 (돌파 전용). 어깨축은 익절(7~15%) 미도달 시 고점 추적·되돌림 컷 — 스캘핑용 0.3%급과 구분. """ return { # [FAST] wide(7/15) Top 근방 스모크 — vol2.5·tp3/12·sl4·trail1.5·sh5/0.5·end1530 분지 # 실매 앵커: chg25 · vol2/w1 · turn0.1 · price3000 · atr · trail3 · end1300 "fast": { "max_daily_chg": _parse_csv_floats( "BREAKOUT_GRID_FAST_MAX_DAILY_CHG", [25.0, 40.0], ), "vol_mult": _parse_csv_floats( "BREAKOUT_GRID_FAST_VOL_MULT", [2.0, 2.5], ), "vol_window": _parse_csv_ints( "BREAKOUT_GRID_FAST_VOL_WINDOW", [1, 10], ), "min_turnover_1m_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_MIN_TURNOVER_1M_PCT", [0.1, 0.15], ), "prev_chg_min": _parse_csv_floats( "BREAKOUT_GRID_FAST_PREV_CHG_MIN", [0.5], ), "prev_chg_max": _parse_csv_floats( "BREAKOUT_GRID_FAST_PREV_CHG_MAX", [8.0, 20.0], ), "min_price": _parse_csv_floats( "BREAKOUT_GRID_FAST_MIN_PRICE", [3000.0], ), "tp_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_TP_PCT", [3.0, 12.0], ), "sl_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_SL_PCT", [4.0], ), "sl_mode": _parse_csv_strs( "BREAKOUT_GRID_FAST_SL_MODE", ["fixed", "atr"], ), "atr_sl_mult": _parse_csv_floats( "BREAKOUT_GRID_FAST_ATR_SL_MULT", [2.5], ), "shoulder_min_high_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_SHOULDER_SMIN", [2.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_SHOULDER_CUT", [0.5, 0.8], ), "trail_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_TRAIL_PCT", [1.5, 3.0], ), "trail_arm_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_TRAIL_ARM", [2.0, 3.0], ), "lookback_min": _parse_csv_ints( "BREAKOUT_GRID_FAST_LOOKBACK_MIN", [1, 5], ), "confirm_margin_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_CONFIRM_MARGIN", [0.2, 0.5], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_BODY_MIN", [0.0, 0.3], ), "time_end_hm": _parse_csv_ints( "BREAKOUT_GRID_FAST_TIME_END_HM", [1300, 1530], ), "ratchet_tiers": _parse_csv_tiers( "BREAKOUT_GRID_FAST_RATCHET_TIERS", ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7"], ), "max_hold_bars": _parse_csv_ints( "BREAKOUT_GRID_FAST_MAX_HOLD_BARS", [60, 120], ), "max_spread_pct": _parse_csv_floats( "BREAKOUT_GRID_FAST_MAX_SPREAD_PCT", [0.45], ), "min_bid_ask_ratio": _parse_csv_floats( "BREAKOUT_GRID_FAST_MIN_BID_ASK_RATIO", [0.85, 1.0], ), "ask_wall_max_qty": _parse_csv_ints( "BREAKOUT_GRID_FAST_ASK_WALL_MAX_QTY", [3000], ), }, # [COARSE] wide Top 밴드 조금 넓게 (1차 스크리닝) "coarse": { "max_daily_chg": _parse_csv_floats( "BREAKOUT_GRID_COARSE_MAX_DAILY_CHG", [25.0, 30.0, 40.0, 50.0], ), "vol_mult": _parse_csv_floats( "BREAKOUT_GRID_COARSE_VOL_MULT", [2.0, 2.5, 3.0], ), "vol_window": _parse_csv_ints( "BREAKOUT_GRID_COARSE_VOL_WINDOW", [1, 5, 10], ), "min_turnover_1m_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_MIN_TURNOVER_1M_PCT", [0.1, 0.15, 0.3], ), "prev_chg_min": _parse_csv_floats( "BREAKOUT_GRID_COARSE_PREV_CHG_MIN", [0.3, 0.5], ), "prev_chg_max": _parse_csv_floats( "BREAKOUT_GRID_COARSE_PREV_CHG_MAX", [8.0, 10.0, 15.0, 20.0], ), "min_price": _parse_csv_floats( "BREAKOUT_GRID_COARSE_MIN_PRICE", [2000.0, 3000.0], ), "tp_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_TP_PCT", [3.0, 5.0, 10.0, 12.0], ), "sl_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_SL_PCT", [3.0, 4.0, 5.0], ), "sl_mode": _parse_csv_strs( "BREAKOUT_GRID_COARSE_SL_MODE", ["fixed", "atr"], ), "atr_sl_mult": _parse_csv_floats( "BREAKOUT_GRID_COARSE_ATR_SL_MULT", [2.0, 2.5, 3.0], ), "shoulder_min_high_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_SHOULDER_SMIN", [2.0, 3.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_SHOULDER_CUT", [0.5, 0.8, 1.0], ), "trail_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_TRAIL_PCT", [1.5, 2.0, 3.0], ), "trail_arm_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_TRAIL_ARM", [1.5, 2.0, 3.0], ), "ratchet_tiers": _parse_csv_tiers( "BREAKOUT_GRID_COARSE_RATCHET_TIERS", ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7", "2:1.5,5:1.0"], ), "lookback_min": _parse_csv_ints( "BREAKOUT_GRID_COARSE_LOOKBACK_MIN", [1, 5], ), "confirm_margin_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_CONFIRM_MARGIN", [0.0, 0.2, 0.5], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_BODY_MIN", [0.0, 0.3], ), "time_end_hm": _parse_csv_ints( "BREAKOUT_GRID_COARSE_TIME_END_HM", [1300, 1430, 1530], ), "max_hold_bars": _parse_csv_ints( "BREAKOUT_GRID_COARSE_MAX_HOLD_BARS", [0, 60, 120], ), "max_spread_pct": _parse_csv_floats( "BREAKOUT_GRID_COARSE_MAX_SPREAD_PCT", [0.30, 0.45], ), "min_bid_ask_ratio": _parse_csv_floats( "BREAKOUT_GRID_COARSE_MIN_BID_ASK_RATIO", [0.85, 1.0], ), "ask_wall_max_qty": _parse_csv_ints( "BREAKOUT_GRID_COARSE_ASK_WALL_MAX_QTY", [3000, 5000], ), }, # [FINE] 2026-07-15 wide Top 재설계 (best~+52k/6건 · mode~+32k/4건) # 실매 앵커(chg25/vol2/turn0.1/end1300/atr) + Top(#1·최빈) 포함. apply는 확인 후. "fine": { "max_daily_chg": _parse_csv_floats( "BREAKOUT_GRID_FINE_MAX_DAILY_CHG", [25.0, 30.0, 40.0, 50.0], ), "vol_mult": _parse_csv_floats( "BREAKOUT_GRID_FINE_VOL_MULT", [2.0, 2.5, 3.0], ), "vol_window": _parse_csv_ints( "BREAKOUT_GRID_FINE_VOL_WINDOW", [1, 5, 10], ), "min_turnover_1m_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_MIN_TURNOVER_1M_PCT", [0.1, 0.15, 0.3], ), "prev_chg_min": _parse_csv_floats( "BREAKOUT_GRID_FINE_PREV_CHG_MIN", [0.3, 0.5], ), "prev_chg_max": _parse_csv_floats( "BREAKOUT_GRID_FINE_PREV_CHG_MAX", [8.0, 10.0, 15.0, 20.0, 25.0], ), "min_price": _parse_csv_floats( "BREAKOUT_GRID_FINE_MIN_PRICE", [2000.0, 3000.0], ), "tp_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_TP_PCT", [3.0, 5.0, 10.0, 12.0, 15.0], ), "sl_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_SL_PCT", [3.0, 4.0, 5.0], ), "sl_mode": _parse_csv_strs( "BREAKOUT_GRID_FINE_SL_MODE", ["fixed", "atr"], ), "atr_sl_mult": _parse_csv_floats( "BREAKOUT_GRID_FINE_ATR_SL_MULT", [2.0, 2.5, 3.0], ), "trail_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_TRAIL_PCT", [1.5, 2.0, 3.0], ), "trail_arm_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_TRAIL_ARM", [1.5, 2.0, 3.0], ), "shoulder_min_high_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_SHOULDER_SMIN", [2.0, 3.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_SHOULDER_CUT", [0.5, 0.8, 1.0], ), "ratchet_tiers": _parse_csv_tiers( "BREAKOUT_GRID_FINE_RATCHET_TIERS", ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7", "2:1.5,5:1.0"], ), "lookback_min": _parse_csv_ints( "BREAKOUT_GRID_FINE_LOOKBACK_MIN", [1, 5], ), "confirm_margin_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_CONFIRM_MARGIN", [0.0, 0.2, 0.5], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_BODY_MIN", [0.0, 0.3], ), "max_hold_bars": _parse_csv_ints( "BREAKOUT_GRID_FINE_MAX_HOLD_BARS", [0, 60, 120], ), "time_end_hm": _parse_csv_ints( "BREAKOUT_GRID_FINE_TIME_END_HM", [1300, 1430, 1530], ), "max_spread_pct": _parse_csv_floats( "BREAKOUT_GRID_FINE_MAX_SPREAD_PCT", [0.30, 0.45, 0.60], ), "min_bid_ask_ratio": _parse_csv_floats( "BREAKOUT_GRID_FINE_MIN_BID_ASK_RATIO", [0.85, 1.0], ), "ask_wall_max_qty": _parse_csv_ints( "BREAKOUT_GRID_FINE_ASK_WALL_MAX_QTY", [3000, 5000], ), }, # [WIDE] 축 스크리닝 — 하루 fine 5건·WR100% 과적합 완화용. # Optuna 소수 trial로 유효 축·구간 먼저 찾고, 이후 fine 재설계. # 실매/직전 fine 앵커: chg25 · vol2/w1 · turn0.1 · price3000 · tp3 · sl4 · atr · trail3 · end1300 # ※ SKIP_HTS 는 사용자 지시 없으면 false 유지(그리드에 True 넣지 않음). "wide": { "max_daily_chg": _parse_csv_floats( "BREAKOUT_GRID_WIDE_MAX_DAILY_CHG", [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 50.0], ), "vol_mult": _parse_csv_floats( "BREAKOUT_GRID_WIDE_VOL_MULT", [0.0, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], ), "vol_window": _parse_csv_ints( "BREAKOUT_GRID_WIDE_VOL_WINDOW", [1, 3, 5, 10], ), "min_turnover_1m_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_MIN_TURNOVER_1M_PCT", [0.03, 0.05, 0.1, 0.15, 0.2, 0.3], ), "prev_chg_min": _parse_csv_floats( "BREAKOUT_GRID_WIDE_PREV_CHG_MIN", [0.3, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0], ), "prev_chg_max": _parse_csv_floats( "BREAKOUT_GRID_WIDE_PREV_CHG_MAX", [6.0, 8.0, 10.0, 12.0, 15.0, 20.0, 25.0], ), "min_price": _parse_csv_floats( "BREAKOUT_GRID_WIDE_MIN_PRICE", [1000.0, 2000.0, 3000.0, 5000.0], ), "tp_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_TP_PCT", [3.0, 5.0, 7.0, 10.0, 12.0, 15.0, 20.0], ), "sl_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_SL_PCT", [1.5, 2.0, 3.0, 4.0, 5.0], ), "sl_mode": _parse_csv_strs( "BREAKOUT_GRID_WIDE_SL_MODE", ["fixed", "atr"], ), "atr_sl_mult": _parse_csv_floats( "BREAKOUT_GRID_WIDE_ATR_SL_MULT", [1.5, 2.0, 2.5, 3.0], ), "trail_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_TRAIL_PCT", [0.0, 1.0, 1.5, 2.0, 3.0, 4.0], ), "trail_arm_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_TRAIL_ARM", [0.0, 1.0, 1.5, 2.0, 3.0], ), "shoulder_min_high_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_SHOULDER_SMIN", [1.0, 2.0, 3.0, 5.0, 7.0], ), "shoulder_cut_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_SHOULDER_CUT", [0.5, 0.8, 1.0, 1.2, 1.5, 2.0], ), # OFF + 늦은 보호 + 기존 fine 선호 주변 (조기만 단독으로 몰지 않음) "ratchet_tiers": _parse_csv_tiers( "BREAKOUT_GRID_WIDE_RATCHET_TIERS", ["", "5:2,10:1.5", "3:1.5,6:1.0,10:0.7", "2:1.5,5:1.0"], ), "lookback_min": _parse_csv_ints( "BREAKOUT_GRID_WIDE_LOOKBACK_MIN", [1, 5, 10, 20], ), "confirm_margin_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_CONFIRM_MARGIN", [0.0, 0.2, 0.5, 1.0], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_BODY_MIN", [0.0, 0.3, 0.5], ), "max_hold_bars": _parse_csv_ints( "BREAKOUT_GRID_WIDE_MAX_HOLD_BARS", [0, 30, 60, 120], ), "time_end_hm": _parse_csv_ints( "BREAKOUT_GRID_WIDE_TIME_END_HM", [1030, 1130, 1300, 1430, 1530], ), "max_spread_pct": _parse_csv_floats( "BREAKOUT_GRID_WIDE_MAX_SPREAD_PCT", [0.30, 0.45, 0.60], ), "min_bid_ask_ratio": _parse_csv_floats( "BREAKOUT_GRID_WIDE_MIN_BID_ASK_RATIO", [0.70, 0.85, 1.0], ), "ask_wall_max_qty": _parse_csv_ints( "BREAKOUT_GRID_WIDE_ASK_WALL_MAX_QTY", [3000, 5000, 8000], ), }, # [FULL] fine + 최대 폭 (HTS 밴드 바깥까지 탐색) "full": { "max_daily_chg": _parse_csv_floats( "BREAKOUT_GRID_FULL_MAX_DAILY_CHG", [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0], ), "vol_mult": _parse_csv_floats( "BREAKOUT_GRID_FULL_VOL_MULT", [0.0, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0], ), "vol_window": _parse_csv_ints( "BREAKOUT_GRID_FULL_VOL_WINDOW", [1, 3, 5, 10], ), "min_turnover_1m_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_MIN_TURNOVER_1M_PCT", [0.03, 0.05, 0.1, 0.15, 0.2, 0.3], ), "skip_hts_scan_dupes": _parse_csv_bools( "BREAKOUT_GRID_FULL_SKIP_HTS_SCAN_DUPES", [False, True], ), "prev_chg_min": _parse_csv_floats( "BREAKOUT_GRID_FULL_PREV_CHG_MIN", [0.3, 0.5, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0], ), "prev_chg_max": _parse_csv_floats( "BREAKOUT_GRID_FULL_PREV_CHG_MAX", [6.0, 8.0, 10.0, 12.0, 15.0, 20.0, 25.0], ), "min_price": _parse_csv_floats( "BREAKOUT_GRID_FULL_MIN_PRICE", [1000.0, 2000.0, 3000.0, 5000.0], ), "tp_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_TP_PCT", [3.0, 5.0, 7.0, 10.0, 15.0, 20.0], ), "sl_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_SL_PCT", [1.0, 1.5, 2.0, 3.0, 4.0, 5.0], ), "sl_mode": _parse_csv_strs( "BREAKOUT_GRID_FULL_SL_MODE", ["fixed", "atr"], ), "atr_sl_mult": _parse_csv_floats( "BREAKOUT_GRID_FULL_ATR_SL_MULT", [1.0, 1.5, 2.0, 2.5, 3.0], ), "trail_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_TRAIL_PCT", [1.0, 1.5, 2.0, 3.0], ), "trail_arm_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_TRAIL_ARM", [0.0, 1.0, 1.5, 2.0], ), "shoulder_min_high_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_SHOULDER_SMIN", [1.0, 2.0, 3.0, 5.0, 7.0], ), "shoulder_cut_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_SHOULDER_CUT", [0.5, 0.8, 1.0, 1.2, 1.5, 2.0], ), "ratchet_tiers": _parse_csv_tiers( "BREAKOUT_GRID_FULL_RATCHET_TIERS", ["", "2:1.5,5:1.0", "3:1.5,6:1.0,10:0.7", "2:2.0,6:1.2,12:0.8"], ), "lookback_min": _parse_csv_ints( "BREAKOUT_GRID_FULL_LOOKBACK_MIN", [1, 5, 10, 20, 30], ), "confirm_margin_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_CONFIRM_MARGIN", [0.0, 0.2, 0.5, 1.0], ), "body_min_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_BODY_MIN", [0.0, 0.3, 0.5], ), "max_hold_bars": _parse_csv_ints( "BREAKOUT_GRID_FULL_MAX_HOLD_BARS", [0, 30, 60, 90], ), "time_end_hm": _parse_csv_ints( "BREAKOUT_GRID_FULL_TIME_END_HM", [1030, 1130, 1300, 1500, 1530], ), "max_spread_pct": _parse_csv_floats( "BREAKOUT_GRID_FULL_MAX_SPREAD_PCT", [0.20, 0.30, 0.45, 0.60, 0.80], ), "min_bid_ask_ratio": _parse_csv_floats( "BREAKOUT_GRID_FULL_MIN_BID_ASK_RATIO", [0.50, 0.70, 0.85, 1.0], ), "ask_wall_max_qty": _parse_csv_ints( "BREAKOUT_GRID_FULL_ASK_WALL_MAX_QTY", [2000, 3000, 5000, 8000, 12000], ), }, } # ────────────────────────────────────────────────────────────────────────────── # DB 적용 # ────────────────────────────────────────────────────────────────────────────── def _get_breakout_field_map() -> Dict[str, Tuple[str, Any]]: return { # 진입 룰 "lookback_min": ("BREAKOUT_LOOKBACK_MIN", lambda v: str(int(float(v)))), "vol_window": ("BREAKOUT_VOL_WIN", lambda v: str(int(float(v)))), "vol_mult": ("BREAKOUT_VOL_MULT", lambda v: str(float(v))), "min_turnover_1m_pct": ("BREAKOUT_MIN_TURNOVER_1M_PCT", lambda v: str(float(v))), "prev_chg_min": ("BREAKOUT_PREV_CHG_MIN", lambda v: str(float(v))), "prev_chg_max": ("BREAKOUT_PREV_CHG_MAX", lambda v: str(float(v))), # 청산 룰 (% → 비율) "sl_pct": ("BREAKOUT_STOP_LOSS_PCT", lambda v: str(float(v) / 100)), "tp_pct": ("BREAKOUT_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)), # ATR 동적 손절 (sl_min/max 는 % 단위 그대로 저장 — 엔진이 /100 처리) "sl_mode": ("BREAKOUT_SL_MODE", lambda v: str(v).strip().lower()), "atr_period": ("BREAKOUT_ATR_PERIOD", lambda v: str(int(float(v)))), "atr_sl_mult": ("BREAKOUT_ATR_SL_MULT", lambda v: str(float(v))), "atr_sl_min_pct": ("BREAKOUT_ATR_SL_MIN_PCT", lambda v: str(float(v))), "atr_sl_max_pct": ("BREAKOUT_ATR_SL_MAX_PCT", lambda v: str(float(v))), "trail_pct": ("BREAKOUT_TRAIL_PCT", lambda v: str(float(v) / 100)), "trail_arm_pct": ("BREAKOUT_TRAIL_ARM_PCT", lambda v: str(float(v) / 100)), "shoulder_min_high_pct": ("BREAKOUT_SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v) / 100)), "shoulder_cut_pct": ("BREAKOUT_SHOULDER_CUT_PCT", lambda v: str(float(v) / 100)), "max_hold_bars": ("BREAKOUT_MAX_HOLD_BARS", lambda v: str(int(float(v)))), # 시간/방어 "time_start_hm": ("BREAKOUT_TIME_START", lambda v: str(int(float(v)))), "time_end_hm": ("BREAKOUT_GOLDEN_END_HM", lambda v: "%d:%02d" % ( int(float(v)) // 100, int(float(v)) % 100, )), "max_daily_chg": ("BREAKOUT_MAX_DAILY_CHG", lambda v: str(float(v))), "min_price": ("BREAKOUT_MIN_PRICE", lambda v: str(int(float(v)))), # 가짜돌파(휩쏘) 필터 — % 그대로 저장 (_eval 가 % 로 읽음) "confirm_margin_pct": ("BREAKOUT_CONFIRM_MARGIN_PCT", lambda v: str(float(v))), "body_min_pct": ("BREAKOUT_BODY_MIN_PCT", lambda v: str(float(v))), "max_loss_krw": ("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))), "slot_money": ("BREAKOUT_SLOT_MONEY", lambda v: str(int(float(v)))), "entry_mode": ("BREAKOUT_ENTRY_MODE", lambda v: str(v).strip().lower()), "intrabar_slippage_pct": ("BREAKOUT_INTRABAR_SLIPPAGE_PCT", lambda v: str(float(v))), # skip_hts_scan_dupes: full 그리드 탐색만, apply 제외 } def _apply_to_db(best_params: dict) -> None: """1위 파라미터 → env_config INSERT (최신 행 복사 후 BREAKOUT_* 패치). ※ 구버전 UPDATE 방식은 DB에 없는 컬럼명으로 실패함(1054). ``param_search_apply_snapshot._patch_from_breakout_merged`` 와 동일 경로. """ from kis_trader.backtest.param_search_apply_snapshot import ( # noqa: WPS433 _patch_from_breakout_merged, ) patch = _patch_from_breakout_merged(best_params) patch.update(session_env_patch("BREAKOUT", best_params)) # 슬롯·동시보유·총한도 제외 — Optuna/Grid 가 slot×종목수로 한도를 덮지 않음 patch = strip_portfolio_keys_from_apply_patch(patch, "BREAKOUT") if not patch: print("DB 적용할 파라미터가 없습니다.") return db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(patch) eid = db.insert_env_snapshot(snap) if not eid: print("❌ env_config INSERT 실패 (컬럼 마이그레이션 후 재시도)") return finally: db.close() print("\n✅ DB env_config INSERT 완료 (BREAKOUT_* 키, id=%s):" % eid) for db_col, val in sorted(patch.items()): print(f" {db_col:<35s} = {val}") def apply_params_to_db(best_params: dict) -> None: """웹·CLI 공통 — 파라서치 merged → DB.""" _apply_to_db(best_params) # ────────────────────────────────────────────────────────────────────────────── # UI(%) → 엔진(비율) 변환 # ────────────────────────────────────────────────────────────────────────────── def _ui_to_engine_params(ui_params: dict) -> dict: """``breakout_ui_to_engine_params`` 와 동일 (웹 백테·파서치 공용).""" return breakout_ui_to_engine_params(ui_params) def evaluate_breakout_param_combo( combo: Dict[str, Any], *, base_fixed: Dict[str, Any], grid_keys: List[str], codes_candles: Dict[str, List[Dict]], min_trades: int, min_win_rate: float, min_pf: float, universe_by_slot: Optional[Dict[str, List[str]]] = None, slot_money: float = 2_000_000.0, max_stocks: int = 3, total_budget_krw: float = 6_000_000.0, fee_rate: float = 0.00015, sell_tax: float = 0.0018, period_days: int = 1, cache_holder: Optional[Dict[str, Any]] = None, ticks_by_code: Any = None, orderbook_by_code: Any = None, program_by_code: Any = None, log_verdict_by_code: Any = None, share_denom_by_code: Optional[Dict[str, float]] = None, ) -> Optional[Dict[str, Any]]: """단일 돌파 조합 백테 — Grid 워커·Optuna objective 공통.""" if "prev_chg_min" in combo and "prev_chg_max" in combo: if float(combo["prev_chg_min"]) >= float(combo["prev_chg_max"]): return None ui_params = dict(base_fixed) ui_params.update(combo) engine_params = _ui_to_engine_params(ui_params) if "max_spread_pct" in ui_params and ui_params.get("max_spread_pct") is not None: engine_params["_ob_max_spread_pct"] = float(ui_params["max_spread_pct"]) if "min_bid_ask_ratio" in ui_params and ui_params.get("min_bid_ask_ratio") is not None: engine_params["_ob_min_bid_ask_ratio"] = float(ui_params["min_bid_ask_ratio"]) if "ask_wall_max_qty" in ui_params and ui_params.get("ask_wall_max_qty") is not None: engine_params["_ob_ask_wall_max_qty"] = float(ui_params["ask_wall_max_qty"]) if ui_params.get("_orderbook_filter_enabled") is not None: engine_params["_orderbook_filter_enabled"] = bool(ui_params["_orderbook_filter_enabled"]) if "skip_hts_scan_dupes" in ui_params: engine_params["skip_hts_scan_dupes"] = bool(ui_params["skip_hts_scan_dupes"]) elif "skip_hts_scan_dupes" not in engine_params: engine_params["skip_hts_scan_dupes"] = resolve_breakout_skip_hts_scan_dupes() if cache_holder: engine_params.update(cache_holder) engine_params["slot_money"] = float(slot_money) engine_params["max_stocks"] = int(max_stocks) engine_params["total_budget_krw"] = float(total_budget_krw) engine_params["portfolio_mode"] = True if share_denom_by_code: engine_params["share_denom_by_code"] = share_denom_by_code if log_verdict_by_code: engine_params["_backtest_log_verdict_by_code"] = log_verdict_by_code meta: Dict[str, Any] = {} trades = bbc.run_breakout_backtest_web_aligned( codes_candles, engine_params, universe_by_slot, slot_money=slot_money, fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=max_stocks, total_budget_krw=total_budget_krw, ticks_by_code=ticks_by_code, orderbook_by_code=orderbook_by_code, program_by_code=program_by_code, meta_out=meta, ) stats = bbc.summarize_breakout_trades( trades, total_budget_krw=total_budget_krw, period_days=period_days, ) total_trades = stats["total_trades"] if total_trades < min_trades: return None total_pnl = stats["total_pnl"] win_rate = stats["win_rate"] pf = float(stats.get("pf") or 0) if not combo_passes_search_filters( win_rate=win_rate, pf=pf, min_win_rate=min_win_rate, min_pf=min_pf, ): return None avg_hold = stats["avg_hold_min"] 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 = dict(ui_params) merged["slot_money"] = float(slot_money) merged["max_stocks"] = int(max_stocks) merged["total_budget_krw"] = float(total_budget_krw) return { "params": {k: ui_params[k] for k in grid_keys if k in ui_params}, "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), "bot_pct": stats["bot_pct"], "daily_avg_pct": stats["daily_avg_pct"], "avg_profit_rate": _avg_profit_rate_pct(trades), "sell_reasons": _count_sell_reasons(trades), "skipped_micro_buys": int( (meta.get("skip_stats") or {}).get("skipped_micro_buys") or 0 ), "merged_params": merged, } def _evaluate_breakout_chunk( param_chunk: List[Dict[str, Any]], base_fixed: Dict[str, Any], keys: List[str], codes_candles: Optional[Dict[str, List[Dict]]], min_trades: int, min_win_rate: float, min_pf: float, top_n: int, universe_by_slot: Optional[Dict[str, List[str]]] = None, slot_money: float = 2_000_000.0, max_stocks: int = 3, total_budget_krw: float = 6_000_000.0, fee_rate: float = 0.00015, sell_tax: float = 0.0018, period_days: int = 1, ) -> List[Tuple[float, float, int, Dict]]: """워커: 청크 내 조합 평가 — 시각순 포트폴리오 (breakout_backtest_common).""" shared = worker_shared_get() ticks_preloaded = None orderbook_preloaded = None program_preloaded = None log_verdict_preloaded = None share_denom_preloaded = None if shared: if codes_candles is None: codes_candles = shared.get("codes_candles") or {} if universe_by_slot is None: universe_by_slot = shared.get("universe_by_slot") ticks_preloaded = shared.get("ticks_by_code") # ws_ticks 공유메모리(opt-in): descriptor 로 read-only attach (워커당 1회 재사용). if not ticks_preloaded: _desc = shared.get("ticks_shared_descriptor") if _desc: _tm = shared.get("_ticks_mapping_cache") if _tm is None: from kis_trader.backtest.shared_ticks import SharedTicksMapping _tm = SharedTicksMapping(_desc) shared["_ticks_mapping_cache"] = _tm ticks_preloaded = _tm orderbook_preloaded = shared.get("orderbook_by_code") program_preloaded = shared.get("program_by_code") log_verdict_preloaded = shared.get("log_verdict_by_code") share_denom_preloaded = shared.get("share_denom_by_code") if codes_candles is None: codes_candles = {} cache_holder: Dict[str, Any] = {} attach_indicator_caches_to_params(cache_holder, codes_candles) local_heap: List[Tuple[float, float, int, Dict]] = [] for combo in param_chunk: assert_parent_alive() result_pkg = evaluate_breakout_param_combo( combo, base_fixed=base_fixed, grid_keys=keys, codes_candles=codes_candles, min_trades=min_trades, min_win_rate=min_win_rate, min_pf=min_pf, universe_by_slot=universe_by_slot, slot_money=slot_money, max_stocks=max_stocks, total_budget_krw=total_budget_krw, fee_rate=fee_rate, sell_tax=sell_tax, period_days=period_days, cache_holder=cache_holder, ticks_by_code=ticks_preloaded, orderbook_by_code=orderbook_preloaded, program_by_code=program_preloaded, log_verdict_by_code=log_verdict_preloaded, share_denom_by_code=share_denom_preloaded, ) if result_pkg is None: continue total_pnl = result_pkg["total_pnl"] win_rate = result_pkg["win_rate"] item_t = (total_pnl, win_rate, id(result_pkg), result_pkg) if len(local_heap) < top_n: heapq.heappush(local_heap, item_t) elif total_pnl > local_heap[0][0]: heapq.heapreplace(local_heap, item_t) return local_heap # ────────────────────────────────────────────────────────────────────────────── # 캔들 로드 # ────────────────────────────────────────────────────────────────────────────── def _load_candles_for_search( start: str, end: str, lookback_min: int = 1, fixed: Optional[Dict[str, Any]] = None, ) -> dict: db = TradeDB() codes_candles: Dict[str, List[Dict]] = {} 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] probe = dict(fixed or {}) probe.setdefault("lookback_min", lookback_min) probe.setdefault("vol_window", 7) need_n = breakout_min_bars_required(probe) 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) < need_n: 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, min_pf: float, apply_rank: Optional[int], from_file_only: bool = False, use_fallback_universe: bool = False, slot_money: Optional[float] = None, max_stocks: Optional[int] = None, total_budget_krw: Optional[float] = None, time_start_hm: Optional[int] = None, time_end_hm: Optional[int] = None, max_combos: Optional[int] = None, orderbook_filter: str = "off", ) -> bool: if from_file_only and apply_rank is not None and apply_rank >= 1: _apply_from_latest_json(apply_rank) return True grid = _breakout_grids()[mode] keys = list(grid.keys()) axes = [grid[k] for k in keys] # 데카르트곱 전체를 RAM 에 펼치지 않는다 (호가 스윕 시 수천만 → OOM 방지). dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform_lazy( keys, axes, mode, strategy_env_prefix="BREAKOUT", default_fast=200, max_combos_override=max_combos, ) total = len(dict_combos) print(f"\n[BREAKOUT {mode.upper()} 모드] 그리드: {total_grid:,} → 백테: {total:,} | 기간: {start} ~ {end}") if mode == "fast": print(f"📌 [fast] {total_grid:,}→{max_combos_cap}균등샘플 · 돌파 TRIGGER 축") if dropped_by_cap: print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)") print(f"📌 1위 정렬 기준: 총손익 최대") print(f"📌 필터: 승률≥{min_win_rate}% · PF≥{min_pf} · 거래≥{min_trades}건") print("📌 청산순: EOD → 익절 → 어깨 → 손절 → 트레일 (돌파 전용)") _em = breakout_entry_mode() print(f"📌 진입 모드: {_em} (intrabar=B·HTS E / align=A·다음봉 시가)") print("=" * 70) FIXED_DEFAULTS = _bo_fixed_defaults() apply_session_to_fixed( FIXED_DEFAULTS, time_start_hm=time_start_hm, time_end_hm=time_end_hm, ) # ── 호가필터 ON/OFF (2단계 워크플로) ─────────────────────────────── # off(기본): 1단계 — 호가 게이트 없이 코어 파라미터만 순수 탐색. # on : 2단계 — kiwoom_0d 본체로 스프레드·잔량비·돌파매도벽 실제 적용. # auto : env/DB 의 *_ORDERBOOK_FILTER_ENABLED 값을 그대로 따름. _ob_mode = (orderbook_filter or "off").strip().lower() if _ob_mode == "off": FIXED_DEFAULTS["_orderbook_filter_enabled"] = False elif _ob_mode == "on": FIXED_DEFAULTS["_orderbook_filter_enabled"] = True _ob_filter_on = bool(FIXED_DEFAULTS.get("_orderbook_filter_enabled")) or _ob_mode == "auto" print( f"📌 호가필터: {_ob_mode.upper()} " f"({'적용' if _ob_filter_on else '스킵 — 코어 파라미터 순수 탐색'})" ) db = TradeDB() try: from kis_trader.backtest.backtest_portfolio_common import load_portfolio_env_row env_row = load_portfolio_env_row(db) finally: db.close() fee_rate, sell_tax, slot_from_env = bbc.fee_and_slot_from_env(env_row) portfolio = bbc.resolve_breakout_portfolio_params( env_row, None, slot_money=slot_money if slot_money is not None else slot_from_env, max_stocks=max_stocks, total_budget_krw=total_budget_krw, ) slot_money_v = float(portfolio["slot_money"]) max_stocks_v = int(portfolio["max_stocks"]) total_budget_v = float(portfolio["total_budget_krw"]) period_days = max( 1, (datetime.strptime(end, "%Y-%m-%d") - datetime.strptime(start, "%Y-%m-%d")).days + 1, ) print( f"💼 포트폴리오: 1회 {slot_money_v:,.0f}원 | 동시 {max_stocks_v}종 | " f"총한도 {total_budget_v:,.0f}원 | 매매 {format_session_hm(FIXED_DEFAULTS)}" ) if portfolio.get("budget_warning"): print(f"💰 {portfolio['budget_warning']}") print("⏳ DB에서 캔들 데이터를 메모리로 불러오는 중...") codes_candles = _load_candles_for_search( start, end, FIXED_DEFAULTS.get("lookback_min", 1), FIXED_DEFAULTS, ) print(f"✅ 데이터 로드 완료: {len(codes_candles):,}종목") share_denom_by_code: Dict[str, float] = {} _share_db = TradeDB() try: from kis_trader.share.stock_share import load_share_denom_map share_denom_by_code = load_share_denom_map(_share_db, codes_candles.keys()) n_ok = sum(1 for v in share_denom_by_code.values() if v > 0) print(f"✅ stock_share_meta: {n_ok}/{len(codes_candles)}종목 유통주식수 로드") finally: _share_db.close() ticks_by_code: Dict[str, Dict[str, List[Dict]]] = {} tick_backtest_meta: Dict[str, Any] = {} engine_probe = _ui_to_engine_params(FIXED_DEFAULTS) engine_probe["_orderbook_filter_enabled"] = FIXED_DEFAULTS.get("_orderbook_filter_enabled") if breakout_backtest_wants_tick_replay(engine_probe): start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" _tick_db = TradeDB() try: ticks_by_code, tick_rows = load_breakout_ticks_by_code( _tick_db, start_key, end_key, set(codes_candles.keys()), ) tick_backtest_meta = tick_coverage_stats(codes_candles, ticks_by_code) tick_backtest_meta["ws_tick_rows_loaded"] = tick_rows cov = tick_backtest_meta.get("tick_bar_coverage_pct", 0) print( f"✅ ws_ticks {tick_rows:,}건 | 분봉 커버리지 {cov}% " f"({tick_backtest_meta.get('tick_codes_with_data', 0)}/" f"{tick_backtest_meta.get('tick_codes_total', 0)}종목)" ) if tick_rows <= 0: print("⚠️ ws_ticks 없음 — B안은 1분봉 high 폴백 (WS_TICK_SAVE_ENABLED 후 재탐색)") finally: _tick_db.close() orderbook_by_code: Dict[str, Dict[str, List[Dict]]] = {} program_by_code: Dict[str, Dict[str, List[Dict]]] = {} log_verdict_by_code: Dict[str, Dict[str, List[Dict]]] = {} trigger_snap_meta: Dict[str, Any] = {} # kiwoom_0d 본체 재계산 — 필터 ON + 호가필터 축 **실제 스윕**(값 2개↑) 일 때만. _ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_wall_max_qty") _ob_sweeping = any(len(set(grid.get(k) or [])) > 1 for k in _ob_axes) if _ob_filter_on and _ob_sweeping: engine_probe["backtest_use_kiwoom_body_snapshot"] = True engine_probe["_backtest_use_kiwoom_body"] = True _ob_axis_vals = {k: grid.get(k) for k in _ob_axes if len(set(grid.get(k) or [])) > 1} print( f"📌 호가필터 스윕 활성 → kiwoom_0d 본체 재계산 " f"(축 {_ob_axis_vals}, 본체 없는 날짜는 log_backfill 폴백)" ) elif _ob_filter_on: print("📌 호가필터 ON · 스윕 없음 → 본체 재계산 생략(판정 재생 경로)") if codes_candles: start_key = (start.replace("-", "") + "0000") if start else "20260101" end_key = (end.replace("-", "") + "2359") if end else "99991231" _snap_db = TradeDB() try: from kis_trader.backtest.trigger_snapshot_loader import load_trigger_snapshots_by_code orderbook_by_code, program_by_code, trigger_snap_meta = load_trigger_snapshots_by_code( _snap_db, start_key, end_key, set(codes_candles.keys()), engine_params=engine_probe, strategy="BREAKOUT", ) log_verdict_by_code = trigger_snap_meta.get("log_verdict_by_code") or {} ob_rows = int(trigger_snap_meta.get("ws_orderbook_rows_loaded") or 0) pg_rows = int(trigger_snap_meta.get("ws_program_rows_loaded") or 0) lv_rows = int(trigger_snap_meta.get("log_verdict_rows") or 0) print( f"✅ TRIGGER 스냅샷 ws_orderbook {ob_rows:,}건 | ws_program {pg_rows:,}건 " f"| log_backfill 판정 {lv_rows:,}건 " f"(호가종목 {trigger_snap_meta.get('orderbook_codes_with_data', 0)} / " f"프로그램종목 {trigger_snap_meta.get('program_codes_with_data', 0)})" ) if ob_rows <= 0 and pg_rows <= 0: print("⚠️ TRIGGER 스냅샷 없음 — 호가·프로그램 필터 스킵 (실매 수집 후 재탐색)") finally: _snap_db.close() # ── 유니버스 ── universe_by_slot = None fallback_sim_interval = 5 start_ymd = start.replace("-", "") if start else "" end_ymd = end.replace("-", "") if end else "" if not use_fallback_universe and start_ymd and end_ymd: try: from kis_trader.backtest.breakout_backtest_common import resolve_breakout_universe history, _src, n_bins, _scan_iv = resolve_breakout_universe( start_ymd, end_ymd, use_saved_history=True, ) if history: universe_by_slot = history avg = sum(len(v) for v in history.values()) / max(1, n_bins) print(f"✅ 유니버스: 신봇 BREAKOUT 이력 사용 | {n_bins:,}분봉 · 평균 {avg:.1f}종목") else: print("ℹ️ BREAKOUT 이력 없음 → 시뮬레이션 fallback 자동 사용") except Exception as _e: logging.getLogger("param_search_breakout").debug( "신봇 유니버스 이력 조회 스킵: %s", _e, ) if universe_by_slot is None: # 모멘텀과 동일하게 scalping_engine.build_universe_simulation 재사용 from kis_trader.engine import scalping_engine as se 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, ) 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"✅ 유니버스: 시뮬레이션 사용 | {fallback_sim_interval}분 슬롯 {n_slots}개 · " f"슬롯당 평균 {avg_per_slot:.1f}종목") # ── ws_ticks 공유메모리 — 워커별 사본 대신 1벌 공유 (momentum·tail 과 동일) ──────── # 기본 ON(검증 완료). ON 이면 컬럼(numpy)로 shared_memory 에 1벌 올리고 워커는 read-only # attach → 메모리 N배 제거 + 워커 상한 해제. breakout 의 반복 핫루프(whipsaw)와 intrabar # 진입 경로 모두 TickColumnView 로 배열 직접 읽어 dict 경로와 bit-identical(E2E 667거래). # 끄려면 BREAKOUT_PARAM_SEARCH_SHARED_TICKS=0. numpy/shm 미지원·빌드 실패 시 자동 폴백. shared_tick_store = None if get_env_bool("BREAKOUT_PARAM_SEARCH_SHARED_TICKS", True) and ticks_by_code: from kis_trader.backtest.shared_ticks import ( build_shared_ticks, shared_ticks_available, ) if shared_ticks_available(): shared_tick_store = build_shared_ticks(ticks_by_code) if shared_tick_store is not None: import atexit as _atexit _atexit.register(shared_tick_store.unlink) print("📦 ws_ticks 공유메모리 ON — 워커 attach(read-only), 사본 제거") ticks_by_code = {} import gc as _gc _gc.collect() try: import ctypes as _ctypes _ctypes.CDLL("libc.so.6").malloc_trim(0) except Exception: pass else: print("⚠️ ws_ticks 공유메모리 build 실패 — 기존 경로 폴백") else: print("⚠️ numpy/shared_memory 미지원 — 기존 경로 폴백") shared = ParamSearchSharedPayload({ "codes_candles": codes_candles, "universe_by_slot": universe_by_slot, "ticks_by_code": ticks_by_code, "ticks_shared_descriptor": (shared_tick_store.descriptor() if shared_tick_store else None), "tick_backtest_meta": tick_backtest_meta, "orderbook_by_code": orderbook_by_code, "program_by_code": program_by_code, "log_verdict_by_code": log_verdict_by_code, "trigger_snapshot_meta": trigger_snap_meta, "share_denom_by_code": share_denom_by_code, }) payload_bytes = shared.estimate_bytes() n_cpu = os.cpu_count() or 4 _cpu_frac = get_env_float("PARAM_SEARCH_CPU_FRAC", 0.8) max_workers, chunk_size, _ = param_search_chunk_plan(total, payload_bytes) # ── 틱재생(ws_ticks) payload 시 워커 상한 (OOM 방지) ───────────────── # 틱재생 기본 ON 이후, 워커별로 ws_ticks 등 사적 구조가 payload 추정치보다 # 크게 부풀어(모멘텀에서 워커당 anon-RSS ~3.3GB 관측) 다수 워커 동시 실행 시 # 저용량 머신이 OOM-kill 로 죽는다. 모멘텀·꼬리잡기와 동일 가드 적용. # (하드코딩 금지 — DB/Env 로 조정, 기본 2) if ticks_by_code: tick_cap = get_env_int("BREAKOUT_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS", 2) if tick_cap > 0 and max_workers > tick_cap: max_workers = tick_cap print(f"📌 ws_ticks payload — 워커 상한 {max_workers} (BREAKOUT_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS)") # 공유메모리 틱은 사본이 없어 OOM 위험이 없다 → 기본 상한 없음(CPU/메모리 계획대로). if shared_tick_store is not None: shared_cap = get_env_int("BREAKOUT_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS", 0) if shared_cap > 0 and max_workers > shared_cap: max_workers = shared_cap print(f"📌 ws_ticks 공유메모리 — 워커 상한 {max_workers} (BREAKOUT_PARAM_SEARCH_MAX_WORKERS_WITH_SHARED_TICKS)") chunks = [dict_combos[i:i + chunk_size] for i in range(0, len(dict_combos), chunk_size)] print(param_search_worker_budget_line(payload_bytes)) print(f"⚙️ 멀티프로세싱 시작 (코어: {n_cpu}, 워커: {max_workers}, CPU {_cpu_frac*100:.0f}%) | 청크: {len(chunks):,}개 (청크당 ~{chunk_size}조합)") start_time = time.time() global_heap: List[Tuple[float, float, Tuple[int, int], Dict]] = [] progress_eta = ParamSearchProgressETA(len(chunks), max_workers) with managed_process_pool(max_workers, shared_payload=shared) as executor: def _submit(chunk: List[Dict[str, Any]]): return executor.submit( _evaluate_breakout_chunk, chunk, FIXED_DEFAULTS, keys, None, min_trades, min_win_rate, min_pf, top_n, None, slot_money_v, max_stocks_v, total_budget_v, fee_rate, sell_tax, period_days, ) processed = 0 use_carriage_return = sys.stdout.isatty() for local_results in iter_pool_chunk_results( executor, chunks, _submit, max_workers=max_workers, ): processed += 1 for idx, item in enumerate(local_results): pnl, wr, _, result_pkg = item tie = (processed, idx) entry = (pnl, wr, tie, result_pkg) if len(global_heap) < top_n: heapq.heappush(global_heap, entry) elif pnl > global_heap[0][0]: heapq.heapreplace(global_heap, entry) progress = (processed / len(chunks)) * 100 elapsed_so_far = time.time() - start_time eta_str = ParamSearchProgressETA.format_sec( progress_eta.remaining_sec(processed, elapsed_so_far), ) elapsed_str = ParamSearchProgressETA.format_elapsed(elapsed_so_far) line = (f"⏳ 진행률: {progress:.1f}% ({processed:,}/{len(chunks):,}) | " f"경과 {elapsed_str} | 남은 {eta_str}") if use_carriage_return: print(f"\r{line}", end="", flush=True) else: print(line, flush=True) if use_carriage_return: print(flush=True) # 워커 종료 후 공유메모리 즉시 해제 (atexit 는 크래시 대비 이중 안전장치). if shared_tick_store is not None: shared_tick_store.unlink() shared_tick_store = None elapsed = time.time() - start_time if not global_heap: print("\n⚠️ 조건을 만족하는 조합이 없습니다. (--min_trades 를 낮추거나 기간을 늘려보세요.)") return False results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))] results.sort(key=lambda r: (-r["total_pnl"], -r["win_rate"])) print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results):,}건") print(f"\n{'='*100}") print(f" 🏆 BREAKOUT TOP {min(top_n, len(results))} (총손익 기준)") print(f"{'='*100}") hdr_keys = list(keys) col_w = max(len(k) for k in hdr_keys) + 2 hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys) print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5}") print("-" * (len(hdr) + 50)) for r in results[:top_n]: p = r["params"] row = " ".join( (f"{str(p[k]):>{col_w}}" if isinstance(p[k], (bool, str)) 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}") trade_counts = sorted({r["total_trades"] for r in results}) print(f"\n📊 거래수 다양성: {len(trade_counts)}가지 → {trade_counts[:20]}{'...' if len(trade_counts) > 20 else ''}") if results: r0 = results[0] reasons = r0.get("sell_reasons") or {} if reasons: reason_line = ", ".join(f"{k}={v}" for k, v in sorted(reasons.items(), key=lambda x: -x[1])) print(f"🏅 1위 청산분포: {reason_line} | 건당평균수익률 {r0.get('avg_profit_rate', 0)}%") 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_breakout_{mode}_{ts}.json") payload = { "strategy": "BREAKOUT", "mode": mode, "start": start, "end": end, "grid_combos": total_grid, "max_combos_cap": max_combos_cap, "min_win_rate": min_win_rate, "min_pf": min_pf, "exit_priority": "eod → take_profit → shoulder_cut → stop_loss → trailing", "top": [ { "rank": i + 1, "params": r["params"], "merged_params": r.get("merged_params", r["params"]), "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"], "bot_pct": r.get("bot_pct"), "daily_avg_pct": r.get("daily_avg_pct"), "avg_profit_rate": r.get("avg_profit_rate"), "sell_reasons": r.get("sell_reasons"), "skipped_micro_buys": r.get("skipped_micro_buys", 0), } for i, r in enumerate(results) ], } payload.update(search_json_meta(portfolio, FIXED_DEFAULTS)) 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_breakout_{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⚠️ 기본 경로 쓰기 실패({type(_e).__name__}). 폴백 저장: {out_path}") if apply_rank is not None and 1 <= apply_rank <= len(results): cand = results[apply_rank - 1] if cand.get("total_pnl", 0) <= 0: print(f"⚠️ {apply_rank}번째 결과 총손익 ≤ 0 → DB 미적용. 기존 설정 유지.") else: merged_apply = merge_param_search_apply_source(cand, payload) _apply_to_db(merged_apply) print(f"✅ {apply_rank}번째 결과 적용 완료") return True # ────────────────────────────────────────────────────────────────────────────── # CLI 진입점 # ────────────────────────────────────────────────────────────────────────────── def main(): from kis_trader.backtest.param_search_dates import resolve_param_search_range week_ago, today = resolve_param_search_range("BREAKOUT", lookback_days=7) parser = argparse.ArgumentParser(description="돌파매매 백테스트 파라미터 Grid Search (BreakoutStrategy + run_breakout_backtest)") 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="fast", choices=["fast", "coarse", "fine", "wide", "full"], help="탐색 모드: fast(그리드→균등200·~10–15분) / coarse / fine / full") parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", help="백테 조합 상한 (fast 기본 env BREAKOUT_FAST_MAX_COMBOS 또는 PARAM_SEARCH_FAST_MAX_COMBOS=200, 0=무제한)", ) parser.add_argument("--top", default=1000, type=int) parser.add_argument("--min_trades", default=1, type=int) add_search_filter_cli_args(parser) parser.add_argument("--apply", nargs="?", const=1, type=int, default=None, metavar="N", help="N번째 결과 DB 적용 (기본 1, 총손익>0 일 때만). --from-file 시 최근 JSON에서 적용") parser.add_argument("--from-file", action="store_true", help="--apply N 과 함께: 탐색 생략, 최근 search_breakout_*.json 에서만 DB 적용") parser.add_argument("--fallback-universe", action="store_true", dest="fallback_universe") parser.add_argument( "--orderbook-filter", default="off", choices=["off", "on", "auto"], dest="orderbook_filter", help="호가필터: off=1단계(코어만·기본) / on=2단계(kiwoom 본체 스프레드·잔량비·돌파매도벽) / auto=DB값", ) add_portfolio_cli_args(parser) args = parser.parse_args() def _sigterm_to_kbd(_sig, _frm): raise KeyboardInterrupt("SIGTERM 수신 → 워커 정리 후 종료") try: signal.signal(signal.SIGTERM, _sigterm_to_kbd) except Exception: pass run_lock = None if not (args.from_file and args.apply is not None): run_lock = try_acquire_run_lock("param_search_breakout") if run_lock is None: print( "⛔ 이미 실행 중인 param_search_breakout 이 있습니다.\n" " ps -ef | grep param_search_breakout\n" " pkill -f 'param_search_breakout.py' 후 재실행하세요.", flush=True, ) sys.exit(2) try: 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, min_pf = args.min_pf, apply_rank = args.apply, from_file_only = args.from_file, use_fallback_universe = args.fallback_universe, slot_money = args.slot_money, max_stocks = args.max_stocks, total_budget_krw = args.total_budget, time_start_hm = args.time_start, time_end_hm = args.time_end, max_combos = args.max_combos, orderbook_filter = args.orderbook_filter, ) except KeyboardInterrupt as e: print(f"\n⛔ {e} — 미완료 결과 없이 종료합니다.", flush=True) sys.exit(130) finally: if run_lock is not None: run_lock.release() if __name__ == "__main__": main()