#!/usr/bin/env python3
"""
kis_trader/engine/dbband_engine.py — 더블 볼린저 백테스트·실매매 공통 엔진
==========================================================================
캐시 리엔 더블 BB(20/2 + 20/3) + 추세 MA(기본 200) 필터.

■ LONG (우상향 지수·ETF용, 기본)
  - 추세: 종가 > SMA(trend_ma) 일 때만 매수
  - 신호봉: 저가가 outer(σ3) 하단 밖 → 종가가 inner(σ2) 밴드 안으로 복귀
  - 진입: 신호봉 고가 돌파 (또는 다음 봉 시가)
  - 손절: 신호봉 저점(또는 %/버퍼)
  - 익절: 반대편 inner 상단 밴드 / 고정% / 손익비

■ SHORT (선택, side_mode=short_only|both)
  - 추세: 종가 < SMA 일 때만
  - 신호: 고가 outer 상단 밖 → 종가 inner 안 복귀 → 신호봉 저가 이탈 시 진입

■ 청산 (exit_mode 로 전략 분리 — 단타 V4 와 혼용 금지)
  - classic (기본·영상 원형): 익절(반대 2σ / RR / %) → 손절(신호저점)
  - v4_scalp (선택·기존 단타식): 어깨컷 → 익절 → 손절 → 트레일 → 시간컷 → 장마감
"""

from __future__ import annotations

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

from kis_trader.engine.dbband_env_keys import (
    dbband_env_bool,
    dbband_env_float,
    dbband_env_int,
)

# 종목별 dbband_stock_config · 웹·파라서치 공통 키
CFG_ENGINE_KEYS: Tuple[str, ...] = (
    "bb_period",
    "bb_inner_std",
    "bb_outer_std",
    "trend_ma_period",
    "use_trend_filter",
    "side_mode",
    "entry_valid_bars",
    "entry_mode",
    "stop_mode",
    "stop_buffer_pct",
    "stop_loss_pct",
    "tp_mode",
    "take_profit_pct",
    "rr_ratio",
    "exit_mode",
    "shoulder_min_high_pct",
    "shoulder_cut_pct",
    "trail_pct",
    "trail_arm_pct",
    "max_hold_bars",
    "slot_money",
    "cooldown_min",
    "max_daily",
)

DEFAULT_DBBAND_CONFIG: Dict[str, Any] = {
    "bb_period": 20.0,
    "bb_inner_std": 2.0,
    "bb_outer_std": 3.0,
    "trend_ma_period": 200.0,
    "use_trend_filter": 1.0,
    "side_mode": "long_only",
    "entry_valid_bars": 3.0,
    "entry_mode": "break_high",
    "stop_mode": "signal_low",
    "stop_buffer_pct": 0.1,
    "stop_loss_pct": 2.0,
    "tp_mode": "opposite_band",
    "take_profit_pct": 3.0,
    "rr_ratio": 2.0,
    "exit_mode": "classic",
    "shoulder_min_high_pct": 0.3,
    "shoulder_cut_pct": 0.2,
    "trail_pct": 0.0,
    "trail_arm_pct": 0.0,
    "max_hold_bars": 0.0,
    "slot_money": 3_000_000.0,
    "cooldown_min": 15.0,
    "max_daily": 3.0,
    "timeframe": 15.0,
    "force_eod_exit": 0.0,
}


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(candle_time: str) -> datetime:
    return datetime.strptime(str(candle_time)[:12], "%Y%m%d%H%M")


def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
    confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)]
    return confirmed if confirmed else list(candles)


def _sma_series(values: List[float], period: int) -> List[Optional[float]]:
    n = len(values)
    out: List[Optional[float]] = [None] * n
    if period <= 0:
        return out
    for i in range(period - 1, n):
        window = values[i - period + 1: i + 1]
        if all(x > 0 for x in window):
            out[i] = sum(window) / period
    return out


def _rolling_std(values: List[float], period: int) -> List[Optional[float]]:
    n = len(values)
    out: List[Optional[float]] = [None] * n
    if period <= 1:
        return out
    for i in range(period - 1, n):
        window = values[i - period + 1: i + 1]
        if not all(x > 0 for x in window):
            continue
        mean = sum(window) / period
        var = sum((x - mean) ** 2 for x in window) / period
        out[i] = math.sqrt(var)
    return out


def compute_bb_series(
    closes: List[float],
    period: int,
    std_mult: float,
) -> Tuple[List[Optional[float]], List[Optional[float]], List[Optional[float]]]:
    """SMA 중심선, 상단, 하단 시리즈."""
    mid = _sma_series(closes, period)
    stds = _rolling_std(closes, period)
    n = len(closes)
    upper: List[Optional[float]] = [None] * n
    lower: List[Optional[float]] = [None] * n
    for i in range(n):
        if mid[i] is not None and stds[i] is not None:
            upper[i] = mid[i] + std_mult * stds[i]
            lower[i] = mid[i] - std_mult * stds[i]
    return mid, upper, lower


def get_dbband_defaults_from_db(db=None) -> Dict[str, Any]:
    """env_config + config_dbband 병합 → 엔진 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 {}

        bb_period = dbband_env_int(r, "DBBAND_BB_PERIOD", 20)
        inner_std = dbband_env_float(r, "DBBAND_BB_INNER_STD", 2.0)
        outer_std = dbband_env_float(r, "DBBAND_BB_OUTER_STD", 3.0)
        trend_ma = dbband_env_int(r, "DBBAND_TREND_MA_PERIOD", 200)
        use_trend = dbband_env_bool(r, "DBBAND_USE_TREND_FILTER", True)
        side_mode = str(r.get("DBBAND_SIDE_MODE") or "long_only").strip().lower()
        entry_valid = dbband_env_int(r, "DBBAND_ENTRY_VALID_BARS", 3)
        entry_mode = str(r.get("DBBAND_ENTRY_MODE") or "break_high").strip().lower()
        stop_mode = str(r.get("DBBAND_STOP_MODE") or "signal_low").strip().lower()
        stop_buffer = dbband_env_float(r, "DBBAND_STOP_BUFFER_PCT", 0.1)
        sl_pct = abs(dbband_env_float(r, "DBBAND_STOP_LOSS_PCT", 2.0)) / 100.0
        tp_mode = str(r.get("DBBAND_TP_MODE") or "opposite_band").strip().lower()
        tp_pct = dbband_env_float(r, "DBBAND_TAKE_PROFIT_PCT", 3.0) / 100.0
        rr_ratio = dbband_env_float(r, "DBBAND_RR_RATIO", 2.0)
        shoulder_high = dbband_env_float(r, "DBBAND_SHOULDER_MIN_HIGH_PCT", 0.3) / 100.0
        shoulder_cut = dbband_env_float(r, "DBBAND_SHOULDER_CUT_PCT", 0.2) / 100.0
        trail_pct = abs(dbband_env_float(r, "DBBAND_TRAIL_PCT", 0.0)) / 100.0
        trail_arm = abs(dbband_env_float(r, "DBBAND_TRAIL_ARM_PCT", 0.0)) / 100.0
        cooldown_sec = dbband_env_int(r, "DBBAND_COOLDOWN_SEC", 900)
        time_start = dbband_env_int(r, "DBBAND_TIME_START", 930)
        time_end = dbband_env_int(r, "DBBAND_TIME_END", 1500)
        max_daily = dbband_env_int(r, "DBBAND_MAX_DAILY", 3)
        min_price = dbband_env_float(r, "DBBAND_MIN_PRICE", 1000.0)
        slot_money = dbband_env_int(r, "DBBAND_SLOT_MONEY", 3_000_000)
        max_stocks = dbband_env_int(r, "DBBAND_MAX_STOCKS", 3)
        total_budget = dbband_env_int(r, "DBBAND_TOTAL_BUDGET_KRW", 0)
        max_hold = dbband_env_int(r, "DBBAND_MAX_HOLD_BARS", 0)
        timeframe = dbband_env_int(r, "DBBAND_TIMEFRAME", 15)
        min_invest = dbband_env_float(r, "DBBAND_MIN_INVEST_RATIO_OF_SLOT", 0.9)
        force_eod = dbband_env_bool(r, "DBBAND_FORCE_EOD_EXIT", False)
        exit_mode = str(r.get("DBBAND_EXIT_MODE") or "classic").strip().lower()

        return {
            "bb_period": bb_period,
            "bb_inner_std": inner_std,
            "bb_outer_std": outer_std,
            "trend_ma_period": trend_ma,
            "use_trend_filter": use_trend,
            "side_mode": side_mode,
            "entry_valid_bars": max(1, entry_valid),
            "entry_mode": entry_mode,
            "stop_mode": stop_mode,
            "stop_buffer_pct": stop_buffer / 100.0,
            "sl_pct": sl_pct,
            "tp_mode": tp_mode,
            "tp_pct": tp_pct,
            "rr_ratio": rr_ratio,
            "exit_mode": exit_mode,
            "shoulder_min_high": shoulder_high,
            "shoulder_cut_pct": shoulder_cut,
            "trail_pct": trail_pct,
            "trail_arm_pct": trail_arm,
            "cooldown_min": cooldown_sec / 60.0,
            "time_start_hm": time_start,
            "time_end_hm": time_end,
            "max_daily": max_daily,
            "min_price": min_price,
            "slot_money": slot_money,
            "max_stocks": max_stocks,
            "total_budget_krw": total_budget,
            "max_hold_bars": max_hold,
            "timeframe": timeframe,
            "min_invest_ratio_of_slot": min_invest,
            "force_eod_exit": force_eod,
        }
    finally:
        if own_db is not None:
            try:
                own_db.close()
            except Exception:
                pass
    return {}


def _hm_from_candle(candle_time: str) -> int:
    s = str(candle_time)
    return int(s[8:12]) if len(s) >= 12 else 0


def _resolve_stop_price(
    side: str,
    entry: float,
    signal_low: float,
    signal_high: float,
    params: Dict[str, Any],
) -> float:
    mode = str(params.get("stop_mode") or "signal_low").strip().lower()
    buf = float(params.get("stop_buffer_pct") or 0.0)
    sl_pct = abs(float(params.get("sl_pct") or 0.02))
    if side == "long":
        if mode == "pct":
            return entry * (1.0 - sl_pct)
        if mode == "buffer_pct":
            return signal_low * (1.0 - buf)
        return signal_low * (1.0 - buf) if buf > 0 else signal_low
    if mode == "pct":
        return entry * (1.0 + sl_pct)
    if mode == "buffer_pct":
        return signal_high * (1.0 + buf)
    return signal_high * (1.0 + buf) if buf > 0 else signal_high


def _resolve_target_price(
    side: str,
    entry: float,
    stop: float,
    inner_upper: Optional[float],
    inner_lower: Optional[float],
    params: Dict[str, Any],
) -> float:
    tp_mode = str(params.get("tp_mode") or "opposite_band").strip().lower()
    tp_pct = abs(float(params.get("tp_pct") or 0.03))
    rr = abs(float(params.get("rr_ratio") or 2.0))
    risk = abs(entry - stop)
    if side == "long":
        if tp_mode == "opposite_band" and inner_upper is not None and inner_upper > entry:
            return float(inner_upper)
        if tp_mode == "rr" and risk > 0:
            return entry + risk * rr
        return entry * (1.0 + tp_pct)
    if tp_mode == "opposite_band" and inner_lower is not None and inner_lower < entry:
        return float(inner_lower)
    if tp_mode == "rr" and risk > 0:
        return entry - risk * rr
    return entry * (1.0 - tp_pct)


def _long_signal_bar(
    i: int,
    lows: List[float],
    closes: List[float],
    inner_lower: List[Optional[float]],
    inner_upper: List[Optional[float]],
    outer_lower: List[Optional[float]],
    trend_ma: List[Optional[float]],
    params: Dict[str, Any],
) -> bool:
    if i < 0 or i >= len(closes):
        return False
    il = inner_lower[i]
    iu = inner_upper[i]
    ol = outer_lower[i]
    cl = closes[i]
    lo = lows[i]
    if il is None or iu is None or ol is None or cl <= 0 or lo <= 0:
        return False
    use_trend = _to_bool(params.get("use_trend_filter"), True)
    if use_trend:
        tm = trend_ma[i]
        if tm is None or cl <= tm:
            return False
    if lo >= ol:
        return False
    if cl <= il or cl >= iu:
        return False
    return True


def _short_signal_bar(
    i: int,
    highs: List[float],
    closes: List[float],
    inner_lower: List[Optional[float]],
    inner_upper: List[Optional[float]],
    outer_upper: List[Optional[float]],
    trend_ma: List[Optional[float]],
    params: Dict[str, Any],
) -> bool:
    if i < 0 or i >= len(closes):
        return False
    il = inner_lower[i]
    iu = inner_upper[i]
    ou = outer_upper[i]
    cl = closes[i]
    hi = highs[i]
    if il is None or iu is None or ou is None or cl <= 0 or hi <= 0:
        return False
    use_trend = _to_bool(params.get("use_trend_filter"), True)
    if use_trend:
        tm = trend_ma[i]
        if tm is None or cl >= tm:
            return False
    if hi <= ou:
        return False
    if cl >= iu or cl <= il:
        return False
    return True


def _try_entry_on_bar(
    side: str,
    bar_i: int,
    signal_i: int,
    candles: List[Dict],
    highs: List[float],
    lows: List[float],
    opens: List[float],
    params: Dict[str, Any],
) -> Optional[Tuple[float, str]]:
    """신호봉 이후 bar_i 에서 돌파 진입 시도."""
    entry_mode = str(params.get("entry_mode") or "break_high").strip().lower()
    sig_hi = highs[signal_i]
    sig_lo = lows[signal_i]
    if side == "long":
        trigger = sig_hi
        if entry_mode == "next_open" and bar_i == signal_i + 1:
            op = opens[bar_i]
            return (op, "next_open") if op > 0 else None
        if highs[bar_i] >= trigger and trigger > 0:
            op = opens[bar_i]
            fill = max(trigger, op) if op > 0 else trigger
            return (fill, "break_high")
    else:
        trigger = sig_lo
        if entry_mode == "next_open" and bar_i == signal_i + 1:
            op = opens[bar_i]
            return (op, "next_open") if op > 0 else None
        if lows[bar_i] <= trigger and trigger > 0:
            op = opens[bar_i]
            fill = min(trigger, op) if op > 0 else trigger
            return (fill, "break_low")
    return None


def _normalize_exit_mode(params: Dict[str, Any]) -> str:
    """classic=영상 원형 BB 청산, v4_scalp=단타·모멘텀 V4 어깨컷 체계."""
    mode = str(params.get("exit_mode") or "classic").strip().lower()
    if mode in ("classic", "video", "video_pure", "bb", "original"):
        return "classic"
    if mode in ("v4", "v4_scalp", "scalp", "v4scalp"):
        return "v4_scalp"
    return "classic"


def _check_sell_classic(
    position: Dict[str, Any],
    current_candle: Dict[str, Any],
    params: Dict[str, Any],
    *,
    inner_upper: Optional[float] = None,
    inner_lower: Optional[float] = None,
) -> Optional[Tuple[str, float]]:
    """
    영상 원형 청산 — 반대 2σ 밴드(또는 RR/%) 익절, 신호저점 손절만.
    어깨컷·장마감·시간컷 없음 (다른 전략과 분리).
    """
    side = str(position.get("side") or "long").strip().lower()
    hi = float(current_candle.get("high", current_candle["close"]))
    lo = float(current_candle.get("low", current_candle["close"]))
    ep = float(position["entry_price"])
    stop = float(position["stop"])
    target = float(position["target"])

    if side == "long":
        tp_touch = target
        if str(params.get("tp_mode") or "").strip().lower() == "opposite_band":
            if inner_upper is not None and inner_upper > ep:
                tp_touch = float(inner_upper)
        if hi >= tp_touch:
            return ("익절", tp_touch)
        if lo <= stop:
            return ("손절", stop)
    else:
        tp_touch = target
        if str(params.get("tp_mode") or "").strip().lower() == "opposite_band":
            if inner_lower is not None and inner_lower < ep:
                tp_touch = float(inner_lower)
        if lo <= tp_touch:
            return ("익절", tp_touch)
        if hi >= stop:
            return ("손절", stop)
    return None


def _check_sell_v4_scalp(
    position: Dict[str, Any],
    current_candle: Dict[str, Any],
    params: Dict[str, Any],
    *,
    inner_upper: Optional[float] = None,
    inner_lower: Optional[float] = None,
    is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
    """
    단타 V4 청산 (모멘텀·스캘핑과 동일 계열) — exit_mode=v4_scalp 일 때만.
    """
    side = str(position.get("side") or "long").strip().lower()
    shoulder_min_high = float(params.get("shoulder_min_high") or 0.003)
    shoulder_cut_pct = float(params.get("shoulder_cut_pct") or 0.002)
    trail_pct = abs(float(params.get("trail_pct") or 0.0))
    trail_arm_pct = abs(float(params.get("trail_arm_pct") or 0.0))
    max_hold = int(float(params.get("max_hold_bars") or 0))

    hi = float(current_candle.get("high", current_candle["close"]))
    lo = float(current_candle.get("low", current_candle["close"]))
    cl = float(current_candle["close"])
    candle_time = current_candle.get("candle_time", "")

    ep = float(position["entry_price"])
    stop = float(position["stop"])
    target = float(position["target"])
    max_p = float(position.get("max_price") or ep)
    min_p = float(position.get("min_price") or ep)

    if side == "long":
        max_p = max(max_p, hi)
        min_p = min(min_p, lo)
    else:
        min_p = min(min_p, lo)
        max_p = max(max_p, hi)

    reason = None
    exit_price = cl
    trail_armed = False

    if side == "long":
        if max_p >= ep * (1.0 + shoulder_min_high):
            trail_armed = True
            trail_stop = max_p * (1.0 - shoulder_cut_pct)
            if lo <= trail_stop:
                reason = "어깨컷"
                exit_price = trail_stop
        if not reason:
            tp_touch = target
            if str(params.get("tp_mode") or "").strip().lower() == "opposite_band":
                if inner_upper is not None and inner_upper > ep:
                    tp_touch = inner_upper
            if hi >= tp_touch:
                reason = "익절"
                exit_price = tp_touch
        if not reason and lo <= stop:
            reason = "손절"
            exit_price = stop
        if not reason and trail_pct > 0 and max_p > ep:
            arm_line = ep * (1.0 + trail_arm_pct)
            if max_p >= arm_line:
                tline = max_p * (1.0 - trail_pct)
                if lo <= tline:
                    reason = "트레일컷"
                    exit_price = tline
    else:
        if min_p <= ep * (1.0 - shoulder_min_high):
            trail_armed = True
            trail_stop = min_p * (1.0 + shoulder_cut_pct)
            if hi >= trail_stop:
                reason = "어깨컷"
                exit_price = trail_stop
        if not reason:
            tp_touch = target
            if str(params.get("tp_mode") or "").strip().lower() == "opposite_band":
                if inner_lower is not None and inner_lower < ep:
                    tp_touch = inner_lower
            if lo <= tp_touch:
                reason = "익절"
                exit_price = tp_touch
        if not reason and hi >= stop:
            reason = "손절"
            exit_price = stop
        if not reason and trail_pct > 0 and min_p < ep:
            arm_line = ep * (1.0 - trail_arm_pct)
            if min_p <= arm_line:
                tline = min_p * (1.0 + trail_pct)
                if hi >= tline:
                    reason = "트레일컷"
                    exit_price = tline

    if not reason and max_hold > 0 and position.get("entry_time") and candle_time:
        try:
            held = int(
                (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() // 60
            )
            tf = int(params.get("timeframe") or 15)
            bars_held = held // max(1, tf)
            if bars_held >= max_hold:
                reason = "시간컷"
                exit_price = cl
        except Exception:
            pass

    if not reason and is_eod and _to_bool(params.get("force_eod_exit"), True):
        reason = "장마감"
        exit_price = cl

    if reason:
        position["max_price"] = max_p if side == "long" else position.get("max_price", max_p)
        position["min_price"] = min_p if side == "short" else position.get("min_price", min_p)
        return (reason, exit_price)
    position["max_price"] = max_p
    position["min_price"] = min_p
    return None


def check_sell_signal_live(
    position: Dict[str, Any],
    current_candle: Dict[str, Any],
    params: Dict[str, Any],
    *,
    inner_upper: Optional[float] = None,
    inner_lower: Optional[float] = None,
    is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
    """
    실시간·백테 공통 청산 — exit_mode 에 따라 classic / v4_scalp 분기.
    position: entry_price, entry_time, stop, target, max_price, qty, side(long|short)
    """
    if _normalize_exit_mode(params) == "classic":
        return _check_sell_classic(
            position, current_candle, params,
            inner_upper=inner_upper, inner_lower=inner_lower,
        )
    return _check_sell_v4_scalp(
        position, current_candle, params,
        inner_upper=inner_upper, inner_lower=inner_lower, is_eod=is_eod,
    )


def check_buy_signal_live(
    candles: List[Dict],
    params: Dict[str, Any],
    state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
    """
    라이브 매수 신호 (LONG 기본, side_mode 에 따라 SHORT 신호도 동일 dict 로 side 태그).
    state: daily_cnt, last_exit_dt
    """
    from kis_trader.utils.env import get_env_int

    min_need = get_env_int("DBBAND_LIVE_MIN_CANDLES", 210)
    confirmed = _confirmed_candles_only(candles)
    if len(confirmed) < min_need:
        return ("탈락-봉부족", f"확정봉 {len(confirmed)} < {min_need}", None)

    lookback = max(1, get_env_int("DBBAND_LIVE_SIGNAL_LOOKBACK_BARS", 5))
    side_mode = str(params.get("side_mode") or "long_only").strip().lower()

    closes = [float(c["close"]) for c in confirmed]
    highs = [float(c["high"]) for c in confirmed]
    lows = [float(c["low"]) for c in confirmed]
    opens = [float(c["open"]) for c in confirmed]

    period = int(params.get("bb_period") or 20)
    inner_std = float(params.get("bb_inner_std") or 2.0)
    outer_std = float(params.get("bb_outer_std") or 3.0)
    trend_period = int(params.get("trend_ma_period") or 200)

    _, i_upper, i_lower = compute_bb_series(closes, period, inner_std)
    _, o_upper, o_lower = compute_bb_series(closes, period, outer_std)
    trend_ma = _sma_series(closes, trend_period)

    ent_i = len(confirmed) - 1
    time_start = int(params.get("time_start_hm") or 930)
    time_end = int(params.get("time_end_hm") or 1500)
    hm = _hm_from_candle(confirmed[ent_i]["candle_time"])
    if hm < time_start or hm > time_end:
        return (None, None, None)
    if int(state.get("daily_cnt") or 0) >= int(params.get("max_daily") or 3):
        return (None, None, None)

    last_exit = state.get("last_exit_dt")
    cooldown_min = float(params.get("cooldown_min") or 15.0)
    if last_exit is not None:
        elapsed = (_t2dt(confirmed[ent_i]["candle_time"]) - last_exit).total_seconds() / 60
        if elapsed < cooldown_min:
            return (None, None, None)

    valid_bars = int(params.get("entry_valid_bars") or 3)

    for k in range(lookback):
        sig_i = ent_i - 1 - k
        if sig_i < max(period, trend_period):
            break

        sides_to_try: List[str] = []
        if side_mode in ("long_only", "both"):
            sides_to_try.append("long")
        if side_mode in ("short_only", "both"):
            sides_to_try.append("short")

        for side in sides_to_try:
            if side == "long":
                if not _long_signal_bar(
                    sig_i, lows, closes, i_lower, i_upper, o_lower, trend_ma, params,
                ):
                    continue
            else:
                if not _short_signal_bar(
                    sig_i, highs, closes, i_lower, i_upper, o_upper, trend_ma, params,
                ):
                    continue

            for j in range(sig_i + 1, min(sig_i + 1 + valid_bars, ent_i + 1)):
                ent = _try_entry_on_bar(
                    side, j, sig_i, confirmed, highs, lows, opens, params,
                )
                if not ent:
                    continue
                entry_px, ent_src = ent
                min_px = float(params.get("min_price") or 1000.0)
                if entry_px < min_px:
                    return ("탈락-가격", f"진입가 {entry_px:,.0f} < 최소 {min_px:,.0f}", None)

                sig_lo = lows[sig_i]
                sig_hi = highs[sig_i]
                stop = _resolve_stop_price(side, entry_px, sig_lo, sig_hi, params)
                iu = i_upper[j] if j < len(i_upper) else i_upper[sig_i]
                il = i_lower[j] if j < len(i_lower) else i_lower[sig_i]
                target = _resolve_target_price(side, entry_px, stop, iu, il, params)

                return (
                    None,
                    None,
                    {
                        "signal": True,
                        "side": side,
                        "entry_price": entry_px,
                        "entry_bar_key": str(confirmed[j].get("candle_time", ""))[:12],
                        "signal_candle_key": str(confirmed[sig_i].get("candle_time", ""))[:12],
                        "stop_price": stop,
                        "target_price": target,
                        "entry_mode": ent_src,
                    },
                )

    return ("탈락-비신호", "더블BB 신호·돌파 미충족", None)


def run_dbband_backtest_single(
    candles: List[Dict],
    params: Dict[str, Any],
) -> List[Dict]:
    """단일 종목 시계열 백테스트."""
    if len(candles) < 50:
        return []

    closes = [float(c["close"]) for c in candles]
    highs = [float(c["high"]) for c in candles]
    lows = [float(c["low"]) for c in candles]
    opens = [float(c["open"]) for c in candles]
    times = [str(c["candle_time"]) for c in candles]

    period = int(params.get("bb_period") or 20)
    inner_std = float(params.get("bb_inner_std") or 2.0)
    outer_std = float(params.get("bb_outer_std") or 3.0)
    trend_period = int(params.get("trend_ma_period") or 200)
    side_mode = str(params.get("side_mode") or "long_only").strip().lower()
    valid_bars = int(params.get("entry_valid_bars") or 3)
    time_start = int(params.get("time_start_hm") or 930)
    time_end = int(params.get("time_end_hm") or 1500)

    _, i_upper, i_lower = compute_bb_series(closes, period, inner_std)
    _, o_upper, o_lower = compute_bb_series(closes, period, outer_std)
    trend_ma = _sma_series(closes, trend_period)

    min_i = max(period, trend_period) + 1
    trades: List[Dict] = []
    position: Optional[Dict[str, Any]] = None
    pending: Optional[Dict[str, Any]] = None
    daily_cnt: Dict[str, int] = {}
    last_exit_dt: Optional[datetime] = None
    cooldown_min = float(params.get("cooldown_min") or 15.0)

    def _day_key(t: str) -> str:
        return t[:8]

    def _is_eod_bar(i: int) -> bool:
        if i + 1 >= len(times):
            return True
        return _day_key(times[i]) != _day_key(times[i + 1])

    for i in range(min_i, len(candles)):
        c = candles[i]
        hm = _hm_from_candle(times[i])
        day = _day_key(times[i])

        if position is not None:
            pos = position
            iu = i_upper[i]
            il = i_lower[i]
            sell = check_sell_signal_live(
                pos, c, params,
                inner_upper=iu,
                inner_lower=il,
                is_eod=_is_eod_bar(i),
            )
            if sell:
                reason, exit_px = sell
                trades.append({
                    "entry_time": pos["entry_time"],
                    "exit_time": times[i],
                    "entry": round(pos["entry_price"]),
                    "exit": round(exit_px),
                    "qty": pos.get("qty", 1),
                    "side": pos.get("side", "long"),
                    "reason": reason,
                    "signal_time": pos.get("signal_time"),
                })
                position = None
                last_exit_dt = _t2dt(times[i])
            continue

        if pending is not None:
            sig_i = pending["signal_i"]
            side = pending["side"]
            if i > sig_i + valid_bars:
                pending = None
            else:
                ent = _try_entry_on_bar(side, i, sig_i, candles, highs, lows, opens, params)
                if ent:
                    entry_px, ent_src = ent
                    sig_lo = lows[sig_i]
                    sig_hi = highs[sig_i]
                    stop = _resolve_stop_price(side, entry_px, sig_lo, sig_hi, params)
                    target = _resolve_target_price(
                        side, entry_px, stop, i_upper[i], i_lower[i], params,
                    )
                    position = {
                        "entry_price": entry_px,
                        "entry_time": times[i],
                        "signal_time": times[sig_i],
                        "stop": stop,
                        "target": target,
                        "max_price": entry_px,
                        "min_price": entry_px,
                        "qty": 1,
                        "side": side,
                    }
                    daily_cnt[day] = daily_cnt.get(day, 0) + 1
                    pending = None
                    continue

        if hm < time_start or hm > time_end:
            continue
        if daily_cnt.get(day, 0) >= int(params.get("max_daily") or 3):
            continue
        if last_exit_dt is not None:
            elapsed = (_t2dt(times[i]) - last_exit_dt).total_seconds() / 60
            if elapsed < cooldown_min:
                continue

        sig_i = i
        sides: List[str] = []
        if side_mode in ("long_only", "both"):
            sides.append("long")
        if side_mode in ("short_only", "both"):
            sides.append("short")

        for side in sides:
            ok = False
            if side == "long":
                ok = _long_signal_bar(
                    sig_i, lows, closes, i_lower, i_upper, o_lower, trend_ma, params,
                )
            else:
                ok = _short_signal_bar(
                    sig_i, highs, closes, i_lower, i_upper, o_upper, trend_ma, params,
                )
            if not ok:
                continue
            ent_now = _try_entry_on_bar(side, i, sig_i, candles, highs, lows, opens, params)
            if ent_now:
                entry_px, _ = ent_now
                stop = _resolve_stop_price(side, entry_px, lows[sig_i], highs[sig_i], params)
                target = _resolve_target_price(
                    side, entry_px, stop, i_upper[i], i_lower[i], params,
                )
                position = {
                    "entry_price": entry_px,
                    "entry_time": times[i],
                    "signal_time": times[sig_i],
                    "stop": stop,
                    "target": target,
                    "max_price": entry_px,
                    "min_price": entry_px,
                    "qty": 1,
                    "side": side,
                }
                daily_cnt[day] = daily_cnt.get(day, 0) + 1
            else:
                pending = {"signal_i": sig_i, "side": side}
            break

    return trades


def run_dbband_backtest_portfolio(
    candles_by_code: Dict[str, List[Dict]],
    params: Dict[str, Any],
    universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> List[Dict]:
    """
    종목별 독립 백테 후 합산 (포트폴리오 동시보유 제약은 1차 단순화).
    universe_by_slot 이 있으면 해당 슬롯 종목만.
    """
    all_trades: List[Dict] = []
    allowed: Optional[set] = None
    if universe_by_slot:
        allowed = set()
        for codes in universe_by_slot.values():
            allowed.update(codes)

    for code, rows in candles_by_code.items():
        if allowed is not None and code not in allowed:
            continue
        tr = run_dbband_backtest_single(rows, params)
        for t in tr:
            t["code"] = code
            all_trades.append(t)

    all_trades.sort(key=lambda x: (x.get("entry_time") or "", x.get("code") or ""))
    return all_trades
