#!/usr/bin/env python3
"""
kis_trader/engine/momentum_engine.py — 모멘텀(1분봉 추세추격) 전용 엔진
======================================================================
스캘핑 reversal(SCALP)과 완전 분리. 백테스트·파라서치·MomentumStrategy 공통.

[전략 컨셉 — SCAN vs TRIGGER]
- SCAN: HTS/KIS ``scalp`` 조건검색 (F/G/H/J) → target_candidates_history
- TRIGGER V2: 추세추격 패턴 OR (단순돌파 / 눌림재돌파) + EMA·거래량·RSI약세컷
  ※ 구 V1(고점추격방지·RSI상한·끝물컷)은 추세추격과 상충 → 기본 OFF

[청산 우선순위 — 추세추격 전용 (SCALP reversal·돌파와 다름, 실매 MomentumStrategy 동일)]
  래칫(설정 시) → 어깨 → 트레일 → 손절 → 시간컷 → 금액손실컷 → 익절(tp_max 상한) → 장마감청산
  ※ 래칫티어가 있어도 미발동 시 어깨로 폴백. 익절%는 마지막 하드 캡.
"""
from __future__ import annotations

from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple

from kis_trader.engine.ema_trend_filter import eval_ema_uptrend_reject
from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal
from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry
from kis_trader.engine.program_filter import program_reject_for_entry
from kis_trader.engine.momentum_chase_patterns import (
    chase_pattern_defaults,
    eval_momentum_chase_pattern,
)
from kis_trader.engine.momentum_env_keys import (
    momentum_env_bool,
    momentum_env_float,
    momentum_env_int,
    _legacy_float,
)
from kis_trader.strategies.base import is_strategy_eod_bar
from kis_trader.utils.env import get_env_int

MOMENTUM_STRATEGY_ID = "MOMENTUM"


def _to_bool(v: Any, default: bool = True) -> bool:
    if v is None:
        return default
    if isinstance(v, bool):
        return v
    s = str(v).strip().lower()
    if s in ("1", "true", "t", "y", "yes", "on"):
        return True
    if s in ("0", "false", "f", "n", "no", "off", ""):
        return False
    return default


def _t2dt(t: str) -> datetime:
    return datetime.strptime(str(t)[:12], "%Y%m%d%H%M")


def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
    date = candle_time[:8]
    hm = int(candle_time[8:12])
    total_min = (hm // 100) * 60 + (hm % 100)
    slot_min = (total_min // scan_interval_min) * scan_interval_min
    slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
    return date + str(slot_hm).zfill(4)


def compute_rsi_series(closes: list, period: int = 3) -> list:
    """RSI 시리즈 (Wilder)."""
    rsi_list = [None] * len(closes)
    if len(closes) < period + 1:
        return rsi_list
    deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
    gains = [max(d, 0) for d in deltas]
    losses = [max(-d, 0) for d in deltas]
    avg_gain = sum(gains[:period]) / period
    avg_loss = sum(losses[:period]) / period
    for i in range(period, len(closes)):
        idx = i - 1
        if i > period:
            avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
            avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
        rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
        rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
        rsi_list[i] = rsi_val
    return rsi_list


def resolve_effective_tp_pct(tp_pct: float, tp_max_pct: float) -> float:
    tp = abs(float(tp_pct))
    cap = abs(float(tp_max_pct))
    if cap > 0:
        return min(tp, cap)
    return tp


def effective_tp_pct_from_params(params: Dict[str, Any]) -> float:
    return resolve_effective_tp_pct(
        params.get("tp_pct", 0.015),
        params.get("tp_max_pct", 0.02),
    )


def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
    """env_config + config_momentum 병합 → 엔진 params dict."""
    own_db = None
    r: Dict[str, Any] = {}
    try:
        if db is None:
            from database import TradeDB
            own_db = TradeDB()
            db = own_db
        if hasattr(db, "get_merged_env_snapshot"):
            r = db.get_merged_env_snapshot()
        elif hasattr(db, "get_latest_env"):
            latest = db.get_latest_env()
            r = dict((latest or {}).get("snapshot") or {})
        else:
            row = db.conn.execute(
                "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
            ).fetchone()
            r = dict(row) if row else {}

        rsi_period = momentum_env_int(r, "MOMENTUM_RSI_PERIOD", 3)
        mom_rsi_min = _legacy_float(r, "MOMENTUM_RSI_MIN", ("SCALP_MOM_RSI_MIN",), 50.0)
        mom_rsi_max = _legacy_float(r, "MOMENTUM_RSI_MAX", ("SCALP_MOM_RSI_MAX",), 80.0)
        mom_vol_mult = _legacy_float(r, "MOMENTUM_VOL_MULT", ("SCALP_MOM_VOL_MULT",), 1.5)
        mom_vol_win = momentum_env_int(r, "MOMENTUM_VOL_WIN", 5)
        if r.get("SCALP_MOM_VOL_WIN") not in (None, "", "None") and "MOMENTUM_VOL_WIN" not in r:
            try:
                mom_vol_win = int(float(r["SCALP_MOM_VOL_WIN"]))
            except (TypeError, ValueError):
                pass
        mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1430)
        if r.get("SCALP_MOM_TIME_END_HM") not in (None, "", "None") and "MOMENTUM_TIME_END_HM" not in r:
            try:
                mom_time_end = int(float(r["SCALP_MOM_TIME_END_HM"]))
            except (TypeError, ValueError):
                pass
        sl_pct = abs(_legacy_float(
            r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.015,
        ))
        tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.025)
        tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.02)
        shoulder_high = _legacy_float(
            r, "MOMENTUM_SHOULDER_MIN_HIGH_PCT",
            ("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), 0.005,
        )
        shoulder_cut = _legacy_float(
            r, "MOMENTUM_SHOULDER_CUT_PCT",
            ("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"), 0.003,
        )
        ratchet_tiers = str(
            r.get("MOMENTUM_RATCHET_TIERS")
            or get_env_from_db("MOMENTUM_RATCHET_TIERS", "")
            or ""
        ).strip()
        trail_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_PCT", 0.0))
        trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.0))
        max_hold_bars = momentum_env_int(r, "MOMENTUM_MAX_HOLD_BARS", 0)
        max_daily = momentum_env_int(r, "MOMENTUM_MAX_DAILY", 5)
        min_price = momentum_env_float(r, "MOMENTUM_MIN_PRICE", 1000.0)
        max_daily_chg = momentum_env_float(r, "MOMENTUM_MAX_DAILY_CHG", 20.0)
        high_chase = momentum_env_float(r, "MOMENTUM_HIGH_CHASE_THR", 0.96)
        mom_max_open = momentum_env_float(r, "MOMENTUM_MAX_FROM_OPEN_PCT", 999.0)
        mom_min_open = momentum_env_float(r, "MOMENTUM_MIN_FROM_OPEN_PCT", -999.0)
        max_loss_krw = momentum_env_int(r, "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", 200_000)
        min_drop_loss = r.get("MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT") or r.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
        min_drop_pct_for_loss_cut = 0.015
        if min_drop_loss not in (None, "", "None"):
            v = float(min_drop_loss)
            min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
        cooldown_sec = momentum_env_int(r, "MOMENTUM_COOLDOWN_SEC", 600)
        time_start = momentum_env_int(r, "MOMENTUM_TIME_START", 900)
        time_end = momentum_env_int(r, "MOMENTUM_TIME_END", mom_time_end)
        skip_hts = momentum_env_bool(r, "MOMENTUM_SKIP_HTS_SCAN_DUPES", True)
        use_defense = momentum_env_bool(r, "MOMENTUM_USE_DEFENSE_FILTERS", True)
        use_high_chase_f = momentum_env_bool(r, "MOMENTUM_USE_HIGH_CHASE_FILTER", False)
        use_daily_range_f = momentum_env_bool(r, "MOMENTUM_USE_DAILY_RANGE_FILTER", False)
        use_ema_filter = momentum_env_bool(r, "MOMENTUM_USE_EMA_FILTER", True)
        use_rsi_max_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_MAX_FILTER", False)
        pattern_breakout = momentum_env_bool(r, "MOMENTUM_PATTERN_BREAKOUT", True)
        pattern_pullback = momentum_env_bool(r, "MOMENTUM_PATTERN_PULLBACK", True)
        chase_lookback_min = momentum_env_int(r, "MOMENTUM_CHASE_LOOKBACK_MIN", 10)
        pullback_lookback_min = momentum_env_int(r, "MOMENTUM_PULLBACK_LOOKBACK_MIN", 15)
        pullback_min_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MIN_PCT", 0.3)
        pullback_max_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MAX_PCT", 3.0)
        setup_vol_max_mult = momentum_env_float(r, "MOMENTUM_SETUP_VOL_MAX_MULT", 0.8)
        setup_bear_bars_min = momentum_env_int(r, "MOMENTUM_SETUP_BEAR_BARS_MIN", 1)
        ema_fast_period = momentum_env_int(r, "MOMENTUM_EMA_FAST_PERIOD", 9)
        ema_slow_period = momentum_env_int(r, "MOMENTUM_EMA_SLOW_PERIOD", 21)
        slot_money = momentum_env_int(r, "MOMENTUM_SLOT_MONEY", 3_000_000)
        max_stocks = momentum_env_int(r, "MOMENTUM_MAX_STOCKS", 3)
        total_budget = momentum_env_int(r, "MOMENTUM_TOTAL_BUDGET_KRW", 0)
        min_hold_sec = momentum_env_float(r, "MOMENTUM_MIN_HOLD_SEC", 30.0)
        live_align = momentum_env_bool(r, "MOMENTUM_LIVE_BACKTEST_ALIGN", True)
        lookback_bars = momentum_env_int(r, "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", 1)
        force_eod = momentum_env_bool(r, "MOMENTUM_FORCE_EOD_EXIT", False)
        eod_enabled = momentum_env_bool(r, "MOMENTUM_EOD_ENABLED", True)
        if r.get("MOMENTUM_EOD_ENABLED") in (None, "", "None") and r.get("MOMENTUM_FORCE_EOD_EXIT") not in (None, "", "None"):
            eod_enabled = force_eod
        eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:25").strip() or "15:25"
        portfolio_mode = True
    except Exception:
        rsi_period, mom_rsi_min, mom_rsi_max = 3, 50.0, 80.0
        mom_vol_mult, mom_vol_win, mom_time_end = 1.5, 5, 1430
        sl_pct, tp_pct, tp_max = 0.015, 0.025, 0.02
        shoulder_high, shoulder_cut = 0.005, 0.003
        ratchet_tiers, trail_pct, trail_arm_pct = "", 0.0, 0.0
        max_hold_bars, max_daily = 0, 5
        min_price, max_daily_chg, high_chase = 1000.0, 20.0, 0.96
        mom_max_open, mom_min_open = 999.0, -999.0
        max_loss_krw, min_drop_pct_for_loss_cut = 200_000, 0.015
        cooldown_sec, time_start, time_end = 600, 900, 1430
        skip_hts, use_defense = True, True
        use_high_chase_f, use_daily_range_f = False, False
        use_ema_filter = True
        use_rsi_max_filter = False
        pattern_breakout, pattern_pullback = True, True
        chase_lookback_min, pullback_lookback_min = 10, 15
        pullback_min_pct, pullback_max_pct = 0.3, 3.0
        setup_vol_max_mult, setup_bear_bars_min = 0.8, 1
        ema_fast_period, ema_slow_period = 9, 21
        slot_money, max_stocks, total_budget = 3_000_000, 3, 0
        min_hold_sec, live_align, lookback_bars = 30.0, True, 1
        force_eod, portfolio_mode = False, True
        eod_enabled, eod_hm = True, "15:25"
    finally:
        if own_db is not None:
            try:
                own_db.close()
            except Exception:
                pass

    return {
        "rsi_period": rsi_period,
        "mom_rsi_min": mom_rsi_min,
        "mom_rsi_max": mom_rsi_max,
        "mom_vol_mult": mom_vol_mult,
        "mom_vol_win": mom_vol_win,
        "mom_time_end_hm": mom_time_end,
        "mom_max_from_open_pct": mom_max_open,
        "mom_min_from_open_pct": mom_min_open,
        "sl_pct": sl_pct,
        "tp_pct": tp_pct,
        "tp_max_pct": tp_max,
        "shoulder_min_high": shoulder_high,
        "shoulder_cut_pct": shoulder_cut,
        "ratchet_tiers": ratchet_tiers,
        "trail_pct": trail_pct,
        "trail_arm_pct": trail_arm_pct,
        "max_hold_bars": max_hold_bars,
        "max_daily": max_daily,
        "min_price": min_price,
        "max_daily_chg": max_daily_chg,
        "high_chase_thr": high_chase,
        "max_loss_krw": float(max_loss_krw),
        "min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
        "cooldown_min": cooldown_sec / 60.0,
        "time_start_hm": time_start,
        "time_end_hm": time_end,
        "skip_hts_scan_dupes": skip_hts,
        "use_defense_filters": use_defense,
        "use_high_chase_filter": use_high_chase_f,
        "use_daily_range_filter": use_daily_range_f,
        "use_ema_filter": use_ema_filter,
        "use_rsi_max_filter": use_rsi_max_filter,
        "pattern_breakout": pattern_breakout,
        "pattern_pullback": pattern_pullback,
        "chase_lookback_min": chase_lookback_min,
        "pullback_lookback_min": pullback_lookback_min,
        "pullback_min_pct": pullback_min_pct,
        "pullback_max_pct": pullback_max_pct,
        "setup_vol_max_mult": setup_vol_max_mult,
        "setup_bear_bars_min": setup_bear_bars_min,
        "ema_fast_period": ema_fast_period,
        "ema_slow_period": ema_slow_period,
        "slot_money": float(slot_money),
        "max_stocks": max_stocks,
        "total_budget_krw": float(total_budget),
        "min_hold_sec": min_hold_sec,
        "live_backtest_align": live_align,
        "live_signal_lookback_bars": lookback_bars,
        "eod_enabled": eod_enabled,
        "eod_hm": eod_hm,
        "force_eod_exit": eod_enabled,
        "portfolio_mode": portfolio_mode,
        "scan_interval_min": 1,
    }


def _parse_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]:
    raw = params.get("ratchet_tiers")
    if raw is None:
        raw = get_env_from_db("MOMENTUM_RATCHET_TIERS", "")
    if isinstance(raw, (list, tuple)):
        pairs = list(raw)
    else:
        s = str(raw or "").strip()
        if not s:
            return []
        pairs = []
        for chunk in s.split(","):
            chunk = chunk.strip()
            if not chunk or ":" not in chunk:
                continue
            g, c = chunk.split(":", 1)
            pairs.append((g, c))
    tiers: List[Tuple[float, float]] = []
    for g, c in pairs:
        try:
            gain = abs(float(g)) / 100.0
            cut = abs(float(c)) / 100.0
        except (TypeError, ValueError):
            continue
        if gain <= 0 or cut <= 0:
            continue
        tiers.append((gain, cut))
    tiers.sort(key=lambda x: x[0])
    return tiers


def _shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]:
    smh = float(params.get("shoulder_min_high", 0.005))
    sc = float(params.get("shoulder_cut_pct", 0.003))
    return max(0.0, smh), max(0.0, sc)


def _minutes_held(position: Dict[str, Any], candle: Dict[str, Any]) -> Optional[int]:
    try:
        e = _t2dt(position.get("entry_time") or position.get("buy_time", ""))
        n = _t2dt(candle.get("candle_time", ""))
        return max(0, int((n - e).total_seconds() / 60))
    except Exception:
        return None


def _day_running_high_low(candles: List[Dict], i: int, day: str) -> Tuple[float, float, float]:
    running_low = float(candles[i]["low"])
    running_high = float(candles[i]["high"])
    day_open = float(candles[i]["open"])
    for j in range(i, -1, -1):
        if candles[j]["candle_time"][:8] != day:
            break
        running_low = min(running_low, float(candles[j]["low"]))
        running_high = max(running_high, float(candles[j]["high"]))
        day_open = float(candles[j]["open"])
    return running_high, running_low, day_open


def eval_momentum_buy_at_index(
    candles: List[Dict],
    i: int,
    params: Dict[str, Any],
    state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
    """TRIGGER V2: 추세추격 패턴 OR + 공통 가드 (시간·쿨다운·EMA·RSI약세·거래량)."""
    if i < 1 or i >= len(candles):
        return ("탈락-봉부족", f"인덱스 부적절 (i={i})", None)

    rsi_period = int(params.get("rsi_period", 3))
    rsi_min = float(params.get("mom_rsi_min", 50.0))
    rsi_max = float(params.get("mom_rsi_max", 80.0))
    time_start_hm = int(params.get("time_start_hm", 900))
    time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
    cooldown_min = float(params.get("cooldown_min", 10))
    max_daily = int(params.get("max_daily", 5))
    max_daily_chg = float(params.get("max_daily_chg", 20.0))
    min_price = float(params.get("min_price", 1000.0))
    use_defense = _to_bool(params.get("use_defense_filters"), True)
    use_high_chase_f = _to_bool(params.get("use_high_chase_filter"), False)
    use_daily_range_f = _to_bool(params.get("use_daily_range_filter"), False)
    use_ema_filter = _to_bool(params.get("use_ema_filter"), True)
    use_rsi_max_filter = _to_bool(params.get("use_rsi_max_filter"), False)
    ema_fast_period = int(params.get("ema_fast_period", 9))
    ema_slow_period = int(params.get("ema_slow_period", 21))
    high_chase_thr = float(params.get("high_chase_thr", 0.96))

    c = candles[i]
    day = c["candle_time"][:8]
    hm = int(c["candle_time"][8:12])
    cl = float(c["close"])

    if hm < time_start_hm or hm >= time_end_hm:
        return (None, None, None)

    if use_defense and cl < min_price:
        return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None)

    last_exit_dt = state.get("last_exit_dt")
    if last_exit_dt is not None:
        elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60
        if elapsed < cooldown_min:
            return (None, None, None)

    if state.get("daily_cnt", 0) >= max_daily:
        return (None, None, None)

    closes = [float(x["close"]) for x in candles]
    ic = params.get("_indicator_cache")
    if ic is not None and hasattr(ic, "rsi_at"):
        rsi = ic.rsi_at(i, rsi_period)
    else:
        rsis = compute_rsi_series(closes, rsi_period)
        rsi = rsis[i] if i < len(rsis) else None
    if rsi is None:
        return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None)
    if rsi <= 0.0:
        return ("탈락-RSI무효", "RSI=0.0 (봉 부족)", None)
    if rsi < rsi_min:
        return ("탈락-모멘텀약함", "RSI=%.1f < %.0f" % (rsi, rsi_min), None)
    if use_rsi_max_filter and rsi > rsi_max:
        return ("탈락-과열끝물", "RSI=%.1f > %.0f" % (rsi, rsi_max), None)

    ema_fast_val = ic.ema_at(i, ema_fast_period) if ic is not None and hasattr(ic, "ema_at") else None
    ema_slow_val = ic.ema_at(i, ema_slow_period) if ic is not None and hasattr(ic, "ema_at") else None
    ema_rej, ema_msg = eval_ema_uptrend_reject(
        closes, i, cl,
        use_filter=use_ema_filter,
        fast_period=ema_fast_period,
        slow_period=ema_slow_period,
        ema_fast_val=ema_fast_val,
        ema_slow_val=ema_slow_val,
    )
    if ema_rej:
        return (ema_rej, ema_msg, None)

    running_high, running_low, day_open = _day_running_high_low(candles, i, day)

    if use_daily_range_f and running_low > 0:
        daily_chg_pct = (running_high - running_low) / running_low * 100
        if daily_chg_pct > max_daily_chg:
            return ("탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg), None)

    if use_high_chase_f and running_high > 0 and cl >= running_high * high_chase_thr:
        return (
            "탈락-고점추격",
            "현재가 %.0f ≥ 고가 %.0f × %.2f" % (cl, running_high, high_chase_thr),
            None,
        )

    mom_max_from_open = float(params.get("mom_max_from_open_pct", 999.0))
    mom_min_from_open = float(params.get("mom_min_from_open_pct", -999.0))
    if day_open > 0 and mom_max_from_open < 900:
        from_open_pct = (cl / day_open - 1) * 100
        if from_open_pct > mom_max_from_open:
            return ("탈락-끝물", "시가+%.1f%% > +%.0f%%" % (from_open_pct, mom_max_from_open), None)
        if from_open_pct < mom_min_from_open:
            return ("탈락-약세", "시가%+.1f%% < %+.0f%%" % (from_open_pct, mom_min_from_open), None)

    pat_ok, pat_name, pat_metrics = eval_momentum_chase_pattern(candles, i, params)
    if not pat_ok:
        return (
            "탈락-패턴미충족",
            "추격패턴(%s) 미충족" % pat_name,
            None,
        )

    sig: Dict[str, Any] = {
        "signal": True,
        "rsi": rsi,
        "mode": "momentum",
        "pattern": pat_name,
        "signal_candle_time": c.get("candle_time"),
    }
    if isinstance(pat_metrics, dict):
        sig.update(pat_metrics)
    ws_rej, ws_msg = whipsaw_reject_for_signal(
        params, "MOMENTUM",
        signal_bar=c,
        current_price=cl,
    )
    if ws_rej:
        return (ws_rej, ws_msg, None)
    ob_rej, ob_msg = orderbook_reject_for_entry(
        params, "MOMENTUM", current_price=cl,
    )
    if ob_rej:
        return (ob_rej, ob_msg, None)
    prog_rej, prog_msg = program_reject_for_entry(
        params, "MOMENTUM", current_price=cl,
    )
    if prog_rej:
        return (prog_rej, prog_msg, None)
    return (None, None, sig)


def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
    out: List[Dict] = []
    for c in candles:
        if c.get("is_confirmed") in (0, False, "0", "false"):
            continue
        out.append(c)
    if len(out) < len(candles) * 0.5:
        return list(candles)
    return out


def check_buy_signal_momentum_live(
    candles: List[Dict],
    params: Dict[str, Any],
    state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
    """실매·백테 공용 모멘텀 진입."""
    live_align = _to_bool(params.get("live_backtest_align", True), True)
    lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
    confirmed = _confirmed_candles_only(candles)

    if len(confirmed) < 6:
        return ("탈락-봉부족", "확정봉 6개 미만", None)

    last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
        None, None, None,
    )

    if live_align:
        entry_i = len(confirmed) - 1
        for k in range(lookback):
            signal_i = entry_i - 1 - k
            if signal_i < 1:
                break
            reject, msg, sig = eval_momentum_buy_at_index(
                confirmed, signal_i, params, state,
            )
            if reject:
                if k == 0:
                    last_reject = (reject, msg, None)
                continue
            if sig:
                ent = confirmed[entry_i]
                entry_open = float(ent.get("open", 0) or 0)
                if entry_open <= 0:
                    entry_open = float(ent.get("close", 0) or 0)
                sig["entry_price"] = entry_open
                sig["entry_bar_key"] = ent.get("candle_time")
                return (None, None, sig)
        return last_reject

    i = len(confirmed) - 1
    return eval_momentum_buy_at_index(confirmed, i, params, state)


def check_sell_signal_momentum_live(
    position: Dict[str, Any],
    current_candle: Dict[str, Any],
    params: Dict[str, Any],
    is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
    """추세추격 전용 청산 — 실매·백테·파서치 공용 (SCALP reversal 과 분리).

    [청산 우선순위 — 스캘핑 V4 어깨 선행과 동일 계열]
      1순위 래칫컷 / 어깨컷 — 고점 대비 되돌림 (상승 보유 → 하락 시 매도)
      2순위 트레일컷
      3순위 손절
      4순위 시간컷
      5순위 금액손실컷 (어깨·래칫 미발동 시)
      6순위 익절 — tp_max 상한 (하드 캡)
      7순위 장마감청산
    """
    sl_pct = -abs(float(params.get("sl_pct", params.get("stop_loss_pct", 0.015))))
    tp_pct = effective_tp_pct_from_params(params)
    trail_pct = abs(float(params.get("trail_pct", 0.0) or 0.0))
    trail_arm_pct = abs(float(params.get("trail_arm_pct", 0.0) or 0.0))
    shoulder_min_high, shoulder_cut_pct = _shoulder_ratios(params)
    ratchet_tiers = _parse_ratchet_tiers(params)
    max_hold_bars = int(params.get("max_hold_bars", 0) or 0)
    max_loss_krw = float(params.get("max_loss_krw", 200_000.0))
    min_hold_sec = float(params.get("min_hold_sec", 30.0))
    min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015))

    try:
        hi = float(current_candle.get("high", current_candle["close"]))
        lo = float(current_candle.get("low", current_candle["close"]))
        cl = float(current_candle["close"])
    except Exception:
        return None

    candle_time = current_candle.get("candle_time", "")
    if candle_time and position.get("entry_time"):
        try:
            if (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() < min_hold_sec:
                return None
        except Exception:
            pass

    max_price = max(float(position.get("max_price", position["entry_price"])), hi)
    position["max_price"] = max_price

    entry = float(position["entry_price"])
    qty = int(position.get("qty", 1) or 1)
    sl_line = entry * (1 + sl_pct)
    tp_line = entry * (1 + tp_pct)

    if ratchet_tiers and entry > 0:
        peak_gain = (max_price - entry) / entry
        cut_ratio = 0.0
        for gain, cut in ratchet_tiers:
            if peak_gain >= gain:
                cut_ratio = cut
        if cut_ratio > 0.0:
            ratchet_line = max_price * (1.0 - cut_ratio)
            if lo <= ratchet_line:
                return ("래칫컷", ratchet_line)

    trail_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high)
    shoulder_line = max_price * (1.0 - shoulder_cut_pct) if trail_armed else 0.0
    if trail_armed and lo <= shoulder_line:
        return ("어깨컷", shoulder_line)

    if lo <= sl_line:
        return ("손절", sl_line)

    if trail_pct > 0 and max_price > entry:
        trail_arm_line = entry * (1.0 + trail_arm_pct)
        if trail_arm_pct <= 0 or max_price >= trail_arm_line:
            trail_line = max_price * (1.0 - trail_pct)
            if lo <= trail_line:
                return ("트레일컷", trail_line)

    if max_hold_bars > 0:
        held = _minutes_held(position, current_candle)
        if held is not None and held >= max_hold_bars:
            return ("시간컷", cl)

    shoulder_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high)
    profit_val = (lo - entry) * qty
    drop_pct = (entry - lo) / entry if entry > 0 else 0.0
    if (
        not shoulder_armed
        and not ratchet_tiers
        and profit_val <= -max_loss_krw
        and drop_pct >= min_drop_pct
    ):
        exit_px = entry - (max_loss_krw / qty) if qty > 0 else lo
        return ("금액손실컷", exit_px)

    if hi >= tp_line:
        return ("익절", tp_line)

    if is_eod:
        return ("장마감청산", cl)

    return None


def _intrabar_exit_prices(
    open_: float, high: float, low: float, close: float, n_checks: int,
) -> List[float]:
    n = max(2, int(n_checks))
    anchors: List[float] = []
    for px in (float(open_), float(high), float(low), float(close)):
        if not anchors or px != anchors[-1]:
            anchors.append(px)
    if len(anchors) == 1:
        return [anchors[0]] * n
    if n <= len(anchors):
        return anchors[:n]
    seg_count = len(anchors) - 1
    extras = n - len(anchors)
    per_seg = extras // seg_count
    rem = extras % seg_count
    out = [anchors[0]]
    for si in range(seg_count):
        a, b = anchors[si], anchors[si + 1]
        extras_here = per_seg + (1 if si < rem else 0)
        for j in range(1, extras_here + 1):
            t = j / (extras_here + 1)
            out.append(a + (b - a) * t)
        if out[-1] != b:
            out.append(b)
    return out[:n] if len(out) >= n else out + [out[-1]] * (n - len(out))


def check_sell_signal_momentum_backtest_bar(
    position: Dict[str, Any],
    candle: Dict[str, Any],
    params: Dict[str, Any],
    is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
    """백테: 1분 OHLC를 N회 가격 체크로 분할 → ``check_sell_signal_momentum_live`` 호출.

    실매 MomentumStrategy 와 동일 청산 함수·우선순위. intrabar 순서만 분봉 OHLC로 근사.
    """
    n_checks = get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6)
    if n_checks <= 1:
        return check_sell_signal_momentum_live(position, candle, params, is_eod)

    o = float(candle.get("open", candle["close"]))
    h = float(candle.get("high", candle["close"]))
    l = float(candle.get("low", candle["close"]))
    c = float(candle["close"])
    ct = candle.get("candle_time", "")
    prices = _intrabar_exit_prices(o, h, l, c, n_checks)
    result: Optional[Tuple[str, float]] = None
    for idx, px in enumerate(prices):
        mp = float(position.get("max_price", 0) or 0)
        if px > mp:
            position["max_price"] = px
            mp = px
        sim = {"open": o, "high": mp, "low": px, "close": px, "candle_time": ct}
        eod_here = bool(is_eod and idx == len(prices) - 1)
        result = check_sell_signal_momentum_live(position, sim, params, is_eod=eod_here)
        if result:
            return result
    return result


def build_universe_simulation_momentum(
    codes_candles: Dict[str, List[Dict]],
    top_n: int = 20,
    min_score: float = 4.0,
    scan_interval_min: int = 5,
) -> Dict[str, List[str]]:
    """모멘텀 SCAN 유니버스 시뮬레이션 (HTS scalp 조건검색 근사)."""
    slot_codes_scores: Dict[str, List[Tuple[str, float, float]]] = {}

    for code, rows in codes_candles.items():
        if len(rows) < 2:
            continue
        candles = [dict(r) for r in rows]
        by_day: Dict[str, List[Dict]] = {}
        for c in candles:
            day = c["candle_time"][:8]
            by_day.setdefault(day, []).append(c)

        for day, day_candles in by_day.items():
            day_candles.sort(key=lambda x: x["candle_time"])
            market_open_min = 9 * 60
            seen_slots = set()
            for c in day_candles:
                ct = c["candle_time"]
                hm = int(ct[8:12])
                total_min = (hm // 100) * 60 + (hm % 100)
                slot_min = (total_min // scan_interval_min) * scan_interval_min
                slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
                slot_key = day + str(slot_hm).zfill(4)
                if slot_key in seen_slots:
                    continue
                seen_slots.add(slot_key)

                slot_min_val = (slot_hm // 100) * 60 + (slot_hm % 100)
                as_of_min = max(market_open_min, slot_min_val - 1)
                as_of_hm = (as_of_min // 60) * 100 + (as_of_min % 60)
                as_of_str = day + str(as_of_hm).zfill(4)
                up_to = [x for x in day_candles if x["candle_time"] <= as_of_str]
                if len(up_to) < 2:
                    continue

                o = float(up_to[0]["open"])
                hi = max(float(x["high"]) for x in up_to)
                cl = float(up_to[-1]["close"])
                if o <= 0 or hi <= 0 or cl <= 0:
                    continue

                from_open_pct = (cl / o - 1) * 100
                if from_open_pct <= 0.3:
                    momentum_score = 0.0
                elif from_open_pct > 20.0:
                    momentum_score = 0.0
                else:
                    momentum_score = from_open_pct * 1.5

                chase_ratio = cl / hi
                chase_score = max(0.0, (chase_ratio - 0.85) * 50)
                up_count = sum(1 for x in up_to if float(x["close"]) > float(x["open"]))
                uptrend_score = (up_count / len(up_to)) * 5.0

                closes = [float(x["close"]) for x in up_to]
                rsis = compute_rsi_series(closes, 3)
                last_rsi = rsis[-1] if rsis and rsis[-1] is not None else 0.0
                if 50 <= last_rsi <= 70:
                    rsi_score = (last_rsi - 50) / 4.0
                elif 70 < last_rsi <= 80:
                    rsi_score = 5.0 - (last_rsi - 70) / 2.0
                else:
                    rsi_score = 0.0

                total_score = momentum_score + chase_score + uptrend_score + rsi_score
                vol_sum = sum(float(x.get("volume", 0)) for x in up_to)
                slot_codes_scores.setdefault(slot_key, []).append((code, total_score, vol_sum))

    universe_by_slot: Dict[str, List[str]] = {}
    for slot_key, lst in slot_codes_scores.items():
        lst = [(c, s, v) for c, s, v in lst if s >= min_score]
        lst.sort(key=lambda x: (-x[1], -x[2]))
        universe_by_slot[slot_key] = [x[0] for x in lst[:top_n]]
    return universe_by_slot


def run_momentum_backtest(
    codes_candles: Dict[str, List[Dict]],
    params: Dict[str, Any],
    universe_by_slot: Optional[Dict[str, List[str]]] = None,
    ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
    orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
    program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
) -> List[Dict]:
    """모멘텀 백테스트 — portfolio_mode 시 포트폴리오 모듈 위임."""
    if _to_bool(params.get("portfolio_mode"), True):
        from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio
        return run_momentum_backtest_portfolio(
            codes_candles, params, universe_by_slot=universe_by_slot,
            ticks_by_code=ticks_by_code,
            orderbook_by_code=orderbook_by_code,
            program_by_code=program_by_code,
        )

    rsi_period = int(params.get("rsi_period", 3))
    sl_pct = abs(float(params.get("sl_pct", 0.015)))
    tp_pct = effective_tp_pct_from_params(params)
    slot_money = float(params.get("slot_money", 300_000))
    fee_rate = float(params.get("fee_rate", 0.00015))
    sell_tax = float(params.get("sell_tax", 0.0018))
    time_start_hm = int(params.get("time_start_hm", 900))
    time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
    max_loss_krw = float(params.get("max_loss_krw", 200_000.0))

    all_trades: List[Dict] = []

    for code, rows in codes_candles.items():
        if len(rows) < max(rsi_period + 5, 6):
            continue
        candles = [dict(r) for r in rows]

        position: Optional[Dict] = None
        last_exit_dt: Dict[str, datetime] = {}
        daily_cnt: Dict[str, int] = {}
        cur_day = None
        from kis_trader.engine.momentum_tick_replay import (
            align_momentum_entry_from_ticks,
            momentum_live_align_enabled,
            resolve_momentum_sell_for_bar,
        )
        live_align = momentum_live_align_enabled(params)

        for i in range(rsi_period + 1, len(candles)):
            c = candles[i]
            day = c["candle_time"][:8]
            hm = int(c["candle_time"][8:12])
            cl = float(c["close"])

            if day != cur_day:
                cur_day = day

            is_eod = is_strategy_eod_bar(c["candle_time"], params, "MOMENTUM")

            if position is not None:
                if str(position["entry_time"])[:12] == str(c["candle_time"])[:12]:
                    continue
                sell_res = resolve_momentum_sell_for_bar(
                    position, c, params,
                    is_eod=is_eod,
                    ticks_by_code=ticks_by_code,
                    code=code,
                )
                if sell_res:
                    reason, exit_price, sell_time_key, hold_min, _exit_src = sell_res
                    qty = position["qty"]
                    buy_amt = position["entry_price"] * qty
                    sell_amt = exit_price * qty
                    pnl = (
                        sell_amt - buy_amt
                        - buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax
                    )
                    all_trades.append({
                        "code": code,
                        "buy_time": position["entry_time"],
                        "sell_time": sell_time_key,
                        "buy_price": position["entry_price"],
                        "sell_price": round(exit_price, 2),
                        "qty": qty,
                        "pnl": round(pnl),
                        "profit_rate": round(
                            (exit_price - position["entry_price"]) / position["entry_price"] * 100, 2
                        ),
                        "hold_min": hold_min,
                        "sell_reason": reason,
                        "rsi_entry": round(position.get("rsi", 0), 1),
                        "strategy": MOMENTUM_STRATEGY_ID,
                    })
                    last_exit_dt[day] = _t2dt(sell_time_key)
                    position = None
                continue

            if universe_by_slot is not None:
                slot_key = _slot_key(c["candle_time"], int(params.get("scan_interval_min", 1)))
                if code not in universe_by_slot.get(slot_key, []):
                    continue
            if hm < time_start_hm or hm >= time_end_hm:
                continue
            if live_align:
                if i < 6:
                    continue
                signal_idx = i - 1
                entry_bar_time = c["candle_time"]
                entry_open = float(c["open"])
                if entry_open <= 0:
                    continue
            else:
                if i < 5:
                    continue
                signal_idx = i
                if i + 1 >= len(candles):
                    continue
                next_c = candles[i + 1]
                if next_c["candle_time"][:8] != day:
                    continue
                entry_bar_time = next_c["candle_time"]
                entry_open = float(next_c["open"])
                if entry_open <= 0:
                    continue

            eval_params = dict(params)
            if universe_by_slot is not None:
                eval_params.setdefault("skip_hts_scan_dupes", True)
            else:
                eval_params.setdefault("skip_hts_scan_dupes", False)
            state = {
                "daily_cnt": daily_cnt.get(day, 0),
                "last_exit_dt": last_exit_dt.get(day),
            }
            reject, _msg, sig = eval_momentum_buy_at_index(
                candles, signal_idx, eval_params, state,
            )
            if reject or not sig:
                continue
            rsi = float(sig.get("rsi") or 0)

            entry_price, entry_time_key, _src = align_momentum_entry_from_ticks(
                ticks_by_code, code, entry_bar_time, entry_open, params,
            )

            invest_amount = slot_money
            if max_loss_krw > 0 and sl_pct > 0:
                invest_amount = min(max_loss_krw / sl_pct, slot_money)
            qty = int(invest_amount / entry_price)
            if qty < 1:
                continue

            position = {
                "entry_price": entry_price,
                "entry_time": entry_time_key,
                "qty": qty,
                "stop": entry_price * (1 - sl_pct),
                "target": entry_price * (1 + tp_pct),
                "max_price": entry_price,
                "rsi": rsi,
            }
            daily_cnt[day] = daily_cnt.get(day, 0) + 1

    return all_trades
