"""
kis_trader/strategies/updow_strategy.py — Updow 라이브 전략
==========================================================
- **유니버스**: ``updow_stock_config`` 에 등록된 종목만 매수 스캔.
- **파라미터**: 종목별 ``updow_stock_config`` (없으면 ``env_config`` ``UPDOW_*`` 폴백).
- **분봉(tf)**: 종목별 ``updow_tf_min`` 이 양수면 해당 분봉 WS/백테, 아니면 env ``UPDOW_TF_MIN``.
- 신호·청산 로직: ``updow_buy`` 엔진과 동일.
- 진입봉 시각은 ``active_trades.size_class`` 에 ``u|YYYYMMDDHHMM`` 저장 (재기동 복원).
- **시장 레짐(실매)**: ``UPDOW_KOSPI_1MIN_PROXY_CODE`` 가 ``PERMANENT_WS_CODES`` 등으로 WS에
  구독돼 있으면 **1분 확정봉을 WS에서 우선** 읽고(REST 절약), 봉 수 부족 시에만
  ``get_minute_chart`` REST 폴백. env ``UPDOW_REGIME_PREFER_WS_CANDLES``(기본 true),
  ``UPDOW_REGIME_WS_CANDLE_MIN``(기본 40, 최소 확보 봉 수 하한).
"""
from __future__ import annotations

import random
import time
from typing import Dict, List, Optional, Tuple

from ..engine.limit_entry_common import (
    floor_limit_price_krw,
    is_limit_atr_entry,
    should_cancel_unfilled_limit,
    updow_entry_mode,
)
from ..engine.updown_box import (
    check_buy_signal_box_live,
    check_sell_signal_box_live,
    get_box_cfg_from_env,
)
from ..utils.env import get_env_bool, get_env_float, get_env_int, get_env_from_db
from .base import BaseStrategy
from .updow_buy import (
    clamp_regime_ma_ease_pct,
    kospi_proxy_regime_block_state,
)
from .updow_holding_cfg import (
    ensure_updow_stock_config_table,
    effective_updow_tf_for_code,
    fetch_latest_updow_stock_config_by_code,
    load_updow_engine_cfg,
)
from .updown_box_stock_cfg import (
    ensure_updown_box_stock_cfg_table,
    get_box_overrides,
)
from .updown_scan import run_updown_scan, scan_fetch_n, scan_tf_min
from .updown_watchlist import (
    ensure_updown_watchlist_table,
    list_active_watchlist,
)


class UpdowStrategy(BaseStrategy):
    strategy_id = "UPDOW"
    loop_min_sleep = 1.5
    loop_max_sleep = 2.5

    # DB size_class 에 저장하는 진입봉 키 접두사 (VARCHAR(20) 이하 유지)
    _ENTRY_TAG_PREFIX = "u|"

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.candle_tf = 60
        self._env_tf_default = 60
        self._updow_cfg: Dict = {}
        self._holding_row_by_code: Dict[str, Dict] = {}
        self.min_price = 1000.0
        self._updow_fired_entry_key: Dict[str, str] = {}
        self._pending_limit_orders: Dict[str, Dict] = {}
        # SCAN(조건검색→박스필터→watchlist) 자체 주기 스로틀 타임스탬프
        self._last_scan_ts: float = 0.0
        # watchlist 박스 경계 캐시 (code → {box_low, box_high}) — 박스이탈 손절 참조용
        self._watch_box_by_code: Dict[str, Dict] = {}
        self.reload_config()

    def reload_config(self) -> None:
        snap = None
        try:
            snap = self.db.get_latest_env()
        except Exception:
            pass
        self._updow_cfg = get_box_cfg_from_env()
        self.candle_tf = scan_tf_min()
        self._env_tf_default = int(self.candle_tf)
        self.min_price = get_env_float("MIN_STOCK_PRICE", 1000.0)
        self.slot_money = int(
            float(self._updow_cfg.get("slot_money", get_env_float("UPDOW_SLOT_MONEY", 3_000_000.0)))
        )
        try:
            ensure_updow_stock_config_table(self.db)
            from .updow_holding_cfg import migrate_all_updow_from_holding

            migrate_all_updow_from_holding(self.db, self._updow_cfg)
            self._holding_row_by_code = fetch_latest_updow_stock_config_by_code(self.db)
        except Exception as e:
            self.logger.warning("updow_stock_config 로드 실패: %s", e)
            self._holding_row_by_code = {}

    def manage_pending_orders(self) -> None:
        if not self._pending_limit_orders:
            return
        from ..execution.order_manager import OrderRequest

        for code in list(self._pending_limit_orders.keys()):
            pend = self._pending_limit_orders.get(code)
            if not pend:
                continue
            if code in self.holdings:
                self._pending_limit_orders.pop(code, None)
                continue
            req = pend.get("request")
            ord_no = pend.get("ord_no")
            if req and ord_no:
                fin = self.order_mgr.try_finalize_limit_buy(req, ord_no)
                if fin.success and fin.filled_qty > 0:
                    self._load_holdings_from_db()
                    self._pending_limit_orders.pop(code, None)
                    continue
            tf_code = self._candle_tf_for_code(code)
            candles_raw = self.ws.get_candles(code, tf_code, n=30)
            if not candles_raw:
                continue
            candles = [self._norm_candle(c) for c in candles_raw]
            latest_key = str(candles[-1].get("candle_time") or "")[:12]
            vu = str(pend.get("valid_until_bar_key") or "")[:12]
            if not should_cancel_unfilled_limit(latest_key, vu):
                continue
            disp = pend.get("name") or code
            if ord_no and self.order_mgr.client.cancel_order(str(ord_no)):
                self.logger.info(
                    "🚫 [UPDOW 지정가취소] %s %s — 유효봉 %s 종료 미체결",
                    disp, code, vu,
                )
            else:
                self.logger.info(
                    "🚫 [UPDOW 지정가만료] %s — 유효봉 %s (HTS 미체결 확인)",
                    code, vu,
                )
            self._pending_limit_orders.pop(code, None)

    def on_limit_buy_submitted(self, signal: Dict, result) -> None:
        from ..execution.order_manager import OrderRequest

        code = signal["code"]
        self._pending_limit_orders[code] = {
            "ord_no": result.ord_no,
            "valid_until_bar_key": signal.get("valid_until_bar_key"),
            "name": signal.get("name", code),
            "request": OrderRequest(
                strategy_id=self.strategy_id,
                code=code,
                name=signal.get("name", code),
                side="BUY",
                qty=int(signal.get("qty", 0)),
                price_ref=float(signal.get("price", 0)),
                stop_price=float(signal.get("stop_price", 0)),
                target_price=float(signal.get("target_price", 0)),
                atr_entry=float(signal.get("atr_entry", 0)),
                size_class=signal.get("size_class"),
                entry_features=signal.get("entry_features"),
                use_limit_buy=True,
            ),
        }

    def _reentry_cooldown_sec(self) -> int:
        v = get_env_int("UPDOW_REENTRY_COOLDOWN_SEC", 0)
        if v > 0:
            return v
        return super()._reentry_cooldown_sec()

    def _max_stocks(self) -> int:
        """동시 보유 종목 수 — ``UPDOW_MAX_STOCKS`` (없으면 ``MAX_STOCKS``)."""
        n = get_env_int("UPDOW_MAX_STOCKS", 0)
        if n > 0:
            return n
        return get_env_int("MAX_STOCKS", 3)

    @staticmethod
    def _updow_slot_default_krw() -> int:
        """1회 매수 시도 금액 기본값 — ``UPDOW_SLOT_MONEY`` (총 한도와 별개)."""
        return int(get_env_float("UPDOW_SLOT_MONEY", 3_000_000.0))

    @staticmethod
    def _updow_total_budget_krw() -> int:
        """
        전략 총 운용 한도(원). UPDOW 보유 종목 매입금 합 ≤ 이 값.
        0 이면 총 한도 검사 생략.
        """
        cap = get_env_int("UPDOW_MAX_BUY_AMOUNT", 0)
        if cap <= 0:
            cap = get_env_int("MAX_BUY_AMOUNT_PER_STOCK", 0)
        return int(cap)

    def _updow_exposure_krw(self) -> float:
        """현재 UPDOW 메모리 보유의 매입금 합 (재기동 시 DB 로드분 포함)."""
        total = 0.0
        for h in self.holdings.values():
            q = int(h.get("qty") or 0)
            p = float(h.get("buy_price") or 0)
            if q > 0 and p > 0:
                total += q * p
        return total

    def _updow_remaining_budget_krw(self) -> float:
        """신규 매수에 쓸 수 있는 잔여 운용 한도."""
        cap = self._updow_total_budget_krw()
        if cap <= 0:
            return float("inf")
        return max(0.0, float(cap) - self._updow_exposure_krw())

    def _candidate_filter(self, candidate: Dict) -> bool:
        return bool(candidate.get("updow_on", True))

    # ──────────────────────────────────────────────────────────
    # SCAN: 조건검색 → 박스필터 → updown_watchlist 충전 (자체 주기)
    # ──────────────────────────────────────────────────────────
    def _scan_interval_sec(self) -> int:
        """SCAN 실행 최소 간격(초). 박스는 느리게 변하므로 기본 300초(5분)."""
        return get_env_int("UPDOWN_SCAN_INTERVAL_SEC", 300)

    def _scan_get_candles(self, code: str, tf_min: int, n: int) -> List[Dict]:
        """SCAN 박스 판별용 분봉 조회 — WS 확정봉 우선, 부족 시 REST 폴백.

        WS(키움) 에 이미 구독돼 있으면 REST 절약. 봉 수 부족하면 한투
        ``get_minute_chart`` REST 로 보충(조회는 항상 실키 market_client).
        """
        out: List[Dict] = []
        try:
            raw = self.ws.get_candles(code, tf_min, n=n) if getattr(self, "ws", None) else []
            out = [self._norm_candle(c) for c in (raw or [])]
        except Exception:
            out = []
        if len(out) >= n:
            return out
        # REST 폴백
        try:
            df = self.client.get_minute_chart(code, str(tf_min), limit=n)
        except Exception:
            df = None
        if df is not None and not getattr(df, "empty", True):
            try:
                rest_rows = [
                    {
                        "candle_time": "",
                        "open": float(r.get("open", 0) or 0),
                        "high": float(r.get("high", 0) or 0),
                        "low": float(r.get("low", 0) or 0),
                        "close": float(r.get("close", 0) or 0),
                        "volume": float(r.get("volume", 0) or 0),
                    }
                    for r in df.to_dict("records")
                ]
                if len(rest_rows) > len(out):
                    out = rest_rows
            except Exception:
                pass
        return out

    def _run_scan_if_due(self) -> None:
        """조건검색 후보를 박스필터링해 watchlist 에 충전 (스로틀 적용).

        - 조건검색 매니저(``self.condition_mgr``) 가 없으면 SCAN 생략(폴백 운영).
        - ``UPDOWN_SCAN_INTERVAL_SEC`` 간격으로만 실행 (5분 스캔 원칙).
        """
        if not getattr(self, "condition_mgr", None):
            return
        now = time.time()
        if now - float(self._last_scan_ts or 0) < self._scan_interval_sec():
            return
        self._last_scan_ts = now
        try:
            cands = self.condition_mgr.get_candidates_for(self.strategy_id) or []
        except Exception as e:
            self.logger.debug("[UPDOWN SCAN] 조건검색 후보 조회 실패: %s", e)
            return
        # 전략별 후보 하드캡 (UPDOW_CAND_LIMIT) + 비본주 필터 — WS/REST 부하 절약
        cands = self._post_filter_candidates(cands)
        if not cands:
            self.logger.debug("[UPDOWN SCAN] 조건후보 0 (CAND_LIMIT/필터 후)")
            return
        try:
            run_updown_scan(
                self.db,
                cands,
                self._scan_get_candles,
                source="condition",
                sleep_between=True,
            )
        except Exception as e:
            self.logger.warning("[UPDOWN SCAN] 실행 실패: %s", e)

    def _load_candidates(self) -> List[Dict]:
        """유니버스 = updown_watchlist(active) 우선, 비면 updow_stock_config 폴백.

        매 루프 호출되지만 SCAN 은 ``_run_scan_if_due`` 내부에서 5분 스로틀.
        (전략 전용 — BaseStrategy 랭킹 경로 미사용)
        """
        # 1) 조건검색 → 박스필터 → watchlist 충전 (스로틀)
        self._run_scan_if_due()

        # 2) watchlist active 를 1순위 유니버스로 사용 (sticky)
        cands: List[Dict] = []
        self._watch_box_by_code = {}
        try:
            ensure_updown_watchlist_table(self.db)
            for row in list_active_watchlist(self.db):
                code = str(row.get("code") or "").strip()
                if not code:
                    continue
                nm = (row.get("name") or code or "").strip() or code
                self._watch_box_by_code[code] = {
                    "box_low": float(row.get("box_low") or 0),
                    "box_high": float(row.get("box_high") or 0),
                }
                cands.append({
                    "code": code, "name": nm,
                    "updow_on": True, "scalp_on": False, "tail_on": False,
                })
        except Exception as e:
            self.logger.warning("[UPDOWN 유니버스] watchlist 로드 실패: %s", e)

        if cands:
            return self._post_filter_candidates(cands)

        # 3) 폴백: 기존 updow_stock_config 기반 유니버스 (수동 등록 종목 보존)
        rows = getattr(self, "_holding_row_by_code", None) or {}
        if not rows:
            self.logger.info(
                "📂 [UPDOW 유니버스] watchlist active 0 + updow_stock_config 0 → 후보 0"
            )
            return []
        fb: List[Dict] = []
        for code in sorted(rows.keys()):
            row = rows[code]
            nm = (row.get("name") or code or "").strip() or code
            fb.append({
                "code": code,
                "name": nm,
                "updow_on": True,
                "scalp_on": False,
                "tail_on": False,
            })
        return self._post_filter_candidates(fb)

    def _norm_candle(self, c: dict) -> dict:
        ct = c.get("candle_time") or c.get("candle_time_str", "")
        if isinstance(ct, str) and len(ct) == 19 and " " in ct:
            ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12]
        return {
            "candle_time": ct,
            "open": float(c.get("open", 0)),
            "high": float(c.get("high", 0)),
            "low": float(c.get("low", 0)),
            "close": float(c.get("close", 0)),
            "volume": float(c.get("volume", 0)),
        }

    def _parse_entry_bar_key(self, holding: dict) -> str:
        sc = (holding.get("size_class") or "").strip()
        if sc.startswith(self._ENTRY_TAG_PREFIX):
            return sc[len(self._ENTRY_TAG_PREFIX) :]
        return ""

    def _after_holdings_sync(self) -> None:
        for _code, h in self.holdings.items():
            ek = self._parse_entry_bar_key(h)
            if ek:
                h["updow_entry_bar_key"] = ek
                self._runtime.setdefault(_code, {})["updow_entry_bar_key"] = ek

    def _load_holdings_from_db(self, *, log_restore: bool = False) -> None:
        super()._load_holdings_from_db(log_restore=log_restore)

    def _submit_buy(self, signal: Dict):
        code = signal.get("code") or ""
        key = signal.get("updow_entry_bar_key") or ""
        if key:
            self._updow_fired_entry_key[code] = key
        result = super()._submit_buy(signal)
        if not (result and result.success) and key:
            self._updow_fired_entry_key.pop(code, None)
        elif result and result.success and code in self.holdings:
            sc = (signal.get("size_class") or "").strip()
            if sc:
                self.holdings[code]["size_class"] = sc
            if key:
                self.holdings[code]["updow_entry_bar_key"] = key
            ef = signal.get("entry_features") or {}
            if ef.get("box_low"):
                self.holdings[code]["box_low"] = float(ef["box_low"])
            if ef.get("box_high"):
                self.holdings[code]["box_high"] = float(ef["box_high"])
            self._capture_runtime_overlay()
        return result

    def _merged_cfg(self, code: str) -> Dict:
        """박스 엔진 글로벌 cfg + (옵션) 종목별 slot/레짐 + 종목별 박스 파라미터 오버라이드.

        우선순위(낮음→높음): 글로벌 env UPDOWN_BOX_* → 구 updow_stock_config(slot/레짐만)
        → updown_box_stock_cfg(종목별 박스 파라미터 핀, 설정된 키만).
        """
        cfg = dict(get_box_cfg_from_env())
        # 1) slot/레짐 — 구 updow_stock_config 폴백 (박스 파라미터는 건드리지 않음)
        row = self._holding_row_by_code.get(code)
        if row:
            for k in ("slot_money", "regime_ma_bars", "regime_ma_ease_pct"):
                if row.get(k) is not None:
                    cfg[k] = row[k]
        else:
            extra = load_updow_engine_cfg(self.db, code, {})
            for k in ("slot_money", "regime_ma_bars", "regime_ma_ease_pct"):
                if extra.get(k) is not None:
                    cfg[k] = extra[k]
        # 2) 종목별 박스 파라미터 핀(updown_box_stock_cfg) — 설정된 키만 글로벌 위에 덮음(hybrid)
        try:
            ov = get_box_overrides(self.db, code)
            for k, v in ov.items():
                cfg[k] = v
        except Exception as e:
            self.logger.debug("box override 로드 실패 %s: %s", code, e)
        return cfg

    def _candle_tf_for_code(self, code: str) -> int:
        """박스 판별·진입 분봉 — SCAN 과 동일 (기본 15분)."""
        return scan_tf_min()

    def _kospi_proxy_regime_eval(self, merged: Dict[str, Any]) -> Tuple[bool, str]:
        """KOSPI 추적 ETF 1분 종가 < N분 단순 MA 이면 신규 매수 차단.

        WS ``CandleAggregator`` 확정 1분봉 우선(``PERMANENT_WS_CODES`` 등과 정합, REST 절약),
        ``ma``개 미만이면 한투 ``get_minute_chart`` 로 폴백.
        차단 조건: ``종가 < SMA×(1−regime_ma_ease_pct/100)`` (ease=0 이면 종가<SMA).

        Returns:
            (True, detail) — 차단 시 ``detail`` 에 프록시·종가·SMA·출처(WS/REST) 요약
            (False, "") — 차단 아님 또는 레짐 미사용·데이터 부족
        """
        try:
            ma = int(float(merged.get("regime_ma_bars", 0)))
        except (TypeError, ValueError):
            ma = 0
        if ma < 1:
            return False, ""
        proxy = (get_env_from_db("UPDOW_KOSPI_1MIN_PROXY_CODE", "069500") or "069500").strip()
        if len(proxy) != 6 or not proxy.isdigit():
            return False, ""

        ws_floor = get_env_int("UPDOW_REGIME_WS_CANDLE_MIN", 40)
        need = max(ma + 10, ws_floor)
        closes: List[float] = []
        src = "WS"

        if get_env_bool("UPDOW_REGIME_PREFER_WS_CANDLES", True) and getattr(self, "ws", None):
            try:
                raw = self.ws.get_candles(proxy, 1, need)
            except Exception:
                raw = []
            for c in raw or []:
                try:
                    cl = float(c.get("close", 0) or 0)
                except (TypeError, ValueError):
                    continue
                if cl > 0:
                    closes.append(cl)

        if len(closes) < ma:
            closes = []
            src = "REST"
            try:
                df = self.client.get_minute_chart(proxy, "1", limit=max(need, ma + 10, ws_floor))
            except Exception:
                df = None
            if df is None or getattr(df, "empty", True):
                return False, ""
            try:
                closes = [float(x) for x in df["close"].tolist()]
            except Exception:
                return False, ""

        if len(closes) < ma:
            return False, ""

        ease = clamp_regime_ma_ease_pct(merged.get("regime_ma_ease_pct", 0))
        st = kospi_proxy_regime_block_state(closes, ma, ease)
        if st is None:
            return False, ""
        blocks, last, sma, floor, easeu = st
        if not blocks:
            return False, ""

        detail = (
            f"프록시={proxy}({src}) 1분종가={last:.2f} SMA({ma})={sma:.2f} "
            f"ease={easeu:.3f}% 차단선={floor:.2f} 갭(종가−차단선)={last - floor:.2f} → 신규매수 정지"
        )
        return True, detail

    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        merged = self._merged_cfg(code)

        # 대형 주도주 등 하락매수 제외 종목 차단 (DIP_BUY_EXCLUDE_CODES 비면 무효)
        if self.is_dip_buy_excluded(code):
            self.logger.info("🔍 [탈락-대형주제외] %s %s: DIP_BUY_EXCLUDE_CODES", name, code)
            return None

        regime_block, regime_detail = self._kospi_proxy_regime_eval(merged)
        if regime_block:
            self.logger.info(
                "🔍 [탈락-시장레짐] %s %s: %s | 참고: 코스피200·지수 일봉 상승과 무관 "
                "(레짐은 프록시 ETF 확정 1분봉의 단기 MA 대비)",
                name,
                code,
                regime_detail,
            )
            return None

        if get_env_bool("FORCE_BUY_TEST", False):
            px = 0.0
            wsd = self.ws.get_price(code)
            if wsd:
                try:
                    px = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
                except Exception:
                    px = 0.0
            if px <= 0:
                self.logger.info("🔍 [탈락-FORCE] %s %s: 현재가 없음", name, code)
                return None
            slot_def = self._updow_slot_default_krw()
            slot = int(float(merged.get("slot_money", slot_def)))
            remain = self._updow_remaining_budget_krw()
            invest = min(float(slot), remain) if remain != float("inf") else float(slot)
            if invest <= 0:
                self.logger.info(
                    "🔍 [탈락-총한도] %s %s: UPDOW 총운용한도 소진 (노출 %.0f / 한도 %d)",
                    name, code, self._updow_exposure_krw(), self._updow_total_budget_krw(),
                )
                return None
            qty = max(1, int(invest / px))
            sl_pct = float(merged.get("stop_loss_pct", get_env_float("UPDOW_STOP_LOSS_PCT", 2.0))) / 100.0
            tp_pct = float(merged.get("tp_pct", get_env_float("UPDOW_TP_PCT", 3.0))) / 100.0
            return {
                "code": code,
                "name": name,
                "price": px,
                "qty": qty,
                "stop_price": px * (1.0 - sl_pct),
                "target_price": px * (1.0 + tp_pct),
                "atr_entry": 0.0,
                "size_class": "",
                "entry_features": {},
            }

        min_len = max(
            int(self._updow_cfg.get("min_bars", 20)),
            get_env_int("UPDOW_MIN_CANDLE_LEN", get_env_int("MIN_CANDLE_LEN_UPDOW", 20)),
        )
        n_fetch = max(min_len + 5, scan_fetch_n(), get_env_int("UPDOW_CANDLE_FETCH_N", 50))
        tf_code = self._candle_tf_for_code(code)
        candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch)
        if len(candles_raw) < min_len:
            self.logger.info(
                "🔍 [탈락-봉부족] %s %s: WS확정봉 %d개 (최소 %d, tf=%d)",
                name, code, len(candles_raw), min_len, tf_code,
            )
            return None

        candles = [self._norm_candle(c) for c in candles_raw]
        box_cfg = self._merged_cfg(code)
        reject, msg, sig = check_buy_signal_box_live(
            candles,
            box_cfg,
            last_fired_entry_key=self._updow_fired_entry_key.get(code),
        )
        if reject:
            self.logger.info("🔍 [%s] %s %s: %s", reject, name, code, msg or "")
            return None
        if not sig:
            self.logger.info("🔍 [탈락-무신호] %s %s", name, code)
            return None

        entry_open = float(sig["entry_price"])
        wsd = self.ws.get_price(code)
        curr_price = entry_open
        if wsd:
            try:
                curr_price = abs(float(str(wsd.get("stck_prpr", entry_open)).replace(",", ""))) or entry_open
            except Exception:
                curr_price = entry_open

        if curr_price <= 0 or curr_price < self.min_price:
            self.logger.info(
                "🔍 [탈락-가격] %s %s: 현재가 %.0f (최소 %.0f)",
                name, code, curr_price, self.min_price,
            )
            return None

        slot_def = self._updow_slot_default_krw()
        total_cap = self._updow_total_budget_krw()
        remain = self._updow_remaining_budget_krw()
        exposure = self._updow_exposure_krw()
        slot = float(box_cfg.get("slot_money", float(self.slot_money)))
        if slot <= 0:
            slot = float(slot_def)
        from ..utils.position_sizing import resolve_invest_amount_krw

        cap_remain = int(remain) if remain != float("inf") else 0
        invest_amount = resolve_invest_amount_krw(
            int(slot),
            extra_cap=cap_remain if cap_remain > 0 else 0,
        )
        if remain != float("inf"):
            invest_amount = min(invest_amount, int(remain))

        if remain != float("inf") and remain < entry_open * 0.99:
            self.logger.info(
                "🔍 [탈락-총한도] %s %s: 잔여 %.0f원 < 1주(%.0f원) | 노출 %.0f / 총한도 %d",
                name, code, remain, entry_open, exposure, total_cap,
            )
            return None

        if invest_amount <= 0:
            self.logger.info(
                "🔍 [탈락-금액] %s %s: invest=0 (slot=%.0f 잔여=%s)",
                name, code, slot,
                f"{remain:,.0f}" if remain != float("inf") else "무제한",
            )
            return None

        qty = max(1, int(invest_amount / entry_open))
        order_krw = qty * entry_open
        if remain != float("inf") and order_krw > remain * 1.001:
            qty = max(1, int(remain / entry_open))
            order_krw = qty * entry_open
        ent_key = str(sig.get("updow_entry_bar_key") or "")
        size_class = f"{self._ENTRY_TAG_PREFIX}{ent_key}" if ent_key else ""
        box_low = float(sig.get("box_low", 0) or self._watch_box_by_code.get(code, {}).get("box_low", 0) or 0)

        self.logger.info(
            "✅ [통과-박스매수] %s %s: tp=%.2f%% sl=%.2f%% box_low=%.0f "
            "주문≈%s원 qty=%d 진입=%.0f score=%.2f",
            name, code,
            float(sig.get("tp_pct", 0)), float(sig.get("sl_pct", 0)),
            box_low, f"{order_krw:,.0f}", qty, entry_open,
            float(sig.get("box_score", 0)),
        )

        return {
            "code": code,
            "name": name,
            "price": entry_open,
            "qty": qty,
            "stop_price": float(sig.get("stop_price", 0.0)),
            "target_price": float(sig.get("target_price", 0.0)),
            "atr_entry": 0.0,
            "size_class": size_class,
            "updow_entry_bar_key": ent_key,
            "entry_features": {
                "box_low": box_low,
                "box_high": float(sig.get("box_high", 0) or 0),
                "box_score": float(sig.get("box_score", 0) or 0),
                "sl_pct": float(sig.get("sl_pct", 0.0)),
                "tp_pct": float(sig.get("tp_pct", 0.0)),
            },
        }

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

        signals: List[Dict] = []
        base_n = get_env_int("UPDOW_CANDLE_FETCH_N", 50)

        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))
                if qty <= 0 or buy_price <= 0:
                    continue

                merged = self._merged_cfg(code)
                max_hold = int(float(merged.get("max_hold_bars", 16)))
                n_fetch = max(max_hold + 8, base_n)

                current_price = 0.0
                wsd = self.ws.get_price(code)
                if wsd:
                    try:
                        current_price = abs(float(str(wsd.get("stck_prpr", 0)).replace(",", "")))
                    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(",", "")))
                        except Exception:
                            current_price = 0.0
                if current_price <= 0:
                    self.logger.debug("[UPDOW 매도] %s %s: 현재가 없음 — 스킵", name, code)
                    continue

                tf_code = self._candle_tf_for_code(code)
                candles_raw = self.ws.get_candles(code, tf_code, n=n_fetch)
                candles = [self._norm_candle(c) for c in candles_raw]
                entry_key = holding.get("updow_entry_bar_key") or self._parse_entry_bar_key(holding)
                buy_time = str(holding.get("buy_time", "") or "")

                mp = float(holding.get("max_price") or buy_price)
                try:
                    h_now = float(candles[-1].get("high", 0) or 0) if candles else 0.0
                except (TypeError, ValueError):
                    h_now = 0.0
                mp = max(mp, current_price, h_now)
                holding["max_price"] = mp

                # 진입 시 저장한 박스 경계 (entry_features → holding) 우선, 없으면 watchlist 캐시
                box_low = float(holding.get("box_low") or 0)
                if box_low <= 0:
                    box_low = float(self._watch_box_by_code.get(code, {}).get("box_low", 0) or 0)
                box_high = float(holding.get("box_high") or 0)
                if box_high <= 0:
                    box_high = float(self._watch_box_by_code.get(code, {}).get("box_high", 0) or 0)

                res = check_sell_signal_box_live(
                    buy_price=buy_price,
                    candles=candles,
                    cfg=merged,
                    entry_bar_key=entry_key,
                    buy_time_str=buy_time,
                    current_price=current_price,
                    max_price=mp,
                    box_low=box_low,
                    box_high=box_high,
                )
                if not res:
                    continue
                reason, exit_price = res
                profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0.0
                self.logger.info(
                    "✅ [통과-매도신호] %s %s: %s (참고가 %.0f, 손익 %.2f%%) [holding tp/sl/hold=%.1f/%.1f/%d]",
                    name,
                    code,
                    reason,
                    exit_price,
                    profit_pct * 100.0,
                    float(merged.get("tp_pct", 0)),
                    float(merged.get("stop_loss_pct", 0)),
                    int(float(merged.get("max_hold_bars", 0))),
                )
                signals.append({
                    "code": code,
                    "name": name,
                    "current_price": current_price,
                    "price": exit_price,
                    "qty": qty,
                    "buy_price": buy_price,
                    "profit_pct": profit_pct,
                    "reason": reason,
                })
            except Exception as e:
                self.logger.error("UPDOW 매도 시그널 체크 오류(%s): %s", code, e)

        time.sleep(random.uniform(0.05, 0.15))
        return signals
