#!/usr/bin/env python3 """ param_search_apply_snapshot.py — 파라미터 탐색 결과 JSON → insert_env_snapshot (config_* + env_config) =========================================================================================== 지원 전략 (파일·내용 자동 판별): MOMENTUM search_momentum_*.json top[].merged_params SCALP search_*.json (rsi_oversold) top[].db_snapshot 우선, 없으면 merged에서 생성 BREAKOUT search_breakout_*.json top[].merged_params BREAKOUT optuna_breakout_*.json results[N-1] (Optuna — top 없음) MOMENTUM optuna_momentum_*.json results[N-1] (Optuna) TAIL search_tail_*.json results[N-1].params (정렬된 순서) TAIL optuna_tail_*.json results[N-1] (Optuna) UPDOW updow_param_*.json top[N-1].apply_cfg + tf 사용 예: cd /path/to/kis_bot python3 kis_trader/backtest/param_search_apply_snapshot.py \\ --json kis_trader/backtest/results/search_momentum_coarse_20260514_150927.json --rank 1 python3 kis_trader/backtest/param_search_apply_snapshot.py \\ --json kis_trader/backtest/results/search_coarse_20260509_183233.json --rank 1 --dry-run python3 kis_trader/backtest/param_search_apply_snapshot.py \\ --json results/search_tail_coarse_*.json --rank 3 --allow-non-positive-pnl """ from __future__ import annotations import argparse import json import os import sys from typing import Any, Dict, List, Optional, Tuple HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) for _p in (ROOT, HERE): if _p not in sys.path: sys.path.insert(0, _p) from database import TradeDB # noqa: E402 from kis_trader.backtest.backtest_portfolio_common import ( # noqa: E402 merge_param_search_apply_source, strip_portfolio_keys_from_apply_patch, session_env_patch, ) def _ranked_items(data: Dict[str, Any]) -> List[Dict[str, Any]]: """Grid ``top[]`` 또는 Optuna ``results[]`` — rank 1 = index 0.""" top = data.get("top") if isinstance(top, list) and top: return top results = data.get("results") if isinstance(results, list) and results: return results return [] def _env_bool_10(v: Any) -> str: if isinstance(v, bool): return "1" if v else "0" s = str(v).strip().lower() return "1" if s in ("1", "true", "t", "y", "yes", "on") else "0" def _detect_strategy(data: Dict[str, Any], path: str) -> str: base = os.path.basename(path).lower() s = (data.get("strategy") or "").strip().upper() if s in ("US_MOMENTUM", "US-MOMENTUM"): return "US_MOMENTUM" if s in ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL", "UPDOW"): return s # CLI / JSON strategy 슬러그 s_low = (data.get("strategy") or "").strip().lower() if s_low == "us_momentum": return "US_MOMENTUM" if base.startswith("updow_param_"): return "UPDOW" if base.startswith("search_tail_") or base.startswith("tail_search_"): return "TAIL" if base.startswith("search_breakout_") or base.startswith("optuna_breakout_"): return "BREAKOUT" if base.startswith("optuna_us_momentum_") or base.startswith("search_us_momentum_"): return "US_MOMENTUM" if base.startswith("search_momentum_") or base.startswith("optuna_momentum_"): # market=US 결과 JSON if str(data.get("market") or "").strip().upper() == "US": return "US_MOMENTUM" return "MOMENTUM" if base.startswith("optuna_tail_"): return "TAIL" if isinstance(data.get("code"), str) and len(str(data.get("code")).strip()) == 6: if "tf" in data and isinstance(data.get("top"), list): return "UPDOW" results = data.get("results") if isinstance(results, list) and results: p0 = results[0].get("params") or {} if isinstance(p0, dict) and ( "min_drop_rate" in p0 or "tail_ratio_min" in p0 or "shoulder_cut_pct" in p0 ): return "TAIL" top = data.get("top") if isinstance(top, list) and top: merged = top[0].get("merged_params") or {} params = top[0].get("params") or {} ref = merged if isinstance(merged, dict) else {} if not ref: ref = params if isinstance(params, dict) else {} if "mom_rsi_min" in ref: return "MOMENTUM" if "prev_chg_min" in ref: return "BREAKOUT" if "rsi_oversold" in ref: return "SCALP" return "UNKNOWN" def _patch_from_momentum_merged(m: Dict[str, Any]) -> Dict[str, str]: """merged_params(UI 퍼센트 등) → env_config 문자열 패치 (웹 momentum 저장과 동일 계열).""" patch: Dict[str, str] = {} def gi(key: str, default: int = 0) -> int: v = m.get(key) return int(float(v)) if v not in (None, "") else default def gf(key: str, default: float = 0.0) -> float: v = m.get(key) return float(v) if v not in (None, "") else default patch["MOMENTUM_RSI_MIN"] = str(gi("mom_rsi_min", 50)) patch["MOMENTUM_RSI_MAX"] = str(gi("mom_rsi_max", 80)) patch["MOMENTUM_VOL_MULT"] = str(gf("mom_vol_mult", 1.5)) patch["MOMENTUM_VOL_WIN"] = str(gi("mom_vol_win", 5)) # HTS K: 전일종가 대비 최소등락(%) — UI/그리드와 동일 단위(0.2 = 0.2%) patch["MOMENTUM_E_MIN_CHG_PCT"] = str(gf("e_min_chg_pct", 0.2)) # TIME_* 는 session_env_patch / PARAM_SEARCH_APPLY_SESSION_TIME 경로만. # Optuna 과적합 창이 실매 매수시간을 덮지 않도록 여기서는 쓰지 않음. # ※ 모멘텀 apply 는 MOMENTUM_* 만 기록. SCALP_* 를 덮으면 스캘핑 실매/웹이 오염되고, # 엔진은 MOMENTUM_COOLDOWN_SEC 등을 읽는데 SCALP_COOLDOWN 만 써서 apply 가 무효가 된다. sl_r = str(abs(gf("sl_pct", 1.5)) / 100.0) tp_r = str(abs(gf("tp_pct", 2.5)) / 100.0) patch["MOMENTUM_STOP_LOSS_PCT"] = sl_r patch["MOMENTUM_TAKE_PROFIT_PCT"] = tp_r patch["MOMENTUM_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60)) md = str(gi("max_daily", 5)) patch["MOMENTUM_MAX_DAILY"] = md sm = m.get("slot_money") if sm not in (None, ""): sms = str(int(float(sm))) patch["MOMENTUM_SLOT_MONEY"] = sms patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms hc = gf("high_chase_thr", 0.96) ratio_hc = hc if 0 < hc <= 1 else hc / 100.0 patch["MOMENTUM_HIGH_CHASE_THR"] = str(ratio_hc) patch["MOMENTUM_MAX_DAILY_CHG"] = str(gf("max_daily_chg", 20.0)) mp = str(gf("min_price", 1000)) patch["MOMENTUM_MIN_PRICE"] = mp ml = str(int(float(m.get("max_loss_krw") or 200000))) patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml mm = str(gf("min_margin", 0.2)) patch["MOMENTUM_MIN_PROFIT_PCT"] = mm if "use_defense_filters" in m: patch["MOMENTUM_USE_DEFENSE_FILTERS"] = _env_bool_10(m.get("use_defense_filters")) if "mom_max_from_open_pct" in m and m.get("mom_max_from_open_pct") not in (None, ""): patch["MOMENTUM_MAX_FROM_OPEN_PCT"] = str(float(m["mom_max_from_open_pct"])) if "mom_min_from_open_pct" in m and m.get("mom_min_from_open_pct") not in (None, ""): patch["MOMENTUM_MIN_FROM_OPEN_PCT"] = str(float(m["mom_min_from_open_pct"])) x = m.get("tp_max_pct") if x not in (None, ""): patch["MOMENTUM_TP_MAX_PCT"] = str(abs(float(x)) / 100.0) x = m.get("shoulder_min_high") if x not in (None, ""): patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = m.get("shoulder_cut_pct") if x not in (None, ""): patch["MOMENTUM_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) # 전용 트레일(momentum_engine) — UI 퍼센트 → MOMENTUM_TRAIL_* 비율. # trail_pct/arm 우선. 레거시 trail_trigger/stop 만 있으면 과거 Optuna와 같이 OFF(0). has_trail_pct = "trail_pct" in m and m.get("trail_pct") not in (None, "") has_trail_arm = "trail_arm_pct" in m and m.get("trail_arm_pct") not in (None, "") has_legacy_trail = ( ("trail_trigger" in m and m.get("trail_trigger") not in (None, "")) or ("trail_stop" in m and m.get("trail_stop") not in (None, "")) ) if has_trail_pct: patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(m["trail_pct"])) / 100.0) elif has_legacy_trail and not has_trail_arm: patch["MOMENTUM_TRAIL_PCT"] = "0" if has_trail_arm: patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(m["trail_arm_pct"])) / 100.0) elif has_legacy_trail and not has_trail_pct: patch["MOMENTUM_TRAIL_ARM_PCT"] = "0" # 래칫·최대보유 — exit Optuna 1위 적용 시 누락되지 않도록 (BREAKOUT_RATCHET_TIERS 와 동일 계열) if "ratchet_tiers" in m: patch["MOMENTUM_RATCHET_TIERS"] = str(m.get("ratchet_tiers") or "").strip() x = m.get("max_hold_bars") if x not in (None, ""): patch["MOMENTUM_MAX_HOLD_BARS"] = str(int(float(x))) if "use_ema_filter" in m: patch["MOMENTUM_USE_EMA_FILTER"] = _env_bool_10(m.get("use_ema_filter")) if "use_rsi_max_filter" in m: patch["MOMENTUM_USE_RSI_MAX_FILTER"] = _env_bool_10(m.get("use_rsi_max_filter")) if "pattern_breakout" in m: patch["MOMENTUM_PATTERN_BREAKOUT"] = _env_bool_10(m.get("pattern_breakout")) if "pattern_pullback" in m: patch["MOMENTUM_PATTERN_PULLBACK"] = _env_bool_10(m.get("pattern_pullback")) if "use_high_chase_filter" in m: patch["MOMENTUM_USE_HIGH_CHASE_FILTER"] = _env_bool_10(m.get("use_high_chase_filter")) if "use_daily_range_filter" in m: patch["MOMENTUM_USE_DAILY_RANGE_FILTER"] = _env_bool_10(m.get("use_daily_range_filter")) x = m.get("chase_lookback_min") if x not in (None, ""): patch["MOMENTUM_CHASE_LOOKBACK_MIN"] = str(int(float(x))) x = m.get("pullback_lookback_min") if x not in (None, ""): patch["MOMENTUM_PULLBACK_LOOKBACK_MIN"] = str(int(float(x))) x = m.get("pullback_min_pct") if x not in (None, ""): patch["MOMENTUM_PULLBACK_MIN_PCT"] = str(float(x)) x = m.get("pullback_max_pct") if x not in (None, ""): patch["MOMENTUM_PULLBACK_MAX_PCT"] = str(float(x)) x = m.get("setup_vol_max_mult") if x not in (None, ""): patch["MOMENTUM_SETUP_VOL_MAX_MULT"] = str(float(x)) x = m.get("setup_bear_bars_min") if x not in (None, ""): patch["MOMENTUM_SETUP_BEAR_BARS_MIN"] = str(int(float(x))) x = m.get("ema_fast_period") if x not in (None, ""): patch["MOMENTUM_EMA_FAST_PERIOD"] = str(int(float(x))) x = m.get("ema_slow_period") if x not in (None, ""): patch["MOMENTUM_EMA_SLOW_PERIOD"] = str(int(float(x))) from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch patch.update(orderbook_params_to_env_patch("MOMENTUM", m)) return patch def apply_env_patch(patch: Dict[str, str]) -> Optional[int]: """병합 스냅샷에 patch 반영 후 insert_env_snapshot — config_scalp/momentum/… 분리 저장.""" if not patch: return None db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(patch) return db.insert_env_snapshot(snap) finally: db.close() def _patch_from_breakout_merged(m: Dict[str, Any]) -> Dict[str, str]: patch: Dict[str, str] = {} def gv(key: str) -> Optional[Any]: v = m.get(key) return None if v in (None, "") else v x = gv("lookback_min") if x is not None: patch["BREAKOUT_LOOKBACK_MIN"] = str(int(float(x))) x = gv("vol_window") if x is not None: patch["BREAKOUT_VOL_WIN"] = str(int(float(x))) x = gv("vol_mult") if x is not None: patch["BREAKOUT_VOL_MULT"] = str(float(x)) x = gv("min_turnover_1m_pct") if x is not None: patch["BREAKOUT_MIN_TURNOVER_1M_PCT"] = str(float(x)) x = gv("prev_chg_min") if x is not None: patch["BREAKOUT_PREV_CHG_MIN"] = str(float(x)) x = gv("prev_chg_max") if x is not None: patch["BREAKOUT_PREV_CHG_MAX"] = str(float(x)) x = gv("sl_pct") if x is not None: patch["BREAKOUT_STOP_LOSS_PCT"] = str(-abs(float(x)) / 100.0) x = gv("tp_pct") if x is not None: patch["BREAKOUT_TAKE_PROFIT_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_pct") if x is not None: patch["BREAKOUT_TRAIL_PCT"] = str(abs(float(x)) / 100.0) x = gv("trail_arm_pct") if x is not None: patch["BREAKOUT_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_min_high_pct") if x is not None: patch["BREAKOUT_SHOULDER_MIN_HIGH_PCT"] = str(abs(float(x)) / 100.0) x = gv("shoulder_cut_pct") if x is not None: patch["BREAKOUT_SHOULDER_CUT_PCT"] = str(abs(float(x)) / 100.0) # ── ATR 동적 손절 (sl_min/max 는 % 단위 그대로 — 엔진 _breakout_sl_line 가 /100) ── x = gv("sl_mode") if x is not None: patch["BREAKOUT_SL_MODE"] = str(x).strip().lower() x = gv("atr_period") if x is not None: patch["BREAKOUT_ATR_PERIOD"] = str(int(float(x))) x = gv("atr_sl_mult") if x is not None: patch["BREAKOUT_ATR_SL_MULT"] = str(float(x)) x = gv("atr_sl_min_pct") if x is not None: patch["BREAKOUT_ATR_SL_MIN_PCT"] = str(float(x)) x = gv("atr_sl_max_pct") if x is not None: patch["BREAKOUT_ATR_SL_MAX_PCT"] = str(float(x)) # 래칫(단계식 트레일) 문자열 — % 단위 그대로 저장(엔진 _breakout_ratchet_tiers 가 /100). # 빈 문자열("")=OFF 도 명시 저장하여, 기존 DB 래칫을 1위가 OFF 일 때 확실히 끈다. if "ratchet_tiers" in m: patch["BREAKOUT_RATCHET_TIERS"] = str(m.get("ratchet_tiers") or "").strip() x = gv("max_hold_bars") if x is not None: patch["BREAKOUT_MAX_HOLD_BARS"] = str(int(float(x))) # 가짜돌파(휩쏘) 필터 — % 그대로 저장 x = gv("confirm_margin_pct") if x is not None: patch["BREAKOUT_CONFIRM_MARGIN_PCT"] = str(float(x)) x = gv("body_min_pct") if x is not None: patch["BREAKOUT_BODY_MIN_PCT"] = str(float(x)) # TIME_* / GOLDEN_END 는 session_env_patch(기본 OFF) — Optuna apply 가 창을 자르지 않음 x = gv("max_daily") if x is not None: # 일일횟수는 포트폴리오성 운영값 — Optuna apply 가 1로 덮지 않음(실매·웹에서 별도) pass x = gv("cooldown_min") if x is not None: patch["BREAKOUT_COOLDOWN_SEC"] = str(int(float(x) * 60)) x = gv("max_daily_chg") if x is not None: patch["BREAKOUT_MAX_DAILY_CHG"] = str(float(x)) x = gv("min_price") if x is not None: patch["BREAKOUT_MIN_PRICE"] = str(int(float(x))) x = gv("entry_mode") if x is not None: patch["BREAKOUT_ENTRY_MODE"] = str(x).strip().lower() x = gv("intrabar_slippage_pct") if x is not None: patch["BREAKOUT_INTRABAR_SLIPPAGE_PCT"] = str(float(x)) x = gv("max_loss_krw") sm_raw = gv("slot_money") ml_i = None if x is not None: from kis_trader.strategies.breakout import ( # noqa: WPS433 breakout_invest_amount_krw, normalize_breakout_max_loss_krw, ) ml_i = normalize_breakout_max_loss_krw(x) patch["BREAKOUT_MAX_LOSS_PER_TRADE_KRW"] = str(ml_i) patch["MAX_LOSS_PER_TRADE_KRW"] = str(ml_i) if sm_raw is not None: cap = int(float(sm_raw)) patch["BREAKOUT_SLOT_MONEY"] = str(cap) patch["SLOT_MONEY_DEFAULT"] = str(cap) patch["BREAKOUT_MAX_BUY_AMOUNT"] = str(cap) patch["MAX_BUY_AMOUNT_PER_STOCK"] = str(cap) elif ml_i is not None: from kis_trader.strategies.breakout import breakout_invest_amount_krw # noqa: WPS433 sl_ui = float(m.get("sl_pct") or 1.5) cap = int(breakout_invest_amount_krw(ml_i, sl_ui, 2_000_000)) patch["BREAKOUT_SLOT_MONEY"] = str(cap) patch["SLOT_MONEY_DEFAULT"] = str(cap) patch["BREAKOUT_MAX_BUY_AMOUNT"] = str(cap) if "use_ema_filter" in m: patch["BREAKOUT_USE_EMA_FILTER"] = _env_bool_10(m.get("use_ema_filter")) x = gv("ema_fast_period") if x is not None: patch["BREAKOUT_EMA_FAST_PERIOD"] = str(int(float(x))) x = gv("ema_slow_period") if x is not None: patch["BREAKOUT_EMA_SLOW_PERIOD"] = str(int(float(x))) from kis_trader.engine.orderbook_env import orderbook_params_to_env_patch patch.update(orderbook_params_to_env_patch("BREAKOUT", m)) return patch def _apply_tail_params(params: Dict[str, Any]) -> None: """tail_param_search.apply_params_to_db 와 동일 경로 (단일 INSERT).""" import tail_param_search as tps # noqa: WPS433 — 동일 디렉터리 스크립트 tps.apply_params_to_db(params) def _apply_updow_row(row: Dict[str, Any], tf: int) -> Dict[str, str]: from kis_trader.strategies import updow_buy as udb ac = row.get("apply_cfg") or {} if not isinstance(ac, dict): return {} return udb.env_snapshot_patch_from_engine_cfg(ac, tf_min=tf) def _apply_updow_stock(code: str, row: Dict[str, Any], tf: int, dry_run: bool) -> None: """탐색 ``apply_cfg`` → ``updow_stock_config`` (하락매수 종목 단일 소스).""" from kis_trader.strategies import updow_holding_cfg as uhc ac = row.get("apply_cfg") or {} if not isinstance(ac, dict) or not ac: print("⚠️ updow_stock_config 미반영: apply_cfg 없음") return code = str(code).strip() if len(code) != 6: print("⚠️ updow_stock_config 미반영: code 없음/형식 오류") return if dry_run: print(f"[dry-run] updow_stock_config {code} ← apply_cfg keys={list(ac.keys())}") return db = TradeDB() try: import holding_bot as hb uhc.ensure_updow_stock_config_table(db) if tf not in hb.KIWOOM_MINUTE_TICS: tf = 60 meta = uhc.get_updow_stock_meta(db, code) name = str(meta.get("name") if meta else code).strip() or code uhc.set_updow_stock_config(db, code, name, ac, tf_min=tf) mh = int(float(ac.get("max_hold_bars", 16))) print(f"✅ updow_stock_config 저장 ({code}, tf={tf}, max_hold={mh})") finally: db.close() def main(argv: Optional[List[str]] = None) -> int: ap = argparse.ArgumentParser( description="파라미터 탐색 JSON의 N위 조합을 env_config 에 INSERT (최신 행 복사 후 패치)", ) ap.add_argument("--json", required=True, help="결과 JSON 경로") ap.add_argument("--rank", type=int, default=1, help="순위 (1부터). TAIL은 results 정렬 기준") ap.add_argument("--dry-run", action="store_true", help="INSERT 생략, 패치 키만 출력") ap.add_argument( "--allow-non-positive-pnl", action="store_true", help="total_pnl<=0 이더라도 적용 (기본: 해당 시 경고 후 종료)", ) ap.add_argument( "--env", action="store_true", help="UPDOW: env_config 전역 UPDOW_* 도 함께 갱신 (기본: updow_stock_config 만)", ) args = ap.parse_args(argv) path = os.path.abspath(args.json) if not os.path.isfile(path): print(f"❌ 파일 없음: {path}") return 2 try: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except Exception as e: print(f"❌ JSON 로드 실패: {e}") return 3 strategy = _detect_strategy(data, path) if strategy == "UNKNOWN": print("❌ 전략을 자동 판별하지 못했습니다. 파일명·키 구조를 확인하세요.") return 4 rank = max(1, int(args.rank)) print(f"📂 {path}") print(f"📌 전략={strategy} rank={rank}") # ── TAIL: 별도 모듈이 INSERT 수행 ───────────────────────────── if strategy == "TAIL": results = data.get("results") or [] if rank > len(results): print(f"❌ rank 범위 초과 (1~{len(results)})") return 5 target = results[rank - 1] pnl = int(target.get("total_pnl") or 0) if pnl <= 0 and not args.allow_non_positive_pnl: print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl") return 6 params = target.get("params") or {} if args.dry_run: merged = merge_param_search_apply_source(target, data) print("[dry-run] TAIL merged:", json.dumps(merged, ensure_ascii=False, indent=2)) return 0 merged = merge_param_search_apply_source(target, data) _apply_tail_params(merged) print("✅ TAIL env_config INSERT 완료 (tail_param_search.apply_params_to_db)") return 0 # ── UPDOW ───────────────────────────────────────────────────── if strategy == "UPDOW": top = data.get("top") or [] if rank > len(top): print(f"❌ rank 범위 초과 (1~{len(top)})") return 5 row = top[rank - 1] pnl = int(row.get("total_pnl") or 0) if pnl <= 0 and not args.allow_non_positive_pnl: print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl") return 6 tf = int(data.get("tf") or 60) patch = _apply_updow_row(row, tf=tf) if not patch: print("❌ apply_cfg 가 비어 있습니다.") return 7 code_h = str(data.get("code") or "").strip() if args.dry_run: if args.env: print("[dry-run] env patch:", json.dumps(patch, ensure_ascii=False, indent=2)) if code_h: _apply_updow_stock(code_h, row, tf=tf, dry_run=True) return 0 if args.env: db = TradeDB() try: latest = db.get_latest_env() snap = dict(latest["snapshot"]) if latest else {} snap.update(patch) eid = db.insert_env_snapshot(snap) print(f"✅ UPDOW env_config INSERT id={eid} keys={list(patch.keys())}") finally: db.close() if code_h: _apply_updow_stock(code_h, row, tf=tf, dry_run=False) else: print("⚠️ JSON 에 code 없음 → updow_stock_config 건너뜀") return 0 # ── top[] (Grid) 또는 results[] (Optuna) — MOMENTUM / SCALP / BREAKOUT ── ranked = _ranked_items(data) if not ranked: print("❌ JSON 에 top[] 또는 results[] 배열이 없습니다.") return 8 if rank > len(ranked): print(f"❌ rank 범위 초과 (1~{len(ranked)})") return 5 item = ranked[rank - 1] if str(data.get("engine") or "").lower() == "optuna": print(f" (Optuna results[{rank - 1}], trial=#{item.get('optuna_trial_number')})") pnl = int(item.get("total_pnl") or 0) if pnl <= 0 and not args.allow_non_positive_pnl: print(f"⚠️ total_pnl={pnl} ≤ 0 → 중단. 적용하려면 --allow-non-positive-pnl") return 6 patch: Dict[str, str] = {} if strategy == "MOMENTUM": merged = merge_param_search_apply_source(item, data) patch = _patch_from_momentum_merged(merged) patch.update(session_env_patch("MOMENTUM", merged)) patch = strip_portfolio_keys_from_apply_patch(patch, "MOMENTUM") elif strategy == "US_MOMENTUM": from kis_trader.engine.us_momentum_env_keys import params_to_us_momentum_env_patch merged = merge_param_search_apply_source(item, data) patch = params_to_us_momentum_env_patch(merged) patch.update(session_env_patch("US_MOMENTUM", merged)) patch = strip_portfolio_keys_from_apply_patch(patch, "US_MOMENTUM") patch = {k: v for k, v in patch.items() if str(k).startswith("US_MOMENTUM_")} elif strategy == "BREAKOUT": merged = merge_param_search_apply_source(item, data) patch = _patch_from_breakout_merged(merged) patch.update(session_env_patch("BREAKOUT", merged)) patch = strip_portfolio_keys_from_apply_patch(patch, "BREAKOUT") elif strategy == "SCALP": merged = merge_param_search_apply_source(item, data) ds = item.get("db_snapshot") if isinstance(ds, dict) and ds: # 구 JSON db_snapshot 에 포트폴리오(600만 등)가 있어도 apply 시 제외 patch = {str(k): str(v) for k, v in ds.items() if v not in (None, "")} else: try: from kis_trader.backtest import param_search_scalping as pss # noqa: WPS433 patch = pss._params_to_db_snapshot(merged) except Exception as e: print(f"❌ SCALP 스냅샷 생성 실패: {e}") return 9 patch = strip_portfolio_keys_from_apply_patch(patch, "SCALP") if not patch: print("❌ 적용할 패치가 비어 있습니다.") return 10 meta_line = ( f" 기간={data.get('start')}~{data.get('end')} mode={data.get('mode')} " f"pnl={item.get('total_pnl')} win={item.get('win_rate')}% trades={item.get('total_trades')}" ) print(meta_line) if args.dry_run: print("[dry-run] patch keys:", len(patch)) print(json.dumps(patch, ensure_ascii=False, indent=2)) return 0 eid = apply_env_patch(patch) if eid is None: print("❌ insert_env_snapshot 실패") return 11 print(f"✅ config_* + env_config INSERT id={eid} 갱신 키 수={len(patch)}") return 0 if __name__ == "__main__": raise SystemExit(main())