"""
kis_trader/strategies/updow_buy.py — 「직전 분봉 음봉·몸통 하락 → 다음 봉 시가 매수」(Updow) 백테스트·파라미터 탐색 엔진
================================================================================================
- 기본값·그리드 끝값: ``env_config`` 최신 행의 ``UPDOW_*`` / ``UPDOW_GRID_*`` → 없으면 ``kis_trader.utils.env`` 폴백.
- **웹 백테·탐색·실매**: 종목별 ``updow_stock_config`` (없으면 ``UPDOW_*`` env 폴백). ``updow_holding_cfg.py`` 참고.
- **시장 레짐(선택)**: ``UPDOW_KOSPI_1MIN_PROXY_CODE``(기본 KODEX KOSPI 등 6자리) 1분봉 종가의
  ``regime_ma_bars`` 이동평균 — **종가 < SMA×(1−ease/100)** 이면(``ease``=``regime_ma_ease_pct``) 코스피 프록시 **하락세**로 보고 **신규 매수만** 스킵.
  ``ease``=0 이면 기존과 동일(종가<SMA). ``ease``>0 이면 MA 아래로 더 내려가야 차단(널널).
  한투 지수(U) 직접 1분봉이 아니라 **주식 분봉 API(J)** 로 ETF 프록시를 씀.
  - 신호 봉 si: 음봉(close < open) 이고, 몸통 하락률 (open-close)/open*100 ≥ body_drop_min_pct
  - 진입: 그 다음 봉 시가 (1봉 지연)
  - 청산(V4): 1순위 어깨컷 → 2순위 익절% → 3순위 손절% → (선택) 양봉 종가 → 최대 보유 봉 수
  - 백테 SL/TP/어깨: 분봉 OHLC intrabar(N회) + 실매 ``current_price`` 정렬; 양봉·보유한도는 봉 마감
  - **RSI·당일 시가→저가 낙폭·꼬리비율 등은 사용하지 않음** (꼬리잡기/단타와 다른 축).

구 ``updowbuyy.py`` 는 ``remove/legacy_standalone_bots/`` 로 이동 (수정 금지).
"""

from __future__ import annotations

from bisect import bisect_left
from datetime import datetime, timedelta
from itertools import product as iproduct
from typing import Any, Dict, List, Optional, Tuple

from ..engine.limit_entry_common import (
    compute_atr_limit_price,
    floor_limit_price_krw,
    is_limit_atr_entry,
    limit_valid_until_bar_key,
    resolve_limit_anchor_price,
    try_limit_fill_on_bar,
    updow_entry_mode,
    updow_limit_params,
)
from ..utils.env import get_env_bool, get_env_float, get_env_int

# 엔진이 다루는 UI/백테 공통 키 (holding_bot·웹 쿼리 파라미터 이름과 동일)
CFG_ENGINE_KEYS: Tuple[str, ...] = (
    "body_drop_min_pct",
    "body_drop_max_pct",
    "tp_pct",
    "stop_loss_pct",
    "atr_use_dynamic",
    "atr_period",
    "atr_sl_mult",
    "atr_tp_mult",
    "atr_sl_min_pct",
    "atr_sl_max_pct",
    "atr_tp_min_pct",
    "atr_tp_max_pct",
    "max_hold_bars",
    "exit_on_green",
    "slot_money",
    "regime_ma_bars",
    "regime_ma_ease_pct",
    "shoulder_min_high_pct",
    "shoulder_cut_pct",
    # 종목별 지정가 깊이(anchor−ATR×배수) — updow_stock_config 컬럼으로 저장/적용
    "limit_atr_mult",
)

# 문서·구버전 호환용 정적 폴백 (실행 시 값은 cfg_from_env_snapshot 우선)
DEFAULT_UPDOW_CONFIG: Dict[str, float] = {
    "body_drop_min_pct": 0.0,
    # 하락률 상한(%) — 0=OFF(상한 없음). 양수면 한 봉에 이 % 초과 폭락한
    # 종목은 신호에서 제외 → 악재성 칼날잡기 완화 (백테·실매 동일 적용)
    "body_drop_max_pct": 0.0,
    "tp_pct": 3.0,
    "stop_loss_pct": 2.0,
    "atr_use_dynamic": 0.0,
    "atr_period": 14.0,
    "atr_sl_mult": 2.0,
    "atr_tp_mult": 4.0,
    "atr_sl_min_pct": 0.8,
    "atr_sl_max_pct": 6.0,
    "atr_tp_min_pct": 1.5,
    "atr_tp_max_pct": 12.0,
    "max_hold_bars": 16.0,
    "exit_on_green": 0.0,
    "slot_money": 3_000_000.0,
    "regime_ma_bars": 0.0,
    "regime_ma_ease_pct": 0.0,
    "shoulder_min_high_pct": 0.3,
    "shoulder_cut_pct": 0.2,
    # 지정가 깊이 기본 1.5 (env UPDOW_LIMIT_ATR_MULT 미설정 시와 동일 — 기존 동작 보존)
    "limit_atr_mult": 1.5,
}

UPDOW_GRID_AXIS_HINTS_KO: Dict[str, str] = {
    "body_drop_min_pct": (
        "직전 봉이 음봉일 때 몸통 하락률(%) 하한 — (시가−종가)/시가×100 이 값 이상이면 "
        "「하락 충분」으로 보고 다음 봉 시가 매수 신호(그리드에서 여러 하한값을 탐색)"
    ),
    "body_drop_max_pct": (
        "몸통 하락률(%) 상한 — 0=OFF(상한 없음). 양수면 한 봉에 이 % 초과 폭락한 종목은 "
        "신호 제외(악재성 떨어지는 칼날잡기 완화). 하한<하락률≤상한 구간만 매수"
    ),
    "tp_pct": (
        "목표 익절률(%) — 매수가 대비 종가·익절 로직 기준 상한(그리드: 여러 익절% 후보)"
    ),
    "stop_loss_pct": (
        "고정 손절률(%) — 매수가 대비 이 비율 이상 손실 시 청산(그리드: 손절 폭 후보)"
    ),
    "atr_use_dynamic": (
        "ATR 동적 청산 사용 여부 — 1=ON: ATR 기반으로 손절/익절 폭 산출, 0=OFF: 고정 % 사용"
    ),
    "atr_period": (
        "ATR 기간(봉) — 최근 변동성 측정 창. 짧을수록 민감(예: 7), 길수록 완만(예: 14)"
    ),
    "atr_sl_mult": (
        "손절 ATR 배수 — 손절폭(%)=ATR×배수/진입가×100 (min/max 캡으로 제한)"
    ),
    "atr_tp_mult": (
        "익절 ATR 배수 — 익절폭(%)=ATR×배수/진입가×100 (min/max 캡으로 제한)"
    ),
    "atr_sl_min_pct": "ATR 손절 하한(%) — 노이즈 구간에서 과도하게 타이트해지는 것 방지",
    "atr_sl_max_pct": "ATR 손절 상한(%) — 비정상 급변동에서 과도하게 넓어지는 것 방지",
    "atr_tp_min_pct": "ATR 익절 하한(%)",
    "atr_tp_max_pct": "ATR 익절 상한(%)",
    "max_hold_bars": (
        "최대 보유 봉 수 — 진입 후 이 봉 수를 넘기면 다음 봉 시가 청산(그리드: 보유 기간 후보)"
    ),
    "exit_on_green": (
        "양봉 청산 사용 여부 — 1=ON: 종가>시가인 봉에서 청산, 0=OFF(그리드: ON/OFF 비교)"
    ),
    "regime_ma_bars": (
        "KOSPI 프록시 1분 종가의 단순 이동평균(분) 봉 수 — 0=OFF. "
        "양수일 때 종가<SMA×(1−ease/100)이면 신규 매수만 일시 정지, 그 위로 올라오면 재개(백테·실매 동일)"
    ),
    "regime_ma_ease_pct": (
        "레짐 MA 완화(%) — 0이면 종가<SMA일 때만 차단. "
        "양수이면 SMA를 (1−ease/100)배로 낮춘 기준선 아래로 더 내려가야 차단(그리드·env로 탐색)"
    ),
    "shoulder_min_high_pct": (
        "어깨컷 발동(%) — 매수가 대비 고가가 이 % 이상 올라가면 트레일링 어깨컷 무장"
    ),
    "shoulder_cut_pct": (
        "어깨컷 폭(%) — 무장 후 고점 대비 이 % 되돌림 시 1순위 청산(모멘텀·스캘핑 V4와 동일 개념)"
    ),
    "limit_atr_mult": (
        "ATR 지정가 깊이(anchor−ATR×배수) — 클수록 체결↓·진입가↓ (limit_atr 모드, env 고정)"
    ),
}


def _grid_limit_atr_mult_values(snap: Optional[Dict[str, Any]] = None) -> List[float]:
    """그리드 끝값 — ``UPDOW_GRID_LIMIT_ATR0/1/2`` (파라서치·웹 탐색)."""
    return [
        _read_snap_float(snap, "UPDOW_GRID_LIMIT_ATR0", 1.0),
        _read_snap_float(snap, "UPDOW_GRID_LIMIT_ATR1", 1.5),
        _read_snap_float(snap, "UPDOW_GRID_LIMIT_ATR2", 2.0),
    ]


def _strip_comment(val: Any) -> Any:
    if isinstance(val, str) and "#" in val:
        return val.split("#", 1)[0].strip()
    return val


def _read_snap_float(snap: Optional[Dict[str, Any]], env_key: str, default: float) -> float:
    """env_config 스냅샷에 값이 있으면 우선, 없으면 get_env_float(DB→os→기본)."""
    if snap:
        raw = snap.get(env_key)
        raw = _strip_comment(raw)
        if raw is not None and str(raw).strip() != "":
            try:
                return float(raw)
            except (TypeError, ValueError):
                pass
    return get_env_float(env_key, default)


def _read_snap_int(snap: Optional[Dict[str, Any]], env_key: str, default: int) -> int:
    if snap:
        raw = snap.get(env_key)
        raw = _strip_comment(raw)
        if raw is not None and str(raw).strip() != "":
            try:
                return int(float(raw))
            except (TypeError, ValueError):
                pass
    return get_env_int(env_key, default)


def clamp_regime_ma_ease_pct(raw: Any) -> float:
    """레짐 MA 완화율(%) — 0 이상, 상한은 env ``UPDOW_REGIME_MA_EASE_CAP`` (기본 2.5)."""
    try:
        v = float(raw)
    except (TypeError, ValueError):
        v = 0.0
    cap = get_env_float("UPDOW_REGIME_MA_EASE_CAP", 2.5)
    return max(0.0, min(v, cap))


def cfg_from_env_snapshot(snap: Optional[Dict[str, Any]] = None) -> Dict[str, float]:
    """env_config 스냅샷(또는 None)에서 엔진 cfg 생성. None이면 DB/env/os 순으로 조회."""
    exit_on = 1.0 if get_env_bool("UPDOW_EXIT_ON_GREEN", False) else 0.0
    if snap:
        raw = snap.get("UPDOW_EXIT_ON_GREEN")
        raw = _strip_comment(raw)
        if raw is not None and str(raw).strip() != "":
            s = str(raw).strip().lower()
            if s in ("true", "1", "yes", "y", "on"):
                exit_on = 1.0
            elif s in ("false", "0", "no", "n", "off"):
                exit_on = 0.0
            else:
                try:
                    exit_on = 1.0 if float(raw) >= 0.5 else 0.0
                except (TypeError, ValueError):
                    exit_on = 1.0 if get_env_bool("UPDOW_EXIT_ON_GREEN", False) else 0.0
    _probe: Dict[str, Any] = {}
    if snap:
        raw_em = _strip_comment(snap.get("UPDOW_ENTRY_MODE"))
        if raw_em is not None and str(raw_em).strip():
            _probe["entry_mode"] = str(raw_em).strip().lower()
        for js_key, env_key, as_int in (
            ("limit_atr_mult", "UPDOW_LIMIT_ATR_MULT", False),
            ("limit_anchor", "UPDOW_LIMIT_ANCHOR", False),
            ("limit_valid_bars", "UPDOW_LIMIT_VALID_BARS", True),
            ("limit_fill_slip_pct", "UPDOW_LIMIT_FILL_SLIP_PCT", False),
        ):
            raw = _strip_comment(snap.get(env_key))
            if raw is None or str(raw).strip() == "":
                continue
            if as_int:
                try:
                    _probe[js_key] = int(float(raw))
                except (TypeError, ValueError):
                    pass
            elif js_key == "limit_anchor":
                _probe[js_key] = str(raw).strip().lower()
            else:
                try:
                    _probe[js_key] = float(raw)
                except (TypeError, ValueError):
                    pass
    _entry_m = updow_entry_mode(_probe if _probe else None)
    _probe["entry_mode"] = _entry_m
    _lp = updow_limit_params(_probe)

    return {
        "body_drop_min_pct": _read_snap_float(snap, "UPDOW_BODY_DROP_MIN_PCT", DEFAULT_UPDOW_CONFIG["body_drop_min_pct"]),
        "body_drop_max_pct": _read_snap_float(snap, "UPDOW_BODY_DROP_MAX_PCT", DEFAULT_UPDOW_CONFIG["body_drop_max_pct"]),
        "tp_pct": _read_snap_float(snap, "UPDOW_TP_PCT", DEFAULT_UPDOW_CONFIG["tp_pct"]),
        "stop_loss_pct": _read_snap_float(snap, "UPDOW_STOP_LOSS_PCT", DEFAULT_UPDOW_CONFIG["stop_loss_pct"]),
        "atr_use_dynamic": _read_snap_float(snap, "UPDOW_ATR_USE_DYNAMIC", DEFAULT_UPDOW_CONFIG["atr_use_dynamic"]),
        "atr_period": float(_read_snap_int(snap, "UPDOW_ATR_PERIOD", int(DEFAULT_UPDOW_CONFIG["atr_period"]))),
        "atr_sl_mult": _read_snap_float(snap, "UPDOW_ATR_SL_MULT", DEFAULT_UPDOW_CONFIG["atr_sl_mult"]),
        "atr_tp_mult": _read_snap_float(snap, "UPDOW_ATR_TP_MULT", DEFAULT_UPDOW_CONFIG["atr_tp_mult"]),
        "atr_sl_min_pct": _read_snap_float(snap, "UPDOW_ATR_SL_MIN_PCT", DEFAULT_UPDOW_CONFIG["atr_sl_min_pct"]),
        "atr_sl_max_pct": _read_snap_float(snap, "UPDOW_ATR_SL_MAX_PCT", DEFAULT_UPDOW_CONFIG["atr_sl_max_pct"]),
        "atr_tp_min_pct": _read_snap_float(snap, "UPDOW_ATR_TP_MIN_PCT", DEFAULT_UPDOW_CONFIG["atr_tp_min_pct"]),
        "atr_tp_max_pct": _read_snap_float(snap, "UPDOW_ATR_TP_MAX_PCT", DEFAULT_UPDOW_CONFIG["atr_tp_max_pct"]),
        "max_hold_bars": float(_read_snap_int(snap, "UPDOW_MAX_HOLD_BARS", int(DEFAULT_UPDOW_CONFIG["max_hold_bars"]))),
        "exit_on_green": exit_on,
        "slot_money": _read_snap_float(snap, "UPDOW_SLOT_MONEY", DEFAULT_UPDOW_CONFIG["slot_money"]),
        "regime_ma_bars": float(_read_snap_int(snap, "UPDOW_REGIME_MA_BARS", int(DEFAULT_UPDOW_CONFIG["regime_ma_bars"]))),
        "regime_ma_ease_pct": _read_snap_float(snap, "UPDOW_REGIME_MA_EASE_PCT", DEFAULT_UPDOW_CONFIG["regime_ma_ease_pct"]),
        "shoulder_min_high_pct": _read_snap_float(
            snap, "UPDOW_SHOULDER_MIN_HIGH_PCT", DEFAULT_UPDOW_CONFIG["shoulder_min_high_pct"],
        ),
        "shoulder_cut_pct": _read_snap_float(
            snap, "UPDOW_SHOULDER_CUT_PCT", DEFAULT_UPDOW_CONFIG["shoulder_cut_pct"],
        ),
        "entry_mode": _entry_m,
        "limit_atr_mult": _lp["mult"],
        "limit_anchor": _lp["anchor"],
        "limit_valid_bars": int(_lp["valid_bars"]),
        "limit_fill_slip_pct": float(_lp["fill_slip_pct"]),
    }


def env_snapshot_patch_from_engine_cfg(
    cfg: Dict[str, Any],
    tf_min: Optional[int] = None,
) -> Dict[str, str]:
    """insert_env_snapshot 병합용 — UPDOW_* 키만 str 로 반환."""
    out: Dict[str, str] = {}
    if "body_drop_min_pct" in cfg and cfg["body_drop_min_pct"] is not None:
        out["UPDOW_BODY_DROP_MIN_PCT"] = str(float(cfg["body_drop_min_pct"]))
    if "body_drop_max_pct" in cfg and cfg["body_drop_max_pct"] is not None:
        out["UPDOW_BODY_DROP_MAX_PCT"] = str(float(cfg["body_drop_max_pct"]))
    if "tp_pct" in cfg and cfg["tp_pct"] is not None:
        out["UPDOW_TP_PCT"] = str(float(cfg["tp_pct"]))
    if "stop_loss_pct" in cfg and cfg["stop_loss_pct"] is not None:
        out["UPDOW_STOP_LOSS_PCT"] = str(float(cfg["stop_loss_pct"]))
    if "atr_use_dynamic" in cfg and cfg["atr_use_dynamic"] is not None:
        out["UPDOW_ATR_USE_DYNAMIC"] = "1" if float(cfg["atr_use_dynamic"]) >= 0.5 else "0"
    if "atr_period" in cfg and cfg["atr_period"] is not None:
        out["UPDOW_ATR_PERIOD"] = str(int(float(cfg["atr_period"])))
    if "atr_sl_mult" in cfg and cfg["atr_sl_mult"] is not None:
        out["UPDOW_ATR_SL_MULT"] = str(float(cfg["atr_sl_mult"]))
    if "atr_tp_mult" in cfg and cfg["atr_tp_mult"] is not None:
        out["UPDOW_ATR_TP_MULT"] = str(float(cfg["atr_tp_mult"]))
    if "atr_sl_min_pct" in cfg and cfg["atr_sl_min_pct"] is not None:
        out["UPDOW_ATR_SL_MIN_PCT"] = str(float(cfg["atr_sl_min_pct"]))
    if "atr_sl_max_pct" in cfg and cfg["atr_sl_max_pct"] is not None:
        out["UPDOW_ATR_SL_MAX_PCT"] = str(float(cfg["atr_sl_max_pct"]))
    if "atr_tp_min_pct" in cfg and cfg["atr_tp_min_pct"] is not None:
        out["UPDOW_ATR_TP_MIN_PCT"] = str(float(cfg["atr_tp_min_pct"]))
    if "atr_tp_max_pct" in cfg and cfg["atr_tp_max_pct"] is not None:
        out["UPDOW_ATR_TP_MAX_PCT"] = str(float(cfg["atr_tp_max_pct"]))
    if "max_hold_bars" in cfg and cfg["max_hold_bars"] is not None:
        out["UPDOW_MAX_HOLD_BARS"] = str(int(float(cfg["max_hold_bars"])))
    if "exit_on_green" in cfg and cfg["exit_on_green"] is not None:
        v = float(cfg["exit_on_green"])
        out["UPDOW_EXIT_ON_GREEN"] = "1" if v >= 0.5 else "0"
    if "slot_money" in cfg and cfg["slot_money"] is not None:
        out["UPDOW_SLOT_MONEY"] = str(int(float(cfg["slot_money"])))
    if "regime_ma_bars" in cfg and cfg["regime_ma_bars"] is not None:
        out["UPDOW_REGIME_MA_BARS"] = str(int(float(cfg["regime_ma_bars"])))
    if "regime_ma_ease_pct" in cfg and cfg["regime_ma_ease_pct"] is not None:
        out["UPDOW_REGIME_MA_EASE_PCT"] = str(float(cfg["regime_ma_ease_pct"]))
    if "shoulder_min_high_pct" in cfg and cfg["shoulder_min_high_pct"] is not None:
        out["UPDOW_SHOULDER_MIN_HIGH_PCT"] = str(float(cfg["shoulder_min_high_pct"]))
    if "shoulder_cut_pct" in cfg and cfg["shoulder_cut_pct"] is not None:
        out["UPDOW_SHOULDER_CUT_PCT"] = str(float(cfg["shoulder_cut_pct"]))
    if cfg.get("entry_mode") is not None:
        out["UPDOW_ENTRY_MODE"] = str(cfg["entry_mode"]).strip().lower()
    for js_key, env_key in (
        ("limit_atr_mult", "UPDOW_LIMIT_ATR_MULT"),
        ("limit_anchor", "UPDOW_LIMIT_ANCHOR"),
        ("limit_valid_bars", "UPDOW_LIMIT_VALID_BARS"),
        ("limit_fill_slip_pct", "UPDOW_LIMIT_FILL_SLIP_PCT"),
    ):
        if js_key in cfg and cfg[js_key] is not None:
            out[env_key] = str(cfg[js_key])
    if tf_min is not None:
        out["UPDOW_TF_MIN"] = str(int(tf_min))
    return out


def default_param_grid(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]:
    """그리드 축 — 끝값은 env_config ``UPDOW_GRID_*`` (또는 get_env_float 폴백). 조합 수 ≈ 9000."""
    return {
        "body_drop_min_pct": [
            0.0,
            _read_snap_float(snap, "UPDOW_GRID_BODY0", 0.3),
            _read_snap_float(snap, "UPDOW_GRID_BODY1", 0.6),
            _read_snap_float(snap, "UPDOW_GRID_BODY2", 1.0),
            _read_snap_float(snap, "UPDOW_GRID_BODY3", 1.5),
        ],
        "tp_pct": [
            _read_snap_float(snap, "UPDOW_GRID_TP0", 2.0),
            _read_snap_float(snap, "UPDOW_GRID_TP1", 3.0),
            _read_snap_float(snap, "UPDOW_GRID_TP2", 4.0),
            _read_snap_float(snap, "UPDOW_GRID_TP3", 6.0),
            _read_snap_float(snap, "UPDOW_GRID_TP4", 8.0),
        ],
        "stop_loss_pct": [
            _read_snap_float(snap, "UPDOW_GRID_SL0", 1.5),
            _read_snap_float(snap, "UPDOW_GRID_SL1", 2.0),
            _read_snap_float(snap, "UPDOW_GRID_SL2", 2.5),
            _read_snap_float(snap, "UPDOW_GRID_SL3", 3.0),
        ],
        # ATR 동적 청산 on/off 비교 (배수/기간은 베이스 cfg를 사용)
        "atr_use_dynamic": [0.0, 1.0],
        "max_hold_bars": [
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD0", 8)),
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD1", 16)),
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD2", 32)),
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD3", 48)),
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD4", 64)),
        ],
        "exit_on_green": [0.0, 1.0],
        "regime_ma_bars": [
            0.0,
            float(_read_snap_int(snap, "UPDOW_GRID_REGIME0", 60)),
            float(_read_snap_int(snap, "UPDOW_GRID_REGIME1", 120)),
        ],
        "regime_ma_ease_pct": [
            0.0,
            _read_snap_float(snap, "UPDOW_GRID_REGIME_EASE0", 0.15),
            _read_snap_float(snap, "UPDOW_GRID_REGIME_EASE1", 0.30),
        ],
        "shoulder_min_high_pct": [
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN0", 0.2),
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN1", 0.3),
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN2", 0.5),
        ],
        "shoulder_cut_pct": [
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT0", 0.15),
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT1", 0.2),
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT2", 0.3),
        ],
        "limit_atr_mult": _grid_limit_atr_mult_values(snap),
    }


def default_param_grid_web_fast(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]:
    """
    웹 탐색 전용 경량 그리드.
    - 브라우저 타임아웃/프록시 타임아웃 방지 목적
    - ATR on/off 비교는 유지하되 조합 수를 크게 줄임
    """
    return {
        "body_drop_min_pct": [
            0.0,
            _read_snap_float(snap, "UPDOW_GRID_BODY1", 0.6),
            _read_snap_float(snap, "UPDOW_GRID_BODY3", 1.5),
        ],
        "tp_pct": [
            _read_snap_float(snap, "UPDOW_GRID_TP1", 3.0),
            _read_snap_float(snap, "UPDOW_GRID_TP2", 4.0),
            _read_snap_float(snap, "UPDOW_GRID_TP3", 6.0),
        ],
        "stop_loss_pct": [
            _read_snap_float(snap, "UPDOW_GRID_SL0", 1.5),
            _read_snap_float(snap, "UPDOW_GRID_SL1", 2.0),
            _read_snap_float(snap, "UPDOW_GRID_SL2", 2.5),
        ],
        "atr_use_dynamic": [0.0, 1.0],
        "max_hold_bars": [
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD1", 1)),
            float(_read_snap_int(snap, "UPDOW_GRID_HOLD2", 2)),
        ],
        "exit_on_green": [0.0, 1.0],
        "regime_ma_bars": [
            0.0,
            float(_read_snap_int(snap, "UPDOW_GRID_REGIME0", 60)),
        ],
        "regime_ma_ease_pct": [0.0],
        "shoulder_min_high_pct": [
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN1", 0.3),
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_SMIN2", 0.5),
        ],
        "shoulder_cut_pct": [
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT1", 0.2),
            _read_snap_float(snap, "UPDOW_GRID_SHOULDER_CUT2", 0.3),
        ],
        "limit_atr_mult": _grid_limit_atr_mult_values(snap),
    }


def default_param_grid_us(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]:
    """
    해외(US) UPDOW 그리드 — 코스피 레짐(069500) 축 제외.
    ``regime_ma_bars`` / ``regime_ma_ease_pct`` 는 0(OFF)만 탐색.
    """
    g = default_param_grid(snap)
    g["regime_ma_bars"] = [0.0]
    g["regime_ma_ease_pct"] = [0.0]
    return g


def read_us_fast_max_combos(snap: Optional[Dict[str, Any]] = None) -> int:
    """US fast 탐색 조합 상한 — ``UPDOW_US_FAST_MAX_COMBOS`` (기본 2880 ≈ 10~15분).

    1회 백테 ≈ 0.25초(60분 400봉) 기준 2880조합 ≈ 약 12분.
    봉 수가 많은(장기) 백테면 비례해서 길어지므로 env로 조절.
    """
    return _read_snap_int(snap, "UPDOW_US_FAST_MAX_COMBOS", 2880)


def default_param_grid_us_fast(snap: Optional[Dict[str, Any]] = None) -> Dict[str, List[float]]:
    """
    해외(US) 전용 fast 그리드 — 저변동 지수 ETF(QQQM·SPYM 등) 15분봉 맞춤.
    - **레짐 OFF 고정**(코스피 069500 미사용).
    - **limit_atr_mult 에 낮은 값(0.0~0.5) 포함**: 지수 ETF는 변동성이 낮아 깊은 지정가
      (저점−ATR×1.5)는 거의 체결이 안 됨 → 체결률을 살리려면 얕은 지정가가 필수.
    - **인덱스 dip-buy 특성 반영**: 눌림이 얕으므로 body_drop 하한을 낮게(0~0.3),
      tp/sl 도 저변동 폭(0.8~4 / 0.6~1.8), 그리고 "회복까지 보유"를 위해 max_hold 를
      15분봉 기준으로 길게(8/16/32봉 ≈ 2/4/8시간) 잡는다. (60분봉이면 그만큼 더 김)
    - 카르테시안 = limit(4)×body(4)×tp(5)×sl(3)×hold(3)×atr(2)×green(2) = **2,880조합**.
    - 끝값은 ``UPDOW_US_GRID_*`` env_config 로 조절 (하드코딩 금지 원칙 준수).
    """
    return {
        # 지정가 깊이(anchor−ATR×배수) — 0=저점 그대로(체결↑), 클수록 더 아래(체결↓·진입가↓)
        # 저변동 ETF는 얕게(0~1.0) — 깊으면 체결 자체가 안 됨
        "limit_atr_mult": [
            _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR0", 0.0),
            _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR1", 0.3),
            _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR2", 0.5),
            _read_snap_float(snap, "UPDOW_US_GRID_LIMIT_ATR3", 1.0),
        ],
        # 몸통 하락 하한 — 인덱스는 눌림이 얕음 → 0~0.3 위주 (0=음봉이면 무조건 신호)
        "body_drop_min_pct": [
            _read_snap_float(snap, "UPDOW_US_GRID_BODY0", 0.0),
            _read_snap_float(snap, "UPDOW_US_GRID_BODY1", 0.1),
            _read_snap_float(snap, "UPDOW_US_GRID_BODY2", 0.2),
            _read_snap_float(snap, "UPDOW_US_GRID_BODY3", 0.3),
        ],
        # 익절 — 저변동 ETF 15분봉 폭에 맞춰 작게~중간 (회복 반등 목표)
        "tp_pct": [
            _read_snap_float(snap, "UPDOW_US_GRID_TP0", 0.8),
            _read_snap_float(snap, "UPDOW_US_GRID_TP1", 1.2),
            _read_snap_float(snap, "UPDOW_US_GRID_TP2", 1.8),
            _read_snap_float(snap, "UPDOW_US_GRID_TP3", 2.5),
            _read_snap_float(snap, "UPDOW_US_GRID_TP4", 4.0),
        ],
        # 손절 — 저변동이라 타이트하게 (노이즈 손절 방지 위해 0.6 하한)
        "stop_loss_pct": [
            _read_snap_float(snap, "UPDOW_US_GRID_SL0", 0.6),
            _read_snap_float(snap, "UPDOW_US_GRID_SL1", 1.0),
            _read_snap_float(snap, "UPDOW_US_GRID_SL2", 1.8),
        ],
        # 보유 봉 수 — 인덱스 "회복까지 보유" → 15분봉 기준 길게 (2/4/8시간)
        "max_hold_bars": [
            float(_read_snap_int(snap, "UPDOW_US_GRID_HOLD0", 8)),
            float(_read_snap_int(snap, "UPDOW_US_GRID_HOLD1", 16)),
            float(_read_snap_int(snap, "UPDOW_US_GRID_HOLD2", 32)),
        ],
        # ATR 동적 청산 on/off 비교 (배수/기간은 베이스 cfg)
        "atr_use_dynamic": [0.0, 1.0],
        # 양봉 청산 on/off 비교 (저변동 ETF에서 효과 큼)
        "exit_on_green": [0.0, 1.0],
        # 고정축 (조합 수 영향 없음) — 레짐 OFF
        "regime_ma_bars": [0.0],
        "regime_ma_ease_pct": [0.0],
    }


def _downsample_combos_uniform(
    combos: List[Tuple[Any, ...]],
    max_combos: int,
) -> Tuple[List[Tuple[Any, ...]], int]:
    """조합 수가 클 때 균등 간격으로 샘플링."""
    cap = int(max_combos)
    total = len(combos)
    if cap <= 0 or total <= cap:
        return combos, 0
    if cap == 1:
        return [combos[0]], total - 1
    last = total - 1
    picked: List[Tuple[Any, ...]] = []
    for i in range(cap):
        idx = int(round(i * last / (cap - 1)))
        picked.append(combos[idx])
    dropped = total - len(picked)
    return picked, max(0, dropped)


def _parse_bar_datetime(val: Any) -> Optional[datetime]:
    """holding_min_candles.candle_date 문자열 → datetime (실패 시 None)."""
    s = str(val or "").strip()
    if not s:
        return None
    if len(s) >= 19 and s[4] == "-" and s[10] in " T":
        try:
            return datetime.strptime(s[:19].replace("T", " "), "%Y-%m-%d %H:%M:%S")
        except ValueError:
            return None
    digits = "".join(ch for ch in s if ch.isdigit())
    if len(digits) >= 12:
        try:
            return datetime.strptime(digits[:12], "%Y%m%d%H%M")
        except ValueError:
            return None
    return None


def precompute_regime_pause_buy_flags(
    stock_candles: List[Dict[str, Any]],
    regime_candles: List[Dict[str, Any]],
    ma_bars: int,
    stock_tf_min: int,
    ease_pct: float = 0.0,
) -> Optional[List[bool]]:
    """
    각 주식 봉 인덱스 ``si`` 에 대해, 해당 봉 구간 종료 시점까지의 KOSPI **프록시** 1분 종가가
    ``ma_bars`` SMA 기준 **차단선**(SMA×(1−ease/100)) **미만**이면 True (신규 매수 스킵).

    ``regime_candles`` 가 비어 있거나 ``ma_bars`` < 1 이면 None (필터 미적용).
    """
    mb = int(ma_bars)
    if mb < 1 or not regime_candles:
        return None
    ease = clamp_regime_ma_ease_pct(ease_pct)
    pairs: List[Tuple[datetime, float]] = []
    for c in regime_candles:
        dt = _parse_bar_datetime(c.get("candle_date"))
        if dt is None:
            continue
        try:
            cl = float(c.get("close", 0) or 0)
        except (TypeError, ValueError):
            continue
        if cl <= 0:
            continue
        pairs.append((dt, cl))
    if not pairs:
        return None
    pairs.sort(key=lambda x: x[0])
    times = [p[0] for p in pairs]
    closes = [p[1] for p in pairs]
    tfm = max(1, int(stock_tf_min))
    out = [False] * len(stock_candles)
    for si in range(len(stock_candles)):
        st = _parse_bar_datetime(stock_candles[si].get("candle_date"))
        if st is None:
            continue
        t_cut = st + timedelta(minutes=tfm)
        j = bisect_left(times, t_cut) - 1
        if j < mb - 1:
            continue
        s0 = j - mb + 1
        sma = sum(closes[s0 : j + 1]) / float(mb)
        floor = sma * (1.0 - ease / 100.0)
        if closes[j] < floor:
            out[si] = True
    return out


def kospi_proxy_regime_block_state(
    closes: List[float], ma_bars: int, ease_pct: float = 0.0
) -> Optional[Tuple[bool, float, float, float, float]]:
    """
    (차단여부, 마지막종가, SMA, 차단선가격, 적용 ease(%)).

    봉 부족 시 None — 실매 로그·백테 공통.
    """
    mb = int(ma_bars)
    if mb < 1 or len(closes) < mb:
        return None
    ease = clamp_regime_ma_ease_pct(ease_pct)
    tail = closes[-mb:]
    sma = sum(tail) / float(mb)
    last = float(closes[-1])
    floor = sma * (1.0 - ease / 100.0)
    blocks = last < floor
    return (blocks, last, sma, floor, ease)


def kospi_proxy_regime_blocks_new_buy_from_closes(
    closes: List[float], ma_bars: int, ease_pct: float = 0.0,
) -> bool:
    """
    실시간용: 1분 종가 시계열(오름차순)이 있을 때 마지막 종가 < SMA×(1−ease/100) 이면 True.
    ``ease_pct``=0 이면 기존과 동일(종가<SMA). 데이터 부족 시 False (차단 안 함).
    """
    st = kospi_proxy_regime_block_state(closes, ma_bars, ease_pct)
    return bool(st and st[0])


def _compute_atr_series(candles: List[Dict[str, Any]], period: int) -> List[Optional[float]]:
    """
    ATR (Wilder) 시리즈.
    - TR = max(high-low, abs(high-prev_close), abs(low-prev_close))
    - ATR: 첫 period 는 단순평균, 이후 Wilder smoothing
    """
    n = len(candles)
    out: List[Optional[float]] = [None] * n
    if n < 2 or period < 1:
        return out

    highs = [float(c.get("high", 0) or 0) for c in candles]
    lows = [float(c.get("low", 0) or 0) for c in candles]
    closes = [float(c.get("close", 0) or 0) for c in candles]

    trs: List[float] = [0.0] * n
    for i in range(1, n):
        h = highs[i]
        l = lows[i]
        pc = closes[i - 1]
        tr = max(h - l, abs(h - pc), abs(l - pc))
        trs[i] = max(0.0, tr)

    if n <= period:
        return out
    seed = trs[1 : period + 1]
    atr = sum(seed) / float(period)
    out[period] = atr
    for i in range(period + 1, n):
        atr = ((atr * (period - 1)) + trs[i]) / float(period)
        out[i] = atr
    return out


def _effective_exit_pcts(
    cfg: Dict[str, Any],
    entry_price: float,
    atr_value: Optional[float],
) -> Tuple[float, float, float]:
    """
    진입 시점 손절/익절 비율(소수) 산출.
    Returns:
      (sl_pct_dec, tp_pct_dec, atr_used)
    """
    exit_floor_dec = max(0.000001, get_env_float("UPDOW_EXIT_PCT_FLOOR", 0.01) / 100.0)
    sl_fix = max(exit_floor_dec, float(cfg.get("stop_loss_pct", DEFAULT_UPDOW_CONFIG["stop_loss_pct"])) / 100.0)
    tp_fix = max(exit_floor_dec, float(cfg.get("tp_pct", DEFAULT_UPDOW_CONFIG["tp_pct"])) / 100.0)
    use_atr = float(cfg.get("atr_use_dynamic", DEFAULT_UPDOW_CONFIG["atr_use_dynamic"])) >= 0.5
    if (not use_atr) or entry_price <= 0 or atr_value is None or atr_value <= 0:
        return sl_fix, tp_fix, 0.0

    mult_floor = max(0.0001, get_env_float("UPDOW_ATR_MULT_FLOOR", 0.1))
    sl_mult = max(mult_floor, float(cfg.get("atr_sl_mult", DEFAULT_UPDOW_CONFIG["atr_sl_mult"])))
    tp_mult = max(mult_floor, float(cfg.get("atr_tp_mult", DEFAULT_UPDOW_CONFIG["atr_tp_mult"])))
    sl_pct = (atr_value * sl_mult / entry_price) * 100.0
    tp_pct = (atr_value * tp_mult / entry_price) * 100.0

    atr_pct_floor = max(0.0001, get_env_float("UPDOW_ATR_PCT_FLOOR", 0.05))
    sl_min = max(atr_pct_floor, float(cfg.get("atr_sl_min_pct", DEFAULT_UPDOW_CONFIG["atr_sl_min_pct"])))
    sl_max = max(sl_min, float(cfg.get("atr_sl_max_pct", DEFAULT_UPDOW_CONFIG["atr_sl_max_pct"])))
    tp_min = max(atr_pct_floor, float(cfg.get("atr_tp_min_pct", DEFAULT_UPDOW_CONFIG["atr_tp_min_pct"])))
    tp_max = max(tp_min, float(cfg.get("atr_tp_max_pct", DEFAULT_UPDOW_CONFIG["atr_tp_max_pct"])))
    sl_pct = min(max(sl_pct, sl_min), sl_max)
    tp_pct = min(max(tp_pct, tp_min), tp_max)

    return max(exit_floor_dec, sl_pct / 100.0), max(exit_floor_dec, tp_pct / 100.0), float(atr_value)


def _updow_shoulder_ratios_from_cfg(cfg: Dict[str, Any]) -> Tuple[float, float]:
    """cfg/env의 어깨 % → 소수 비율 (0.3% → 0.003)."""
    smh_pct = float(cfg.get("shoulder_min_high_pct", 0.0) or 0.0)
    if smh_pct <= 0:
        smh_pct = get_env_float("UPDOW_SHOULDER_MIN_HIGH_PCT", DEFAULT_UPDOW_CONFIG["shoulder_min_high_pct"])
    sc_pct = float(cfg.get("shoulder_cut_pct", 0.0) or 0.0)
    if sc_pct <= 0:
        sc_pct = get_env_float("UPDOW_SHOULDER_CUT_PCT", DEFAULT_UPDOW_CONFIG["shoulder_cut_pct"])
    return max(0.0, smh_pct / 100.0), max(0.0, sc_pct / 100.0)


def _eval_updow_exit_v4_at_price(
    avg: float,
    max_price: float,
    px: float,
    sl_eff: float,
    tp_eff: float,
    shoulder_min_high: float,
    shoulder_cut_pct: float,
) -> Tuple[Optional[Tuple[str, float]], float]:
    """
    UPDOW V4 intrabar/실매 공용 — 1순위 어깨컷 → 2순위 익절 → 3순위 손절.
    반환: ((reason, exit_px) 또는 None, 갱신된 max_price)
    """
    if avg <= 0 or px <= 0:
        return None, max_price
    max_p = max(float(max_price or avg), px)
    pnl_pct = (px - avg) / avg

    trail_armed = max_p >= avg * (1.0 + shoulder_min_high)
    trail_stop_px = max_p * (1.0 - shoulder_cut_pct) if trail_armed else 0.0
    if trail_armed and px <= trail_stop_px:
        return ("어깨컷", float(trail_stop_px)), max_p
    if pnl_pct >= tp_eff:
        return (f"익절(+{pnl_pct * 100:.2f}/{tp_eff * 100:.2f}%)", float(px)), max_p
    if pnl_pct <= -sl_eff:
        return (f"손절({pnl_pct * 100:.2f}/{sl_eff * 100:.2f}%)", float(px)), max_p
    return None, max_p


def _check_sell_updow_backtest_bar(
    position: Dict[str, Any],
    candle: Dict[str, Any],
    bar_index: int,
    *,
    sl_pct_default: float,
    tp_pct_default: float,
    max_hold: int,
    exit_green: bool,
    shoulder_min_high: float,
    shoulder_cut_pct: float,
) -> Optional[Tuple[str, float]]:
    """UPDOW 백테 청산 — V4 intrabar(어깨→익절→손절) + 봉마감 양봉·보유한도.

    실매 ``check_sell_signal_updow_live`` 와 동일 우선순위.
    """
    from kis_trader.engine.scalping_engine import _intrabar_exit_prices

    avg = float(position.get("avg", 0) or 0)
    ei = int(position.get("entry_i", 0))
    if avg <= 0:
        return None

    sl_eff = float(position.get("sl_pct", sl_pct_default))
    tp_eff = float(position.get("tp_pct", tp_pct_default))
    bars_held = bar_index - ei

    o = float(candle.get("open", candle.get("close", 0)) or 0)
    h = float(candle.get("high", candle.get("close", 0)) or 0)
    l = float(candle.get("low", candle.get("close", 0)) or 0)
    c = float(candle.get("close", 0) or 0)
    if c <= 0:
        return None

    max_p = max(float(position.get("max_price", avg) or avg), h)
    n_checks = get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6)
    prices = [c] if n_checks <= 1 else _intrabar_exit_prices(o, h, l, c, n_checks)

    for px in prices:
        res, max_p = _eval_updow_exit_v4_at_price(
            avg, max_p, float(px), sl_eff, tp_eff, shoulder_min_high, shoulder_cut_pct,
        )
        if res:
            position["max_price"] = max_p
            return res
    position["max_price"] = max_p

    if exit_green and c > o and o > 0:
        return ("양봉청산", c)
    if bars_held >= max_hold and bars_held > 0:
        return (f"보유한도({max_hold}봉)", c)
    return None


def run_backtest_updow(
    candles: List[Dict[str, Any]],
    cfg: Dict[str, Any],
    fee_rate: float = 0.015 / 100,
    sell_tax: float = 0.18 / 100,
    *,
    regime_candles: Optional[List[Dict[str, Any]]] = None,
    stock_tf_min: int = 60,
) -> Dict[str, Any]:
    """분봉 리스트(시간 오름차순, holding_bot.get_stored_min_candles 형식)를 받아 백테스트."""
    if len(candles) < 5:
        return {"error": "봉 부족", "summary": {}, "trades": [], "equity": [], "reasons": {}}

    body_min = float(cfg.get("body_drop_min_pct", DEFAULT_UPDOW_CONFIG["body_drop_min_pct"]))
    body_max = float(cfg.get("body_drop_max_pct", DEFAULT_UPDOW_CONFIG["body_drop_max_pct"]))
    tp_pct = float(cfg.get("tp_pct", DEFAULT_UPDOW_CONFIG["tp_pct"])) / 100.0
    sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_UPDOW_CONFIG["stop_loss_pct"])) / 100.0
    atr_period = max(1, int(float(cfg.get("atr_period", DEFAULT_UPDOW_CONFIG["atr_period"]))))
    max_hold = int(cfg.get("max_hold_bars", DEFAULT_UPDOW_CONFIG["max_hold_bars"]))
    max_hold = max(1, max_hold)
    exit_green = float(cfg.get("exit_on_green", DEFAULT_UPDOW_CONFIG["exit_on_green"])) >= 0.5
    slot_money = float(cfg.get("slot_money", DEFAULT_UPDOW_CONFIG["slot_money"]))
    regime_ma = int(float(cfg.get("regime_ma_bars", DEFAULT_UPDOW_CONFIG["regime_ma_bars"])))
    regime_ease = clamp_regime_ma_ease_pct(cfg.get("regime_ma_ease_pct", DEFAULT_UPDOW_CONFIG["regime_ma_ease_pct"]))
    shoulder_min_high, shoulder_cut_pct = _updow_shoulder_ratios_from_cfg(cfg)
    pause_flags = precompute_regime_pause_buy_flags(
        candles, regime_candles or [], regime_ma, stock_tf_min, regime_ease,
    )

    opens = [float(c["open"]) for c in candles]
    closes = [float(c["close"]) for c in candles]
    dts = [str(c["candle_date"]) for c in candles]
    atr_series = _compute_atr_series(candles, atr_period)

    position: Optional[Dict[str, Any]] = None
    trades: List[Dict[str, Any]] = []
    equity: List[Dict[str, Any]] = []
    cum_pnl = 0.0

    def _signal(si: int) -> bool:
        o1 = opens[si]
        c1 = closes[si]
        if o1 <= 0 or c1 <= 0:
            return False
        if c1 >= o1:
            return False
        body_drop = (o1 - c1) / o1 * 100.0
        if body_max > 0.0 and body_drop > body_max:
            return False
        return body_drop >= body_min

    for si in range(0, len(candles) - 2):
        i = si + 1
        next_open = float(opens[i]) if opens[i] > 0 else closes[i]
        if next_open <= 0:
            continue

        if position is not None:
            ei = position["entry_i"]
            avg = position["avg"]
            qty = position["qty"]

            sell_res = _check_sell_updow_backtest_bar(
                position,
                candles[i],
                i,
                sl_pct_default=sl_pct,
                tp_pct_default=tp_pct,
                max_hold=max_hold,
                exit_green=exit_green,
                shoulder_min_high=shoulder_min_high,
                shoulder_cut_pct=shoulder_cut_pct,
            )

            if sell_res:
                sell_reason, exit_price = sell_res
                fee = exit_price * qty * (fee_rate + sell_tax)
                pnl = (exit_price - avg) * qty - fee
                cum_pnl += pnl
                hold_b = i + 1 - ei
                trades.append({
                    "buy_date":   dts[ei][:16],
                    "sell_date":  dts[i][:16],
                    "avg_price":  round(avg),
                    "exit_price": round(exit_price),
                    "qty":        qty,
                    "pnl":        round(pnl),
                    "hold_days":  hold_b,
                    "reason":     sell_reason,
                })
                equity.append({"date": dts[i][:16], "cum_pnl": round(cum_pnl)})
                position = None
            continue

        if _signal(si):
            if pause_flags is not None and regime_ma >= 1 and si < len(pause_flags) and pause_flags[si]:
                continue
            entry_px = next_open
            entry_i = i
            if is_limit_atr_entry(updow_entry_mode(cfg)):
                lp_cfg = updow_limit_params(cfg)
                sig_bar = candles[si]
                anchor_px = resolve_limit_anchor_price(
                    lp_cfg["anchor"], sig_bar, candles, si,
                )
                atr_sig = atr_series[si] if si < len(atr_series) else None
                limit_px = compute_atr_limit_price(anchor_px, atr_sig, lp_cfg["mult"])
                filled = False
                if limit_px > 0:
                    for j in range(si + 1, min(si + 1 + lp_cfg["valid_bars"], len(candles))):
                        fp = try_limit_fill_on_bar(
                            candles[j], limit_px, lp_cfg["fill_slip_pct"],
                        )
                        if fp and fp > 0:
                            entry_px = fp
                            entry_i = j
                            filled = True
                            break
                if not filled:
                    continue
            invest = slot_money
            qty = max(1, int(invest / entry_px))
            sl_eff, tp_eff, atr_used = _effective_exit_pcts(cfg, entry_px, atr_series[si])
            position = {
                "avg":     entry_px,
                "qty":     qty,
                "entry_i": entry_i,
                "sl_pct":  sl_eff,
                "tp_pct":  tp_eff,
                "atr_entry": atr_used,
                "max_price": entry_px,
            }

    # 백테스트 마지막 시점까지도 포지션이 남아 있으면: 실제 장이 아니므로 "다음 봉 시가" 청산을 시뮬할 수 없음
    # → 마지막 봉 **종가**로 강제 청산하고 사유를「기간종료」로 표기 (미결 포지션 정리용)
    if position is not None and len(candles) > 0:
        exit_price = closes[-1]
        avg = position["avg"]
        qty = position["qty"]
        ei = position["entry_i"]
        fee = exit_price * qty * (fee_rate + sell_tax)
        pnl = (exit_price - avg) * qty - fee
        cum_pnl += pnl
        trades.append({
            "buy_date":   dts[ei][:16],
            "sell_date":  dts[-1][:16],
            "avg_price":  round(avg),
            "exit_price": round(exit_price),
            "qty":        qty,
            "pnl":        round(pnl),
            "hold_days":  len(candles) - 1 - ei,
            "reason":     "기간종료",
            "sl_pct":     round(float(position.get("sl_pct", sl_pct)) * 100.0, 3),
            "tp_pct":     round(float(position.get("tp_pct", tp_pct)) * 100.0, 3),
            "atr_entry":  round(float(position.get("atr_entry", 0.0)), 4),
        })
        equity.append({"date": dts[-1][:16], "cum_pnl": round(cum_pnl)})

    total = len(trades)
    wins = [t for t in trades if t["pnl"] > 0]
    losses = [t for t in trades if t["pnl"] < 0]
    total_pnl = sum(t["pnl"] for t in trades)
    avg_hold = sum(t["hold_days"] for t in trades) / total if total else 0.0
    win_pnl = sum(t["pnl"] for t in wins)
    loss_pnl = sum(t["pnl"] for t in losses)
    pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0
    peak, mdd, cum = 0.0, 0.0, 0.0
    for t in trades:
        cum += t["pnl"]
        peak = max(peak, cum)
        mdd = max(mdd, peak - cum)
    reasons: Dict[str, int] = {}
    for t in trades:
        prefix = t["reason"].split("(")[0]
        reasons[prefix] = reasons.get(prefix, 0) + 1

    c0 = closes[0]
    c1 = closes[-1]
    bnh_pct = round((c1 - c0) / c0 * 100, 2) if c0 > 0 else 0.0
    bnh_pnl = round(slot_money * bnh_pct / 100)
    bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0
    alpha_pct = round(bot_pct - bnh_pct, 2)

    return {
        "summary": {
            "total_trades":  total,
            "win_trades":    len(wins),
            "loss_trades":   len(losses),
            "win_rate":      round(len(wins) / total * 100, 1) if total else 0.0,
            "total_pnl":     round(total_pnl),
            "avg_hold_days": round(avg_hold, 1),
            "profit_factor": round(min(pf, 9999.0), 2),
            "max_drawdown":  round(mdd),
            "bnh_pct":       bnh_pct,
            "bnh_pnl":       bnh_pnl,
            "bot_pct":       bot_pct,
            "alpha_pct":     alpha_pct,
            "bnh_aligned_pct":   bnh_pct,
            "bnh_aligned_pnl":   bnh_pnl,
            "alpha_aligned_pct": alpha_pct,
        },
        "equity":  equity,
        "reasons": reasons,
        "trades":  trades[-200:],
    }


def run_param_search_updow(
    candles: List[Dict[str, Any]],
    grid: Optional[Dict[str, List[float]]] = None,
    min_trades: int = 1,
    base_cfg: Optional[Dict[str, Any]] = None,
    env_snapshot: Optional[Dict[str, Any]] = None,
    *,
    regime_candles: Optional[List[Dict[str, Any]]] = None,
    stock_tf_min: int = 60,
    max_combos: int = 0,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    """그리드 서치. ``env_snapshot`` 에 env_config 최신 스냅샷을 넘기면 그 값으로 그리드·베이스 고정."""
    if grid is None:
        grid = default_param_grid(env_snapshot)

    keys = list(grid.keys())
    combos = list(iproduct(*[grid[k] for k in keys]))
    cart_total = len(combos)
    combos, dropped_by_cap = _downsample_combos_uniform(combos, max_combos)

    _base = cfg_from_env_snapshot(env_snapshot)
    if base_cfg:
        for k, v in base_cfg.items():
            if k in CFG_ENGINE_KEYS:
                try:
                    _base[k] = float(v)
                except (TypeError, ValueError):
                    pass

    results: List[Dict[str, Any]] = []
    skip_low = 0
    skip_err = 0
    ran = 0

    for vals in combos:
        cfg = dict(_base)
        cfg.update(dict(zip(keys, vals)))
        ran += 1
        res = run_backtest_updow(
            candles,
            cfg,
            regime_candles=regime_candles,
            stock_tf_min=stock_tf_min,
        )
        if res.get("error"):
            skip_err += 1
            continue
        s = res.get("summary", {})
        if min_trades > 0 and s.get("total_trades", 0) < min_trades:
            skip_low += 1
            continue
        results.append({
            "params":    {k: cfg[k] for k in keys},
            "apply_cfg": dict(cfg),
            "total_pnl": s["total_pnl"],
            "win_rate":  s["win_rate"],
            "total_trades": s["total_trades"],
            "pf":        s["profit_factor"],
            "avg_hold":  s["avg_hold_days"],
            "mdd":       s["max_drawdown"],
        })

    results.sort(key=lambda x: x["total_pnl"], reverse=True)

    fixed_keys = sorted(set(CFG_ENGINE_KEYS) - set(keys))
    meta = {
        "grid_keys":                 keys,
        "fixed_param_keys":          fixed_keys,
        "cartesian_product":         cart_total,
        "sampled_backtests":         len(combos),
        "dropped_by_combo_cap":      dropped_by_cap,
        "max_combos":                int(max_combos),
        "backtests_run":             ran,
        "skipped_backtest_error":    skip_err,
        "skipped_below_min_trades":  skip_low,
        "min_trades":                min_trades,
        "passed":                    len(results),
        "grid_axis_hints":          {k: UPDOW_GRID_AXIS_HINTS_KO.get(k, k) for k in keys},
    }
    return results, meta


def read_updow_tf_min(snap: Optional[Dict[str, Any]] = None) -> int:
    """웹·CLI 기본 분봉 — ``UPDOW_TF_MIN`` (env_config 또는 get_env_int 폴백)."""
    return _read_snap_int(snap, "UPDOW_TF_MIN", 60)


def read_updow_total_budget_krw(snap: Optional[Dict[str, Any]] = None) -> int:
    """
    실매 UPDOW **전략 총 운용 한도(원)** — 동시 보유 매입금 합이 이 값을 넘지 않음.
    ``UPDOW_MAX_BUY_AMOUNT`` → ``MAX_BUY_AMOUNT_PER_STOCK`` 폴백. 0 이면 한도 없음.
    (1회 주문 상한이 아님 — 1회 크기는 ``UPDOW_SLOT_MONEY`` / 종목 slot_money)
    """
    cap = _read_snap_int(snap, "UPDOW_MAX_BUY_AMOUNT", 0)
    if cap <= 0:
        cap = _read_snap_int(snap, "MAX_BUY_AMOUNT_PER_STOCK", 0)
    return int(cap)


def read_updow_max_buy_krw(snap: Optional[Dict[str, Any]] = None) -> int:
    """하위 호환 — ``read_updow_total_budget_krw`` 와 동일 (총 운용 한도)."""
    return read_updow_total_budget_krw(snap)


def read_updow_max_stocks(snap: Optional[Dict[str, Any]] = None) -> int:
    """UPDOW 동시 보유 종목 수 — ``UPDOW_MAX_STOCKS`` → ``MAX_STOCKS``."""
    n = _read_snap_int(snap, "UPDOW_MAX_STOCKS", 0)
    if n > 0:
        return n
    return _read_snap_int(snap, "MAX_STOCKS", 3)


def _norm_candle_time_key(c: Dict[str, Any]) -> str:
    """WS/DB 혼용 candle_time·candle_date·candle_time_str → YYYYMMDDHHMM 비교용."""
    ct = c.get("candle_time") or c.get("candle_time_str") or c.get("candle_date") or ""
    s = str(ct).strip()
    if not s:
        return ""
    if len(s) >= 19 and (" " in s or "-" in s[:5]):
        return s.replace("-", "").replace(" ", "").replace(":", "")[:12]
    digits = "".join(ch for ch in s if ch.isdigit())
    return digits[:12] if digits else s


def _signal_bar_updow(
    candle: Dict[str, Any],
    body_drop_min_pct: float,
    body_drop_max_pct: float = 0.0,
) -> Tuple[bool, float]:
    """직전 확정봉이 음봉이며 몸통 하락률(%)이 하한 이상·(상한>0이면)상한 이하면 True."""
    try:
        o1 = float(candle.get("open", 0) or 0)
        c1 = float(candle.get("close", 0) or 0)
    except (TypeError, ValueError):
        return False, 0.0
    if o1 <= 0 or c1 <= 0:
        return False, 0.0
    if c1 >= o1:
        return False, 0.0
    body_drop = (o1 - c1) / o1 * 100.0
    # 상한(>0)을 넘는 과도 폭락은 악재성 칼날잡기로 보고 제외 (0=OFF)
    if float(body_drop_max_pct) > 0.0 and body_drop > float(body_drop_max_pct):
        return False, body_drop
    return body_drop >= float(body_drop_min_pct), body_drop


def check_buy_signal_updow_live(
    candles: List[Dict[str, Any]],
    cfg: Dict[str, Any],
    *,
    last_fired_entry_key: Optional[str] = None,
) -> Tuple[Optional[str], str, Optional[Dict[str, Any]]]:
    """
    라이브 매수 판정 (``run_backtest_updow`` 와 동일 규칙).
    - 신호봉: 확정봉 중 직전 봉(``candles[-2]``) — 음봉 + 몸통 하락률 ≥ ``body_drop_min_pct``
    - 진입가: 최신 확정봉(``candles[-1]``) 시가 (백테의 '다음 봉 시가'에 대응)

    Returns:
        ``(reject_code, message, signal_or_none)`` — ``reject_code`` 가 None 이면 통과.
    """
    min_need = get_env_int("UPDOW_LIVE_MIN_CANDLES", 5)
    if len(candles) < min_need:
        return ("탈락-봉부족", f"확정봉 {len(candles)}개 (권장 최소 {min_need})", None)

    if len(candles) < 2:
        return ("탈락-봉부족", "신호·진입 봉을 나누려면 확정봉 2개 이상 필요", None)

    sig_bar = candles[-2]
    ent_bar = candles[-1]
    ent_key = _norm_candle_time_key(ent_bar)
    if not ent_key:
        return ("탈락-봉시각", "진입봉 candle_time 비어 있음", None)

    if last_fired_entry_key and last_fired_entry_key == ent_key:
        return ("탈락-중복진입봉", f"이번 진입봉({ent_key})에 이미 주문 시도함", None)

    body_min = float(cfg.get("body_drop_min_pct", DEFAULT_UPDOW_CONFIG["body_drop_min_pct"]))
    body_max = float(cfg.get("body_drop_max_pct", DEFAULT_UPDOW_CONFIG["body_drop_max_pct"]))
    ok_sig, body_drop = _signal_bar_updow(sig_bar, body_min, body_max)
    if not ok_sig:
        sk = _norm_candle_time_key(sig_bar)
        return (
            "탈락-비신호봉",
            f"직전봉({sk}) 음봉·몸통하락 미달/초과 "
            f"(하락률 {body_drop:.3f}% / 하한 {body_min}% · 상한 {body_max or 'OFF'})",
            None,
        )

    atr_period = max(1, int(float(cfg.get("atr_period", DEFAULT_UPDOW_CONFIG["atr_period"]))))
    atr_series = _compute_atr_series(candles, atr_period)
    sig_i = len(candles) - 2
    atr_sig = atr_series[sig_i] if sig_i < len(atr_series) else None

    if is_limit_atr_entry(updow_entry_mode(cfg)):
        lp_cfg = updow_limit_params(cfg)
        anchor_px = resolve_limit_anchor_price(
            lp_cfg["anchor"], sig_bar, candles, sig_i,
        )
        min_px = float(cfg.get("min_price", get_env_float("MIN_STOCK_PRICE", 1000.0)))
        limit_px = compute_atr_limit_price(
            anchor_px, atr_sig, lp_cfg["mult"], min_price=min_px,
        )
        limit_int = floor_limit_price_krw(limit_px)
        if limit_int <= 0:
            return ("탈락-지정가", "ATR 지정가 산출 실패(가격·ATR)", None)
        entry_px = float(limit_int)
        valid_until = limit_valid_until_bar_key(candles, sig_i, lp_cfg["valid_bars"])
        sl_eff, tp_eff, atr_used = _effective_exit_pcts(cfg, entry_px, atr_sig)
        sig = {
            "entry_price": entry_px,
            "updow_entry_bar_key": ent_key,
            "signal_candle_key": _norm_candle_time_key(sig_bar),
            "body_drop_pct": body_drop,
            "stop_price": entry_px * (1.0 - sl_eff),
            "target_price": entry_px * (1.0 + tp_eff),
            "sl_pct": sl_eff * 100.0,
            "tp_pct": tp_eff * 100.0,
            "atr_entry": atr_used,
            "use_limit_buy": True,
            "valid_until_bar_key": valid_until,
            "entry_mode": "limit_atr",
        }
        return (None, f"직전봉 하락 → ATR 지정가 {limit_int:,}원 (유효~{valid_until})", sig)

    try:
        entry_open = float(ent_bar.get("open", 0) or 0)
    except (TypeError, ValueError):
        entry_open = 0.0
    if entry_open <= 0:
        return ("탈락-시가없음", "진입봉 시가가 0 이하", None)

    sl_eff, tp_eff, atr_used = _effective_exit_pcts(cfg, entry_open, atr_sig)

    sig = {
        "entry_price": entry_open,
        "updow_entry_bar_key": ent_key,
        "signal_candle_key": _norm_candle_time_key(sig_bar),
        "body_drop_pct": body_drop,
        "stop_price": entry_open * (1.0 - sl_eff),
        "target_price": entry_open * (1.0 + tp_eff),
        "sl_pct": sl_eff * 100.0,
        "tp_pct": tp_eff * 100.0,
        "atr_entry": atr_used,
    }
    return (None, "직전봉 하락 신호 → 최신봉 시가 진입", sig)


def estimate_updow_entry_bar_index(
    candles: List[Dict[str, Any]],
    entry_bar_key: str,
    buy_time_str: str,
) -> int:
    """
    진입봉 키로 인덱스를 찾고, 없으면 buy_time 기준으로 근사(봇 재시작 등).
    못 찾으면 -1.
    """
    if not candles:
        return -1
    want = _norm_candle_time_key({"candle_time": entry_bar_key})
    if want:
        for i, c in enumerate(candles):
            if _norm_candle_time_key(c) == want:
                return i
    # buy_time "YYYY-MM-DD HH:MM:SS" → 분 단위 비교
    bts = (buy_time_str or "").strip().replace("-", "").replace(":", "").replace(" ", "")
    b12 = "".join(ch for ch in bts if ch.isdigit())[:12]
    if len(b12) >= 12:
        for i, c in enumerate(candles):
            ck = _norm_candle_time_key(c)
            if ck and ck >= b12[:12]:
                return i
    return -1


def check_sell_signal_updow_live(
    *,
    buy_price: float,
    candles: List[Dict[str, Any]],
    cfg: Dict[str, Any],
    entry_bar_key: str,
    buy_time_str: str,
    current_price: float,
    stop_price: float = 0.0,
    target_price: float = 0.0,
    max_price: float = 0.0,
) -> Optional[Tuple[str, float]]:
    """
    라이브 매도 판정 (백테와 동일 V4 우선순위).
    1순위 어깨컷 → 2순위 익절% → 3순위 손절% → (옵션) 양봉 청산 → 최대 보유 봉.

    ``current_price`` 는 WS 현재가 등으로 청산 주문 참고가에 사용.
    ``max_price`` 는 보유 중 고점 추적(호출 측 holding에 저장 후 재전달).
    """
    if buy_price <= 0 or not candles:
        return None

    tp_pct = float(cfg.get("tp_pct", DEFAULT_UPDOW_CONFIG["tp_pct"])) / 100.0
    sl_pct = float(cfg.get("stop_loss_pct", DEFAULT_UPDOW_CONFIG["stop_loss_pct"])) / 100.0
    # 진입 시점에 고정된 손절/익절 가격이 있으면 우선 사용 (ATR 동적 값 유지)
    exit_floor_dec = max(0.000001, get_env_float("UPDOW_EXIT_PCT_FLOOR", 0.01) / 100.0)
    if stop_price > 0 and buy_price > 0:
        sl_pct = max(exit_floor_dec, (buy_price - stop_price) / buy_price)
    if target_price > 0 and buy_price > 0:
        tp_pct = max(exit_floor_dec, (target_price - buy_price) / buy_price)
    max_hold = int(float(cfg.get("max_hold_bars", DEFAULT_UPDOW_CONFIG["max_hold_bars"])))
    max_hold = max(1, max_hold)
    exit_green = float(cfg.get("exit_on_green", DEFAULT_UPDOW_CONFIG["exit_on_green"])) >= 0.5
    shoulder_min_high, shoulder_cut_pct = _updow_shoulder_ratios_from_cfg(cfg)

    last = candles[-1]
    try:
        c_now = float(last.get("close", 0) or 0)
        o_now = float(last.get("open", 0) or 0)
        h_now = float(last.get("high", c_now) or c_now)
    except (TypeError, ValueError):
        return None
    if c_now <= 0:
        return None

    px = float(current_price) if current_price > 0 else c_now
    mp = float(max_price) if max_price > 0 else buy_price
    mp = max(mp, h_now, px)

    j = estimate_updow_entry_bar_index(candles, entry_bar_key, buy_time_str)
    last_idx = len(candles) - 1

    # 백테: 진입 봉(i)에서는 청산 루프를 돌지 않고, 다음 봉(i+1)부터 평가.
    if j >= 0 and last_idx <= j:
        return None

    entry_unknown = j < 0
    if entry_unknown:
        bars_held = 0
    else:
        bars_held = last_idx - j

    v4_res, _ = _eval_updow_exit_v4_at_price(
        buy_price, mp, px, sl_pct, tp_pct, shoulder_min_high, shoulder_cut_pct,
    )
    sell_reason: Optional[str] = None
    exit_px = px
    if v4_res:
        sell_reason, exit_px = v4_res
    elif (not entry_unknown) and exit_green and c_now > o_now and o_now > 0:
        sell_reason = "양봉청산"
        exit_px = c_now
    elif (not entry_unknown) and bars_held >= max_hold and bars_held > 0:
        sell_reason = f"보유한도({max_hold}봉)"
        exit_px = c_now

    if not sell_reason:
        return None
    return (sell_reason, float(exit_px))


__all__ = [
    "CFG_ENGINE_KEYS",
    "DEFAULT_UPDOW_CONFIG",
    "UPDOW_GRID_AXIS_HINTS_KO",
    "cfg_from_env_snapshot",
    "default_param_grid",
    "default_param_grid_web_fast",
    "default_param_grid_us",
    "default_param_grid_us_fast",
    "env_snapshot_patch_from_engine_cfg",
    "read_updow_tf_min",
    "run_backtest_updow",
    "run_param_search_updow",
    "precompute_regime_pause_buy_flags",
    "kospi_proxy_regime_blocks_new_buy_from_closes",
    "kospi_proxy_regime_block_state",
    "clamp_regime_ma_ease_pct",
    "check_buy_signal_updow_live",
    "check_sell_signal_updow_live",
    "estimate_updow_entry_bar_index",
    "_norm_candle_time_key",
]
