#!/usr/bin/env python3 """ kis_trader/backtest/param_search_momentum.py — 모멘텀 백테스트 파라미터 자동 탐색 (Grid Search) ======================================================================================================= [전략 = MOMENTUM A안 — HTS ``momentum`` (E∧F∧H∧I) 일봉 SCAN + E 정합 TRIGGER] - SCAN: 키움 ``momentum`` 조건검색 → ``target_candidates_history`` - TRIGGER: ``momentum_hts_logic`` — 전일시가(E) 유지 + 양봉·거래량 펄스 - 청산: ``check_sell_signal_momentum_live`` — 래칫·어깨·트레일·손절·시간컷 - ``--mode exit``: 진입 DB 고정 × 청산(어깨·래칫·트레일·SL·TP) 광범위 그리드 - 백테·파서치: ``momentum_backtest_common.run_momentum_backtest_web_aligned`` → ``momentum_engine.run_momentum_backtest`` → ``check_sell_signal_momentum_backtest_bar`` [관련 CLI (전략별 파일 분리)] 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 통합 적용 CLI : kis_trader/backtest/param_search_apply_snapshot.py (--json · --rank) 실행: cd /home/hoon/kis_bot python3 kis_trader/backtest/param_search_momentum.py --start 2026-05-11 --end 2026-05-27 python3 -m kis_trader.backtest.param_search_momentum --mode fast --top 30 옵션: --start 시작일 (기본: 오늘-7일) --end 종료일 (기본: 오늘) --mode 탐색 모드: fast(기본) / rr / coarse / fine / full --top 상위 N개 출력·JSON 저장 (기본: 1000) --min_trades 최소 거래 건수 필터 (기본: 1) --apply N번째 결과를 DB(MOMENTUM_* env)에 자동 적용 (총손익 > 0 일 때만) --fallback-universe 저장 이력 무시, 시뮬 유니버스만 사용 ⚠️ 모멘텀 백테는 ``momentum_engine`` 매수 루프 사용 (SCALP reversal high_chase 등 미적용). 방어필터는 MOMENTUM_USE_* / 시가·일변동 컷만 반영. ⚠️ 2026-05 이전 JSON 은 워커 힙 병합 버그로 순위 불일치 가능 — 동일 기간 재탐색 권장. """ 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 # fast: V2 TRIGGER 축 → MOMENTUM_FAST_MAX_COMBOS(기본 200) 균등샘플 (~10–15분·이력유니버스 기준) DEFAULT_MAX_COMBOS = 5000 # coarse/fine 상한 (env MOMENTUM_PARAM_SEARCH_MAX_COMBOS) # 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 from kis_trader.engine import momentum_engine as me from kis_trader.engine.momentum_hts_logic import resolve_momentum_skip_hts_scan_dupes from kis_trader.engine.indicator_cache import attach_indicator_caches_to_params from kis_trader.backtest import momentum_backtest_common as mbc from kis_trader.backtest import scalping_backtest_common as sbc 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, grid_total_combinations, 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.strategies.breakout import normalize_breakout_max_loss_krw # noqa: E402 from kis_trader.utils.env import ( # noqa: E402 — DB/os 폴백, 하드코딩 금지 원칙 get_env_bool, get_env_float, get_env_from_db, get_env_int, ) def _parse_csv_floats(env_key: str, fallback: List[float]) -> List[float]: """env 에 콤마 구분 목록이 있으면 사용 (예: `1.0,1.5,2.0`). 없으면 fallback.""" 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_strings(env_key: str, fallback: List[str], sep: str = "|") -> List[str]: """문자열 축(래칫 티어 등) — env 는 ``|`` 구분, 빈 토큰은 OFF.""" raw = get_env_from_db(env_key, "") if not raw: return list(fallback) s = str(raw).strip() if sep in s: parts = [p.strip() for p in s.split(sep)] else: parts = [p.strip() for p in s.split(",")] if not parts: return list(fallback) return parts def _parse_csv_bools(env_key: str, fallback: List[bool]) -> List[bool]: raw = str(get_env_from_db(env_key, "") or "").strip() if not raw: return list(fallback) out: List[bool] = [] for part in raw.replace("|", ",").split(","): part = part.strip().lower() if not part: continue out.append(part in ("1", "true", "t", "y", "yes", "on")) return out if out else list(fallback) # ────────────────────────────────────────────────────────────────────────────── # 모멘텀 기본값 (DB 우선, 없으면 코드 default) # ────────────────────────────────────────────────────────────────────────────── def _rr_search_base_from_env() -> Dict[str, float]: """``MOMENTUM_RR_SEARCH_BASE_JSON`` — R:R 스윕 1위 청산축 (UI % 단위).""" import json as _json raw = str(os.environ.get("MOMENTUM_RR_SEARCH_BASE_JSON") or "").strip() if not raw: raw = str(get_env_from_db("MOMENTUM_RR_SEARCH_BASE_JSON", "") or "").strip() if not raw: return {} try: data = _json.loads(raw) if isinstance(data, dict): return {k: float(v) for k, v in data.items()} except (TypeError, ValueError, _json.JSONDecodeError): pass return {} def _mom_fixed_defaults(market: str = "KR") -> Dict[str, Any]: """그리드에 안 넣은 축만 채움 + 각 축의 폴백값. - 숫자 기본값은 전부 ``get_env_*`` / DB ``_pick`` 으로만 둠 (리터럴 하드코딩 금지). - 트레일·쿨다운·일일한도·방어·슬롯 등은 coarse/fine/full 그리드가 덮어씀 (폴백은 JSON 재현용). - ``MOMENTUM_RR_SEARCH_BASE_JSON`` 이 있으면 청산 축 폴백을 R:R 스윕 1위로 맞춤. - market=US 이면 ``US_MOMENTUM_*``(config_us_momentum) 로 실매 앵커 덮어씀. """ rr_base = _rr_search_base_from_env() _d = me.get_momentum_defaults_from_db() from kis_trader.utils.env import get_strategy_env_dict env = get_strategy_env_dict("MOMENTUM") 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 _fee_r = float(_d.get("fee_rate", 0.00015)) _tax_r = float(_d.get("sell_tax", 0.0018)) out = { "rsi_period": get_env_int("MOMENTUM_SEARCH_RSI_PERIOD", int(_d.get("rsi_period", 3))), # 슬롯: 탐색 시 max_loss/sl 로 _ui_to_engine_params 가 재계산. 폴백만 env/DB. "slot_money": float(_pick( "MOMENTUM_SLOT_MONEY", "SLOT_MONEY_DEFAULT", default=get_env_int("MOMENTUM_SEARCH_SLOT_MONEY_KRW", int(_d.get("slot_money", 3_000_000))), cast=lambda v: float(v), )), "vol_mult": get_env_float("MOMENTUM_SEARCH_VOL_MULT", 0.0), # 아래는 그리드에 해당 키가 없을 때만 쓰는 폴백 (모든 모드에서 키가 있으면 조합값이 덮어씀) "shoulder_min_high": float(rr_base.get( "shoulder_min_high", float(_d.get("shoulder_min_high", 0.005)) * 100.0, )), "shoulder_cut_pct": float(rr_base.get( "shoulder_cut_pct", float(_d.get("shoulder_cut_pct", 0.003)) * 100.0, )), "tp_max_pct": float(rr_base.get( "tp_max_pct", float(_d.get("tp_max_pct", 0.02)) * 100.0, )), "sl_pct": float(rr_base.get("sl_pct", float(_d.get("sl_pct", 0.02)) * 100.0)), "tp_pct": float(rr_base.get("tp_pct", float(_d.get("tp_pct", 0.02)) * 100.0)), # 모멘텀 전용 트레일(UI%) — momentum_hts_logic 의 trail_pct/arm (0=OFF) "trail_pct": float(rr_base.get( "trail_pct", float(_d.get("trail_pct", 0.0)) * 100.0, )), "trail_arm_pct": float(rr_base.get( "trail_arm_pct", float(_d.get("trail_arm_pct", 0.0)) * 100.0, )), "cooldown_min": float( _d.get("cooldown_min") if _d.get("cooldown_min") is not None else get_env_int("MOMENTUM_SEARCH_COOLDOWN_MIN", 1) ), "time_start_hm": int(_pick( "MOMENTUM_TIME_START", "SCALP_TIME_START", "TIME_START", default=get_env_int("MOMENTUM_SEARCH_TIME_START_HM", int(_d.get("time_start_hm", 900))), cast=lambda v: int(float(v)), )), "time_end_hm": int(_pick( "MOMENTUM_TIME_END", "SCALP_TIME_END", "TIME_END", default=get_env_int("MOMENTUM_SEARCH_TIME_END_HM", int(_d.get("time_end_hm", 1530))), cast=lambda v: int(float(v)), )), # 실매 앵커 = config_momentum (SEARCH_* 단독 기본값 금지 — fine 그리드 누락 방지) "max_daily": float(int(float(_d.get("max_daily") or get_env_int("MOMENTUM_SEARCH_MAX_DAILY", 10)))), "fee_rate": _fee_r * 100.0, "sell_tax": _tax_r * 100.0, "high_chase_thr": float(_d.get("high_chase_thr") or get_env_float("MOMENTUM_SEARCH_HIGH_CHASE_THR", 0.99)), "max_daily_chg": float(_d.get("max_daily_chg") or get_env_float("MOMENTUM_SEARCH_MAX_DAILY_CHG_PCT", 50.0)), "min_price": int(_pick( "MOMENTUM_MIN_PRICE", "MIN_STOCK_PRICE", default=get_env_float("MOMENTUM_SEARCH_MIN_PRICE_KRW", float(_d.get("min_price", 1000))), cast=lambda v: int(float(v)), )), # 99M 등 OFF 값 → 20만 정규화 (breakout·웹 백테와 동일, 포지션 부풀림 방지) "max_loss_krw": float(normalize_breakout_max_loss_krw(_pick( "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW", default=get_env_int("MOMENTUM_SEARCH_MAX_LOSS_KRW", 200_000), cast=lambda v: int(float(v)), ))), # 탐색 기본 0.2% — DB MOMENTUM_MIN_PROFIT_PCT=10 은 TP(3~7%)와 어긋나 본절선이 비현실적 "min_margin": get_env_float("MOMENTUM_SEARCH_MIN_MARGIN_PCT", 0.2), "use_defense_filters": get_env_bool( "MOMENTUM_SEARCH_USE_DEFENSE", bool(_d.get("use_defense_filters", True)), ), "mom_vol_win": float(get_env_int( "MOMENTUM_SEARCH_VOL_WIN", int(_pick("MOMENTUM_VOL_WIN", default=5, cast=lambda v: int(float(v)))), )), "mom_time_end_hm": int(_pick( "MOMENTUM_TIME_END_HM", default=get_env_int("MOMENTUM_SEARCH_TIME_END_BUY_HM", 1530), cast=lambda v: int(float(v)), )), "mom_min_from_open_pct": get_env_float("MOMENTUM_SEARCH_MIN_FROM_OPEN_PCT", -999.0), "mom_max_from_open_pct": float(_d.get("mom_max_from_open_pct", 999.0)), "pattern_breakout": bool(_d.get("pattern_breakout", True)), "pattern_pullback": bool(_d.get("pattern_pullback", True)), "chase_lookback_min": int( _d.get("chase_lookback_min") if _d.get("chase_lookback_min") is not None else get_env_int("MOMENTUM_CHASE_LOOKBACK_MIN", 10) ), "pullback_lookback_min": int( _d.get("pullback_lookback_min") if _d.get("pullback_lookback_min") is not None else get_env_int("MOMENTUM_PULLBACK_LOOKBACK_MIN", 15) ), "pullback_min_pct": float( _d.get("pullback_min_pct") if _d.get("pullback_min_pct") is not None else get_env_float("MOMENTUM_PULLBACK_MIN_PCT", 0.3) ), "pullback_max_pct": float( _d.get("pullback_max_pct") if _d.get("pullback_max_pct") is not None else get_env_float("MOMENTUM_PULLBACK_MAX_PCT", 3.0) ), "setup_vol_max_mult": float( _d.get("setup_vol_max_mult") if _d.get("setup_vol_max_mult") is not None else get_env_float("MOMENTUM_SETUP_VOL_MAX_MULT", 0.8) ), "setup_bear_bars_min": int( _d.get("setup_bear_bars_min") if _d.get("setup_bear_bars_min") is not None else get_env_int("MOMENTUM_SETUP_BEAR_BARS_MIN", 1) ), "use_rsi_max_filter": bool(_d.get("use_rsi_max_filter", False)), "use_ema_filter": bool(_d.get("use_ema_filter", True)), "use_high_chase_filter": bool(_d.get("use_high_chase_filter", False)), "use_daily_range_filter": bool(_d.get("use_daily_range_filter", False)), "ema_fast_period": int(_d.get("ema_fast_period", 9)), "ema_slow_period": int(_d.get("ema_slow_period", 21)), "eod_enabled": bool(_d.get("eod_enabled", True)), "eod_hm": str(_d.get("eod_hm") or "15:25").strip() or "15:25", "skip_hts_scan_dupes": False, # Optuna/그리드: false 고정 (True 스윕·universe 폴백 금지) # HTS E(전일시가) TRIGGER — 실매 ``MOMENTUM_TRIGGER_E_CONFIRM`` 과 동일 키. # 전일 1분봉이 DB에 없으면 E가 전원 탈락 → Optuna -1e18. 그때만 env=0 으로 탐색. "trigger_e_confirm": get_env_bool( "MOMENTUM_TRIGGER_E_CONFIRM", bool(_d.get("trigger_e_confirm", True)), ), "trigger_require_bull_bar": get_env_bool( "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", bool(_d.get("trigger_require_bull_bar", True)), ), # 래칫(이익구간 손절선 상향) — 빈칸=OFF. 실매 MOMENTUM_RATCHET_TIERS 앵커. "ratchet_tiers": str(_pick( "MOMENTUM_RATCHET_TIERS", default=str(_d.get("ratchet_tiers") or ""), cast=lambda v: str(v or "").strip(), ) or "").strip(), } # mom_rsi / mom_vol — get_momentum_defaults 에 있으면 포함 (그리드 앵커) if _d.get("mom_rsi_min") is not None: out["mom_rsi_min"] = float(_d["mom_rsi_min"]) if _d.get("mom_rsi_max") is not None: out["mom_rsi_max"] = float(_d["mom_rsi_max"]) if _d.get("mom_vol_mult") is not None: out["mom_vol_mult"] = float(_d["mom_vol_mult"]) if _d.get("e_min_chg_pct") is not None: out["e_min_chg_pct"] = float(_d["e_min_chg_pct"]) if _d.get("max_hold_bars") is not None: out["max_hold_bars"] = int(float(_d["max_hold_bars"])) if str(market or "KR").strip().upper() == "US": out = _overlay_us_momentum_ui_fixed(out) return out def _overlay_us_momentum_ui_fixed(base: Dict[str, Any]) -> Dict[str, Any]: """국내 UI fixed 위에 config_us_momentum(US_MOMENTUM_*) 실매 앵커 덮어씀.""" out = dict(base or {}) from kis_trader.utils.env import get_strategy_env_dict, get_env_bool, get_env_float, get_env_int env = get_strategy_env_dict("US_MOMENTUM") 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 _pct_ui(key: str, default_ui: float) -> float: raw = env.get(key) if raw in (None, "", "None"): return float(default_ui) try: x = abs(float(raw)) except (TypeError, ValueError): return float(default_ui) if x == 0: return 0.0 return x if x >= 0.5 else round(x * 100.0, 6) def _bool(key: str, default: bool) -> bool: v = env.get(key) if v in (None, "", "None"): return bool(default) return str(v).strip().lower() in ("1", "true", "t", "y", "yes", "on") # 수수료·SEC·환전 — UI% 단위(fee/tax는 *100). fx는 엔진 비율 그대로. try: from kis_trader.engine.us_momentum_env_keys import us_momentum_trading_cost_rates _c = us_momentum_trading_cost_rates() out["fee_rate"] = float(_c["fee_rate"]) * 100.0 out["sell_tax"] = float(_c["sell_tax"]) * 100.0 out["fx_fee_rate"] = float(_c["fx_fee_rate"]) except Exception: out["fee_rate"] = float(get_env_float("US_MOMENTUM_FEE_RATE", 0.0025)) * 100.0 out["sell_tax"] = float(get_env_float("US_MOMENTUM_SELL_TAX", 0.0000206)) * 100.0 out["fx_fee_rate"] = float(get_env_float("US_MOMENTUM_FX_FEE_RATE", 0.0005)) out["time_start_hm"] = int(_pick("US_MOMENTUM_TIME_START", default=2230, cast=lambda v: int(float(v)))) out["time_end_hm"] = int(_pick("US_MOMENTUM_TIME_END", default=500, cast=lambda v: int(float(v)))) out["mom_time_end_hm"] = out["time_end_hm"] out["slot_money"] = float(_pick("US_MOMENTUM_SLOT_MONEY", "US_MOMENTUM_MAX_BUY_AMOUNT", default=out.get("slot_money", 200000), cast=float)) # USD 최소가 — 국내 1000원 잔존 금지. 기본 $1 (천달러 필터 아님) _mp = _pick("US_MOMENTUM_MIN_PRICE", default=None, cast=float) out["min_price"] = float(_mp) if _mp is not None else 1.0 out["max_daily"] = float(int(_pick("US_MOMENTUM_MAX_DAILY", default=int(out.get("max_daily") or 5), cast=lambda v: int(float(v))))) cd = _pick("US_MOMENTUM_COOLDOWN_SEC", default=None, cast=lambda v: int(float(v))) if cd is not None and cd >= 0: out["cooldown_min"] = float(cd) / 60.0 # 고정 유니버스 — Optuna/TPE 에서 금액(슬롯·손절액) 탐색/연동 안 함. DB 슬롯만 사용. out["max_loss_krw"] = 0.0 out["sl_pct"] = _pct_ui("US_MOMENTUM_STOP_LOSS_PCT", float(out.get("sl_pct") or 1.5)) out["tp_pct"] = _pct_ui("US_MOMENTUM_TAKE_PROFIT_PCT", float(out.get("tp_pct") or 2.5)) out["tp_max_pct"] = _pct_ui("US_MOMENTUM_TP_MAX_PCT", float(out.get("tp_max_pct") or 2.0)) out["shoulder_min_high"] = _pct_ui("US_MOMENTUM_SHOULDER_MIN_HIGH_PCT", float(out.get("shoulder_min_high") or 0.5)) out["shoulder_cut_pct"] = _pct_ui("US_MOMENTUM_SHOULDER_CUT_PCT", float(out.get("shoulder_cut_pct") or 0.3)) out["trail_pct"] = _pct_ui("US_MOMENTUM_TRAIL_PCT", float(out.get("trail_pct") or 0.0)) out["trail_arm_pct"] = _pct_ui("US_MOMENTUM_TRAIL_ARM_PCT", float(out.get("trail_arm_pct") or 0.0)) mh = _pick("US_MOMENTUM_MAX_HOLD_BARS", default=None, cast=lambda v: int(float(v))) if mh is not None: out["max_hold_bars"] = mh rt = env.get("US_MOMENTUM_RATCHET_TIERS") if rt not in (None, "", "None"): out["ratchet_tiers"] = str(rt).strip() out["mom_rsi_min"] = float(_pick("US_MOMENTUM_RSI_MIN", default=out.get("mom_rsi_min", 50), cast=float)) out["mom_rsi_max"] = float(_pick("US_MOMENTUM_RSI_MAX", default=out.get("mom_rsi_max", 90), cast=float)) out["mom_vol_mult"] = float(_pick("US_MOMENTUM_VOL_MULT", default=out.get("mom_vol_mult", 1.5), cast=float)) out["mom_vol_win"] = float(_pick("US_MOMENTUM_VOL_WIN", default=out.get("mom_vol_win", 5), cast=float)) out["high_chase_thr"] = float(_pick("US_MOMENTUM_HIGH_CHASE_THR", default=out.get("high_chase_thr", 0.96), cast=float)) out["max_daily_chg"] = float(_pick("US_MOMENTUM_MAX_DAILY_CHG", default=out.get("max_daily_chg", 20), cast=float)) out["mom_max_from_open_pct"] = float(_pick("US_MOMENTUM_MAX_FROM_OPEN_PCT", default=out.get("mom_max_from_open_pct", 999), cast=float)) out["mom_min_from_open_pct"] = float(_pick("US_MOMENTUM_MIN_FROM_OPEN_PCT", default=out.get("mom_min_from_open_pct", -999), cast=float)) mm = _pick("US_MOMENTUM_MIN_PROFIT_PCT", default=None, cast=float) if mm is not None: out["min_margin"] = float(mm) out["use_defense_filters"] = _bool("US_MOMENTUM_USE_DEFENSE_FILTERS", bool(out.get("use_defense_filters", True))) out["use_high_chase_filter"] = _bool("US_MOMENTUM_USE_HIGH_CHASE_FILTER", False) out["use_daily_range_filter"] = _bool("US_MOMENTUM_USE_DAILY_RANGE_FILTER", False) out["use_ema_filter"] = _bool("US_MOMENTUM_USE_EMA_FILTER", True) out["use_rsi_max_filter"] = _bool("US_MOMENTUM_USE_RSI_MAX_FILTER", False) out["pattern_breakout"] = _bool("US_MOMENTUM_PATTERN_BREAKOUT", True) out["pattern_pullback"] = _bool("US_MOMENTUM_PATTERN_PULLBACK", True) out["chase_lookback_min"] = int(_pick("US_MOMENTUM_CHASE_LOOKBACK_MIN", default=out.get("chase_lookback_min", 10), cast=lambda v: int(float(v)))) out["pullback_lookback_min"] = int(_pick("US_MOMENTUM_PULLBACK_LOOKBACK_MIN", default=out.get("pullback_lookback_min", 15), cast=lambda v: int(float(v)))) out["pullback_min_pct"] = float(_pick("US_MOMENTUM_PULLBACK_MIN_PCT", default=out.get("pullback_min_pct", 0.3), cast=float)) out["pullback_max_pct"] = float(_pick("US_MOMENTUM_PULLBACK_MAX_PCT", default=out.get("pullback_max_pct", 3.0), cast=float)) out["setup_vol_max_mult"] = float(_pick("US_MOMENTUM_SETUP_VOL_MAX_MULT", default=out.get("setup_vol_max_mult", 0.8), cast=float)) out["setup_bear_bars_min"] = int(_pick("US_MOMENTUM_SETUP_BEAR_BARS_MIN", default=out.get("setup_bear_bars_min", 1), cast=lambda v: int(float(v)))) out["ema_fast_period"] = int(_pick("US_MOMENTUM_EMA_FAST_PERIOD", default=out.get("ema_fast_period", 9), cast=lambda v: int(float(v)))) out["ema_slow_period"] = int(_pick("US_MOMENTUM_EMA_SLOW_PERIOD", default=out.get("ema_slow_period", 21), cast=lambda v: int(float(v)))) out["eod_enabled"] = _bool("US_MOMENTUM_EOD_ENABLED", False) out["eod_hm"] = str(env.get("US_MOMENTUM_EOD_HM") or out.get("eod_hm") or "05:00").strip() or "05:00" # 해외: HTS 없음 — 엔진 skip 은 True. Optuna 그리드 false 스윕은 하지 않음(고정). out["skip_hts_scan_dupes"] = True out["trigger_e_confirm"] = False out["use_rsi_filter"] = True out["market"] = "US" out["_session_wrap_midnight"] = True return out # ────────────────────────────────────────────────────────────────────────────── # 결과 디렉터리 (param_search_scalping.py 와 공유) # ────────────────────────────────────────────────────────────────────────────── 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]: """읽기용: 신규 results → 홈 폴백 (param_search_scalping 과 동일).""" return [ os.path.join(HERE, "results"), os.path.join(os.path.expanduser("~"), ".kis_bot_search_results"), ] def _latest_json(prefix: str) -> Optional[str]: """prefix 로 시작하는 가장 최근 search_momentum_*.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 # 호가필터 스윕 축 (2단계 정밀에서 '전수'로 돌리는 손잡이) — 나머지는 코어로 간주. _MOMENTUM_OB_AXES = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult") def _load_stage1_cores( json_path: str, top_n: int, core_keys: List[str], ) -> List[Dict[str, Any]]: """stage1 결과 JSON의 top[:N] 에서 '코어축만' 추출 (호가 3축 제외). 2단계 정밀(coarse-to-fine)에서 1단계 우승 코어를 고정하기 위함. 양수 손익(total_pnl>0) 결과만 채택해, 1단계에서 검증된 코어만 넘긴다. """ with open(json_path, "r", encoding="utf-8") as f: data = json.load(f) top = data.get("top") or [] cores: List[Dict[str, Any]] = [] for item in top: if len(cores) >= top_n: break if float(item.get("total_pnl", 0) or 0) <= 0: continue # 1단계에서 손실난 코어는 정밀 단계에서 제외 p = item.get("params") or {} core = {k: p[k] for k in core_keys if k in p} if core: cores.append(core) return cores def _resolve_stage1_json_path(explicit: Optional[str] = None) -> str: """2단계 코어고정용 stage1 결과 JSON 경로 (명시 없으면 최신 search_momentum_*.json).""" if explicit and os.path.isfile(explicit): return explicit latest = _latest_json("search_momentum_") if not latest: raise FileNotFoundError( "stage1 결과 JSON이 없습니다. 1단계를 먼저 실행하거나 --stage1-json 경로를 지정하세요." ) return latest def _build_stage2_core_lock_combos( grid: Dict[str, List[Any]], stage1_json: str, core_top_n: int, ) -> Tuple[List[Dict[str, Any]], List[str], Dict[str, Any], List[Dict[str, Any]]]: """1단계 Top-N 코어 고정 × 호가 3축 전수(27) 조합 생성. coarse-to-fine 2단계: 코어는 1단계 우승 레시피 그대로, 호가필터만 정밀 스윕. """ keys = list(grid.keys()) core_keys = [k for k in keys if k not in _MOMENTUM_OB_AXES] ob_keys = [k for k in keys if k in _MOMENTUM_OB_AXES] with open(stage1_json, "r", encoding="utf-8") as f: stage1_data = json.load(f) cores = _load_stage1_cores(stage1_json, core_top_n, core_keys) if not cores: raise RuntimeError( f"stage1 JSON에서 양수 손익 코어를 {core_top_n}개 찾지 못했습니다: {stage1_json}" ) ob_axes = [grid[k] for k in ob_keys] ob_n = grid_total_combinations(ob_axes) dict_combos: List[Dict[str, Any]] = [] for core in cores: for ob_t in product(*ob_axes): combo = dict(core) combo.update(dict(zip(ob_keys, ob_t))) if _momentum_combo_grid_valid(combo): dict_combos.append(combo) # stage1 top 에서 코어와 매칭되는 1단계 성과(비교용) stage1_baselines: List[Dict[str, Any]] = [] for item in (stage1_data.get("top") or [])[:core_top_n * 2]: if float(item.get("total_pnl", 0) or 0) <= 0: continue p = item.get("params") or {} stage1_baselines.append({ "params": {k: p[k] for k in core_keys if k in p}, "total_pnl": item.get("total_pnl"), "win_rate": item.get("win_rate"), "total_trades": item.get("total_trades"), "pf": item.get("pf"), }) if len(stage1_baselines) >= core_top_n: break return dict_combos, keys, stage1_data, stage1_baselines def _apply_from_latest_json(rank: int) -> None: """최근 search_momentum_*.json 에서 rank(1-based) merged_params → env_config.""" latest_path = _latest_json("search_momentum_") if not latest_path: print("⚠️ search_momentum_*.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) # ────────────────────────────────────────────────────────────────────────────── # 모멘텀 그리드 — coarse / fine / full # ────────────────────────────────────────────────────────────────────────────── def _momentum_grids(market: str = "KR") -> Dict[str, Dict[str, List]]: """탐색 모드별 모멘텀 그리드. 각 축은 DB ``env_config`` 문자열 ``MOMENTUM_GRID_{MODE}_{키대문자}`` 로 덮어쓸 수 있다 (예: ``MOMENTUM_GRID_COARSE_TRAIL_TRIGGER_PCT`` — 키 suffix 는 아래 ``_parse_csv_*`` 호출 첫 인자 참고). - fast : HTS TRIGGER(거래량) + 청산 광범위(어깨·래칫·트레일·SL·TP) 균등샘플 - exit : **청산 전용** 광범위 그리드 — 진입은 DB 고정, 어깨·래칫·트레일·SL·TP·시간컷 - rr : 손익비(어깨·SL·TP) 중심 - wide : **축 스크리닝** — 축당 ~10값 초광범위, Optuna 소수 trial로 유효 축·구간 탐색용 (실매 앵커 포함, 적대값 min_margin≥10 / vol≥20 단독 제외) - market=US: ``config_us_momentum`` 실매값을 그리드에 강제 포함 """ live = _mom_fixed_defaults(market=market) # 실매 래칫 문자열 (빈칸=OFF). 그리드에 없으면 DB 고정값으로만 동작. _live_ratchet = str(live.get("ratchet_tiers") or "").strip() # 안전용 래칫 = 늦게 잠금 (이익 충분히 난 뒤 보호). 조기(+1~2%) 익절형 제외. # OFF + 실매 앵커 + 근처 밴드. env MOMENTUM_GRID_*_RATCHET_TIERS 로 덮어쓰기 가능. _late_ratchet_cands = [ "", "5:2,10:1.5", "5:2,10:1", "5:2.5,10:1.5", "7:2,12:1.5", ] def _fmerge(cands: List[float], live_v: float) -> List[float]: return sorted(set([float(x) for x in cands] + [float(live_v)])) def _imerge(cands: List[int], live_v: int) -> List[int]: return sorted(set([int(x) for x in cands] + [int(live_v)])) def _smerge(cands: List[str], live_v: str) -> List[str]: """문자열 축 머지 — 순서 유지, 실매 앵커 말미 추가(미포함 시).""" out: List[str] = [] seen = set() for x in list(cands) + [str(live_v or "").strip()]: s = str(x).strip() if s not in seen: seen.add(s) out.append(s) return out grids = { # ───────────────────────────────────────────────────────────────────── # [FAST] wide(7/15) Top 근방 스모크 — tp15·sl5·sh0.8·0.2·trail3/1.5 분지 # 실매 앵커: rsi55/80 · vol2 · tp3 · sl3.5 · sh3/0.15 · trail0/0.5 · cd5 · daily50 # ───────────────────────────────────────────────────────────────────── "fast": { "mom_rsi_min": _parse_csv_ints( "MOMENTUM_GRID_FAST_MOM_RSI_MIN", _imerge([49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))), ), "mom_rsi_max": _parse_csv_ints( "MOMENTUM_GRID_FAST_MOM_RSI_MAX", _imerge([80, 90], int(float(live.get("mom_rsi_max") or 80))), ), "mom_vol_mult": _parse_csv_floats( "MOMENTUM_GRID_FAST_MOM_VOL_MULT", _fmerge([1.0, 2.0, 5.0], float(live.get("mom_vol_mult") or 2.0)), ), # HTS K: 전일종가 대비 최소등락(%) — 실매 0.2 포함 "e_min_chg_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_E_MIN_CHG_PCT", _fmerge([0.0, 0.2, 0.5, 1.0], float(live.get("e_min_chg_pct") or 0.2)), ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_TP_PCT", _fmerge([3.0, 5.0, 15.0], float(live.get("tp_pct") or 3.0)), ), "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_SL_PCT", _fmerge([3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)), ), "mom_time_end_hm": _parse_csv_ints( "MOMENTUM_GRID_FAST_MOM_TIME_END_HM", _imerge([1430, 1520, 1530], int(float(live.get("mom_time_end_hm") or 1530))), ), "mom_max_from_open_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_MOM_MAX_FROM_OPEN_PCT", _fmerge( [25.0, 40.0], float(live.get("mom_max_from_open_pct") or 40.0), ), ), "min_margin": _parse_csv_floats( "MOMENTUM_GRID_FAST_MIN_MARGIN", _fmerge([0.5, 0.8], float(live.get("min_margin") or 0.5)), ), "shoulder_min_high": _parse_csv_floats( "MOMENTUM_GRID_FAST_SHOULDER_MIN_HIGH_PCT", _fmerge([0.8, 3.0], float(live.get("shoulder_min_high") or 3.0)), ), "shoulder_cut_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_SHOULDER_CUT_PCT", _fmerge([0.15, 0.2], float(live.get("shoulder_cut_pct") or 0.15)), ), "trail_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_TRAIL_PCT", _fmerge([0.0, 0.7, 3.0], float(live.get("trail_pct") or 0.0)), ), "trail_arm_pct": _parse_csv_floats( "MOMENTUM_GRID_FAST_TRAIL_ARM_PCT", _fmerge([0.5, 1.5], float(live.get("trail_arm_pct") or 0.5)), ), "cooldown_min": _parse_csv_floats( "MOMENTUM_GRID_FAST_COOLDOWN_MIN", _fmerge([5.0, 15.0], float(live.get("cooldown_min") or 5.0)), ), "max_daily": _parse_csv_ints( "MOMENTUM_GRID_FAST_MAX_DAILY", _imerge([30, 50], int(float(live.get("max_daily") or 50))), ), "max_daily_chg": _parse_csv_floats( "MOMENTUM_GRID_FAST_MAX_DAILY_CHG_PCT", _fmerge([25.0, 30.0], float(live.get("max_daily_chg") or 30.0)), ), "max_hold_bars": _parse_csv_ints( "MOMENTUM_GRID_FAST_MAX_HOLD_BARS", _imerge([0, 15], int(float(live.get("max_hold_bars") or 15))), ), # 래칫: OFF + 실매 근처(늦게 잠금). 조기(+1~2%) 티어 제외 "ratchet_tiers": _parse_csv_strings( "MOMENTUM_GRID_FAST_RATCHET_TIERS", _smerge(_late_ratchet_cands, _live_ratchet), ), }, # exit: 청산 전용 광범위 — TRIGGER/진입은 _mom_fixed_defaults(DB) 고정 "exit": { "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_EXIT_SL_PCT", [1.5, 2.0, 2.5, 3.0, 4.0, 5.0, 6.0], ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_EXIT_TP_PCT", [3.0, 5.0, 6.0, 8.0, 10.0, 12.0, 15.0], ), "tp_max_pct": _parse_csv_floats( "MOMENTUM_GRID_EXIT_TP_MAX_PCT", [5.0, 8.0, 10.0, 12.0, 15.0, 20.0], ), "shoulder_min_high": _parse_csv_floats( "MOMENTUM_GRID_EXIT_SHOULDER_MIN_HIGH_PCT", [0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( "MOMENTUM_GRID_EXIT_SHOULDER_CUT_PCT", [0.10, 0.15, 0.20, 0.25, 0.30, 0.40, 0.50, 0.80], ), "trail_pct": _parse_csv_floats( "MOMENTUM_GRID_EXIT_TRAIL_PCT", [0.0, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], ), "trail_arm_pct": _parse_csv_floats( "MOMENTUM_GRID_EXIT_TRAIL_ARM_PCT", [0.0, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0], ), "ratchet_tiers": _parse_csv_strings( "MOMENTUM_GRID_EXIT_RATCHET_TIERS", [ "", "1:0.5,2:0.8,5:1", "2:1.5,5:1", "2:1,5:0.8,8:0.6", "3:2,6:1.2,10:0.8", "5:2,10:1.5", ], ), "max_hold_bars": _parse_csv_ints( "MOMENTUM_GRID_EXIT_MAX_HOLD_BARS", [0, 15, 30, 45, 60, 90, 120, 180, 240], ), }, # rr: 손익비(어깨·SL·TP) 중심 — 진입은 운영값 1점 고정 (~720조합) "rr": { "mom_rsi_min": _parse_csv_ints( "MOMENTUM_GRID_RR_MOM_RSI_MIN", [int(get_env_int("MOMENTUM_RSI_MIN", 55))], ), "mom_rsi_max": _parse_csv_ints( "MOMENTUM_GRID_RR_MOM_RSI_MAX", [int(get_env_int("MOMENTUM_RSI_MAX", 90))], ), "mom_vol_mult": _parse_csv_floats( "MOMENTUM_GRID_RR_MOM_VOL_MULT", [float(get_env_float("MOMENTUM_VOL_MULT", 1.5))], ), "e_min_chg_pct": _parse_csv_floats( "MOMENTUM_GRID_RR_E_MIN_CHG_PCT", _fmerge([0.0, 0.2, 0.5, 1.0, 1.5], float(live.get("e_min_chg_pct") or 0.2)), ), "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_RR_SL_PCT", [1.2, 1.5, 1.8, 2.0], ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_RR_TP_PCT", [2.0, 2.5, 3.0], ), "tp_max_pct": _parse_csv_floats( "MOMENTUM_GRID_RR_TP_MAX_PCT", [2.0, 2.5, 3.0], ), "shoulder_min_high": _parse_csv_floats( "MOMENTUM_GRID_RR_SHOULDER_MIN_HIGH_PCT", [0.5, 1.0, 50.0], ), "shoulder_cut_pct": _parse_csv_floats( "MOMENTUM_GRID_RR_SHOULDER_CUT_PCT", [0.02, 0.15, 0.25], ), }, # coarse — wide Top 밴드 조금 넓게 (1차 스크리닝) "coarse": { "mom_rsi_min": _parse_csv_ints( "MOMENTUM_GRID_COARSE_MOM_RSI_MIN", _imerge([48, 49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))), ), "mom_rsi_max": _parse_csv_ints( "MOMENTUM_GRID_COARSE_MOM_RSI_MAX", _imerge([70, 80, 90, 100], int(float(live.get("mom_rsi_max") or 80))), ), "mom_vol_mult": _parse_csv_floats( "MOMENTUM_GRID_COARSE_MOM_VOL_MULT", _fmerge([1.0, 2.0, 3.0, 5.0], float(live.get("mom_vol_mult") or 2.0)), ), "e_min_chg_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_E_MIN_CHG_PCT", _fmerge([0.0, 0.2, 0.5, 1.0], float(live.get("e_min_chg_pct") or 0.2)), ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_TP_PCT", _fmerge([3.0, 5.0, 8.0, 15.0], float(live.get("tp_pct") or 3.0)), ), "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_SL_PCT", _fmerge([3.0, 3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)), ), "mom_time_end_hm": _parse_csv_ints( "MOMENTUM_GRID_COARSE_MOM_TIME_END_HM", _imerge( [1400, 1430, 1520, 1530], int(float(live.get("mom_time_end_hm") or 1530)), ), ), "mom_max_from_open_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_MOM_MAX_FROM_OPEN_PCT", _fmerge( [25.0, 40.0, 45.0], float(live.get("mom_max_from_open_pct") or 40.0), ), ), "min_margin": _parse_csv_floats( "MOMENTUM_GRID_COARSE_MIN_MARGIN", _fmerge([0.3, 0.5, 0.8, 2.0], float(live.get("min_margin") or 0.5)), ), "shoulder_min_high": _parse_csv_floats( "MOMENTUM_GRID_COARSE_SHOULDER_MIN_HIGH_PCT", _fmerge([0.8, 1.5, 3.0, 5.0], float(live.get("shoulder_min_high") or 3.0)), ), "shoulder_cut_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_SHOULDER_CUT_PCT", _fmerge( [0.15, 0.2, 0.25, 0.4], float(live.get("shoulder_cut_pct") or 0.15), ), ), "trail_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_TRAIL_PCT", _fmerge([0.0, 0.7, 1.0, 3.0], float(live.get("trail_pct") or 0.0)), ), "trail_arm_pct": _parse_csv_floats( "MOMENTUM_GRID_COARSE_TRAIL_ARM_PCT", _fmerge([0.5, 1.5, 2.0, 3.0], float(live.get("trail_arm_pct") or 0.5)), ), "cooldown_min": _parse_csv_floats( "MOMENTUM_GRID_COARSE_COOLDOWN_MIN", _fmerge([5.0, 8.0, 15.0], float(live.get("cooldown_min") or 5.0)), ), "max_daily": _parse_csv_ints( "MOMENTUM_GRID_COARSE_MAX_DAILY", _imerge([5, 30, 50, 80], int(float(live.get("max_daily") or 50))), ), "max_daily_chg": _parse_csv_floats( "MOMENTUM_GRID_COARSE_MAX_DAILY_CHG_PCT", _fmerge( [25.0, 30.0, 60.0], float(live.get("max_daily_chg") or 30.0), ), ), "max_hold_bars": _parse_csv_ints( "MOMENTUM_GRID_COARSE_MAX_HOLD_BARS", _imerge([0, 15, 60, 120], int(float(live.get("max_hold_bars") or 15))), ), "ratchet_tiers": _parse_csv_strings( "MOMENTUM_GRID_COARSE_RATCHET_TIERS", _smerge(_late_ratchet_cands, _live_ratchet), ), }, # fine — 2026-07-15 wide Top 재설계 (best~+60k / Top: tp15·sl5·vol2·rsi52/90) # 실매 앵커 포함. apply는 별도 확인 후. + 늦게잠금 래칫 "fine": { "mom_rsi_min": _parse_csv_ints( "MOMENTUM_GRID_FINE_MOM_RSI_MIN", _imerge([49, 52, 55, 58], int(float(live.get("mom_rsi_min") or 55))), ), "mom_rsi_max": _parse_csv_ints( "MOMENTUM_GRID_FINE_MOM_RSI_MAX", _imerge([80, 90, 100], int(float(live.get("mom_rsi_max") or 80))), ), "mom_vol_mult": _parse_csv_floats( "MOMENTUM_GRID_FINE_MOM_VOL_MULT", _fmerge([1.0, 2.0, 3.0, 5.0], float(live.get("mom_vol_mult") or 2.0)), ), "e_min_chg_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_E_MIN_CHG_PCT", _fmerge([0.0, 0.2, 0.5, 1.0], float(live.get("e_min_chg_pct") or 0.2)), ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_TP_PCT", _fmerge([2.0, 3.0, 5.0, 8.0, 15.0], float(live.get("tp_pct") or 3.0)), ), "tp_max_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_TP_MAX_PCT", _fmerge([3.0, 5.0, 8.0], float(live.get("tp_max_pct") or 5.0)), ), "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_SL_PCT", _fmerge([3.0, 3.5, 4.0, 5.0], float(live.get("sl_pct") or 3.5)), ), # V4 추격 패턴 축 (기존 fine 미포함 → 실매 고정값만 사용하던 구간) "chase_lookback_min": _parse_csv_ints( "MOMENTUM_GRID_FINE_CHASE_LOOKBACK_MIN", _imerge( [5, 10, 15], int(float(live.get("chase_lookback_min") or 10)), ), ), "pullback_lookback_min": _parse_csv_ints( "MOMENTUM_GRID_FINE_PULLBACK_LOOKBACK_MIN", _imerge( [10, 15, 20], int(float(live.get("pullback_lookback_min") or 15)), ), ), "pullback_min_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_PULLBACK_MIN_PCT", _fmerge( [0.2, 0.3, 0.5], float(live.get("pullback_min_pct") or 0.3), ), ), "pullback_max_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_PULLBACK_MAX_PCT", _fmerge( [2.0, 3.0, 5.0], float(live.get("pullback_max_pct") or 3.0), ), ), "setup_vol_max_mult": _parse_csv_floats( "MOMENTUM_GRID_FINE_SETUP_VOL_MAX_MULT", _fmerge( [0.5, 0.8, 1.0], float(live.get("setup_vol_max_mult") or 0.8), ), ), "setup_bear_bars_min": _parse_csv_ints( "MOMENTUM_GRID_FINE_SETUP_BEAR_BARS_MIN", _imerge( [0, 1, 2], int(float(live.get("setup_bear_bars_min") or 1)), ), ), "mom_vol_win": _parse_csv_ints( "MOMENTUM_GRID_FINE_MOM_VOL_WIN", _imerge([3, 5, 8], int(float(live.get("mom_vol_win") or 5))), ), "time_start_hm": _parse_csv_ints( "MOMENTUM_GRID_FINE_TIME_START_HM", _imerge( [830, 900, 930], int(float(live.get("time_start_hm") or 830)), ), ), "mom_time_end_hm": _parse_csv_ints( "MOMENTUM_GRID_FINE_MOM_TIME_END_HM", _imerge( [1430, 1520, 1530], int(float(live.get("mom_time_end_hm") or 1530)), ), ), "mom_max_from_open_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_MOM_MAX_FROM_OPEN_PCT", _fmerge( [25.0, 40.0, 45.0], float(live.get("mom_max_from_open_pct") or 40.0), ), ), "min_margin": _parse_csv_floats( "MOMENTUM_GRID_FINE_MIN_MARGIN", _fmerge([0.3, 0.5, 0.8, 2.0], float(live.get("min_margin") or 0.5)), ), "shoulder_min_high": _parse_csv_floats( "MOMENTUM_GRID_FINE_SHOULDER_MIN_HIGH_PCT", _fmerge([0.8, 1.5, 3.0, 5.0], float(live.get("shoulder_min_high") or 3.0)), ), "shoulder_cut_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_SHOULDER_CUT_PCT", _fmerge( [0.15, 0.2, 0.25, 0.4], float(live.get("shoulder_cut_pct") or 0.15), ), ), "trail_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_TRAIL_PCT", _fmerge([0.0, 0.7, 1.0, 3.0], float(live.get("trail_pct") or 0.0)), ), "trail_arm_pct": _parse_csv_floats( "MOMENTUM_GRID_FINE_TRAIL_ARM_PCT", _fmerge([0.5, 1.5, 2.0, 3.0], float(live.get("trail_arm_pct") or 0.5)), ), "cooldown_min": _parse_csv_floats( "MOMENTUM_GRID_FINE_COOLDOWN_MIN", _fmerge([5.0, 8.0, 15.0], float(live.get("cooldown_min") or 5.0)), ), "max_daily": _parse_csv_ints( "MOMENTUM_GRID_FINE_MAX_DAILY", _imerge([5, 30, 50, 80], int(float(live.get("max_daily") or 50))), ), "max_daily_chg": _parse_csv_floats( "MOMENTUM_GRID_FINE_MAX_DAILY_CHG_PCT", _fmerge( [25.0, 30.0, 60.0], float(live.get("max_daily_chg") or 30.0), ), ), "max_hold_bars": _parse_csv_ints( "MOMENTUM_GRID_FINE_MAX_HOLD_BARS", _imerge([0, 15, 60, 120], int(float(live.get("max_hold_bars") or 15))), ), "ratchet_tiers": _parse_csv_strings( "MOMENTUM_GRID_FINE_RATCHET_TIERS", _smerge(_late_ratchet_cands, _live_ratchet), ), }, # wide: 축 스크리닝용 초광범위 (축당 ~10값) — fine 승자(+761)가 너무 얇을 때 # Optuna trials≈50 으로 어떤 축·구간이 PnL/건수에 반응하는지 먼저 찾고, 이후 fine 재세팅 # 실매 앵커: vol1.05 / sh0.3·0.1 / trail0.7·0.4 / chg40 / hold15 / daily100 / cd1 / end1530 "wide": { "mom_rsi_min": _parse_csv_ints( "MOMENTUM_GRID_WIDE_MOM_RSI_MIN", [40, 45, 48, 49, 50, 52, 55, 58, 60, 65], ), "mom_rsi_max": _parse_csv_ints( "MOMENTUM_GRID_WIDE_MOM_RSI_MAX", [70, 75, 80, 85, 88, 90, 92, 95, 98, 100], ), "mom_vol_mult": _parse_csv_floats( "MOMENTUM_GRID_WIDE_MOM_VOL_MULT", [1.0, 1.05, 1.2, 1.5, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0], ), "e_min_chg_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_E_MIN_CHG_PCT", _fmerge( [0.0, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0], float(live.get("e_min_chg_pct") or 0.2), ), ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_TP_PCT", [2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 10.0, 12.0, 15.0], ), "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_SL_PCT", [1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0, 3.5, 4.0, 5.0], ), "mom_time_end_hm": _parse_csv_ints( "MOMENTUM_GRID_WIDE_MOM_TIME_END_HM", [1100, 1200, 1300, 1330, 1400, 1430, 1500, 1510, 1520, 1530], ), "mom_max_from_open_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_MOM_MAX_FROM_OPEN_PCT", [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 60.0], ), "min_margin": _parse_csv_floats( "MOMENTUM_GRID_WIDE_MIN_MARGIN", [0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0], ), "shoulder_min_high": _parse_csv_floats( "MOMENTUM_GRID_WIDE_SHOULDER_MIN_HIGH_PCT", [0.1, 0.2, 0.3, 0.5, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0], ), "shoulder_cut_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_SHOULDER_CUT_PCT", [0.05, 0.1, 0.15, 0.2, 0.25, 0.3, 0.4, 0.5, 0.8, 1.0], ), "trail_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_TRAIL_PCT", [0.0, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], ), "trail_arm_pct": _parse_csv_floats( "MOMENTUM_GRID_WIDE_TRAIL_ARM_PCT", [0.0, 0.3, 0.5, 0.7, 1.0, 1.5, 2.0, 2.5, 3.0, 4.0], ), "cooldown_min": _parse_csv_floats( "MOMENTUM_GRID_WIDE_COOLDOWN_MIN", [0.0, 1.0, 2.0, 3.0, 5.0, 8.0, 10.0, 15.0, 20.0, 30.0], ), "max_daily": _parse_csv_ints( "MOMENTUM_GRID_WIDE_MAX_DAILY", [5, 10, 15, 20, 30, 50, 80, 100, 150, 200], ), "max_daily_chg": _parse_csv_floats( "MOMENTUM_GRID_WIDE_MAX_DAILY_CHG_PCT", [10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0, 60.0], ), "max_hold_bars": _parse_csv_ints( "MOMENTUM_GRID_WIDE_MAX_HOLD_BARS", [0, 10, 15, 20, 30, 45, 60, 90, 120, 180], ), "ratchet_tiers": _parse_csv_strings( "MOMENTUM_GRID_WIDE_RATCHET_TIERS", _smerge(_late_ratchet_cands, _live_ratchet), ), }, # full: 가설 폭넓힘 (낮은 vol_mult·짧은 TP 가설 포함) + 트레일 0 = TP 상한 위주 청산 # 조합 수 대략 수만 단위 — env 로 각 축 목록 줄이는 것 권장. "full": { "mom_rsi_min": _parse_csv_ints( "MOMENTUM_GRID_FULL_MOM_RSI_MIN", [45, 55], ), "mom_rsi_max": _parse_csv_ints( "MOMENTUM_GRID_FULL_MOM_RSI_MAX", [80, 90, 95], ), "mom_vol_mult": _parse_csv_floats( "MOMENTUM_GRID_FULL_MOM_VOL_MULT", [3.0, 10.0, 20.0], ), "e_min_chg_pct": _parse_csv_floats( "MOMENTUM_GRID_FULL_E_MIN_CHG_PCT", _fmerge([0.0, 0.2, 0.5, 1.0], float(live.get("e_min_chg_pct") or 0.2)), ), "tp_pct": _parse_csv_floats( "MOMENTUM_GRID_FULL_TP_PCT", [3.0, 5.0, 7.0], ), "sl_pct": _parse_csv_floats( "MOMENTUM_GRID_FULL_SL_PCT", [1.5, 2.5, 3.0], ), "mom_time_end_hm": _parse_csv_ints( "MOMENTUM_GRID_FULL_MOM_TIME_END_HM", [1330, 1430, 1530], ), "mom_max_from_open_pct": _parse_csv_floats( "MOMENTUM_GRID_FULL_MOM_MAX_FROM_OPEN_PCT", [22.0, 30.0], ), "min_margin": _parse_csv_floats( "MOMENTUM_GRID_FULL_MIN_MARGIN", [0.2, 10.0], ), "trail_pct": _parse_csv_floats( "MOMENTUM_GRID_FULL_TRAIL_PCT", [0.0, 0.8, 1.2], ), "trail_arm_pct": _parse_csv_floats( "MOMENTUM_GRID_FULL_TRAIL_ARM_PCT", [0.0, 0.5, 1.0], ), "cooldown_min": _parse_csv_floats( "MOMENTUM_GRID_FULL_COOLDOWN_MIN", [1.0, 5.0], ), "max_daily": _parse_csv_ints( "MOMENTUM_GRID_FULL_MAX_DAILY", [5, 10], ), "max_daily_chg": _parse_csv_floats( "MOMENTUM_GRID_FULL_MAX_DAILY_CHG_PCT", [35.0, 50.0], ), # skip_hts_scan_dupes: full 스윕 금지 — 운영 false 고정 (FIXED_DEFAULTS) }, } # 해외: 국내 장종료(1430/1520/1530) 스윕이 base 2230~500 을 덮어 세션이 깨짐 # → US 세션축만 해외 RTH(기본 2230~500) 로 교체 if str(market or "KR").strip().upper() == "US": us_start = int(float(live.get("time_start_hm") or 2230)) us_end = int(float(live.get("mom_time_end_hm") or live.get("time_end_hm") or 500)) us_ends = _imerge([400, 500, 530, 600], us_end) us_starts = _imerge([2200, 2230, 2300], us_start) for _mode, _g in grids.items(): if "mom_time_end_hm" in _g: _g["mom_time_end_hm"] = _parse_csv_ints( "US_MOMENTUM_GRID_%s_MOM_TIME_END_HM" % str(_mode).upper(), us_ends, ) if "time_start_hm" in _g: _g["time_start_hm"] = _parse_csv_ints( "US_MOMENTUM_GRID_%s_TIME_START_HM" % str(_mode).upper(), us_starts, ) return grids MOMENTUM_GRID_AXIS_HINTS_KO: Dict[str, str] = { "mom_rsi_min": "RSI3 하한 — 약세 제외", "mom_rsi_max": "RSI3 상한 — use_rsi_max_filter ON 시만", "mom_vol_mult": "TRIGGER 거래량 펄스: 현재봉 ≥ N봉평균 × 배수", "e_min_chg_pct": "HTS K: 전일 종가 대비 최소 등락(%) — 0=종가초과만, 0.2=HTS기본", "trigger_require_bull_bar": "양봉 필수 (0=OFF, 1=ON) — HTS TRIGGER", "pattern_breakout": "(레거시) N분 고가 돌파 패턴", "pattern_pullback": "(레거시) 눌림 재돌파 패턴", "chase_lookback_min": "돌파 패턴 관찰 구간(분)", "pullback_lookback_min": "눌림 패턴 스윙고점 탐색 구간(분)", "pullback_min_pct": "눌림 최소 깊이(%)", "pullback_max_pct": "눌림 최대 깊이(%)", "setup_vol_max_mult": "눌림 구간 거래량 상한배수 (0=OFF)", "setup_bear_bars_min": "눌림 음봉 최소 개수 (0=OFF)", "mom_vol_win": "거래량 관찰 봉수", "time_start_hm": "매수 시작 HHMM", "mom_max_from_open_pct": "레거시: 당일 시가 대비 과열 상한(%)", "sl_pct": "고정 손절(%)", "tp_pct": "고정 익절(%)", "tp_max_pct": "익절 상한(%) — 어깨·트레일 이후 하드 캡", "shoulder_min_high": "어깨 발동: 진입가 대비 최소 수익(%) 후 고점 추적", "shoulder_cut_pct": "고점 대비 되돌림(%) 시 어깨컷", "trail_pct": "전용 트레일 폭(%, 0=OFF) — momentum_engine", "trail_arm_pct": "트레일 무장: 진입×(1+arm%) 도달 후 (0=즉시)", "max_hold_bars": "최대 보유 1분봉 (0=비활성)", "ratchet_tiers": "래칫 티어 문자열 (빈칸=OFF)", "use_ema_filter": "EMA 추세필터 (0=OFF, 1=ON) — TRIGGER", "ema_fast_period": "EMA 빠른 기간 (fast)", "ema_slow_period": "EMA 느린 기간 (slow, fast보다 커야 함)", "max_spread_pct": "호가 스프레드 상한(%) — kiwoom_0d 본체 재계산(6/25~)", "min_bid_ask_ratio": "매수/매도 잔량비 하한 — kiwoom_0d 본체 재계산(6/25~)", "ask_max_mult": "매도벽 허용배수(필요수량×N) — kiwoom_0d 본체 재계산(6/25~)", } def _momentum_ema_filter_on(raw: Any) -> bool: if isinstance(raw, bool): return raw if raw in (None, ""): return False return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") def _momentum_combo_grid_valid(combo: Dict[str, Any]) -> bool: """그리드 무효 조합 제거 — HTS TRIGGER + 레거시 패턴 축 호환.""" if "mom_rsi_min" in combo and "mom_rsi_max" in combo: if float(combo["mom_rsi_min"]) >= float(combo["mom_rsi_max"]): return False if "pattern_breakout" in combo or "pattern_pullback" in combo: bo = _momentum_ema_filter_on(combo.get("pattern_breakout", 1)) pb = _momentum_ema_filter_on(combo.get("pattern_pullback", 1)) if not bo and not pb: return False if "pullback_min_pct" in combo and "pullback_max_pct" in combo: if float(combo["pullback_min_pct"]) >= float(combo["pullback_max_pct"]): return False if "use_ema_filter" in combo and ( "ema_fast_period" in combo or "ema_slow_period" in combo ): ema_on = _momentum_ema_filter_on(combo.get("use_ema_filter", 0)) fast = int(float(combo.get("ema_fast_period", 9))) slow = int(float(combo.get("ema_slow_period", 21))) if not ema_on: # EMA OFF이면 기간 축은 엔진이 안 읽음 — 값 무관하게 통과 return True return slow > fast return True # ────────────────────────────────────────────────────────────────────────────── # DB 적용 — MOMENTUM_* env 키 # ────────────────────────────────────────────────────────────────────────────── def _get_momentum_field_map() -> Dict[str, Tuple[str, Any]]: """모멘텀 파라미터 → env_config 컬럼 매핑. ※ MOMENTUM_* 키만 저장. --apply 사용자 의도에 따라 향후 옵션 추가 가능. 지금은 새 키만. """ return { # 진입 룰 "mom_rsi_min": ("MOMENTUM_RSI_MIN", lambda v: str(int(v))), "mom_rsi_max": ("MOMENTUM_RSI_MAX", lambda v: str(int(v))), "mom_vol_mult": ("MOMENTUM_VOL_MULT", lambda v: str(float(v))), "mom_vol_win": ("MOMENTUM_VOL_WIN", lambda v: str(int(float(v)))), "e_min_chg_pct": ("MOMENTUM_E_MIN_CHG_PCT", lambda v: str(float(v))), "mom_time_end_hm":("MOMENTUM_TIME_END_HM", lambda v: str(int(float(v)))), "mom_max_from_open_pct": ("MOMENTUM_MAX_FROM_OPEN_PCT", lambda v: str(float(v))), "mom_min_from_open_pct": ("MOMENTUM_MIN_FROM_OPEN_PCT", lambda v: str(float(v))), "pattern_breakout": ("MOMENTUM_PATTERN_BREAKOUT", lambda v: "true" if _momentum_ema_filter_on(v) else "false"), "pattern_pullback": ("MOMENTUM_PATTERN_PULLBACK", lambda v: "true" if _momentum_ema_filter_on(v) else "false"), "chase_lookback_min": ("MOMENTUM_CHASE_LOOKBACK_MIN", lambda v: str(int(float(v)))), "pullback_lookback_min": ("MOMENTUM_PULLBACK_LOOKBACK_MIN", lambda v: str(int(float(v)))), "pullback_min_pct": ("MOMENTUM_PULLBACK_MIN_PCT", lambda v: str(float(v))), "pullback_max_pct": ("MOMENTUM_PULLBACK_MAX_PCT", lambda v: str(float(v))), "setup_vol_max_mult": ("MOMENTUM_SETUP_VOL_MAX_MULT", lambda v: str(float(v))), "setup_bear_bars_min": ("MOMENTUM_SETUP_BEAR_BARS_MIN", lambda v: str(int(float(v)))), "use_ema_filter": ("MOMENTUM_USE_EMA_FILTER", lambda v: "true" if _momentum_ema_filter_on(v) else "false"), "ema_fast_period": ("MOMENTUM_EMA_FAST_PERIOD", lambda v: str(int(float(v)))), "ema_slow_period": ("MOMENTUM_EMA_SLOW_PERIOD", lambda v: str(int(float(v)))), # 청산 룰 "sl_pct": ("MOMENTUM_STOP_LOSS_PCT", lambda v: str(float(v) / 100)), "tp_pct": ("MOMENTUM_TAKE_PROFIT_PCT", lambda v: str(float(v) / 100)), # 청산 보조 — 웹 저장과 동일: UI 퍼센트 → DB 비율 (/100) # trail_trigger/stop 레거시는 모멘텀 미사용(전용 trail_pct/arm). SCALP ATR 오염 금지. "shoulder_min_high": ("MOMENTUM_SHOULDER_MIN_HIGH_PCT", lambda v: str(float(v) / 100)), "shoulder_cut_pct": ("MOMENTUM_SHOULDER_CUT_PCT", lambda v: str(float(v) / 100)), "trail_pct": ("MOMENTUM_TRAIL_PCT", lambda v: str(abs(float(v)) / 100.0)), "trail_arm_pct": ("MOMENTUM_TRAIL_ARM_PCT", lambda v: str(abs(float(v)) / 100.0)), "max_hold_bars": ("MOMENTUM_MAX_HOLD_BARS", lambda v: str(int(float(v)))), "ratchet_tiers": ("MOMENTUM_RATCHET_TIERS", lambda v: str(v or "").strip()), "trigger_require_bull_bar": ( "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", lambda v: "true" if _momentum_ema_filter_on(v) else "false", ), # skip_hts_scan_dupes: 그리드 스윕 금지 — FIXED_DEFAULTS false 고정 "tp_max_pct": ("MOMENTUM_TP_MAX_PCT", lambda v: str(float(v) / 100)), # 엔진 get_momentum_defaults 와 동일 — SCALP_* 오염 금지 "cooldown_min": ("MOMENTUM_COOLDOWN_SEC", lambda v: str(int(float(v)) * 60)), "time_start_hm": ("MOMENTUM_TIME_START", lambda v: str(int(float(v)))), "high_chase_thr": ("MOMENTUM_HIGH_CHASE_THR", lambda v: str(float(v))), "max_daily_chg": ("MOMENTUM_MAX_DAILY_CHG", lambda v: str(float(v))), "min_price": ("MOMENTUM_MIN_PRICE", lambda v: str(int(float(v)))), "max_loss_krw": ("MOMENTUM_MAX_LOSS_PER_TRADE_KRW", lambda v: str(int(float(v)))), "min_margin": ("MOMENTUM_MIN_PROFIT_PCT", lambda v: str(float(v))), "use_defense_filters": ("MOMENTUM_USE_DEFENSE_FILTERS", lambda v: "true" if v else "false"), "max_daily": ("MOMENTUM_MAX_DAILY", lambda v: str(int(float(v)))), "slot_money": ("MOMENTUM_SLOT_MONEY", lambda v: str(int(float(v)))), } def _apply_to_db(best_params: dict) -> Optional[int]: """1위 파라미터 → insert_env_snapshot (config_momentum + env_config). env_id 반환.""" from kis_trader.backtest.param_search_apply_snapshot import ( _patch_from_momentum_merged, apply_env_patch, ) patch = _patch_from_momentum_merged(best_params) patch.update(session_env_patch("MOMENTUM", best_params)) # 슬롯·동시보유·총한도 제외 — Optuna/Grid 가 slot×종목수로 한도를 덮지 않음 patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM") if not patch: print("DB 적용할 파라미터가 없습니다.") return None eid = apply_env_patch(patch) if eid is None: print("❌ insert_env_snapshot 실패") return None print(f"\n✅ config_momentum + env_config INSERT id={eid} (MOMENTUM_* 키):") for k, v in sorted(patch.items()): print(f" {k:<35s} = {v}") return eid def apply_params_to_db(best_params: dict) -> Optional[int]: """웹·CLI 공통 — 파라서치 merged → DB (tail_param_search.apply_params_to_db 와 동일 UX).""" return _apply_to_db(best_params) def _apply_to_db_us(best_params: dict) -> Optional[int]: """1위 파라미터 → config_us_momentum INSERT (US_MOMENTUM_* 만, 국내 미오염).""" from kis_trader.engine.us_momentum_env_keys import params_to_us_momentum_env_patch from kis_trader.backtest.param_search_apply_snapshot import apply_env_patch patch = params_to_us_momentum_env_patch(best_params) patch.update(session_env_patch("US_MOMENTUM", best_params)) patch = strip_portfolio_keys_from_apply_patch(patch, "US_MOMENTUM") # 안전: US_ 접두만 patch = {k: v for k, v in patch.items() if str(k).startswith("US_MOMENTUM_")} if not patch: print("DB 적용할 US 파라미터가 없습니다.") return None eid = apply_env_patch(patch) if eid is None: print("❌ insert_env_snapshot 실패 (US)") return None print(f"\n✅ config_us_momentum INSERT id={eid} (US_MOMENTUM_* 키):") for k, v in sorted(patch.items()): print(f" {k:<40s} = {v}") return eid def apply_params_to_db_us(best_params: dict, *, symbol: str = "") -> Optional[int]: """해외 모멘텀 Optuna/웹 apply — US_MOMENTUM_* 전용. symbol 이 있으면 전역 INSERT 대신 ``us_momentum_stock_config`` 행에 TRIGGER/청산 축을 핀(성격별 탐색 결과). """ sym = str(symbol or best_params.get("_apply_symbol") or "").strip().upper() if sym: try: from kis_trader.strategies.us_momentum_stock_cfg import ( upsert_us_momentum_stock_config, ) from database import TradeDB p = best_params or {} fields = { "sl_pct": abs(float(p["sl_pct"])) if p.get("sl_pct") is not None else None, "tp_pct": abs(float(p["tp_pct"])) if p.get("tp_pct") is not None else None, "tp_max_pct": abs(float(p["tp_max_pct"])) if p.get("tp_max_pct") is not None else None, "shoulder_min_high_pct": abs(float(p["shoulder_min_high"])) if p.get("shoulder_min_high") is not None else None, "shoulder_cut_pct": abs(float(p["shoulder_cut_pct"])) if p.get("shoulder_cut_pct") is not None else None, "trail_pct": abs(float(p["trail_pct"])) if p.get("trail_pct") is not None else None, "trail_arm_pct": abs(float(p["trail_arm_pct"])) if p.get("trail_arm_pct") is not None else None, "ratchet_tiers": str(p.get("ratchet_tiers") or "").strip() or None, "max_hold_bars": int(float(p["max_hold_bars"])) if p.get("max_hold_bars") not in (None, "") else None, "max_daily": int(float(p["max_daily"])) if p.get("max_daily") not in (None, "") else None, "slot_money": float(p["slot_money"]) if p.get("slot_money") not in (None, "") else None, "rsi_min": float(p["mom_rsi_min"]) if p.get("mom_rsi_min") is not None else None, "rsi_max": float(p["mom_rsi_max"]) if p.get("mom_rsi_max") is not None else None, "vol_mult": float(p["mom_vol_mult"]) if p.get("mom_vol_mult") is not None else None, "vol_win": int(float(p["mom_vol_win"])) if p.get("mom_vol_win") not in (None, "") else None, "chase_lookback_min": int(float(p["chase_lookback_min"])) if p.get("chase_lookback_min") not in (None, "") else None, "pullback_lookback_min": int(float(p["pullback_lookback_min"])) if p.get("pullback_lookback_min") not in (None, "") else None, "pullback_min_pct": float(p["pullback_min_pct"]) if p.get("pullback_min_pct") is not None else None, "pullback_max_pct": float(p["pullback_max_pct"]) if p.get("pullback_max_pct") is not None else None, "setup_vol_max_mult": float(p["setup_vol_max_mult"]) if p.get("setup_vol_max_mult") is not None else None, "setup_bear_bars_min": int(float(p["setup_bear_bars_min"])) if p.get("setup_bear_bars_min") not in (None, "") else None, "high_chase_thr": float(p["high_chase_thr"]) if p.get("high_chase_thr") is not None else None, "max_daily_chg": float(p["max_daily_chg"]) if p.get("max_daily_chg") is not None else None, "min_price": float(p["min_price"]) if p.get("min_price") is not None else None, "ema_fast_period": int(float(p["ema_fast_period"])) if p.get("ema_fast_period") not in (None, "") else None, "ema_slow_period": int(float(p["ema_slow_period"])) if p.get("ema_slow_period") not in (None, "") else None, } # bool 축 — 키가 있을 때만 for eng, col in ( ("use_defense_filters", "use_defense_filters"), ("use_high_chase_filter", "use_high_chase_filter"), ("use_daily_range_filter", "use_daily_range_filter"), ("use_ema_filter", "use_ema_filter"), ("use_rsi_max_filter", "use_rsi_max_filter"), ("pattern_breakout", "pattern_breakout"), ("pattern_pullback", "pattern_pullback"), ): if eng in p and p.get(eng) is not None: fields[col] = 1 if p.get(eng) else 0 if p.get("cooldown_min") is not None: fields["cooldown_sec"] = int(float(p["cooldown_min"]) * 60) # None 값 키는 upsert 에 넣지 않음 → 기존 컬럼 보존 fields = {k: v for k, v in fields.items() if v is not None} db = TradeDB() try: ok = upsert_us_momentum_stock_config( db, sym, exchange=str(p.get("exchange") or "NASD"), symbol=sym, name=sym, stock_group=str(p.get("stock_group") or "STOCK"), fields=fields, ) finally: db.close() if ok: print(f"\n✅ us_momentum_stock_config UPSERT {sym}") return 1 print(f"❌ stock_config 저장 실패 {sym}") return None except Exception as e: print(f"❌ stock apply 실패: {e}") return None return _apply_to_db_us(best_params) # ────────────────────────────────────────────────────────────────────────────── # UI(%) → 엔진(비율) 변환 + 워커 청크 평가 # ────────────────────────────────────────────────────────────────────────────── 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 if "tp_max_pct" in ui_params: engine_params["tp_max_pct"] = ui_params["tp_max_pct"] / 100 elif "tp_max_pct" not in engine_params: engine_params["tp_max_pct"] = get_env_float("MOMENTUM_TP_MAX_PCT", 0.02) # 모멘텀 청산은 trail_pct/arm 만 사용 (0=OFF). # 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 동일하게 전용 트레일 OFF. if "trail_pct" in ui_params: engine_params["trail_pct"] = abs(float(ui_params["trail_pct"])) / 100.0 if "trail_arm_pct" in ui_params: engine_params["trail_arm_pct"] = abs(float(ui_params["trail_arm_pct"])) / 100.0 if "trail_pct" not in ui_params and "trail_arm_pct" not in ui_params: if "trail_trigger" in ui_params or "trail_stop" in ui_params: engine_params["trail_pct"] = 0.0 engine_params["trail_arm_pct"] = 0.0 if "trail_trigger" in ui_params: engine_params["trail_trigger"] = ui_params["trail_trigger"] / 100 if "trail_stop" in ui_params: engine_params["trail_stop"] = ui_params["trail_stop"] / 100 if "max_hold_bars" in ui_params: engine_params["max_hold_bars"] = int(float(ui_params["max_hold_bars"] or 0)) if "shoulder_min_high" in ui_params: engine_params["shoulder_min_high"] = ui_params["shoulder_min_high"] / 100 if "shoulder_cut_pct" in ui_params: engine_params["shoulder_cut_pct"] = ui_params["shoulder_cut_pct"] / 100 if "ratchet_tiers" in ui_params: engine_params["ratchet_tiers"] = str(ui_params["ratchet_tiers"] or "").strip() if "trigger_require_bull_bar" in ui_params: engine_params["trigger_require_bull_bar"] = _momentum_ema_filter_on( ui_params["trigger_require_bull_bar"], ) if "trigger_e_confirm" in ui_params: engine_params["trigger_e_confirm"] = _momentum_ema_filter_on( ui_params["trigger_e_confirm"], ) 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_momentum_skip_hts_scan_dupes() 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"]) if "use_ema_filter" in ui_params: engine_params["use_ema_filter"] = _momentum_ema_filter_on(ui_params["use_ema_filter"]) if "use_rsi_max_filter" in ui_params: engine_params["use_rsi_max_filter"] = _momentum_ema_filter_on(ui_params["use_rsi_max_filter"]) if "pattern_breakout" in ui_params: engine_params["pattern_breakout"] = _momentum_ema_filter_on(ui_params["pattern_breakout"]) if "pattern_pullback" in ui_params: engine_params["pattern_pullback"] = _momentum_ema_filter_on(ui_params["pattern_pullback"]) if "chase_lookback_min" in ui_params: engine_params["chase_lookback_min"] = int(float(ui_params["chase_lookback_min"])) if "pullback_lookback_min" in ui_params: engine_params["pullback_lookback_min"] = int(float(ui_params["pullback_lookback_min"])) if "pullback_min_pct" in ui_params: engine_params["pullback_min_pct"] = float(ui_params["pullback_min_pct"]) if "pullback_max_pct" in ui_params: engine_params["pullback_max_pct"] = float(ui_params["pullback_max_pct"]) if "setup_vol_max_mult" in ui_params: engine_params["setup_vol_max_mult"] = float(ui_params["setup_vol_max_mult"]) if "setup_bear_bars_min" in ui_params: engine_params["setup_bear_bars_min"] = int(float(ui_params["setup_bear_bars_min"])) if "mom_vol_win" in ui_params: engine_params["mom_vol_win"] = int(float(ui_params["mom_vol_win"])) if "time_start_hm" in ui_params: engine_params["time_start_hm"] = int(float(ui_params["time_start_hm"])) if "ema_fast_period" in ui_params: engine_params["ema_fast_period"] = int(float(ui_params["ema_fast_period"])) if "ema_slow_period" in ui_params: engine_params["ema_slow_period"] = int(float(ui_params["ema_slow_period"])) # 호가필터 오버라이드 (파람서치 — 본체/스냅샷 재평가) if ui_params.get("_orderbook_filter_enabled") is not None: engine_params["_orderbook_filter_enabled"] = bool(ui_params["_orderbook_filter_enabled"]) if "max_spread_pct" in ui_params and ui_params["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["min_bid_ask_ratio"] is not None: engine_params["_ob_min_bid_ask_ratio"] = float(ui_params["min_bid_ask_ratio"]) if "ask_max_mult" in ui_params and ui_params["ask_max_mult"] is not None: engine_params["_ob_ask_max_mult"] = float(ui_params["ask_max_mult"]) engine_params["max_loss_krw"] = normalize_breakout_max_loss_krw( ui_params.get("max_loss_krw", 200_000), ) # 손절 % 에 맞춰 1회 투자금(slot_money) 자동 계산 (라이브 봇과 동일 공식) # ※ 해외 US: 고정 유니버스 — 금액 재계산 금지. DB/포트폴리오 슬롯만 사용. if str(ui_params.get("market") or "").strip().upper() == "US": engine_params["max_loss_krw"] = float(ui_params.get("max_loss_krw") or 0.0) if "slot_money" in ui_params and ui_params.get("slot_money") is not None: try: engine_params["slot_money"] = float(ui_params["slot_money"]) except (TypeError, ValueError): pass else: max_loss = engine_params.get("max_loss_krw", 200_000) if engine_params["sl_pct"] > 0 and 0 < max_loss < 10_000_000: # max_loss < 1000만일 때만 자동 계산 (라이브 운영 정상 범위) engine_params["slot_money"] = max_loss / engine_params["sl_pct"] return engine_params def evaluate_momentum_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 = 3_000_000.0, max_stocks: int = 3, total_budget_krw: float = 9_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, start_key: str = "", end_key: str = "", include_trades: bool = False, ) -> Optional[Dict[str, Any]]: """단일 모멘텀 조합 백테 — Grid 워커·Optuna objective 공통.""" if not _momentum_combo_grid_valid(combo): return None ui_params = dict(base_fixed) ui_params.update(combo) engine_params = _ui_to_engine_params(ui_params) 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 log_verdict_by_code: engine_params["_backtest_log_verdict_by_code"] = log_verdict_by_code meta: Dict[str, Any] = {} if len(start_key) >= 12: meta["start_key"] = start_key engine_params["_backtest_period_start_key"] = start_key[:12] if len(end_key) >= 12: meta["end_key"] = end_key trades = mbc.run_momentum_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 = mbc.summarize_momentum_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) from kis_trader.backtest.optuna_common import attach_daily_stability, attach_optional_backtest_trades return attach_optional_backtest_trades(attach_daily_stability({ "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"], "sell_reasons": mbc.count_momentum_sell_reasons(trades), "skipped_micro_buys": int( (meta.get("skip_stats") or {}).get("skipped_micro_buys") or 0 ), "merged_params": merged, }, trades), trades, include_trades) def _evaluate_momentum_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 = 3_000_000.0, max_stocks: int = 3, total_budget_krw: float = 9_000_000.0, fee_rate: float = 0.00015, sell_tax: float = 0.0018, period_days: int = 1, ) -> List[Tuple[float, float, int, Dict]]: """워커: 청크 내 조합 평가 — 시각순 포트폴리오 (scalping_backtest_common, mode=momentum).""" shared = worker_shared_get() orderbook_preloaded = None program_preloaded = None ticks_preloaded = None log_verdict_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") orderbook_preloaded = shared.get("orderbook_by_code") program_preloaded = shared.get("program_by_code") ticks_preloaded = shared.get("ticks_by_code") # ws_ticks 공유메모리(opt-in): descriptor 만 pickle 로 받고, 워커는 이름으로 # shared_memory 에 read-only attach 한다(사본 없음). SharedTicksMapping 은 # 워커당 1회만 만들어 재사용(attach lazy·1회) → dict 경로와 산출물 동일. 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 # 모듈 전역 _WORKER_SHARED 에 유지 ticks_preloaded = _tm log_verdict_preloaded = shared.get("log_verdict_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_momentum_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, start_key=str(shared.get("start_key") or "") if shared else "", end_key=str(shared.get("end_key") or "") if shared else "", ) 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 # ────────────────────────────────────────────────────────────────────────────── # 캔들 로드 (param_search_scalping.py 와 동일 패턴) # ────────────────────────────────────────────────────────────────────────────── def _load_candles_for_search( start: str, end: str, rsi_period: int, *, market: Optional[str] = None, codes_filter: Optional[List[str]] = 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" mk = (market or "").strip().upper() want = { str(c).strip().upper() for c in (codes_filter or []) if str(c).strip() } from kis_trader.backtest.bt_candle_source import fetch_ws_candles_by_code_bulk loaded = fetch_ws_candles_by_code_bulk( db, 1, start_key, end_key, market=mk if mk in ("US", "KR") else None, confirmed_only=True, ) codes = list(loaded.keys()) if mk == "US": try: from permanent_subs import codes_by_market as _perm_us _us_perm = {str(r.get("code") or "").upper() for r in _perm_us(db, "US")} if _us_perm: codes = [c for c in codes if str(c).upper() in _us_perm] or codes except Exception: pass if want: codes = [c for c in codes if str(c).upper() in want] min_bars = int(rsi_period) + 5 for code in codes: rows = loaded.get(code) or [] if len(rows) < min_bars: continue codes_candles[code] = rows if codes_candles: from kis_trader.backtest.momentum_backtest_common import ( prepend_momentum_candle_warmup, ) prepend_momentum_candle_warmup(db, codes_candles, start_key[:12]) 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", stage2_core_lock: bool = False, stage1_json: Optional[str] = None, ) -> bool: if from_file_only and apply_rank is not None and apply_rank >= 1: _apply_from_latest_json(apply_rank) return True grid = _momentum_grids()[mode] keys = list(grid.keys()) stage1_baselines: List[Dict[str, Any]] = [] stage1_source_path = "" stage2_core_lock_meta: Dict[str, Any] = {} if stage2_core_lock: # 2단계 정밀: 1단계 Top-N 코어 고정 × 호가 3축 전수 (균등샘플 아님). stage1_source_path = _resolve_stage1_json_path(stage1_json) core_top_n = get_env_int("MOMENTUM_STAGE2_CORE_TOPN", 7) dict_combos, keys, stage1_data, stage1_baselines = _build_stage2_core_lock_combos( grid, stage1_source_path, core_top_n, ) ob_axes_n = grid_total_combinations([grid[k] for k in _MOMENTUM_OB_AXES]) total_grid = len(dict_combos) total_combos = total_grid max_combos_cap = total_grid dropped_by_cap = 0 sampled = len(dict_combos) # 2단계는 호가필터 ON + kiwoom 본체 강제 orderbook_filter = "on" stage2_core_lock_meta = { "stage2_core_lock": True, "stage1_json": stage1_source_path, "stage1_start": stage1_data.get("start"), "stage1_end": stage1_data.get("end"), "core_top_n": core_top_n, "ob_combos_per_core": ob_axes_n, "stage1_baselines": stage1_baselines, } print(f"\n[MOMENTUM STAGE2 · 코어고정] 1단계: {stage1_source_path}") print( f"📌 Top-{core_top_n} 코어(양수) × 호가 {ob_axes_n}전수 " f"= {sampled:,}조합 (균등샘플 없음) | 기간: {start} ~ {end}" ) for k in _MOMENTUM_OB_AXES: hint = MOMENTUM_GRID_AXIS_HINTS_KO.get(k) if hint: print(f" [{k}] {hint} → {grid.get(k)}") print("📌 1단계 코어 성과(필터 OFF · 비교 기준):") for i, bl in enumerate(stage1_baselines, 1): print( f" 코어#{i}: 손익 {bl.get('total_pnl', 0):+,.0f}원 | " f"승률 {bl.get('win_rate', 0):.1f}% | " f"거래 {bl.get('total_trades', 0)} | PF {bl.get('pf', 0):.2f}" ) # ★ 2단계 = 검증된 코어에 호가필터만 씌워 '비교'하는 단계. # 여기에 승률/PF/거래수 합격필터를 적용하면 전부 탈락 → "만족 0개" 가 떠버린다. # 필터를 켜서 결과가 '나빠지는 것'도 의미있는 데이터이므로, 컷 없이 전 조합을 출력한다. # (0거래 조합 = "필터가 해당 코어의 진입을 전부 막음" 을 그대로 보여줌) min_trades = 0 min_win_rate = 0.0 min_pf = 0.0 print("📌 2단계 출력 정책: 합격필터 없음 — 전 조합 비교 출력(거래0 포함)") else: axes = [grid[k] for k in keys] # 데카르트곱 전체를 RAM 에 펼치지 않는다 (호가 3축 스윕 시 수천만 → OOM 방지). total_combos = grid_total_combinations(axes) dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform_lazy( keys, axes, mode, strategy_env_prefix="MOMENTUM", default_fast=get_env_int("MOMENTUM_FAST_MAX_COMBOS", 512), default_other=( get_env_int("MOMENTUM_EXIT_MAX_COMBOS", 2000) if mode == "exit" else DEFAULT_MAX_COMBOS ), max_combos_override=max_combos, valid_fn=_momentum_combo_grid_valid, ) sampled = len(dict_combos) print(f"\n[MOMENTUM {mode.upper()} 모드] 그리드: {total_grid:,} → 백테: {sampled:,} | 기간: {start} ~ {end}") if mode == "fast": print( f"📌 [fast] HTS TRIGGER + 청산 광범위 · {len(keys)}축 · " f"{total_grid:,}→{max_combos_cap}균등샘플" ) elif mode == "exit": print( f"📌 [exit] 청산 전용 광범위(어깨·래칫·트레일·SL·TP·시간) · " f"{total_grid:,}→{max_combos_cap}균등샘플 · 진입=DB고정" ) if dropped_by_cap: print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)") if not stage2_core_lock: for k in keys: hint = MOMENTUM_GRID_AXIS_HINTS_KO.get(k) if hint: print(f" [{k}] {hint}") print(f"📌 1위 정렬 기준: 순수익/MDD 점수 최대 (낙폭 대비 자본효율 — 동점 시 순익)") print(f"📌 필터: 승률≥{min_win_rate}% · PF≥{min_pf} · 거래≥{min_trades}건") print("=" * 70) t0 = time.time() FIXED_DEFAULTS = _mom_fixed_defaults() apply_session_to_fixed( FIXED_DEFAULTS, time_start_hm=time_start_hm, time_end_hm=time_end_hm, ) # ── 틱재생(ws_ticks) — 기본 ON (실매 체결 정합) ────────────────────── # 실매 청산은 초단위 실제 틱(ws_ticks)으로 체결된다. OHLC 경로(open→high→low→close) # 는 "고가 먼저" 낙관 가정이라 어깨컷·트레일컷·익절을 실제보다 유리하게 체결해 # 백테 손익을 부풀린다(검증: 2026-07-03 OHLC 순위 전 조합 흑자 → 틱 순위 전 조합 # 적자, 실매 -48,742 정합). OHLC 로 뽑은 "최적"은 실매에서 손실나는 파라미터였다. # → 파람서치·웹 백테 모두 기본 틱재생. 끄려면 MOMENTUM_BACKTEST_USE_TICK_EXIT=0. from kis_trader.engine.momentum_tick_replay import ( momentum_backtest_use_tick_entry as _mom_use_tick_entry, momentum_backtest_use_tick_exit as _mom_use_tick_exit, ) FIXED_DEFAULTS["backtest_use_tick_entry"] = _mom_use_tick_entry(None) FIXED_DEFAULTS["backtest_use_tick_exit"] = _mom_use_tick_exit(None) if FIXED_DEFAULTS["backtest_use_tick_exit"] or FIXED_DEFAULTS["backtest_use_tick_entry"]: print( f"📌 틱재생(ws_ticks): 진입={FIXED_DEFAULTS['backtest_use_tick_entry']} " f"청산={FIXED_DEFAULTS['backtest_use_tick_exit']} — 실매 체결 정합 모드" ) # ── 호가필터 ON/OFF (2단계 워크플로) ─────────────────────────────── # off(기본): 1단계 — 호가 게이트 없이 코어 파라미터만 순수 탐색(표본↑). # log_backfill 판정도 무시되어 "필터 없는 세상의 최적" 을 찾는다. # 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 # auto → 키 주입 안 함 (orderbook_filter._orderbook_filter_enabled_for_entry 가 DB값 사용) _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 = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM") portfolio = sbc.resolve_scalp_portfolio_params( env_row, None, strategy="MOMENTUM", 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("rsi_period", 3)) print(f"✅ 데이터 로드 완료: {len(codes_candles):,}종목") # ── 유니버스 ── # 기본: target_candidates_history(MOMENTUM) 저장 이력 (웹 momentum 탭과 동일). # --fallback-universe: 시뮬만 사용. 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.momentum_backtest_common import resolve_momentum_universe history, src, n_bins, _scan_iv, timing = resolve_momentum_universe( start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM", ) if history: universe_by_slot = history avg = sum(len(v) for v in history.values()) / max(1, n_bins) timing_label = "strict" if timing == "strict" else "분단위" print( f"✅ 유니버스: 신봇 MOMENTUM 이력({timing_label}) | " f"{n_bins:,}분봉 · 평균 {avg:.1f}종목" ) else: print("ℹ️ MOMENTUM 이력 없음 → 시뮬레이션 fallback 자동 사용") except Exception as _e: logging.getLogger("param_search_momentum").debug( "신봇 유니버스 이력 조회 스킵: %s", _e, ) 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")) # ★ 2026-05: 모멘텀 전용 점수 공식 사용 (Reversal 의 drop_rate 점수 → Momentum 의 강세/추세 점수) # ``build_universe_simulation_momentum`` 은 시가대비 상승률·고점근접·양봉비율·RSI 모멘텀 # 기반으로 점수 매겨, 진짜 "모멘텀형" 종목만 universe 에 올림. universe_by_slot = me.build_universe_simulation_momentum( 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"✅ 유니버스: 모멘텀 시뮬레이션 사용 (Reversal 점수 → Momentum 점수 공식 전환) | " f"{fallback_sim_interval}분 슬롯 {n_slots}개 · 슬롯당 평균 {avg_per_slot:.1f}종목") FIXED_DEFAULTS["scan_interval_min"] = fallback_sim_interval else: FIXED_DEFAULTS["scan_interval_min"] = 1 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] = {} ticks_by_code: Dict[str, Dict[str, List[Dict]]] = {} # kiwoom_0d 본체 재계산 모드 (6/25~ 유효) — 다음을 **모두** 만족할 때만 켠다. # ① 호가필터 ON (off/1단계에서는 본체가 불필요 → 메모리·시간 절약) # ② 호가필터 축 중 하나라도 **실제 스윕**(값 2개↑) → 단일 운영값이면 의미 없음 # 단일값 스프레드(0.45 하나)만으로 본체를 끌어와 동작이 바뀌던 버그를 막는다. _ob_axes = ("max_spread_pct", "min_bid_ask_ratio", "ask_max_mult") _ob_sweeping = stage2_core_lock or any( len(set(grid.get(k) or [])) > 1 for k in _ob_axes ) if _ob_filter_on and _ob_sweeping: FIXED_DEFAULTS["backtest_use_kiwoom_body_snapshot"] = True FIXED_DEFAULTS["_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 ( backtest_needs_trigger_snapshot_load, load_trigger_snapshots_by_code, ) if backtest_needs_trigger_snapshot_load(FIXED_DEFAULTS, strategy="MOMENTUM"): 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=FIXED_DEFAULTS, strategy="MOMENTUM", ) 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 스냅샷 없음 — 호가·프로그램 필터 스킵 (실매 수집 후 재탐색)") else: print("📌 호가·프로그램 필터 OFF → TRIGGER 스냅샷 DB 로딩 생략") from kis_trader.engine.momentum_tick_replay import ( momentum_backtest_use_tick_entry, momentum_backtest_use_tick_exit, ) if momentum_backtest_use_tick_exit(FIXED_DEFAULTS) or momentum_backtest_use_tick_entry(FIXED_DEFAULTS): from kis_trader.backtest.momentum_tick_loader import ( load_momentum_ticks_by_code, tick_coverage_stats, ) ticks_by_code, tick_rows = load_momentum_ticks_by_code( _snap_db, start_key, end_key, set(codes_candles.keys()), market=str(FIXED_DEFAULTS.get("market") or "KR").strip().upper() or "KR", ) tick_meta = tick_coverage_stats(codes_candles, ticks_by_code) tick_meta["ws_tick_rows_loaded"] = tick_rows _mkt = str(FIXED_DEFAULTS.get("market") or "KR").strip().upper() _tick_tbl = "ws_ticks_us" if _mkt == "US" else "ws_ticks" print( f"✅ 모멘텀 청산 {_tick_tbl} {tick_rows:,}건 " f"(커버리지 {tick_meta.get('tick_bar_coverage_pct', 0):.1f}% · " f"{tick_meta.get('tick_codes_with_data', 0)}/{tick_meta.get('tick_codes_total', 0)}종목)" ) if tick_rows <= 0: print(f"⚠️ {_tick_tbl} 없음 — 1분봉 OHLC 청산 폴백") finally: _snap_db.close() # 멀티프로세싱 — start_key·end_key 둘 다 워커에 전달 (scan_at 유니버스 = 웹 백테 정합) _ps_start_key = (start.replace("-", "") + "0000") if start else "202601010000" _ps_end_key = (end.replace("-", "") + "2359") if end else "999912312359" # ── ws_ticks 공유메모리 — 워커별 사본(~3.3GB×N) 대신 1벌만 공유 ──────────── # 기본 ON(검증 완료: 실측 CPU 184%→1061%·메모리 오히려↓). ON 이면 틱을 컬럼 # (numpy)로 shared_memory 에 1벌 올리고, 워커는 이름으로 read-only attach(mmap # 공유) → 메모리 N배 제거 + 워커 상한 해제 → CPU 포화 회복. 엔진 핫루프는 # TickColumnView 로 배열을 직접 읽어(속도 회복) dict 경로와 bit-identical. # 끄려면 MOMENTUM_PARAM_SEARCH_SHARED_TICKS=0. numpy/shm 미지원·빌드 실패 시 자동 폴백. shared_tick_store = None if get_env_bool("MOMENTUM_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) # 크래시 시 /dev/shm 누수 방지 print("📦 ws_ticks 공유메모리 ON — 워커 attach(read-only), 사본 제거") ticks_by_code = {} # 부모 대용량 dict 해제 (틱은 shm 에 1벌 존재) # 해제한 힙을 OS 로 돌려줘 메모리 플래너가 가용 RAM 을 제대로 보고 # 워커 수를 충분히(≈CPU 상한) 잡게 한다. (안 하면 잔존 힙 때문에 과소산정) 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 실패 — 기존(디스크 pickle) 경로 폴백") else: print("⚠️ numpy/shared_memory 미지원 — 기존(디스크 pickle) 경로 폴백") shared = ParamSearchSharedPayload({ "codes_candles": codes_candles, "universe_by_slot": universe_by_slot, "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, "ticks_by_code": ticks_by_code, "ticks_shared_descriptor": (shared_tick_store.descriptor() if shared_tick_store else None), "start_key": _ps_start_key, "end_key": _ps_end_key, }) 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(sampled, payload_bytes) # ── 틱재생(ws_ticks) payload 시 워커 상한 (OOM 방지) ───────────────── # 틱재생 기본 ON 이후, 워커별로 ws_ticks·휩쏘틱 등 사적(private) 구조가 # payload 추정치(~수백MB)보다 훨씬 크게(관측: anon-RSS ~3.3GB/워커) 부풀어 # 5워커 동시 실행 시 13GB 머신이 OOM-kill 로 죽는 현상 확인(2026-07-05). # 꼬리잡기(tail_param_search)와 동일하게 틱 존재 시 워커 수를 상한한다. # (하드코딩 금지 — DB/Env 로 조정, 기본 3) if ticks_by_code: # 기본 2 — 13GB 머신에서 워커당 anon-RSS ~3.3GB(틱 사적구조) 관측, # 3워커는 부모+payload 합산 시 OOM 경계. 여유 있는 머신은 Env 로 상향. tick_cap = get_env_int("MOMENTUM_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} (MOMENTUM_PARAM_SEARCH_MAX_WORKERS_WITH_TICKS)") # 공유메모리 틱은 사본이 없어 OOM 위험이 없다 → 기본 상한 없음(CPU/메모리 계획대로). # 필요 시 Env 로 별도 상한(기본 0=무제한). if shared_tick_store is not None: shared_cap = get_env_int("MOMENTUM_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} (MOMENTUM_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_momentum_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() n_chunks = len(chunks) # 진행 바 + 경과 + ETA 한 줄 렌더 (청크 완료 시·대기 중 하트비트 시 공용). # last_eta_str: 청크 사이 대기 중엔 ETA 재계산이 안 되므로 직전 값을 유지해 표시. _state = {"last_eta": "계산 중..."} def _render(done: int, elapsed_so_far: float, eta_str: str, running: int = 0) -> None: progress = (done / max(1, n_chunks)) * 100 bar = ParamSearchProgressETA.render_bar(done / max(1, n_chunks)) elapsed_str = ParamSearchProgressETA.format_elapsed(elapsed_so_far) run_tail = f" | 진행 {running}개" if (running and done > 0) else "" line = (f"⏳ {bar} {progress:.1f}% ({done:,}/{n_chunks:,}) | " f"경과 {elapsed_str} | 남은 {eta_str}{run_tail}") if use_carriage_return: print(f"\r{line} ", end="", flush=True) else: print(line, flush=True) def _heartbeat(done: int, total: int, elapsed_so_far: float, running: int) -> None: # 청크 사이 텀·워밍업 동안 같은 줄을 경과 시간만 갱신해 다시 그림 (멈춘 듯 안 보이게). _render(done, elapsed_so_far, _state["last_eta"], running) for local_results in iter_pool_chunk_results( executor, chunks, _submit, max_workers=max_workers, on_heartbeat=_heartbeat, ): 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) elapsed_so_far = time.time() - start_time eta_str = ParamSearchProgressETA.format_sec( progress_eta.remaining_sec(processed, elapsed_so_far), ) _state["last_eta"] = eta_str _render(processed, elapsed_so_far, eta_str) if use_carriage_return: print(flush=True) # 워커 종료(풀 close) 후 공유메모리 즉시 해제 (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 # 힙 → 결과 취합 (힙 프리필터는 총손익 최대 기준 — 가장 수익난 후보 top_n 보관) results = [heapq.heappop(global_heap)[3] for _ in range(len(global_heap))] # ── 위험조정 점수(순수익/MDD, Calmar 유사) ───────────────────────────── # PF 는 "손실 대비 이익" 비율일 뿐 낙폭(자금 최대 손실)을 못 본다. # → 순익 +10만/MDD 8만 (거칠게 벌었다) 보다 순익 +8만/MDD 2만 (안정적)이 # 실전에서 더 좋은 파라미터다. score = 순수익 ÷ max(MDD, 하한) 으로 # '낙폭 대비 얼마나 벌었나'(자본 효율)를 랭킹한다. # MDD 하한(SCORE_MDD_FLOOR)은 0낙폭·초소액 조합이 무한대 점수로 튀는 것을 # 막는 방어값(하드코딩 금지 규칙 준수 — DB/Env 로 조정). mdd_floor = get_env_float("MOMENTUM_SCORE_MDD_FLOOR", 10000.0) for r in results: _pnl = float(r.get("total_pnl", 0) or 0) _mdd = float(r.get("mdd", 0) or 0) r["score"] = round(_pnl / max(_mdd, mdd_floor), 4) # 정렬: 위험조정 점수(순수익/MDD) 최대 → 동점 시 순익 → 승률. # 음수(적자) 조합은 score 도 음수라 자연히 하위로 밀린다. results.sort(key=lambda r: (-r.get("score", 0.0), -r["total_pnl"], -r["win_rate"])) # ★ 양수 조합 별도 카운트 (양수가 없어도 아래 JSON 저장은 그대로 진행된다) pos_results = [r for r in results if r.get("total_pnl", 0) > 0] print(f"\n💰 양수 조합: {len(pos_results)}건 / 전체 top {len(results)}건") # ── 콘솔 출력 ── print(f"\n완료: {elapsed:.1f}초 | 유효 결과: {len(results):,}건") print(f"\n{'='*100}") print(f" 🏆 MOMENTUM TOP {min(top_n, len(results))} (순수익/MDD 점수 기준 — 낙폭 대비 자본효율)") 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} {'MDD':>10} {'점수':>8}") print("-" * (len(hdr) + 72)) 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.get('mdd', 0):>10,.0f} " f"{r.get('score', 0.0):>8.3f}") # 거래수 다양성 통계 (사용자 의문 해소용) 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 stage2_core_lock and stage1_baselines: core_keys = [k for k in keys if k not in _MOMENTUM_OB_AXES] print("\n📊 코어별 최고 호가필터 (stage1 OFF vs stage2 ON):") for i, bl in enumerate(stage1_baselines, 1): base_core = bl.get("params") or {} matching = [ r for r in results if all(r["params"].get(k) == base_core.get(k) for k in core_keys if k in base_core) ] if not matching: print(f" 코어#{i}: stage2 유효 결과 없음") continue best = max(matching, key=lambda r: (r["total_pnl"], r["win_rate"])) bp = best["params"] wr_delta = float(best["win_rate"]) - float(bl.get("win_rate") or 0) print( f" 코어#{i}: stage1 {bl.get('total_pnl', 0):+,.0f}원/{bl.get('win_rate', 0):.1f}% " f"→ stage2최고 {best['total_pnl']:+,.0f}원/{best['win_rate']:.1f}% " f"(승률 {wr_delta:+.1f}%p) | " f"spread={bp.get('max_spread_pct')} ratio={bp.get('min_bid_ask_ratio')} " f"ask×{bp.get('ask_max_mult')}" ) # ── JSON 저장 ── out_dir = _results_dir_for_write() ts = datetime.now().strftime("%Y%m%d_%H%M%S") _fname_mode = f"{mode}_stage2" if stage2_core_lock else mode out_path = os.path.join(out_dir, f"search_momentum_{_fname_mode}_{ts}.json") payload = { "strategy": "MOMENTUM", "mode": mode, "orderbook_filter": _ob_mode, "start": start, "end": end, "min_win_rate": min_win_rate, "min_pf": min_pf, "slot_money": slot_money_v, "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "time_start_hm": FIXED_DEFAULTS.get("time_start_hm"), "time_end_hm": FIXED_DEFAULTS.get("time_end_hm"), "grid_keys": keys, "grid_axis_hints": {k: MOMENTUM_GRID_AXIS_HINTS_KO[k] for k in keys if k in MOMENTUM_GRID_AXIS_HINTS_KO}, "cartesian_product": total_combos, "tested_combos": sampled, "max_combos_cap": max_combos_cap, "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"], "score": r.get("score", 0.0), "bot_pct": r.get("bot_pct"), "daily_avg_pct": r.get("daily_avg_pct"), "skipped_micro_buys": r.get("skipped_micro_buys", 0), } for i, r in enumerate(results) ], } if stage2_core_lock_meta: payload.update(stage2_core_lock_meta) 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_momentum_{_fname_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}") # ── DB 적용 ── 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("MOMENTUM", lookback_days=7) parser = argparse.ArgumentParser( description="모멘텀 Grid Search (momentum_engine · MomentumStrategy 실매 동일 경로)", ) 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="exit", choices=["fast", "exit", "rr", "coarse", "fine", "wide", "full"], help="탐색 모드: exit(청산광범위·기본) / fast / rr / coarse / fine / wide(축스크리닝) / full") parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", help="백테 조합 상한 (fast 기본 env MOMENTUM_FAST_MAX_COMBOS=200, coarse 등 5000, 0=무제한)", ) parser.add_argument("--top", default=1000, type=int, help="상위 N개 출력·JSON 저장 (기본 1000)") parser.add_argument("--min_trades", default=1, type=int, help="최소 거래 건수 (기본 1 — 0건 조합만 제외)") 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_momentum_*.json 에서만 DB 적용") parser.add_argument("--fallback-universe", action="store_true", dest="fallback_universe", help="저장 이력 무시, 시뮬 유니버스만 사용 (기본: 저장 이력 우선)") parser.add_argument( "--orderbook-filter", default="off", choices=["off", "on", "auto"], dest="orderbook_filter", help="호가필터: off=1단계(코어만·기본) / on=2단계(kiwoom 본체 스프레드·잔량비·매도벽) / auto=DB값", ) parser.add_argument( "--stage2-core-lock", action="store_true", dest="stage2_core_lock", help="2단계 정밀: stage1 Top-N 코어 고정 × 호가 3축 전수 " "(MOMENTUM_STAGE2_CORE_TOPN 기본 7, --stage1-json 미지정 시 최신 JSON)", ) parser.add_argument( "--stage1-json", default=None, dest="stage1_json", help="2단계용 stage1 결과 JSON 경로 (미지정 시 최신 search_momentum_*.json)", ) 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_momentum") if run_lock is None: print( "⛔ 이미 실행 중인 param_search_momentum 이 있습니다.\n" " ps -ef | grep param_search_momentum\n" " pkill -f 'param_search_momentum.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, stage2_core_lock = args.stage2_core_lock, stage1_json = args.stage1_json, ) 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()