#!/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,
    portfolio_env_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 ("MOMENTUM", "BREAKOUT", "SCALP", "TAIL", "UPDOW"):
        return s

    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("search_momentum_") or base.startswith("optuna_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["SCALP_MOM_RSI_MIN"] = patch["MOMENTUM_RSI_MIN"]
    patch["SCALP_MOM_RSI_MAX"] = patch["MOMENTUM_RSI_MAX"]

    patch["MOMENTUM_VOL_MULT"] = str(gf("mom_vol_mult", 1.5))
    patch["SCALP_MOM_VOL_MULT"] = patch["MOMENTUM_VOL_MULT"]

    patch["MOMENTUM_VOL_WIN"] = str(gi("mom_vol_win", 5))
    patch["SCALP_MOM_VOL_WIN"] = patch["MOMENTUM_VOL_WIN"]

    patch["MOMENTUM_TIME_END_HM"] = str(gi("mom_time_end_hm", 1430))
    patch["SCALP_MOM_TIME_END_HM"] = patch["MOMENTUM_TIME_END_HM"]

    ts = m.get("time_start_hm")
    if ts not in (None, ""):
        patch["MOMENTUM_TIME_START"] = str(int(float(ts)))

    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["SCALP_STOP_LOSS_PCT"] = sl_r
    patch["MOMENTUM_TAKE_PROFIT_PCT"] = tp_r
    patch["SCALP_TAKE_PROFIT_PCT"] = tp_r

    patch["SCALP_ATR_UP_MULT"] = str(abs(gf("trail_trigger", 0.7)) / 100.0)
    patch["SCALP_ATR_DOWN_MULT"] = str(abs(gf("trail_stop", 0.4)) / 100.0)

    patch["SCALP_COOLDOWN_SEC"] = str(int(float(gf("cooldown_min", 10)) * 60))

    md = str(gi("max_daily", 5))
    patch["MOMENTUM_MAX_DAILY"] = md
    patch["SCALP_MAX_DAILY"] = md

    sm = m.get("slot_money")
    if sm not in (None, ""):
        sms = str(int(float(sm)))
        patch["MOMENTUM_SLOT_MONEY"] = sms
        patch["SLOT_MONEY_DEFAULT"] = sms
        patch["MOMENTUM_MAX_BUY_AMOUNT"] = sms
        patch["MAX_BUY_AMOUNT_PER_STOCK"] = sms

    hc = gf("high_chase_thr", 0.96)
    ratio_hc = hc if 0 < hc <= 1 else hc / 100.0
    sr = str(ratio_hc)
    patch["HIGH_CHASE_THR"] = sr
    patch["SCALP_HIGH_PRICE_CHASE_THRESHOLD"] = sr
    patch["HIGH_PRICE_CHASE_THRESHOLD"] = sr

    vchg = str(gf("max_daily_chg", 20.0))
    patch["MAX_DAILY_CHG"] = vchg
    patch["SCALP_MAX_DAILY_CHANGE_PCT"] = vchg
    patch["MAX_DAILY_CHANGE_PCT"] = vchg

    mp = str(gf("min_price", 1000))
    patch["MOMENTUM_MIN_PRICE"] = mp
    patch["SCALP_MIN_PRICE"] = mp

    ml = str(int(float(m.get("max_loss_krw") or 200000)))
    patch["MOMENTUM_MAX_LOSS_PER_TRADE_KRW"] = ml
    patch["SCALP_MAX_LOSS_PER_TRADE_KRW"] = ml
    patch["MAX_LOSS_PER_TRADE_KRW"] = ml

    mm = str(gf("min_margin", 0.2))
    patch["MOMENTUM_MIN_PROFIT_PCT"] = mm
    patch["SCALP_MIN_PROFIT_PCT"] = mm

    if "use_defense_filters" in m:
        patch["SCALP_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, ""):
        sr = str(abs(float(x)) / 100.0)
        patch["MOMENTUM_TP_MAX_PCT"] = sr
        patch["SCALP_TP_MAX_PCT"] = sr

    x = m.get("shoulder_min_high")
    if x not in (None, ""):
        sr = str(abs(float(x)) / 100.0)
        patch["MOMENTUM_SHOULDER_MIN_HIGH_PCT"] = sr
        patch["SCALP_SHOULDER_MIN_HIGH_PCT"] = sr
        patch["SHOULDER_MIN_HIGH_PCT"] = sr

    x = m.get("shoulder_cut_pct")
    if x not in (None, ""):
        sr = str(abs(float(x)) / 100.0)
        patch["MOMENTUM_SHOULDER_CUT_PCT"] = sr
        patch["SCALP_SHOULDER_CUT_PCT"] = sr
        patch["SHOULDER_CUT_PCT"] = sr

    # 전용 트레일(momentum_engine) — UI 퍼센트(0.5) → 엔진 비율(0.005) 저장.
    # (기존엔 trail_trigger/stop=SCALP_ATR_*만 기록돼 MOMENTUM_TRAIL_PCT가 0으로 남던 버그 수정)
    x = m.get("trail_pct")
    if x not in (None, ""):
        patch["MOMENTUM_TRAIL_PCT"] = str(abs(float(x)) / 100.0)
    x = m.get("trail_arm_pct")
    if x not in (None, ""):
        patch["MOMENTUM_TRAIL_ARM_PCT"] = str(abs(float(x)) / 100.0)

    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("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)))

    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))

    x = gv("time_start_hm")
    if x is not None:
        patch["BREAKOUT_TIME_START"] = str(int(float(x)))
    x = gv("time_end_hm")
    if x is not None:
        te = str(int(float(x)))
        patch["BREAKOUT_TIME_END"] = te
        patch["BREAKOUT_GOLDEN_END_HM"] = te

    x = gv("max_daily")
    if x is not None:
        patch["BREAKOUT_MAX_DAILY"] = str(int(float(x)))
    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)))

    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(portfolio_env_patch("MOMENTUM", merged))
        patch.update(session_env_patch("MOMENTUM", merged))

    elif strategy == "BREAKOUT":
        merged = merge_param_search_apply_source(item, data)
        patch = _patch_from_breakout_merged(merged)
        patch.update(portfolio_env_patch("BREAKOUT", merged))
        patch.update(session_env_patch("BREAKOUT", merged))

    elif strategy == "SCALP":
        merged = merge_param_search_apply_source(item, data)
        ds = item.get("db_snapshot")
        if isinstance(ds, dict) and ds:
            patch = {str(k): str(v) for k, v in ds.items() if v not in (None, "")}
            patch.update(portfolio_env_patch("SCALP", merged))
        else:
            try:
                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

    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())
