"""
kis_trader/strategies/breakout.py — 돌파 매매 (Breakout)
=============================================================
전제 (SCAN vs TRIGGER):
  * [SCAN] HTS 조건식은 **널넬하게** — 후보 풀만 확보 (등락·거래량·돌파를 HTS에서
    약하게 걸거나 일부 조건 제거). ETF/관리종목 제외 등 구조 필터만 HTS에 둠.
  * [TRIGGER] 본 파일 매수체크는 **엄격하게** — 등락·고가돌파·회전율(유통주식 대비)
    ·선택 vol_mult(직전봉 평균 대비 배수)·골든타임·이격·양봉 등은 **항상 코드**에서 검사.
    HTS 와 수치를 맞출 필요 없음 — HTS 는 후보만 넓게.
  * 진입은 '장 초반 골든타임' 한정 (09:00 ~ BREAKOUT_GOLDEN_END_HM).
    오후장 돌파는 거의 속임수(휩쏘) → 봇이 쫓지 않는다.

매수 필터 (네 가지 모두 만족):
  1) 저항선 돌파  : 최근 ``BREAKOUT_LOOKBACK_MIN`` 분봉의 고가 > 최대고가(전고점)
  2) 거래량(1차)  : 1분 거래량/유통주식 ≥ ``BREAKOUT_MIN_TURNOVER_1M_PCT``% (키움 ka10001 ``dstr_stk``)
  2b) 거래량(보조): ``BREAKOUT_VOL_MULT``>0 일 때만 — 돌파봉 ≥ 직전 ``BREAKOUT_VOL_WIN`` 평균×배수
  3) 상승 확증    : 직전 봉 종가 대비 현재가 상승 & 종가 > 시가
  4) 이격 과열 X  : 당일 상승률 ≤ BREAKOUT_MAX_DAILY_CHG (기본 15%)
  5) 최소 거래대금: 1분봉 거래량×가격 ≥ BREAKOUT_MIN_BAR_TRADE_VALUE_KRW (0=OFF)

매도 (돌파 전용 — 익절 우선, 모멘텀·스캘핑 V4 어깨 선행과 순위 다름):
  * 1순위 익절 : 진입가 × (1 + BREAKOUT_TAKE_PROFIT_PCT)  (기본 +5%)
  * 2순위 어깨컷 : 고가 ≥ 진입×(1+SHOULDER_MIN_HIGH) 무장 후, 고점 대비 SHOULDER_CUT 되돌림
  * 3순위 손절 : 진입가 × (1 + BREAKOUT_STOP_LOSS_PCT)  (기본 -2%)
  * 4순위 트레일링 : 최고가 대비 BREAKOUT_TRAIL_PCT 하락 (기본 1.5%, 어깨 미무장 시 보조)
  * EOD 강제청산 (15:15 이후 전량)
  * 백테 청산: 1분 OHLC → N회 intrabar (``check_sell_signal_backtest_bar`` + ``check_sell_signal_breakout_live``)

주문 집행은 모두 ``OrderManager.place()`` 경유 → ODNO·실잔고검증·종목락 공유.
"""
from __future__ import annotations

import time
from datetime import datetime as dt
from typing import Any, Dict, List, Optional, Tuple

from ..utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from ..utils.logger import get_logger

_bo_logger = get_logger("kis_trader.breakout")
from ..utils.position_sizing import invest_qty_for_price
from ..engine.ema_trend_filter import eval_ema_uptrend_reject
from ..engine.scalping_engine import check_sell_signal_backtest_bar
from ..engine.tail_engine import compute_atr_series
from ..engine.whipsaw_filter import whipsaw_reject_for_signal
from ..engine.orderbook_filter import orderbook_reject_for_entry
from ..engine.program_filter import program_reject_for_entry
from ..share.stock_share import share_denom_for_code
from .base import BaseStrategy, is_strategy_eod_bar


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 _confirmed_candles_only(candles: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)]
    return confirmed if confirmed else list(candles)


def _golden_end_hm_parts(raw: Any) -> Tuple[int, int]:
    """골든타임 종료 시각 — env ``10:30`` / ``1030`` / HHMM 정수 모두 허용."""
    try:
        s = str(raw or "").strip()
        if ":" in s:
            hh, mm = s.split(":", 1)
            return int(hh), int(mm)
        if s.isdigit() and len(s) >= 3:
            hm = int(s[:4]) if len(s) >= 4 else int(s)
            return hm // 100, hm % 100
    except Exception:
        pass
    return 10, 30


# ── 1회 최대 손실 / 슬롯 금액 (백테·탐색·웹 공통) ─────────────────────────────
# env 에 99_999_999 등 “금액손실컷 OFF” 가 들어있으면 포지션이 수억~수십억으로 부풀어
# PF·총손익이 말이 안 되게 나옴 → 1천만 이상은 실전 기본 20만으로 정규화.
MAX_LOSS_OFF_THRESHOLD_KRW = 10_000_000
DEFAULT_BREAKOUT_MAX_LOSS_KRW = 200_000


def normalize_breakout_max_loss_krw(val: Any, default: int = DEFAULT_BREAKOUT_MAX_LOSS_KRW) -> int:
    try:
        m = int(float(val))
    except (TypeError, ValueError):
        return int(default)
    if m <= 0 or m >= MAX_LOSS_OFF_THRESHOLD_KRW:
        return int(default)
    return m


def _breakout_shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]:
    """params/env의 어깨 비율(0.02=2%) — 엔진·백테·실매 공용.

    어깨컷은 '돌파 러너 보호용' 이다. 기본값을 스캘프급(0.3%/0.2%)에서
    무장 2% / 되돌림 1% 로 올려, 진입 직후 정상 출렁임에 털리지 않고
    +5% 익절(BREAKOUT_TAKE_PROFIT_PCT)까지 달릴 공간을 준다.
    (env BREAKOUT_SHOULDER_* 또는 파라서치 적용값이 있으면 그게 우선)
    """
    smh = params.get("shoulder_min_high")
    if smh is None:
        smh = get_env_float("BREAKOUT_SHOULDER_MIN_HIGH_PCT", 0.02)
    sc = params.get("shoulder_cut_pct")
    if sc is None:
        sc = get_env_float("BREAKOUT_SHOULDER_CUT_PCT", 0.01)
    return max(0.0, float(smh)), max(0.0, float(sc))


def _breakout_entry_atr_from_candles(
    candles: List[Dict[str, Any]], period: int,
) -> float:
    """진입 시점 변동성(ATR, RMA) — 마지막 확정 ATR 값. 백테·실매 공용.

    ATR(Average True Range)은 '최근 변동성'을 가격 단위로 나타내는 지표다.
    값이 클수록 그 종목이 위아래로 크게 출렁였다는 뜻 → 손절폭도 그만큼 넓혀야
    노이즈(휩쏘)에 안 털린다. 계산 불가(데이터 부족 등) 시 0.0 → 고정손절 폴백.
    """
    try:
        n = int(period)
        if not candles or len(candles) < n + 1:
            return 0.0
        atrs = compute_atr_series(candles, n)
        for a in reversed(atrs):
            if a is not None and float(a) > 0.0:
                return float(a)
    except Exception:
        pass
    return 0.0


def _breakout_sl_line(
    entry: float, sl_pct: float, position: Dict[str, Any], params: Dict[str, Any],
) -> float:
    """손절선 계산 — sl_mode='atr' 이면 변동성(ATR) 비례 동적 손절, 아니면 고정%(기존).

    동적 손절(%) = clamp( ATR×배수 / 진입가 , 하한% , 상한% )
      · 하한(atr_sl_min_pct): 변동성이 너무 작아 손절이 과도하게 타이트해지는 것 방지(휩쏘 방어)
      · 상한(atr_sl_max_pct): 비정상 급변동에서 손절이 너무 넓어져 큰 손실 보는 것 방지
    entry_atr 가 없거나(0) sl_mode!=atr 이면 기존 고정% 손절(sl_pct, 음수)로 폴백 → 회귀 불변.
    """
    sl_mode = str(params.get("sl_mode", "fixed") or "fixed").strip().lower()
    entry_atr = float(position.get("entry_atr") or 0.0)
    if sl_mode == "atr" and entry_atr > 0.0 and entry > 0.0:
        mult = float(params.get("atr_sl_mult", 2.0) or 2.0)
        sl_frac = (entry_atr * mult) / entry
        sl_min = abs(float(params.get("atr_sl_min_pct", 0.8) or 0.8)) / 100.0
        sl_max = abs(float(params.get("atr_sl_max_pct", 6.0) or 6.0)) / 100.0
        if sl_max < sl_min:
            sl_max = sl_min
        sl_frac = max(sl_min, min(sl_max, sl_frac))
        return entry * (1.0 - sl_frac)
    # 기존 고정% 손절 (sl_pct 음수) — sl_mode='fixed' 또는 ATR 데이터 없을 때
    return entry * (1 + sl_pct)


def _breakout_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]:
    """래칫형 단계 트레일 정의 파싱 → [(수익비율, 컷비율), ...] (수익 오름차순).

    수익 구간이 올라갈수록 되돌림 컷을 좁혀 이익을 단계적으로 잠그는 트레일.
    형식(env/params 모두 %단위 문자열): ``"2:1.5,5:1.0,8:0.7"``
      → +2% 수익부터 1.5% 되돌림컷, +5% 부터 1.0%, +8% 부터 0.7%.
    비어 있으면 [] (= 래칫 OFF, 기존 단일 어깨컷 사용). 기본 OFF.
    """
    raw = params.get("ratchet_tiers")
    if raw is None:
        raw = get_env_from_db("BREAKOUT_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 _breakout_max_hold_bars(params: Dict[str, Any]) -> int:
    """시간컷: 최대 보유 분(=1분봉 개수). 0=OFF(기본). env BREAKOUT_MAX_HOLD_BARS."""
    v = params.get("max_hold_bars")
    if v is None:
        v = get_env_int("BREAKOUT_MAX_HOLD_BARS", 0)
    try:
        return max(0, int(float(v)))
    except (TypeError, ValueError):
        return 0


def _bo_dt_to_minutes(s: Any) -> Optional[int]:
    """캔들시각/매수시각 → epoch 분(minute) 변환. 백테(YYYYMMDDHHMM)·실매(YYYY-MM-DD HH:MM:SS) 모두 허용."""
    if s is None:
        return None
    txt = str(s).strip()
    if not txt:
        return None
    try:
        if "-" in txt or ":" in txt:
            # 실매 포맷 "YYYY-MM-DD HH:MM:SS"
            d = dt.strptime(txt[:19], "%Y-%m-%d %H:%M:%S")
        else:
            # 백테 candle_time "YYYYMMDDHHMM[SS]"
            digits = "".join(ch for ch in txt if ch.isdigit())
            if len(digits) < 12:
                return None
            d = dt.strptime(digits[:12], "%Y%m%d%H%M")
        return int(d.timestamp() // 60)
    except Exception:
        return None


def _breakout_minutes_held(position: Dict[str, Any], current_candle: Dict[str, Any]) -> Optional[int]:
    """진입 이후 경과 분 — 시간컷(max_hold) 판정용. 산출 불가 시 None."""
    e = _bo_dt_to_minutes(position.get("entry_time") or position.get("buy_time"))
    n = _bo_dt_to_minutes(current_candle.get("candle_time"))
    if e is None or n is None:
        return None
    return max(0, n - e)


def breakout_invest_amount_krw(
    max_loss_krw: Any,
    sl_pct_ui: float,
    slot_money_cap: Any = 2_000_000,
) -> float:
    """손절 % 와 1회 최대손실로 투자금 산출. ``min(슬롯캡, max_loss / |sl|)``."""
    ml = normalize_breakout_max_loss_krw(max_loss_krw)
    sl_abs = abs(float(sl_pct_ui)) / 100.0
    cap = float(slot_money_cap or 2_000_000)
    if sl_abs <= 0:
        return cap
    return min(cap, ml / sl_abs)

# 진입 룰 모듈 함수 — 라이브(BreakoutStrategy.check_buy) ↔ 백테스트 100% 공유
# ════════════════════════════════════════════════════════════════════════════
# TRIGGER 진입 룰 (HTS SCAN 이 널넬어도 여기서 전부 재검사):
#   1) 직전봉 등락률 ∈ [prev_chg_min, prev_chg_max] — 전전봉 종가 대비 직전봉 종가 %
#   2) 직전 ``lookback_min`` 봉 최대고가 < 현재 종가
#   3) 1분 회전율 ≥ ``min_turnover_1m_pct`` (유통주식 대비, ka10001) — 1차 게이트
#   3b) ``vol_mult``>0 일 때만 — 현재 거래량 ≥ 직전 ``vol_window`` 평균 × 배수 (HTS 300%=3.0)
# + 코드 전용 가드:
#   4) 양봉(close > open)
#   5) 전봉 종가 초과(close > prev_close) — 2)와 중복될 수 있으나 종가 갭 안전판
#   6) 일중 누적 상승률 ≤ ``max_daily_chg`` (이격 과열 회피)
#   7) 최소가격 가드
# 시간대 가드(``time_start_hm`` ~ ``time_end_hm``)는 옵션 — 라이브는 외부의
# ``_is_golden_time`` 가 처리하므로 0~2400 으로 비활성화하고, 백테스트는
# 골든타임 (예: 0900~1030) 을 명시적으로 지정.
def _eval_breakout_buy_at_index(
    candles: List[Dict[str, Any]],
    i: int,
    params: Dict[str, Any],
    day_open: Optional[float] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
    """신호봉 인덱스 ``i`` 에서 돌파 매수 조건 평가 (백테 ``sub_candles[:i+1]`` 와 동일).

    [성능] 과거 구현은 ``sub = candles[:i+1]`` 로 매 호출마다 전체 히스토리를 복사하고,
    '당일 시가' 를 리스트 처음부터 정주행 스캔(O(n)) 했다. B안 틱재생 백테는 이 함수를
    틱마다(수백만 회) 호출하므로 O(n²)~O(n³) 로 폭증한다.
    → 동작은 **완전히 동일**하게 두고, ①전체 복사 제거(인덱스 직접 접근) ②당일 시가를
      뒤에서 앞으로(현재 봉 i → 당일 경계) 역스캔(O(당일봉수)) 으로 바꿔 비용을 낮춘다.
      호출측이 ``day_open`` 을 미리 계산해 넘기면 그 스캔조차 생략한다(분봉당 1회 재사용).
    """
    lookback_min = int(params.get("lookback_min", 1))
    vol_window = int(params.get("vol_window", 7))
    vol_mult = float(params.get("vol_mult", 0) or 0)
    prev_chg_min = float(params.get("prev_chg_min", 1.0))
    prev_chg_max = float(params.get("prev_chg_max", 10.0))
    max_daily_chg = float(params.get("max_daily_chg", 15.0))
    min_price = float(params.get("min_price", 1000.0))
    time_start_hm = int(params.get("time_start_hm", 0))
    time_end_hm = int(params.get("time_end_hm", 2400))
    # ── 가짜돌파(휩쏘) 필터 — 둘 다 0=OFF (기존과 100% 동일) ────────────────
    # confirm_margin_pct: 저항선을 이 %만큼 '확실히' 넘겨야 진입 (선만 찌르는 가짜돌파 차단)
    # body_min_pct       : 돌파봉 양봉 몸통(종가-시가)/시가 가 이 % 이상이어야 진입 (도지·윗꼬리 차단)
    confirm_margin_pct = float(params.get("confirm_margin_pct", 0.0) or 0.0)
    body_min_pct = float(params.get("body_min_pct", 0.0) or 0.0)
    # 1분봉 최소 거래대금(원) — 0=OFF. 초저유동 돌파(배수 착시) 차단.
    min_bar_trade_value = float(params.get("min_bar_trade_value_krw", 0) or 0)
    # 1분 회전율(%) — 유통주식 대비. 0=OFF. share_denom 은 라이브 WS 캐시.
    min_turnover_1m_pct = float(params.get("min_turnover_1m_pct", 0) or 0)
    share_denom = float(params.get("share_denom", 0) or 0)
    use_ema_filter = _to_bool(params.get("use_ema_filter"), False)
    ema_fast_period = int(params.get("ema_fast_period", 9))
    ema_slow_period = int(params.get("ema_slow_period", 21))

    need_len = max(lookback_min, vol_window) + 2
    if i < 2 or i + 1 > len(candles) or (i + 1) < need_len:
        return ("탈락-캔들부족", "have=%d need_len=%d i=%d" % (len(candles), need_len, i), None)

    # ※ sub=candles[:i+1] 복사 없이 인덱스로 직접 접근 (결과 동일)
    c = candles[i]
    prev_c = candles[i - 1]
    prev_prev_c = candles[i - 2]
    try:
        cl = float(c.get("close", 0))
        op = float(c.get("open", 0))
        vol = float(c.get("volume", 0))
        prev_close = float(prev_c.get("close", 0))
        prev_prev_close = float(prev_prev_c.get("close", 0))
    except Exception as e:
        return ("탈락-캔들파싱", str(e), None)

    # 시간대 가드 (라이브는 외부에서 골든타임 처리, 백테스트는 여기서)
    ct = c.get("candle_time", "")
    if ct and len(ct) >= 12:
        try:
            hm = int(ct[8:12])
            if hm < time_start_hm or hm >= time_end_hm:
                return (None, None, None)
        except Exception:
            pass

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

    # 2) 직전봉 등락률 (TRIGGER — HTS 는 널넬게, 여기서 1~10% 등으로 조임)
    if prev_prev_close <= 0:
        return ("탈락-직전봉(전전봉)없음", "prev_prev_close=0", None)
    prev_chg = (prev_close - prev_prev_close) / prev_prev_close * 100.0
    if prev_chg < prev_chg_min:
        return ("탈락-직전봉약세", "prev=%.2f%% < %.2f%%" % (prev_chg, prev_chg_min), None)
    if prev_chg > prev_chg_max:
        return ("탈락-직전봉과열", "prev=%.2f%% > %.2f%%" % (prev_chg, prev_chg_max), None)

    # 3) 저항선 돌파  (sub[-(lookback+1):-1] == candles[i-lookback:i])
    window_highs = [float(x.get("high", 0)) for x in candles[i - lookback_min:i]]
    if not window_highs:
        return ("탈락-저항창없음", "lookback=%d" % lookback_min, None)
    resistance = max(window_highs)
    if resistance <= 0 or cl <= resistance:
        gap_pct = (cl - resistance) / resistance * 100.0 if resistance > 0 else 0.0
        return ("탈락-저항미돌파", "close=%.0f ≤ 저항=%.0f (gap=%.2f%%)" % (cl, resistance, gap_pct), None)
    # 3-1) 돌파 확인 여유 — 저항을 confirm_margin_pct% 이상 확실히 넘겨야 진입 (가짜돌파 차단)
    if confirm_margin_pct > 0:
        need_price = resistance * (1.0 + confirm_margin_pct / 100.0)
        if cl < need_price:
            over_pct = (cl - resistance) / resistance * 100.0
            return (
                "탈락-돌파여유부족",
                "돌파 %.2f%% < 요구 %.2f%% (close=%.0f 저항=%.0f)"
                % (over_pct, confirm_margin_pct, cl, resistance),
                None,
            )

    # 3-2) 1분 최소 거래대금 — vol_mult 배수 착시(직전 1분 28주 등) 차단 (0=OFF)
    if min_bar_trade_value > 0:
        bar_trade_value = vol * cl
        if bar_trade_value < min_bar_trade_value:
            return (
                "탈락-거래대금부족",
                "%.0f원 < %.0f원 (vol=%.0f px=%.0f)"
                % (bar_trade_value, min_bar_trade_value, vol, cl),
                None,
            )

    # 3-3) 거래량 — 1차: 유통주식 대비 1분 회전율 (키움 ka10001 ``dstr_stk``)
    #      보조: vol_mult>0 일 때만 직전 vol_window 평균 대비 배수 (0=OFF)
    use_turnover = min_turnover_1m_pct > 0
    use_vol_mult = vol_mult > 0
    if use_turnover:
        if share_denom <= 0:
            return (
                "탈락-유통주식없음",
                "share_denom=0 (ka10001·stock_share_meta 필요)",
                None,
            )
        turnover_pct = (vol / share_denom) * 100.0
        if turnover_pct < min_turnover_1m_pct:
            return (
                "탈락-회전율부족",
                "%.3f%% < %.3f%% (vol=%.0f / denom=%.0f)"
                % (turnover_pct, min_turnover_1m_pct, vol, share_denom),
                None,
            )
    if use_vol_mult:
        vol_window_arr = [float(x.get("volume", 0)) for x in candles[i - vol_window:i]]
        if not vol_window_arr or sum(vol_window_arr) <= 0:
            return ("탈락-거래량창없음", "vol_window=%d" % vol_window, None)
        avg_vol = sum(vol_window_arr) / len(vol_window_arr)
        if avg_vol <= 0 or vol < avg_vol * vol_mult:
            ratio = (vol / avg_vol) if avg_vol > 0 else 0.0
            return (
                "탈락-거래량부족",
                "%.2fx < %.1fx (curr=%.0f avg=%.0f)" % (ratio, vol_mult, vol, avg_vol),
                None,
            )

    # 4) 양봉 (close > open) — 안전 가드
    if cl <= op:
        return ("탈락-음봉/도지", "close=%.0f ≤ open=%.0f" % (cl, op), None)
    # 5-1) 양봉 몸통 최소 — 몸통이 너무 얇으면(도지·윗꼬리) 가짜돌파로 보고 제외
    if body_min_pct > 0 and op > 0:
        body_pct = (cl - op) / op * 100.0
        if body_pct < body_min_pct:
            return ("탈락-몸통부족", "몸통 %.2f%% < %.2f%%" % (body_pct, body_min_pct), None)

    # 6) 전봉 종가 초과 — 갭 안전판
    if cl <= prev_close:
        chg = (cl - prev_close) / prev_close * 100.0 if prev_close > 0 else 0.0
        return ("탈락-전봉종가미달", "close=%.0f ≤ prev=%.0f (%.2f%%)" % (cl, prev_close, chg), None)

    closes_bo = params.get("_ema_full_closes")
    if closes_bo is not None:
        ema_i = int(params.get("_ema_full_index", len(closes_bo) - 1))
    else:
        closes_bo = [float(x.get("close", 0)) for x in candles]
        ema_i = i
    ic = params.get("_indicator_cache")
    ema_fast_val = ic.ema_at(ema_i, ema_fast_period) if ic is not None and hasattr(ic, "ema_at") else None
    ema_slow_val = ic.ema_at(ema_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_bo, ema_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)

    # 7) 일중 이격 과열 — 당일 시가 대비 누적 상승률
    #    day_open 미주입 시: 현재 봉 i 에서 뒤로 가며 '당일 첫 봉' 을 찾는다(역스캔, 결과 동일).
    if day_open is None:
        day = ct[:8] if (ct and len(ct) >= 8) else None
        if day:
            earliest = i
            j = i
            while j >= 0 and str(candles[j].get("candle_time", ""))[:8] == day:
                earliest = j
                j -= 1
            day_open = float(candles[earliest].get("open", 0))
        if day_open is None or day_open <= 0:
            day_open = float(candles[0].get("open", 0))
    if day_open and day_open > 0:
        daily_chg = (cl - day_open) / day_open * 100.0
        if daily_chg > max_daily_chg:
            return ("탈락-이격과열", "일중 %.2f%% > %.1f%%" % (daily_chg, max_daily_chg), None)

    vol_ratio = 0.0
    if use_vol_mult:
        _vw = [float(x.get("volume", 0)) for x in candles[i - vol_window:i]]
        _avg = sum(_vw) / len(_vw) if _vw else 0.0
        vol_ratio = (vol / _avg) if _avg > 0 else 0.0
    elif use_turnover and share_denom > 0:
        vol_ratio = (vol / share_denom) * 100.0
    sig = {
        "resistance": resistance,
        "vol_ratio": vol_ratio,
        "prev_chg": prev_chg,
        "close": cl,
        "signal_candle_time": ct,
    }
    ws_rej, ws_msg = whipsaw_reject_for_signal(
        params, "BREAKOUT",
        signal_bar=candles[i],
        current_price=cl,
        resistance=resistance,
    )
    if ws_rej:
        return (ws_rej, ws_msg, None)
    ob_rej, ob_msg = orderbook_reject_for_entry(
        params, "BREAKOUT",
        current_price=cl,
        resistance=resistance,
    )
    if ob_rej:
        return (ob_rej, ob_msg, None)
    prog_rej, prog_msg = program_reject_for_entry(
        params, "BREAKOUT", current_price=cl,
    )
    if prog_rej:
        return (prog_rej, prog_msg, None)
    return (None, None, sig)


# A안=확정봉 종가 돌파 → 다음 봉 시가 / B안=봉 내 전고점·저항 돌파 → 현재가(HTS E)
_INTRABAR_ENTRY_MODES = frozenset({"intrabar", "b", "live_b", "hts"})


def breakout_entry_mode(params: Optional[Dict[str, Any]] = None) -> str:
    """``BREAKOUT_ENTRY_MODE``: ``align``(A) | ``intrabar``(B, 기본)."""
    if params and params.get("entry_mode") is not None:
        return str(params.get("entry_mode") or "intrabar").strip().lower()
    return str(get_env_from_db("BREAKOUT_ENTRY_MODE", "intrabar") or "intrabar").strip().lower()


def check_buy_signal_breakout_intrabar_live(
    confirmed: List[Dict[str, Any]],
    current_price: float,
    forming_bar: Dict[str, Any],
    params: Dict[str, Any],
    day_open: Optional[float] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
    """
    B안 실매·백테 공용 — 직전 확정봉들로 필터, **현재가**로 저항 돌파 판정.

    * 저항·거래량·직전봉 등락은 확정봉+진행봉(OHLCV)으로 ``_eval_breakout_buy_at_index`` 재사용.
    * 진입가 = ``current_price`` (다음 봉 시가 대기 없음).
    """
    lookback_min = int(params.get("lookback_min", 1))
    vol_window = int(params.get("vol_window", 7))
    need_n = max(lookback_min, vol_window) + 2
    confirmed_only = _confirmed_candles_only(confirmed)

    if current_price <= 0:
        return ("탈락-가격없음", "WS 현재가 없음", None)
    if len(confirmed_only) < need_n - 1:
        return (
            "탈락-캔들부족",
            "confirmed=%d need>=%d (+진행봉)" % (len(confirmed_only), need_n - 1),
            None,
        )

    cur = dict(forming_bar or {})
    cur["close"] = float(current_price)
    try:
        cur["high"] = max(float(cur.get("high") or 0), float(current_price))
        cur["low"] = min(float(cur.get("low") or current_price), float(current_price))
    except Exception:
        cur["high"] = float(current_price)
        cur["low"] = float(current_price)
    if not cur.get("open") or float(cur.get("open") or 0) <= 0:
        cur["open"] = float(current_price)

    virtual = list(confirmed_only) + [cur]
    i = len(virtual) - 1
    reason, msg, sig = _eval_breakout_buy_at_index(virtual, i, params, day_open=day_open)
    if sig:
        sig["entry_price"] = float(current_price)
        sig["entry_bar_key"] = cur.get("candle_time")
        sig["entry_mode"] = "intrabar"
    return reason, msg, sig


def breakout_intrabar_entry_price(
    forming_bar: Dict[str, Any],
    signal: Dict[str, Any],
    params: Dict[str, Any],
) -> float:
    """백테 B안 체결가 — 저항선·시가·고가·슬리피지(env) 중 현실적 터치가."""
    _raw_slip = params.get("intrabar_slippage_pct")
    if _raw_slip in (None, ""):  # params 에 없을 때만 env(→DB) — 0.0 설정값은 그대로 사용
        slip_pct = get_env_float("BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0)
    else:
        slip_pct = float(_raw_slip or 0.0)
    res = float(signal.get("resistance") or 0)
    op = float(forming_bar.get("open") or 0)
    hi = float(forming_bar.get("high") or 0)
    if res > 0:
        touch = res * (1.0 + slip_pct / 100.0)
        ep = max(touch, op) if op > 0 else touch
        if hi > 0:
            ep = min(ep, hi)
        return ep
    return hi if hi > 0 else op


def breakout_backtest_wants_tick_replay(params: Optional[Dict[str, Any]] = None) -> bool:
    """B안 + 틱 DB 백테 재생이 필요한지."""
    return (
        breakout_entry_mode(params) in _INTRABAR_ENTRY_MODES
        and breakout_backtest_use_tick_db(params)
    )


def breakout_backtest_use_tick_db(params: Optional[Dict[str, Any]] = None) -> bool:
    """B안 백테 시 ``ws_ticks`` 재생 사용 여부 (기본 ON — 실매 체결 정합).

    1분봉 OHLC 경로(open→high→low→close 가정)는 손절보다 익절/어깨컷을 먼저
    체결하는 **낙관적 편향**을 만들어 실매 손실을 백테 수익으로 둔갑시킨다.
    (7/3 검증: 모멘텀 OHLC +22k → 틱재생 -57k, 실매 -49k 와 정합) 따라서
    꼬리·모멘텀과 동일하게 기본 ON 으로 저장 틱(실 체결 경로)을 재생한다.
    틱이 없는 구간은 ``BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC`` 로 OHLC 폴백.
    끄려면 BREAKOUT_BACKTEST_USE_TICK_DB=0 또는 params 로 명시 OFF.
    """
    if params is not None and params.get("backtest_use_tick_db") is not None:
        return _to_bool(params.get("backtest_use_tick_db"), True)
    return get_env_bool("BREAKOUT_BACKTEST_USE_TICK_DB", True)


def breakout_backtest_tick_fallback_ohlc(params: Optional[Dict[str, Any]] = None) -> bool:
    """해당 분에 틱 없을 때 1분봉 high 근사 폴백."""
    if params is not None and params.get("backtest_tick_fallback_ohlc") is not None:
        return _to_bool(params.get("backtest_tick_fallback_ohlc"), True)
    return get_env_bool("BREAKOUT_BACKTEST_TICK_FALLBACK_OHLC", True)


def breakout_scan_buy_intrabar_from_ticks(
    confirmed: List[Dict[str, Any]],
    minute_ticks: List[Dict[str, Any]],
    minute_bar: Dict[str, Any],
    params: Dict[str, Any],
    day_open: Optional[float] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]], float, str]:
    """
    ``ws_ticks`` 시간순 재생 — 실매 B안(현재가 돌파)과 동일하게 **첫 돌파 틱** 에 진입.

    틱이 없으면 (None, ...) 호출측에서 OHLC 폴백.
    """
    if not minute_ticks:
        return ("탈락-틱없음", "해당 분봉 ws_ticks 없음", None, 0.0, "")

    _raw_slip = params.get("intrabar_slippage_pct")
    if _raw_slip in (None, ""):  # params 에 없을 때만 env(→DB) — 0.0 설정값은 그대로 사용
        slip_pct = get_env_float("BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0)
    else:
        slip_pct = float(_raw_slip or 0.0)

    minute_key = str(minute_bar.get("candle_time") or "")[:12]
    forming_base = dict(minute_bar)
    cum_vol = 0
    last_reject: Tuple[Optional[str], Optional[str]] = (None, None)

    # ── [성능 핵심] 분봉 내 불변값을 틱 루프 밖에서 1회만 계산 ─────────────────
    # confirmed(직전 확정봉)는 한 분봉 동안 고정이다. 과거엔 틱마다
    #   _confirmed_candles_only(confirmed) + (confirmed_only+[cur]) 전체복사 + 당일시가
    #   정주행 스캔을 반복 → ws_ticks 수백만 건에서 O(n²)~O(n³) 폭증(웹 백테 무응답).
    # 아래처럼 ①확정봉 필터 ②윈도우 꼬리 ③당일시가 를 분봉당 1회만 만들고,
    # 틱마다 진행봉(cur)만 buf[-1] 에 갈아끼우면 결과는 동일하면서 비용이 급감한다.
    confirmed_only = _confirmed_candles_only(confirmed)
    lookback_min = int(params.get("lookback_min", 1))
    vol_window = int(params.get("vol_window", 7))
    need_n = max(lookback_min, vol_window) + 2
    if len(confirmed_only) < need_n - 1:
        return (
            "탈락-캔들부족",
            "confirmed=%d need>=%d" % (len(confirmed_only), need_n - 1),
            None, 0.0, "",
        )

    minute_open = float(forming_base.get("open") or 0)
    day_open_hoist: Optional[float] = day_open  # 호출측(포트폴리오)에서 사전계산값 주입 가능
    if day_open_hoist is not None and day_open_hoist > 0:
        # 주입된 당일시가 사용 → 당일 전체봉 불필요, 윈도우 꼬리만.
        base_tail = confirmed_only[-need_n:] if len(confirmed_only) > need_n else confirmed_only
    elif minute_open > 0:
        # 당일 시가(이격과열 필터용): 진행봉 open 이 분봉 내 고정이라 1회 산출 가능.
        # check_buy_signal_breakout_intrabar_live → _eval 의 '당일 첫 봉 시가' 와 동일.
        day = minute_key[:8]
        fm_open: Optional[float] = None
        for x in confirmed_only:
            xt = str(x.get("candle_time") or "")
            if xt[:8] == day:
                fm_open = float(x.get("open", 0) or 0)
                break
        if fm_open is None:
            fm_open = minute_open  # 당일 첫 봉이 진행봉(cur) → cur.open ≈ 분봉 open
        day_open_hoist = fm_open
        if day_open_hoist is None or day_open_hoist <= 0:
            day_open_hoist = float(
                (confirmed_only[0].get("open", 0) if confirmed_only else minute_open) or 0
            )
        # 꼬리: 윈도우(need_n)만큼만. 당일시가를 주입하므로 당일 전체봉은 불필요.
        base_tail = confirmed_only[-need_n:] if len(confirmed_only) > need_n else confirmed_only
    else:
        # 진행봉 open 부재(비정상 데이터) → 정확성 우선: 전체 confirmed + eval 내부 역스캔.
        base_tail = confirmed_only

    buf = list(base_tail)
    buf.append(None)  # 진행봉(cur) 자리 — 틱마다 교체

    # ── [성능] 분봉 단위 사전 게이트(불변 조건) ─────────────────────────────
    # 시간대·직전봉등락·저항선은 한 분봉 동안 고정이다(_eval 과 동일 기준).
    # 통과 불가한 분봉은 수십~수백 틱의 dict 생성+_eval 호출을 통째로 생략하고,
    # 통과 분봉에서도 'price ≤ 저항' 틱은 _eval(저항미돌파 확정) 없이 즉시 스킵한다.
    # → ws_ticks 수백만 건에서 비싼 평가를 '실제 돌파 후보 틱' 으로만 한정. 결과 동일.
    # 1) 시간대 가드 (_eval: hm<start or hm>=end → (None,None,None))
    try:
        _hm = int(minute_key[8:12])
    except Exception:
        _hm = None
    _ts = int(params.get("time_start_hm", 0))
    _te = int(params.get("time_end_hm", 2400))
    if _hm is not None and (_hm < _ts or _hm >= _te):
        return (None, None, None, 0.0, "")
    # 2) 직전봉 등락 (_eval step2: prev/prev_prev = 확정봉 끝 2개)
    if len(confirmed_only) >= 2:
        _pc = float(confirmed_only[-1].get("close", 0) or 0)
        _ppc = float(confirmed_only[-2].get("close", 0) or 0)
        if _ppc > 0:
            _prev_chg = (_pc - _ppc) / _ppc * 100.0
            _pmin = float(params.get("prev_chg_min", 1.0))
            _pmax = float(params.get("prev_chg_max", 10.0))
            if _prev_chg < _pmin:
                return ("탈락-직전봉약세", "prev=%.2f%% < %.2f%%" % (_prev_chg, _pmin), None, 0.0, "")
            if _prev_chg > _pmax:
                return ("탈락-직전봉과열", "prev=%.2f%% > %.2f%%" % (_prev_chg, _pmax), None, 0.0, "")
    # 3) 저항선(불변): price ≤ 저항 인 틱은 _eval 도 '탈락-저항미돌파'(sig 없음)
    _res_window = (
        [float(x.get("high", 0) or 0) for x in confirmed_only[-lookback_min:]]
        if lookback_min > 0 else []
    )
    resistance_gate = max(_res_window) if _res_window else 0.0
    if resistance_gate <= 0:
        return ("탈락-저항창없음", "lookback=%d" % lookback_min, None, 0.0, "")

    # ── [백테 틱재생·공유메모리] 컬럼 뷰 직행 경로 (dict 재구성/재정렬 제거) ─────────
    # minute_ticks 가 공유메모리 컬럼 뷰(또는 그 _TickList)면, 매 틱 sorted()+tick_upto
    # 리스트 재생성(O(n²))을 없애고 배열을 직접 순회한다. 저장 순서(로더 ORDER BY
    # tick_time)가 dict 경로의 stable sort 결과와 동일하고, _whipsaw_ticks 는 'tt<=현재'
    # prefix 뷰로 넘겨 whipsaw 컬럼 경로를 그대로 탄다 → dict 경로와 bit-identical.
    _cview = None
    try:
        from kis_trader.backtest.shared_ticks import TickColumnView, _TickList
        if isinstance(minute_ticks, TickColumnView):
            _cview = minute_ticks
        elif isinstance(minute_ticks, _TickList):
            _cview = minute_ticks.column_view()
    except Exception:
        _cview = None

    if _cview is not None:
        owner = _cview.owner
        _price = owner._price
        _volume = owner._volume
        _ttarr = owner._tick_time
        idxs = list(_cview.iter_idx())  # 저장(=tick_time 오름차순) 순서 절대 인덱스
        n = len(idxs)
        # tt 문자열(≤14) 미리 디코드 + 동일 tick_time 묶음의 끝 위치(group_end) 사전계산.
        # dict 의 tick_upto = [tt <= 현재] 는 정렬상 연속 prefix 이고, 동률(같은 tt)은
        # 모두 포함되므로 prefix 끝 = 현재와 같은 tt 의 마지막 위치다.
        tts = [_ttarr[idxs[k]].decode("utf-8") for k in range(n)]
        group_end = [0] * n
        j = 0
        while j < n:
            k = j
            while k + 1 < n and tts[k + 1] == tts[j]:
                k += 1
            for t in range(j, k + 1):
                group_end[t] = k
            j = k + 1
        off0 = idxs[0] if n > 0 else 0
        for pos in range(n):
            ai = idxs[pos]
            price = float(_price[ai])
            if price <= 0:
                continue
            cum_vol += int(_volume[ai])
            if price <= resistance_gate:
                last_reject = ("탈락-저항미돌파", "price=%.0f ≤ 저항=%.0f" % (price, resistance_gate))
                continue
            forming = dict(forming_base)
            forming["close"] = price
            forming["high"] = max(float(forming.get("high") or 0), price)
            forming["low"] = min(
                float(forming.get("low") or price) if forming.get("low") else price,
                price,
            )
            if not forming.get("open") or float(forming.get("open") or 0) <= 0:
                forming["open"] = price
            forming["volume"] = cum_vol
            forming["candle_time"] = minute_key

            buf[-1] = forming
            # tt<=현재 prefix 뷰(동률 포함) — 단일 분봉이라 연속 세그먼트 1개.
            prefix_cnt = group_end[pos] + 1
            eval_params = dict(params)
            if prefix_cnt > 0:
                eval_params["_whipsaw_ticks"] = TickColumnView(owner, [(off0, prefix_cnt)])
            reason, msg, sig = _eval_breakout_buy_at_index(
                buf, len(buf) - 1, eval_params, day_open=day_open_hoist,
            )
            if sig:
                res = float(sig.get("resistance") or 0)
                ep = price
                if res > 0 and slip_pct:
                    ep = max(ep, res * (1.0 + slip_pct / 100.0))
                et = minute_key
                sig["entry_price"] = ep
                sig["entry_bar_key"] = et
                sig["entry_mode"] = "intrabar"
                sig["entry_tick_time"] = tts[pos]
                sig["backtest_entry_source"] = "ws_ticks"
                return None, None, sig, ep, et
            if reason and reason not in ("탈락-틱없음",):
                last_reject = (reason, msg)
            else:
                last_reject = (None, None)

        if last_reject[0]:
            return last_reject[0], last_reject[1], None, 0.0, ""
        return ("탈락-돌파틱없음", "분봉 내 돌파 틱 없음", None, 0.0, "")

    for tick in sorted(minute_ticks, key=lambda x: str(x.get("tick_time") or "")):
        price = float(tick.get("price") or 0)
        if price <= 0:
            continue
        cum_vol += int(tick.get("volume") or 0)
        # 저항 미돌파 틱 → 비싼 _eval 생략 (결과 동일: sig 없음)
        if price <= resistance_gate:
            last_reject = ("탈락-저항미돌파", "price=%.0f ≤ 저항=%.0f" % (price, resistance_gate))
            continue
        forming = dict(forming_base)
        forming["close"] = price
        forming["high"] = max(float(forming.get("high") or 0), price)
        forming["low"] = min(
            float(forming.get("low") or price) if forming.get("low") else price,
            price,
        )
        if not forming.get("open") or float(forming.get("open") or 0) <= 0:
            forming["open"] = price
        forming["volume"] = cum_vol
        forming["candle_time"] = minute_key

        # check_buy_signal_breakout_intrabar_live() 와 동일 평가 (cur=forming 그대로).
        # 당일시가는 호이스팅 값 주입(없으면 eval 내부에서 역스캔).
        buf[-1] = forming
        tick_buf = sorted(minute_ticks, key=lambda x: str(x.get("tick_time") or ""))
        tick_upto = [t for t in tick_buf if str(t.get("tick_time") or "") <= str(tick.get("tick_time") or "")]
        eval_params = dict(params)
        if tick_upto:
            eval_params["_whipsaw_ticks"] = tick_upto
        reason, msg, sig = _eval_breakout_buy_at_index(
            buf, len(buf) - 1, eval_params, day_open=day_open_hoist,
        )
        if sig:
            res = float(sig.get("resistance") or 0)
            ep = price
            if res > 0 and slip_pct:
                ep = max(ep, res * (1.0 + slip_pct / 100.0))
            et = minute_key
            sig["entry_price"] = ep
            sig["entry_bar_key"] = et
            sig["entry_mode"] = "intrabar"
            sig["entry_tick_time"] = str(tick.get("tick_time") or "")
            sig["backtest_entry_source"] = "ws_ticks"
            return None, None, sig, ep, et
        if reason and reason not in ("탈락-틱없음",):
            last_reject = (reason, msg)
        else:
            last_reject = (None, None)

    if last_reject[0]:
        return last_reject[0], last_reject[1], None, 0.0, ""
    return ("탈락-돌파틱없음", "분봉 내 돌파 틱 없음", None, 0.0, "")


def breakout_min_bars_required(params: Optional[Dict[str, Any]] = None) -> int:
    """백테/파서치 캔들 최소 개수 — A안(+1 다음봉) vs B안(당일봉 진입)."""
    p = params or {}
    lookback_min = int(p.get("lookback_min", 1))
    vol_window = int(p.get("vol_window", 7))
    need_n = max(lookback_min, vol_window) + 2
    extra = 0 if breakout_entry_mode(p) in _INTRABAR_ENTRY_MODES else 1
    return need_n + extra


def breakout_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]:
    """
    웹·파서치 UI(%) → ``run_breakout_backtest`` 엔진 dict.

    ``entry_mode``: intrabar(B, 실매 HTS E) | align(A, 다음 봉 시가).
    """
    sl_pct_ui = float(ui.get("sl_pct", 2.0))
    max_loss = normalize_breakout_max_loss_krw(ui.get("max_loss_krw", 200_000))
    slot_cap = float(ui.get("slot_money", 2_000_000))
    slot = breakout_invest_amount_krw(max_loss, sl_pct_ui, slot_cap)

    mode_raw = str(ui.get("entry_mode") or "").strip().lower()
    mode = mode_raw if mode_raw else breakout_entry_mode()

    def _fee_ratio(key_ui: str, key_alt: str, default_ui: float) -> float:
        raw = ui.get(key_ui)
        if raw in (None, ""):
            raw = ui.get(key_alt, default_ui)
        v = float(raw)
        return v / 100.0

    smh_ui = ui.get("shoulder_min_high_pct")
    sc_ui = ui.get("shoulder_cut_pct")

    def _ui_bool(key: str, default: bool = False) -> bool:
        raw = ui.get(key)
        if raw is None or raw == "":
            return default
        if isinstance(raw, bool):
            return raw
        return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")

    return {
        "lookback_min": int(ui.get("lookback_min", 1)),
        "vol_window": int(ui.get("vol_window", 1)),
        "vol_mult": float(ui.get("vol_mult", 0) or 0),
        "prev_chg_min": float(ui.get("prev_chg_min", 1.0)),
        "prev_chg_max": float(ui.get("prev_chg_max", 10.0)),
        "max_daily_chg": float(ui.get("max_daily_chg", 15.0)),
        "min_price": float(ui.get("min_price", 1000.0)),
        "min_bar_trade_value_krw": float(ui.get("min_bar_trade_value_krw", 0) or 0),
        "min_turnover_1m_pct": float(ui.get("min_turnover_1m_pct", 0.05) or 0),
        # 가짜돌파 필터 (둘 다 0=OFF)
        "confirm_margin_pct": float(ui.get("confirm_margin_pct", 0.0) or 0.0),
        "body_min_pct": float(ui.get("body_min_pct", 0.0) or 0.0),
        "time_start_hm": int(ui.get("time_start_hm", 900)),
        "time_end_hm": int(ui.get("time_end_hm", 1030)),
        "cooldown_min": float(ui.get("cooldown_min", 30)),
        "max_daily": int(ui.get("max_daily", 1)),
        "max_loss_krw": max_loss,
        "slot_money": slot,
        "stop_loss_pct": -abs(sl_pct_ui) / 100.0,
        # ── ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정% 동작) ──
        #   min/max_pct 는 % 단위 그대로 전달(check_sell 의 _breakout_sl_line 가 /100 처리).
        "sl_mode": str(ui.get("sl_mode", "fixed") or "fixed").strip().lower(),
        "atr_period": int(float(ui.get("atr_period", 14) or 14)),
        "atr_sl_mult": float(ui.get("atr_sl_mult", 2.0) or 2.0),
        "atr_sl_min_pct": float(ui.get("atr_sl_min_pct", 0.8) or 0.8),
        "atr_sl_max_pct": float(ui.get("atr_sl_max_pct", 6.0) or 6.0),
        "take_profit_pct": abs(float(ui.get("tp_pct", 5.0))) / 100.0,
        "trail_pct": abs(float(ui.get("trail_pct", 1.5))) / 100.0,
        # 트레일링 무장 임계값(%) — 고점이 진입가×(1+arm) 도달 후에만 보조 트레일 작동.
        # 0 = 즉시 무장(기존과 동일). 진입 직후 본전 청산 방지용.
        "trail_arm_pct": abs(float(ui.get("trail_arm_pct", 0.0) or 0.0)) / 100.0,
        "shoulder_min_high": (
            abs(float(smh_ui)) / 100.0 if smh_ui is not None else None
        ),
        "shoulder_cut_pct": (
            abs(float(sc_ui)) / 100.0 if sc_ui is not None else None
        ),
        # 래칫 단계 트레일(문자열 "g:c,..", 기본 OFF) · 시간컷(분, 0=OFF)
        "ratchet_tiers": ui.get("ratchet_tiers", "") or "",
        "max_hold_bars": int(float(ui.get("max_hold_bars", 0) or 0)),
        "fee_rate": _fee_ratio("fee_rate_pct", "fee_rate", 0.015),
        "sell_tax": _fee_ratio("sell_tax_pct", "sell_tax", 0.18),
        "entry_mode": mode,
        "intrabar_slippage_pct": float(ui.get("intrabar_slippage_pct", 0.0) or 0.0),
        "live_backtest_align": mode not in _INTRABAR_ENTRY_MODES,
        "live_signal_lookback_bars": int(ui.get("live_signal_lookback_bars", 1)),
        "use_ema_filter": _ui_bool("use_ema_filter", False),
        "ema_fast_period": int(float(ui.get("ema_fast_period", 9) or 9)),
        "ema_slow_period": int(float(ui.get("ema_slow_period", 21) or 21)),
        # EOD — 실매 BREAKOUT_EOD_* 와 동일 (백테·파서치 공용)
        "eod_enabled": _ui_bool("eod_enabled", True),
        "eod_hm": str(ui.get("eod_hm") or "15:15").strip() or "15:15",
    }


def _breakout_params_with_ema_closes(
    params: Dict[str, Any],
    candles: List[Dict[str, Any]],
    bar_index: int,
    *,
    forming_close: Optional[float] = None,
) -> Dict[str, Any]:
    """intrabar 짧은 virtual 창에서도 EMA 를 전체 히스토리 기준으로 계산."""
    if not _to_bool(params.get("use_ema_filter"), False):
        return params
    slow_p = max(1, int(params.get("ema_slow_period", 21)))
    start = max(0, bar_index - slow_p - 10)
    closes = [float(x.get("close", 0)) for x in candles[start:bar_index]]
    if forming_close is not None and forming_close > 0:
        closes.append(float(forming_close))
    elif 0 <= bar_index < len(candles):
        closes.append(float(candles[bar_index].get("close", 0)))
    if not closes:
        return params
    out = dict(params)
    out["_ema_full_closes"] = closes
    out["_ema_full_index"] = len(closes) - 1
    return out


def breakout_scan_buy_at_bar(
    candles: List[Dict[str, Any]],
    bar_index: int,
    params: Dict[str, Any],
    *,
    minute_ticks: Optional[List[Dict[str, Any]]] = None,
    day_open: Optional[float] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]], float, str]:
    """
    백테 단일 봉 매수 스캔 — A/B 통합.

    B안 + ``minute_ticks`` + ``BREAKOUT_BACKTEST_USE_TICK_DB`` → ws_ticks 재생.
    틱 없으면 (옵션) 1분봉 high OHLC 폴백.

    [성능] ``day_open`` 주입 시(포트폴리오 백테가 종목별 1회 사전계산) intrabar 경로의
    ``candles[:i+1]`` 전체복사를 피하고 윈도우 꼬리만 슬라이스 → 슬롯×종목 반복에서 O(n²) 제거.

    Returns:
        reason, msg, signal, entry_price, entry_time(candle_time)
    """
    mode = breakout_entry_mode(params)
    i = bar_index
    if mode in _INTRABAR_ENTRY_MODES:
        if i < 1:
            return ("탈락-캔들부족", "intrabar i<1", None, 0.0, "")
        forming = candles[i]
        if day_open is not None and day_open > 0:
            # 당일시가 주입됨 → 윈도우(need_n) 꼬리만 (전체복사 방지). 백테 캔들은 모두 확정봉.
            need_n = max(int(params.get("lookback_min", 1)), int(params.get("vol_window", 7))) + 2
            confirmed = candles[max(0, i - need_n):i]
        else:
            confirmed = candles[:i]

        use_ticks = breakout_backtest_use_tick_db(params) and minute_ticks is not None
        if use_ticks and minute_ticks:
            r, m, sig, ep, et = breakout_scan_buy_intrabar_from_ticks(
                confirmed, minute_ticks, forming, params, day_open=day_open,
            )
            if sig:
                return r, m, sig, ep, et
            if not breakout_backtest_tick_fallback_ohlc(params):
                return r, m, None, 0.0, ""

        probe = float(forming.get("high") or forming.get("close") or 0)
        ema_params = _breakout_params_with_ema_closes(
            params, candles, i, forming_close=probe,
        )
        reason, msg, sig = check_buy_signal_breakout_intrabar_live(
            confirmed, probe, forming, ema_params, day_open=day_open,
        )
        if not sig:
            return reason, msg, None, 0.0, ""
        ep = breakout_intrabar_entry_price(forming, sig, params)
        et = str(forming.get("candle_time") or "")
        if sig:
            sig["backtest_entry_source"] = "ohlc_high" if use_ticks else "ohlc_intrabar"
        return None, None, sig, ep, et

    sub = candles[: i + 1]
    align_params = _breakout_params_with_ema_closes(params, candles, i)
    reason, msg, sig = check_buy_signal_breakout_live(sub, align_params)
    if not sig:
        return reason, msg, None, 0.0, ""
    if i + 1 >= len(candles):
        return ("탈락-진입봉없음", "다음 봉 없음", None, 0.0, "")
    next_c = candles[i + 1]
    if next_c["candle_time"][:8] != sub[-1]["candle_time"][:8]:
        return ("탈락-일자넘김", "다음봉 일자 불일치", None, 0.0, "")
    ep = float(next_c.get("open") or 0)
    if ep <= 0:
        ep = float(sig.get("entry_price") or sig.get("close") or 0)
    return None, None, sig, ep, str(next_c.get("candle_time") or "")


def check_buy_signal_breakout_live(
    candles: List[Dict[str, Any]],
    params: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
    """
    돌파 1분봉 매수 신호.

    - ``live_backtest_align=True`` (기본): 직전 확정봉=신호, 현재 확정봉=진입(시가).
    - ``live_backtest_align=False``: 구버전 — 마지막 확정봉만 신호봉.
    """
    lookback_min = int(params.get("lookback_min", 1))
    vol_window = int(params.get("vol_window", 7))
    need_n = max(lookback_min, vol_window) + 2
    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) < need_n:
        return ("탈락-캔들부족", "have=%d need=%d" % (len(confirmed), need_n), None)

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

    if live_align:
        if len(confirmed) < need_n + 1:
            return ("탈락-캔들부족", "진입봉 분리하려면 확정봉 %d개 필요" % (need_n + 1), None)
        entry_i = len(confirmed) - 1
        for k in range(lookback):
            signal_i = entry_i - 1 - k
            if signal_i < need_n - 1:
                break
            reason, msg, sig = _eval_breakout_buy_at_index(confirmed, signal_i, params)
            if reason:
                if k == 0:
                    last_reject = (reason, 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(sig.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_breakout_buy_at_index(confirmed, i, params)


# ════════════════════════════════════════════════════════════════════════════
# 매도 룰 모듈 함수 — 백테스트·실매 공용. 돌파 전용 우선순위:
#   EOD → 익절 → 어깨컷 → 손절 → 트레일링(보조)
# ════════════════════════════════════════════════════════════════════════════
def check_sell_signal_breakout_live(
    position: Dict[str, Any],
    current_candle: Dict[str, Any],
    params: Dict[str, Any],
    is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
    """돌파매매 매도 신호 판단. (reason, exit_price) 또는 None."""
    sl_pct    = float(params.get("stop_loss_pct", -0.02))
    tp_pct    = float(params.get("take_profit_pct", 0.05))
    trail_pct = float(params.get("trail_pct", 0.015))
    shoulder_min_high, shoulder_cut_pct = _breakout_shoulder_ratios(params)
    ratchet_tiers = _breakout_ratchet_tiers(params)
    max_hold_bars = _breakout_max_hold_bars(params)

    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

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

    if is_eod:
        return ("eod", cl)

    entry = float(position["entry_price"])
    # 손절선: sl_mode='atr' 이면 변동성 비례 동적, 아니면 고정%(기존 100% 동일)
    sl_line = _breakout_sl_line(entry, sl_pct, position, params)
    tp_line = entry * (1 + tp_pct)

    # 1순위: 익절
    if hi >= tp_line:
        return ("take_profit", tp_line)

    # 2순위: 어깨 트레일 — 래칫(단계식) 우선, 없으면 단일 어깨컷
    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 ("shoulder_cut", ratchet_line)
    else:
        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_cut", shoulder_line)

    # 3순위: 손절
    if lo <= sl_line:
        return ("stop_loss", sl_line)

    # 4순위: 보조 트레일링 (어깨 미무장 구간 보호)
    #   trail_arm_pct > 0 이면 '고점이 진입가×(1+arm)' 에 도달한 뒤에만 트레일 무장.
    #   진입 직후 정상 출렁임(팝 후 되돌림)에 본전 청산되는 것을 막는다.
    #   0 = 즉시 무장(기존과 100% 동일). 그 전 구간은 손절(sl_pct)이 방어한다.
    trail_arm_pct = float(params.get("trail_arm_pct", 0.0) or 0.0)
    trail_arm_line = entry * (1.0 + trail_arm_pct)
    if max_price > entry and max_price >= trail_arm_line:
        trail_line = max_price * (1 - trail_pct)
        if lo <= trail_line:
            return ("trailing", trail_line)

    # 5순위: 시간컷 — 옆으로 기는 실패 돌파를 N분 후 종가 정리 (기본 OFF)
    if max_hold_bars > 0:
        held = _breakout_minutes_held(position, current_candle)
        if held is not None and held >= max_hold_bars:
            return ("max_hold", cl)

    return None


# ════════════════════════════════════════════════════════════════════════════
# 백테스트 — 매수/매도 모듈 함수 호출 (실매 BreakoutStrategy 와 동일 판정 함수)
# ════════════════════════════════════════════════════════════════════════════
def _bt_t2dt(t: str):
    """candle_time(YYYYMMDDHHMI) → datetime."""
    return dt.strptime(t, "%Y%m%d%H%M")


def _bt_slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
    """봉 시각을 N분 슬롯 키로 변환. (universe_by_slot 조회용)"""
    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 run_breakout_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[str, Any]]]]] = 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]:
    """돌파매매 백테스트.

    universe_by_slot 이 주어지면 해당 슬롯에 종목이 들어와 있을 때만 매수
    검사. (실매매의 키움 'breakout' 조건검색 폴링 시뮬레이션과 동일.)
    매수 신호는 ``check_buy_signal_breakout_live`` 를, 매도는
    ``check_sell_signal_breakout_live`` 를 그대로 호출 → 실매매 룰 100% 동등.
    """
    # ── [성능] env(→DB) 기반 백테 플래그를 시뮬 시작 전 '1회만' 해석 ──────────
    # get_latest_env() 는 캐시가 없어 호출마다 env_config+config_* 를 DB 조회한다.
    # 과거엔 스캔(슬롯×종목)·분봉마다 use_tick_db·tick_fallback_ohlc·slip 을 env 로
    # 재조회 → 수만 회 DB 왕복으로 웹 백테가 사실상 무응답이었다.
    # 아래에서 값을 확정해 params 에 박아두면 downstream 함수는 params 만 읽어(재조회 X)
    # 동작은 동일하면서 DB 폭주가 사라진다.
    params = dict(params)
    if params.get("backtest_use_tick_db") is None:
        params["backtest_use_tick_db"] = breakout_backtest_use_tick_db(params)
    if params.get("backtest_tick_fallback_ohlc") is None:
        params["backtest_tick_fallback_ohlc"] = breakout_backtest_tick_fallback_ohlc(params)
    if params.get("intrabar_slippage_pct") in (None, ""):
        params["intrabar_slippage_pct"] = get_env_float("BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0)
    # 가짜돌파 필터 기본값도 1회 확정 (params 에 없을 때만 env) — 0=OFF
    if params.get("confirm_margin_pct") is None:
        params["confirm_margin_pct"] = get_env_float("BREAKOUT_CONFIRM_MARGIN_PCT", 0.0)
    if params.get("body_min_pct") is None:
        params["body_min_pct"] = get_env_float("BREAKOUT_BODY_MIN_PCT", 0.0)
    # 매도 트레일(어깨) 비율도 1회 확정 (params 에 없을 때만 env)
    if params.get("shoulder_min_high") is None or params.get("shoulder_cut_pct") is None:
        _smh, _sc = _breakout_shoulder_ratios(params)
        params["shoulder_min_high"] = _smh
        params["shoulder_cut_pct"] = _sc

    if _to_bool(params.get("portfolio_mode"), True):
        from kis_trader.backtest.breakout_portfolio_backtest import run_breakout_backtest_portfolio
        return run_breakout_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,
        )

    # 매수 룰 파라미터 (check_buy 함수에 그대로 전달)
    lookback_min  = int(params.get("lookback_min", 1))
    vol_window    = int(params.get("vol_window", 7))

    # 매도/포지션 파라미터
    sl_pct        = float(params.get("stop_loss_pct", -0.02))   # 음수
    slot_money    = float(params.get("slot_money", 2_000_000))
    fee_rate      = float(params.get("fee_rate", 0.00015))
    sell_tax      = float(params.get("sell_tax", 0.0018))
    cooldown_min  = float(params.get("cooldown_min", 30))       # 종목당 재진입 금지 분
    max_daily     = int(params.get("max_daily", 1))             # 종목당 일일 최대 진입
    max_loss_krw  = float(params.get("max_loss_krw", 200000))

    # 매수 시간대(골든타임) — 라이브 ``_is_golden_time`` 와 일치 (기본 09:00~10:30)
    time_start_hm = int(params.get("time_start_hm", 900))
    time_end_hm   = int(params.get("time_end_hm", 1030))

    # 매수 룰 함수에 전달할 params (시간 가드 포함)
    buy_params = dict(params)
    buy_params["time_start_hm"] = time_start_hm
    buy_params["time_end_hm"]   = time_end_hm

    all_trades: List[Dict] = []

    _bt_mode = breakout_entry_mode(params)
    _align_extra = 0 if _bt_mode in _INTRABAR_ENTRY_MODES else 1

    for code, rows in codes_candles.items():
        need_n = max(lookback_min, vol_window) + 2
        if len(rows) < need_n + _align_extra:
            continue
        candles = [dict(r) for r in rows]
        # ATR 시리즈(진입 봉 변동성) — sl_mode='atr' 일 때만. 아니면 None(기존과 동일).
        _atr_series = (
            compute_atr_series(candles, int(params.get("atr_period", 14) or 14))
            if str(params.get("sl_mode", "fixed") or "fixed").strip().lower() == "atr"
            else None
        )

        position: Optional[Dict] = None
        last_exit_dt: Dict[str, Any] = {}  # day → datetime
        daily_cnt: Dict[str, int] = {}

        for i in range(need_n - 1, len(candles)):
            c = candles[i]
            day = c["candle_time"][:8]
            cl = float(c["close"])

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

            # ── 보유 중: 청산 체크 (intrabar — 실매 current_price 폴링 정렬) ──
            if position is not None:
                bar = dict(c)
                if "open" not in bar or bar.get("open") in (None, ""):
                    bar["open"] = float(c.get("open") or cl)
                rp = check_sell_signal_backtest_bar(
                    position,
                    bar,
                    params,
                    is_eod=is_eod,
                    sell_fn=check_sell_signal_breakout_live,
                    low_mode="current",
                )
                if rp:
                    reason, exit_price = rp
                    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
                    )
                    hold_min = int(
                        (_bt_t2dt(c["candle_time"]) - _bt_t2dt(position["entry_time"])).total_seconds() / 60
                    )
                    all_trades.append({
                        "code": code,
                        "buy_time": position["entry_time"],
                        "sell_time": c["candle_time"],
                        "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,
                    })
                    last_exit_dt[day] = _bt_t2dt(c["candle_time"])
                    position = None
                continue

            # ── 포지션 없음: 매수 시그널 ───────────────────────────────────
            # 유니버스 시뮬레이션 (있으면 해당 슬롯에 종목이 있어야 진입 후보)
            if universe_by_slot is not None:
                slot_key = _bt_slot_key(c["candle_time"], int(params.get("scan_interval_min", 1)))
                if code not in universe_by_slot.get(slot_key, []):
                    continue

            # 쿨다운
            if day in last_exit_dt:
                elapsed_min = (_bt_t2dt(c["candle_time"]) - last_exit_dt[day]).total_seconds() / 60
                if elapsed_min < cooldown_min:
                    continue

            # 일일 횟수
            if daily_cnt.get(day, 0) >= max_daily:
                continue

            # 매수 룰 검사 (시간 가드는 함수가 처리)
            minute_ticks = None
            if ticks_by_code:
                minute_ticks = (ticks_by_code.get(code) or {}).get(
                    str(c["candle_time"])[:12],
                )
            reason, msg, signal, entry_price, entry_time = breakout_scan_buy_at_bar(
                candles, i, {
                    **buy_params,
                    "share_denom": share_denom_for_code(buy_params, code),
                }, minute_ticks=minute_ticks,
            )
            if reason and i == len(candles) - need_n:
                pass  # 첫 스캔 탈락은 로그 생략
            if not signal or entry_price <= 0 or not entry_time:
                continue
            if entry_time[:8] != day:
                continue

            # 포지션 크기 = 손실허용액 / |손절비율| (캡 = slot_money)
            max_loss_krw = normalize_breakout_max_loss_krw(max_loss_krw)
            invest_amount = breakout_invest_amount_krw(
                max_loss_krw, abs(sl_pct) * 100.0, slot_money,
            )
            qty = max(1, int(invest_amount / entry_price))

            position = {
                "entry_price": entry_price,
                "entry_time":  entry_time,
                "qty":         qty,
                "max_price":   entry_price,
                "entry_atr": (
                    float(_atr_series[i])
                    if _atr_series is not None
                    and 0 <= i < len(_atr_series)
                    and _atr_series[i] is not None
                    else 0.0
                ),
            }
            daily_cnt[day] = daily_cnt.get(day, 0) + 1

    all_trades.sort(key=lambda x: x["sell_time"])
    return all_trades


class BreakoutStrategy(BaseStrategy):
    strategy_id = "BREAKOUT"
    loop_min_sleep = 0.8
    loop_max_sleep = 1.8

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.candle_tf = 1  # 1분봉 기준
        self._intrabar_fired: Dict[str, str] = {}  # code → 마지막 진입봉 키 (분당 1회)
        self.reload_config()

    # ------------------------------------------------------------------
    def reload_config(self) -> None:
        self.golden_end_hm = get_env_from_db("BREAKOUT_GOLDEN_END_HM", "10:30")
        # TRIGGER: 회전율(유통주식 대비) 1차 · vol_mult=0 이면 직전봉평균 배수 OFF
        self.lookback_min = get_env_int("BREAKOUT_LOOKBACK_MIN", 1)
        self.vol_window = get_env_int("BREAKOUT_VOL_WIN", 1)
        self.vol_mult = get_env_float("BREAKOUT_VOL_MULT", 0.0)
        self.prev_chg_min = get_env_float("BREAKOUT_PREV_CHG_MIN", 1.0)
        self.prev_chg_max = get_env_float("BREAKOUT_PREV_CHG_MAX", 10.0)
        self.max_daily_chg = get_env_float("BREAKOUT_MAX_DAILY_CHG", 15.0)
        self.min_price = get_env_float("BREAKOUT_MIN_PRICE", 1000.0)
        # 1분봉 최소 거래대금(원). 0=OFF — 고정 원화는 소형주에 과함.
        self.min_bar_trade_value_krw = get_env_float(
            "BREAKOUT_MIN_BAR_TRADE_VALUE_KRW", 0.0,
        )
        # 1분 회전율(%) — 유통주식 대비. 기본 0.05%. 0=OFF.
        self.min_turnover_1m_pct = get_env_float(
            "BREAKOUT_MIN_TURNOVER_1M_PCT", 0.05,
        )
        # 가짜돌파(휩쏘) 필터 — 0=OFF. 저항을 이 %만큼 확실히 넘기고(여유),
        # 돌파봉 양봉 몸통이 이 % 이상이어야 진입 → 선만 찌르는 가짜돌파 차단.
        self.confirm_margin_pct = get_env_float("BREAKOUT_CONFIRM_MARGIN_PCT", 0.0)
        self.body_min_pct = get_env_float("BREAKOUT_BODY_MIN_PCT", 0.0)
        self.use_ema_filter = get_env_bool("BREAKOUT_USE_EMA_FILTER", False)
        self.ema_fast_period = get_env_int("BREAKOUT_EMA_FAST_PERIOD", 9)
        self.ema_slow_period = get_env_int("BREAKOUT_EMA_SLOW_PERIOD", 21)
        self.slot_money = get_env_int(
            "BREAKOUT_SLOT_MONEY",
            get_env_int("SLOT_MONEY_DEFAULT", 3_000_000),
        )

        self.stop_loss_pct = get_env_float("BREAKOUT_STOP_LOSS_PCT", -0.02)
        self.take_profit_pct = get_env_float("BREAKOUT_TAKE_PROFIT_PCT", 0.05)
        self.trail_pct = get_env_float("BREAKOUT_TRAIL_PCT", 0.015)
        # 트레일링 무장 임계값(비율) — 고점이 진입가×(1+arm) 도달 후에만 보조 트레일 작동.
        # 0 = 즉시 무장(기존과 동일). 진입 직후 정상 출렁임에 본전 청산되는 것 방지.
        self.trail_arm_pct = get_env_float("BREAKOUT_TRAIL_ARM_PCT", 0.0)
        # 어깨컷 기본 상향: 스캘프급(0.3%/0.2%)은 돌파 러너를 질식 → 무장 2%/되돌림 1%
        self.shoulder_min_high = get_env_float("BREAKOUT_SHOULDER_MIN_HIGH_PCT", 0.02)
        self.shoulder_cut_pct = get_env_float("BREAKOUT_SHOULDER_CUT_PCT", 0.01)
        # 래칫 단계 트레일(기본 OFF) · 시간컷(기본 OFF) — 청산 일원화에서 사용
        self.ratchet_tiers_raw = get_env_from_db("BREAKOUT_RATCHET_TIERS", "")
        self.max_hold_bars = get_env_int("BREAKOUT_MAX_HOLD_BARS", 0)
        # EOD 당일청산 — OFF면 15:15 강제청산 비활성(익절·손절·시간컷만)
        self.eod_enabled = get_env_bool("BREAKOUT_EOD_ENABLED", True)
        self.eod_hm = get_env_from_db("BREAKOUT_EOD_HM", "15:15")
        # ── ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 'fixed'=기존 고정% 손절) ──
        # 돌파봉 변동성에 손절폭을 비례시켜 휩쏘(속임수 하락)에 시드 헌납하는 것을 줄인다.
        self.sl_mode = str(
            get_env_from_db("BREAKOUT_SL_MODE", "fixed") or "fixed"
        ).strip().lower()
        self.atr_period = get_env_int("BREAKOUT_ATR_PERIOD", 14)
        self.atr_sl_mult = get_env_float("BREAKOUT_ATR_SL_MULT", 2.0)
        self.atr_sl_min_pct = get_env_float("BREAKOUT_ATR_SL_MIN_PCT", 0.8)
        self.atr_sl_max_pct = get_env_float("BREAKOUT_ATR_SL_MAX_PCT", 6.0)

    def _breakout_invest_cap_krw(self) -> float:
        max_loss = normalize_breakout_max_loss_krw(
            get_env_int("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", 0)
            or get_env_int("MAX_LOSS_PER_TRADE_KRW", DEFAULT_BREAKOUT_MAX_LOSS_KRW)
        )
        sl_pct_ui = abs(float(self.stop_loss_pct)) * 100.0
        return breakout_invest_amount_krw(max_loss, sl_pct_ui, self.slot_money)

    # 유니버스 로드는 BaseStrategy._load_candidates 가 이미 조건검색 우선 처리.
    def _candidate_filter(self, candidate: Dict) -> bool:
        if not candidate.get("code"):
            return False
        # 골든타임 외에는 check_buy 호출 자체를 차단 → 탈락 로그 노이즈 방지
        if not self._is_golden_time():
            return False
        return True

    # ------------------------------------------------------------------
    # 매수
    # ------------------------------------------------------------------
    def _is_golden_time(self) -> bool:
        hh, mm = _golden_end_hm_parts(self.golden_end_hm)
        now = dt.now()
        if now.hour < 9:
            return False
        if now.hour > hh:
            return False
        if now.hour == hh and now.minute > mm:
            return False
        return True

    def _live_current_price(self, code: str) -> float:
        """WS 현재가 → 틱 링버퍼 폴백."""
        wsd = self.ws.get_price(code)
        if wsd:
            try:
                p = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
                if p > 0:
                    return p
            except Exception:
                pass
        try:
            lp = self.ws.get_recent_ticks(code, limit=1)
            if lp:
                p2 = float(lp[-1].get("price") or 0)
                if p2 > 0:
                    return p2
        except Exception:
            pass
        return 0.0

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        # 골든타임 가드 (_candidate_filter 가 미리 거른 뒤에도 방어용 재검사)
        if not self._is_golden_time():
            return None

        need_n = max(self.lookback_min, self.vol_window) + 2
        if self.use_ema_filter:
            need_n = max(need_n, self.ema_slow_period + 5)
        confirmed = self.ws.get_candles(code, self.candle_tf, n=need_n + 5)
        if len(confirmed) < need_n - 1:
            self.logger.info(
                "🔍 [캔들부족] %s(%s) need>=%d have=%d",
                name, code, need_n - 1, len(confirmed),
            )
            return None

        params = {
            "lookback_min":  self.lookback_min,
            "vol_window":    self.vol_window,
            "vol_mult":      self.vol_mult,
            "prev_chg_min":  self.prev_chg_min,
            "prev_chg_max":  self.prev_chg_max,
            "max_daily_chg": self.max_daily_chg,
            "min_price":     self.min_price,
            "min_bar_trade_value_krw": self.min_bar_trade_value_krw,
            "min_turnover_1m_pct": self.min_turnover_1m_pct,
            "share_denom": (
                float(self.ws.get_share_denom(code))
                if getattr(self.ws, "get_share_denom", None)
                else 0.0
            ),
            "confirm_margin_pct": self.confirm_margin_pct,  # 가짜돌파 필터 (0=OFF)
            "body_min_pct":  self.body_min_pct,             # 가짜돌파 필터 (0=OFF)
            "use_ema_filter": self.use_ema_filter,
            "ema_fast_period": self.ema_fast_period,
            "ema_slow_period": self.ema_slow_period,
            "time_start_hm": 0,
            "time_end_hm":   2400,
            "entry_mode":    breakout_entry_mode(),
            "live_backtest_align": get_env_bool(
                "BREAKOUT_LIVE_BACKTEST_ALIGN", True,
            ),
            "live_signal_lookback_bars": get_env_int(
                "BREAKOUT_LIVE_SIGNAL_LOOKBACK_BARS", 1,
            ),
            "intrabar_slippage_pct": get_env_float(
                "BREAKOUT_INTRABAR_SLIPPAGE_PCT", 0.0,
            ),
            "_whipsaw_ws": self.ws,
            "_whipsaw_code": code,
            "_orderbook_ws": self.ws,
            "_orderbook_code": code,
            "_program_ws": self.ws,
            "_program_code": code,
            "slot_money": self.slot_money,
        }

        mode = breakout_entry_mode(params)
        forming = None
        if mode in _INTRABAR_ENTRY_MODES:
            curr_price = self._live_current_price(code)
            if curr_price <= 0:
                # 저유동 B안: 체결 틱 없음 — 정상 스킵. 기본 debug (SCAN_REJECT_LOG_VERBOSE 시 60s 1회 info)
                if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False):
                    if not hasattr(self, "_no_price_log"):
                        self._no_price_log = {}
                    last = self._no_price_log.get(code, 0)
                    if time.time() - last >= 60:
                        self._no_price_log[code] = time.time()
                        self.logger.info("🔍 [가격없음] %s(%s) B안", name, code)
                else:
                    self.logger.debug("🔍 [가격없음] %s(%s) B안", name, code)
                return None

            forming = None
            if getattr(self.ws, "get_current_candle", None):
                forming = self.ws.get_current_candle(code, self.candle_tf)
            if not forming:
                now_key = dt.now().strftime("%Y%m%d%H%M")
                forming = {
                    "candle_time": now_key,
                    "open": curr_price,
                    "high": curr_price,
                    "low": curr_price,
                    "close": curr_price,
                    "volume": 0,
                }
            bar_key = str(forming.get("candle_time") or "")
            if bar_key and self._intrabar_fired.get(code) == bar_key:
                return None

            reason, msg, signal = check_buy_signal_breakout_intrabar_live(
                confirmed, curr_price, forming,
                _breakout_params_with_ema_closes(
                    params, confirmed + ([forming] if forming else []),
                    len(confirmed),
                    forming_close=curr_price,
                ),
            )
            if reason:
                self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "")
                return None
            if not signal:
                return None
            if bar_key:
                self._intrabar_fired[code] = bar_key
            curr_price = float(signal.get("entry_price") or curr_price)
        else:
            reason, msg, signal = check_buy_signal_breakout_live(confirmed, params)
            if reason:
                self.logger.info("🔍 [%s] %s(%s) %s", reason, name, code, msg or "")
                return None
            if not signal:
                return None
            align_on = get_env_bool("BREAKOUT_LIVE_BACKTEST_ALIGN", True)
            entry_open = float(signal.get("entry_price", 0) or 0)
            if align_on and entry_open > 0:
                curr_price = entry_open
            else:
                curr_price = float(signal.get("close") or 0.0)
                p = self._live_current_price(code)
                if p > 0:
                    curr_price = p
            if curr_price <= 0:
                if get_env_bool("SCAN_REJECT_LOG_VERBOSE", False):
                    if not hasattr(self, "_no_price_log"):
                        self._no_price_log = {}
                    last = self._no_price_log.get(code, 0)
                    if time.time() - last >= 60:
                        self._no_price_log[code] = time.time()
                        self.logger.info("🔍 [가격없음] %s(%s)", name, code)
                else:
                    self.logger.debug("🔍 [가격없음] %s(%s)", name, code)
                return None

        invest_cap = self._breakout_invest_cap_krw()
        if self._live_portfolio_budget_enabled():
            qty, _, rej = self._resolve_live_buy_qty(curr_price, invest_cap=invest_cap)
            if rej:
                self.logger.info(
                    "🔍 [탈락-%s] %s(%s) price=%.0f cap=%.0f",
                    rej, name, code, curr_price, invest_cap,
                )
                return None
        else:
            qty = invest_qty_for_price(curr_price, float(invest_cap))
            if qty < 1:
                self.logger.info(
                    "🔍 [탈락-수량0] %s(%s) price=%.0f cap=%.0f",
                    name, code, curr_price, invest_cap,
                )
                return None

        stop_price = curr_price * (1 + self.stop_loss_pct)
        target_price = curr_price * (1 + self.take_profit_pct)

        mode_tag = "B" if mode in _INTRABAR_ENTRY_MODES else "A"
        self.logger.info(
            "🚀 [BREAKOUT-%s] %s(%s) price=%.0f qty=%d 저항=%.0f volX=%.1f prevChg=%.2f%%",
            mode_tag, name, code, curr_price, qty,
            signal["resistance"], signal["vol_ratio"], signal["prev_chg"],
        )
        return {
            "code": code,
            "name": name,
            "price": curr_price,
            "qty": qty,
            "stop_price": stop_price,
            "target_price": target_price,
            # 진입 시점 ATR(변동성) — ATR 동적 손절(sl_mode='atr') 용. holding 에 보존되면
            # check_sell_signals 가 매수 시점 변동성으로 손절선을 잡는다(백테와 정합).
            "atr_entry": _breakout_entry_atr_from_candles(confirmed, self.atr_period),
            "size_class": "",
            "entry_features": {
                "resistance": signal["resistance"],
                "vol_ratio":  signal["vol_ratio"],
                "prev_chg":   signal["prev_chg"],
            },
        }

    # ------------------------------------------------------------------
    # 매도
    # ------------------------------------------------------------------
    def check_sell_signals(self) -> List[Dict]:
        if not self.holdings:
            return []

        try:
            eod_hh, eod_mm = [int(x) for x in self.eod_hm.split(":")]
        except Exception:
            eod_hh, eod_mm = 15, 15

        now = dt.now()
        is_eod = False
        if self.eod_enabled:
            is_eod = (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm)

        signals: List[Dict] = []
        for code, holding in list(self.holdings.items()):
            try:
                name = holding.get("name", code)
                buy_price = float(holding.get("buy_price", 0))
                qty = int(holding.get("qty", 0))
                max_price = float(holding.get("max_price", buy_price))
                if qty <= 0 or buy_price <= 0:
                    self.logger.info(
                        "🔍 [매도-잘못된보유] %s(%s) qty=%d buy=%.0f",
                        name, code, qty, buy_price,
                    )
                    continue

                current_price = 0.0
                price_src = ""
                wsd = self.ws.get_price(code)
                if wsd:
                    try:
                        current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
                        price_src = "WS"
                    except Exception:
                        current_price = 0.0
                if current_price <= 0:
                    pd_ = self.client.inquire_price(code)
                    if pd_:
                        try:
                            current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", "")))
                            price_src = "REST"
                        except Exception:
                            current_price = 0.0
                if current_price <= 0:
                    # 가격 소스 둘 다 실패 — 매도 판단 불가. 분당 1회만 경고.
                    last_warn = getattr(self, "_sell_no_price_log", {}).get(code, 0)
                    if time.time() - last_warn >= 60:
                        if not hasattr(self, "_sell_no_price_log"):
                            self._sell_no_price_log = {}
                        self._sell_no_price_log[code] = time.time()
                        self.logger.warning(
                            "⚠️ [매도-가격없음] %s(%s) WS+REST 둘 다 실패 → 매도 판단 보류",
                            name, code,
                        )
                    continue

                # 최고가 갱신
                if current_price > max_price:
                    max_price = current_price
                    holding["max_price"] = max_price

                profit_pct = (current_price - buy_price) / buy_price

                # ── 청산 판정: 백테와 100% 동일한 공용 함수에 위임 (중복 제거) ──
                # 실매는 현재가만 알 수 있으므로 high=low=close=현재가 단일점 봉으로 전달.
                sell_params = {
                    "stop_loss_pct":     self.stop_loss_pct,
                    "take_profit_pct":   self.take_profit_pct,
                    "trail_pct":         self.trail_pct,
                    "trail_arm_pct":     self.trail_arm_pct,
                    "shoulder_min_high": self.shoulder_min_high,
                    "shoulder_cut_pct":  self.shoulder_cut_pct,
                    "ratchet_tiers":     self.ratchet_tiers_raw,
                    "max_hold_bars":     self.max_hold_bars,
                    # ATR 동적 손절 (sl_mode='atr' 일 때만 활성, 기본 fixed=기존 고정%)
                    "sl_mode":           self.sl_mode,
                    "atr_sl_mult":       self.atr_sl_mult,
                    "atr_sl_min_pct":    self.atr_sl_min_pct,
                    "atr_sl_max_pct":    self.atr_sl_max_pct,
                }
                # 진입 변동성(ATR): 매수 시 저장값 우선, 없으면 1회 lazy 계산 후 holding 캐시.
                # sl_mode='fixed' 면 계산 자체를 건너뛴다(불필요한 캔들 조회 방지).
                entry_atr = float(holding.get("entry_atr") or holding.get("atr_entry") or 0.0)
                if self.sl_mode == "atr" and entry_atr <= 0.0:
                    _cand = self.ws.get_candles(code, self.candle_tf, n=self.atr_period + 10)
                    entry_atr = _breakout_entry_atr_from_candles(_cand, self.atr_period)
                    if entry_atr > 0.0:
                        holding["entry_atr"] = entry_atr
                sell_pos = {
                    "entry_price": buy_price,
                    "max_price":   max_price,
                    "entry_time":  holding.get("buy_time", ""),
                    "entry_atr":   entry_atr,
                }
                live_candle = {
                    "high":  current_price,
                    "low":   current_price,
                    "close": current_price,
                    "candle_time": now.strftime("%Y-%m-%d %H:%M:%S"),
                }
                rp = check_sell_signal_breakout_live(
                    sell_pos, live_candle, sell_params, is_eod=is_eod,
                )
                # 공용 함수가 max_price 를 갱신 → 보유 정보에 즉시 반영(다음 폴링 정합)
                new_mp = float(sell_pos.get("max_price", max_price))
                if new_mp > max_price:
                    max_price = new_mp
                    holding["max_price"] = max_price
                reason = rp[0] if rp else None

                if not reason:
                    # 보유 중이지만 매도 조건 미충족 — 종목별 60초 1회 상태 로그
                    if not hasattr(self, "_sell_state_log"):
                        self._sell_state_log = {}
                    last = self._sell_state_log.get(code, 0)
                    if time.time() - last >= 60:
                        self._sell_state_log[code] = time.time()
                        sl_line = buy_price * (1 + self.stop_loss_pct)
                        tp_line = buy_price * (1 + self.take_profit_pct)
                        trail_line = max_price * (1 - self.trail_pct) if max_price > buy_price else 0.0
                        sh_armed = max_price >= buy_price * (1.0 + self.shoulder_min_high)
                        sh_line = max_price * (1.0 - self.shoulder_cut_pct) if sh_armed else 0.0
                        self.logger.info(
                            "🔍 [보유중] %s(%s) 현재=%.0f 매수=%.0f (%.2f%%) "
                            "손절=%.0f 익절=%.0f 어깨=%.0f 트레일=%.0f [%s]",
                            name, code, current_price, buy_price, profit_pct * 100.0,
                            sl_line, tp_line, sh_line, trail_line, price_src,
                        )
                    continue

                signals.append({
                    "code": code,
                    "name": name,
                    "current_price": current_price,
                    "price": current_price,
                    "qty": qty,
                    "buy_price": buy_price,
                    "profit_pct": profit_pct,
                    "reason": reason,
                })
            except Exception as e:
                self.logger.error("매도 시그널 체크 오류(%s): %s", code, e)

        return signals
