"""
kis_trader/strategies/base.py — 전략 공통 기반 클래스
======================================================
각 전략은 **독립 쓰레드**로 돌아간다. 구조:
  start() → 백그라운드 thread 기동 → self._run_loop() (while self._running)
  stop()  → self._running = False + join

루프 안에서 하는 일:
  1. 장 세션 체크 (check_market_status — 매도·EOD 포함, 정규장 마감까지)
  2. 보유 종목(active_trades where strategy=self.strategy_id) 로드 → 매도 시그널 체크
  3. check_buy_allowed() 통과 시 후보 순회 → 매수 시그널 체크 ({SID}_TIME_END = 매수 종료만)
  4. 시그널 발생 시 OrderManager.place() 로 집중 (실제 주문은 OrderManager 내부에서 종목Lock+ODNO+실잔고검증)
"""
from __future__ import annotations

import random
import threading
import time
from abc import ABC, abstractmethod
from datetime import datetime as dt
from typing import Dict, List, Optional, Any, Tuple

from ..database.db_manager import TradeDBExt
from ..execution.kis_client import KISClient
from ..execution.order_manager import OrderManager
from ..network.ws_manager import WSManager
from ..utils.env import get_env_bool, get_env_from_db, get_env_int
from ..utils.logger import get_logger


# ETN/ETF/레버리지/인버스/스팩/우선주 자동 필터 키워드
# (KISClient._is_valid_stock_for_rank 와 동기화 — 한곳만 수정해도 양쪽 적용되도록 모듈 상수화)
_NON_STOCK_KEYWORDS = (
    "ETN", "ETF", "레버리지", "인버스", "2X", "3X", "선물",
    "KODEX", "TIGER", "KBSTAR", "ARIRANG", "HANARO", "SOL ",
    "KOSEF", "ACE ", "KINDEX", "RISE ", "PLUS ", "TIMEFOLIO",
    "파워", "히어로", "SMART", "TREX", "WON", "KOACT",
)


def _is_non_stock(name: str, code: str) -> bool:
    """ETN/ETF/스팩/우선주 등 비본주 여부.

    조건검색·랭킹 매니저 양쪽에서 들어오는 후보를 동일 규칙으로 거르기 위해
    BaseStrategy 단계에서 한번 더 차단한다.  False 면 매매 가능 종목.
    """
    if not name or not code:
        # 이름이 비어있으면 보수적으로 통과 (이후 KIS 응답으로 자연 거름)
        return False
    nm = name.upper()
    if any(k in nm for k in _NON_STOCK_KEYWORDS):
        return True
    if "스팩" in name or "SPAC" in nm:
        return True
    if name.endswith("우") or name.endswith("우B") or name.endswith("(전환)"):
        return True
    # KIS 코드 체계: 7로 시작하는 6자리는 ETN (예: 760006)
    code = code.strip()
    if len(code) == 6 and code[0] == "7":
        return True
    return False


def parse_eod_hm(raw: str, default: str = "15:25") -> Tuple[int, int]:
    """EOD 시각 문자열 → (시, 분). ``1515`` / ``15:15`` 모두 허용."""
    s = str(raw or default).strip()
    if not s or s.lower() == "none":
        s = default
    if ":" in s:
        parts = s.split(":", 1)
        try:
            return int(parts[0]), int(parts[1])
        except (ValueError, TypeError):
            pass
    if len(s) == 4 and s.isdigit():
        return int(s[:2]), int(s[2:])
    try:
        hh, mm = [int(x) for x in s.split(":")]
        return hh, mm
    except Exception:
        return 15, 25


def is_live_eod_now(
    enabled: bool,
    eod_hm: str,
    now: dt,
    *,
    default_hm: str = "15:25",
) -> bool:
    """실매 EOD 당일청산 시각 도달 여부."""
    if not enabled:
        return False
    eod_hh, eod_mm = parse_eod_hm(eod_hm, default_hm)
    return (now.hour > eod_hh) or (now.hour == eod_hh and now.minute >= eod_mm)


# 전략별 EOD env 키 — 실매·백테·파라서치 공통
_STRATEGY_EOD_SPEC: Dict[str, Tuple[str, str, bool, str, str]] = {
    "BREAKOUT": ("BREAKOUT_EOD_ENABLED", "BREAKOUT_EOD_HM", True, "15:15", ""),
    "MOMENTUM": ("MOMENTUM_EOD_ENABLED", "MOMENTUM_EOD_HM", True, "15:25", "MOMENTUM_FORCE_EOD_EXIT"),
    "TAIL": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"),
    "SHORT": ("TAIL_EOD_ENABLED", "TAIL_EOD_HM", True, "15:25", "force_eod_exit"),
}


def _params_truthy_bool(val: Any, default: bool) -> bool:
    if val is None or val == "" or val == "None":
        return default
    if isinstance(val, bool):
        return val
    return str(val).strip().lower() in ("1", "true", "t", "y", "yes", "on")


def resolve_strategy_eod_params(
    params: Dict[str, Any],
    strategy_id: str,
) -> Tuple[bool, str]:
    """params → (eod_enabled, eod_hm). UI ``eod_enabled``/``eod_hm`` 우선, 없으면 env 키."""
    sid = str(strategy_id or "").strip().upper()
    if sid == "SHORT":
        sid = "TAIL"
    spec = _STRATEGY_EOD_SPEC.get(sid)
    if spec is None:
        return False, "15:25"
    en_key, hm_key, def_en, def_hm, leg_key = spec

    if "eod_enabled" in params:
        enabled = _params_truthy_bool(params.get("eod_enabled"), def_en)
    elif en_key in params:
        enabled = _params_truthy_bool(params.get(en_key), def_en)
    elif leg_key and leg_key in params:
        enabled = _params_truthy_bool(params.get(leg_key), def_en)
    else:
        enabled = def_en

    raw_hm = params.get("eod_hm")
    if raw_hm not in (None, "", "None"):
        eod_hm = str(raw_hm).strip()
    elif params.get(hm_key) not in (None, "", "None"):
        eod_hm = str(params.get(hm_key)).strip()
    else:
        eod_hm = def_hm
    return enabled, eod_hm


def is_backtest_eod_bar(
    candle_time: str,
    enabled: bool,
    eod_hm: str,
    *,
    default_hm: str = "15:25",
) -> bool:
    """백테 1분봉/스캔키 — 실매 ``is_live_eod_now`` 와 동일 시각 기준."""
    if not enabled:
        return False
    eod_hh, eod_mm = parse_eod_hm(eod_hm, default_hm)
    t = str(candle_time).strip()
    if len(t) < 12:
        return False
    try:
        bar_hh = int(t[8:10])
        bar_mm = int(t[10:12])
    except (ValueError, TypeError):
        return False
    return (bar_hh > eod_hh) or (bar_hh == eod_hh and bar_mm >= eod_mm)


def is_strategy_eod_bar(
    candle_time: str,
    params: Dict[str, Any],
    strategy_id: str,
) -> bool:
    """전략 params + 봉시각 → EOD 청산 여부 (실매와 동일 키·시각)."""
    sid = str(strategy_id or "").strip().upper()
    if sid == "SHORT":
        sid = "TAIL"
    spec = _STRATEGY_EOD_SPEC.get(sid)
    if spec is None:
        return False
    _, _, _, def_hm, _ = spec
    enabled, eod_hm = resolve_strategy_eod_params(params, sid)
    return is_backtest_eod_bar(candle_time, enabled, eod_hm, default_hm=def_hm)


class BaseStrategy(ABC, threading.Thread):
    """
    모든 전략의 공통 부모 클래스.  threading.Thread 상속 → start() 시 독립 쓰레드.

    서브클래스 구현 필수:
      - strategy_id (class attribute 또는 property, 고유 문자열)
      - check_buy(code, name) -> Optional[dict]  (매수 시그널 dict)
      - check_sell_signals() -> List[dict]        (매도 시그널 리스트)
      - _candidate_filter(c)  -> bool             (본인이 관심 있는 후보인지)
    """

    strategy_id: str = "BASE"
    loop_min_sleep: float = 1.0
    loop_max_sleep: float = 2.0

    # 전략별 유니버스 소스 기본값 (env 미설정 시) — HTS 조건검색 단일 정책
    DEFAULT_UNIVERSE_SOURCES: Dict[str, str] = {
        "SCALP":    "condition",
        "SHORT":    "kiwoom_condition",  # 키움 tail(A 시가대비+F 저가회복) WS 실시간
        "BREAKOUT": "kiwoom_condition",
        "MOMENTUM": "kiwoom_condition",  # 키움 WS 실시간 조건 (KIS REST 폴링 대비 유니버스 품질↑)
        "UPDOW":    "condition",
    }

    def __init__(
        self,
        *,
        db: TradeDBExt,
        client: KISClient,
        ws: WSManager,
        order_mgr: OrderManager,
        condition_mgr=None,
        ranking_mgr=None,
        kiwoom_condition_mgr=None,
        market_guard=None,
    ):
        super().__init__(daemon=True, name=f"Strat-{self.strategy_id}")
        self.db = db
        self.client = client
        self.ws = ws
        self.order_mgr = order_mgr
        self.condition_mgr = condition_mgr  # ConditionSearchManager (KIS REST, 선택)
        self.ranking_mgr = ranking_mgr      # VolumeRankManager (선택)
        # KiwoomConditionSearchManager (키움 WS 실시간 조건검색, 선택). KIS 와 별개 소스.
        self.kiwoom_condition_mgr = kiwoom_condition_mgr
        self.market_guard = market_guard    # MarketGuard (선택, None 이면 가드 없음)
        self.logger = get_logger(f"kis_trader.strategy.{self.strategy_id}")
        # MarketGuard PANIC 차단 로그 스팸 방지용 (분당 1회)
        self._panic_log_ts: float = 0.0

        # 유니버스 소스: "ranking" | "condition"(KIS) | "kiwoom_condition"(키움 WS)
        # env: {STRATEGY_ID}_UNIVERSE_SOURCE — 런타임에 스위치 가능.
        key = f"{self.strategy_id}_UNIVERSE_SOURCE"
        default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
        self.universe_source = (
            (get_env_from_db(key, default) or default).strip().lower()
        )
        if self.universe_source not in ("ranking", "condition", "kiwoom_condition"):
            self.logger.warning(
                "알 수 없는 UNIVERSE_SOURCE=%s → 기본값 %s 사용",
                self.universe_source, default,
            )
            self.universe_source = default

        self._running = False
        # 보유 종목 — 매 루프 DB active_trades 와 동기화 (진실의 원천 = DB)
        self.holdings: Dict[str, dict] = {}
        # 장중 고점·세션저점·전략별 부가키 — DB sync 로 덮어쓰지 않음 (래칫/어깨 퇴행 방지)
        self._runtime: Dict[str, dict] = {}
        # 최근 매도 쿨다운 (종목별 마지막 매도 타임스탬프)
        self.recently_sold: Dict[str, float] = {}
        # 당일 매매불가 종목 (다음 후보로 넘어감)
        self.untradable_skip: set = set()

        # 일일 익절 목표 가드 (Orchestrator 주입, 없으면 OFF)
        self.daily_profit_halt: Any = None

        self._sync_holdings_from_db(log_restore=True)

    # ------------------------------------------------------------------
    # 외부 인터페이스
    # ------------------------------------------------------------------
    def stop_loop(self) -> None:
        """쓰레드 정지 요청 (daemon 이지만 정상 종료 시 호출)."""
        self._running = False

    # ------------------------------------------------------------------
    # 스캔 루프 sleep (env 핫리로드 — 재시작 없이 반영)
    # ------------------------------------------------------------------
    # REST 유량과는 별개의 "스캔 회전율" 조절. WS 따라가기 속도를 결정한다.
    # 전략별 클래스 기본값(loop_min_sleep 등)을 폴백으로 두고, env 가 있으면 우선.
    def _scan_sleep(self, kind: str) -> float:
        """kind: 'loop'(루프끝) | 'reject'(탈락) | 'buy_ok'(매수성공) | 'buy_fail'(주문실패)."""
        from ..utils.env import get_env_float
        if kind == "loop":
            lo = get_env_float("STRATEGY_LOOP_SLEEP_MIN", self.loop_min_sleep)
            hi = get_env_float("STRATEGY_LOOP_SLEEP_MAX", self.loop_max_sleep)
        elif kind == "reject":
            lo = get_env_float("SCAN_REJECT_SLEEP_MIN", 0.2)
            hi = get_env_float("SCAN_REJECT_SLEEP_MAX", 0.5)
        elif kind == "buy_ok":
            lo = get_env_float("SCAN_BUY_OK_SLEEP_MIN", 1.0)
            hi = get_env_float("SCAN_BUY_OK_SLEEP_MAX", 2.0)
        else:  # buy_fail
            lo = get_env_float("SCAN_BUY_FAIL_SLEEP_MIN", 0.3)
            hi = get_env_float("SCAN_BUY_FAIL_SLEEP_MAX", 0.8)
        lo = max(0.0, lo)
        hi = max(lo, hi)
        return random.uniform(lo, hi)

    # ------------------------------------------------------------------
    # 하락매수(dip) 종목 제외 — 대형주에 떨어지는 칼날 잡기 방지
    # ------------------------------------------------------------------
    def is_dip_buy_excluded(self, code: str) -> bool:
        """
        하락매수 계열(UPDOW·SHORT) 이 매수하면 안 되는 종목인지 판정.
        - ``DIP_BUY_EXCLUDE_CODES`` : 콤마구분 종목코드 화이트리스트 제외 (기본 빈 값 → 무효)
          예) 삼성전자·하이닉스 등 대형 주도주는 하락매수가 아니라 추세추종 대상.
        기본값이 비어 있으면 기존 동작과 100% 동일 (필터 OFF).
        """
        raw = str(get_env_from_db("DIP_BUY_EXCLUDE_CODES", "") or "").strip()
        if not raw:
            return False
        code = str(code or "").strip()
        excluded = {c.strip() for c in raw.split(",") if c.strip()}
        return code in excluded

    def run(self) -> None:
        """threading.Thread.run() 오버라이드 — 전략 메인 루프."""
        self._running = True
        self.logger.info("🚀 전략 쓰레드 시작 [%s]", self.strategy_id)
        try:
            self._run_loop()
        except Exception as e:
            self.logger.exception("전략 루프 예외: %s", e)
        finally:
            self.logger.info("⏹ 전략 쓰레드 종료 [%s]", self.strategy_id)

    # ------------------------------------------------------------------
    # 메인 루프
    # ------------------------------------------------------------------
    def _run_loop(self) -> None:
        last_date = dt.now().strftime("%Y-%m-%d")
        last_closed_log = 0.0
        while self._running:
            try:
                now = dt.now()
                today = now.strftime("%Y-%m-%d")

                # 날짜 변경 처리 (당일 매매불가 리셋 등)
                if today != last_date:
                    last_date = today
                    self.untradable_skip.clear()
                    self.on_new_day()

                # 장 시간 체크 (서브클래스 오버라이드 가능)
                if not self.check_market_status():
                    # 장외 heartbeat: 봇 기동 직후 1회 + 이후 1시간마다 1회.
                    # (봇 살아있음·보유·유니버스 상태만 확인용 — 잦은 로그 노이즈 제거)
                    interval = get_env_int("OFF_HOURS_LOG_INTERVAL_SEC", 3600)
                    if time.time() - last_closed_log >= interval:
                        try:
                            universe = self._load_candidates()
                        except Exception:
                            universe = []
                        self.logger.info(
                            "🌙 [장외] holdings=%d universe=%d recently_sold=%d",
                            len(self.holdings), len(universe), len(self.recently_sold),
                        )
                        last_closed_log = time.time()
                    time.sleep(30)
                    continue

                # 설정 리로드 (DB env_config 실시간 반영)
                self.reload_config()

                # 보유 목록 = DB 진실 + _runtime 오버레이 (poll 체결·재시작 정합)
                self._sync_holdings_from_db()

                # ── [1] 매도 먼저 ────────────────────────────────
                sell_signals = self.check_sell_signals()
                if sell_signals and get_env_bool("REAL_BALANCE_VERIFY_BEFORE_SELL", True):
                    self.order_mgr.prefetch_broker_holdings()
                for sig in sell_signals:
                    self._submit_sell(sig)

                # ── [2] 후보 구독 동기화 (공유 WS) ────────────────
                candidates = self._load_candidates()
                cand_codes = [c.get("code") for c in candidates if c.get("code")]
                hold_codes = list(self.holdings.keys())
                # KIS 최소 구독 모드: 후보=키움 WS, KIS=영구+보유 (WSManager.sync_targets_split)
                self.ws.sync_targets_split(self.strategy_id, cand_codes, hold_codes)

                # ── [2b] 미체결 지정가 만료 취소 ───────────────────
                self.manage_pending_orders()

                # ── [3] 매수 체크 (매수 종료 TIME_END 와 매도 세션 분리) ──
                max_stocks = self._max_stocks()
                active_cnt = len(self.holdings)
                if candidates and active_cnt < max_stocks and self.check_buy_allowed():
                    self._scan_and_buy(candidates, max_stocks, active_cnt)

                # 고점·세션저점 등 런타임 오버레이 저장 (다음 루프 DB sync 시 max merge)
                self._capture_runtime_overlay()

                time.sleep(self._scan_sleep("loop"))

            except KeyboardInterrupt:
                self._running = False
                break
            except Exception as e:
                self.logger.error("루프 예외: %s", e)
                time.sleep(5)

    def manage_pending_orders(self) -> None:
        """미체결 ATR 지정가 만료 시 취소 — 서브클래스에서 구현."""
        return None

    def on_limit_buy_submitted(self, signal: Dict, result) -> None:
        """지정가 접수 성공 — 체결 전까지 holdings 미반영 (서브클래스)."""
        return None

    def _resolve_buy_qty_live(
        self,
        curr_price: float,
        *,
        invest_cap: Optional[float] = None,
        hard_cap: int = 0,
        max_stocks: Optional[int] = None,
    ) -> Tuple[int, Optional[str]]:
        """포트폴리오 정합 ON → ``resolve_live_buy_qty``, OFF → ``invest_qty_for_price``."""
        from ..utils.position_sizing import invest_qty_for_price

        cap = float(
            invest_cap if invest_cap is not None
            else getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000)
        )
        if hard_cap > 0 and cap > hard_cap:
            cap = float(hard_cap)
        if self._live_portfolio_budget_enabled():
            qty, _, rej = self._resolve_live_buy_qty(
                curr_price, invest_cap=cap, max_stocks=max_stocks,
            )
            return qty, rej
        qty = invest_qty_for_price(curr_price, cap)
        if qty < 1:
            return 0, "수량0"
        return qty, None

    def _live_portfolio_budget_enabled(self) -> bool:
        from ..utils.live_portfolio_common import live_portfolio_budget_align_enabled
        return live_portfolio_budget_align_enabled(self.strategy_id)

    def _portfolio_exposure_krw(self) -> float:
        from ..backtest.backtest_portfolio_common import portfolio_exposure_krw
        return portfolio_exposure_krw(self.holdings)

    def _live_total_budget_krw(self, max_stocks: Optional[int] = None) -> float:
        from ..utils.live_portfolio_common import resolve_live_total_budget_krw
        ms = max_stocks if max_stocks is not None else self._max_stocks()
        slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
        return resolve_live_total_budget_krw(self.strategy_id, ms, slot)

    def _live_portfolio_budget_full(self, max_stocks: Optional[int] = None) -> bool:
        from ..utils.live_portfolio_common import live_portfolio_budget_full
        if not self._live_portfolio_budget_enabled():
            return False
        ms = max_stocks if max_stocks is not None else self._max_stocks()
        slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
        return live_portfolio_budget_full(self.holdings, self.strategy_id, slot, ms)

    def _live_portfolio_entry_guard(self, code: str, max_stocks: Optional[int] = None) -> Optional[str]:
        from ..utils.live_portfolio_common import live_portfolio_entry_reject
        if not self._live_portfolio_budget_enabled():
            return None
        ms = max_stocks if max_stocks is not None else self._max_stocks()
        slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
        return live_portfolio_entry_reject(
            self.db, self.holdings, self.strategy_id, code, slot, ms,
        )

    def _resolve_live_buy_qty(
        self,
        curr_price: float,
        *,
        invest_cap: Optional[float] = None,
        max_stocks: Optional[int] = None,
    ) -> Tuple[int, float, Optional[str]]:
        from ..utils.live_portfolio_common import resolve_live_buy_qty
        ms = max_stocks if max_stocks is not None else self._max_stocks()
        slot = float(getattr(self, "slot_money", 0) or get_env_int("SLOT_MONEY_DEFAULT", 3_000_000))
        return resolve_live_buy_qty(
            curr_price,
            self.holdings,
            self.strategy_id,
            slot,
            max_stocks=ms,
            invest_cap=invest_cap,
        )

    def _scan_and_buy(self, candidates: List[Dict], max_stocks: int, active_cnt: int) -> None:
        if self._live_portfolio_budget_full(max_stocks):
            now_ts = time.time()
            last = getattr(self, "_live_budget_full_log_ts", 0.0)
            if now_ts - last >= 300:
                self._live_budget_full_log_ts = now_ts
                self.logger.info(
                    "🔍 [탈락-총한도] %s exposure=%.0f >= budget=%.0f (보유 %d/%d)",
                    self.strategy_id,
                    self._portfolio_exposure_krw(),
                    self._live_total_budget_krw(max_stocks),
                    active_cnt, max_stocks,
                )
            return

        # ── 시장 급락 서킷브레이커 가드 ────────────────────────────────
        # MarketGuard 가 PANIC 모드면 신규 매수 전면 차단.
        # 매도는 평소처럼 동작 (포지션 정리·손실 확대 방지).
        if self.market_guard is not None and self.market_guard.is_panic():
            now_ts = time.time()
            if now_ts - self._panic_log_ts >= 60:  # 분당 1회만 로그
                self._panic_log_ts = now_ts
                self.logger.warning(
                    "⛔ [매수차단] MarketGuard PANIC: %s (보유 %d, 후보 %d)",
                    self.market_guard.panic_reason(), active_cnt, len(candidates),
                )
            return

        guard = getattr(self, "daily_profit_halt", None)
        if guard is not None:
            try:
                allowed, halt_msg = guard.buy_allowed(self.strategy_id)
                if not allowed:
                    now_ts = time.time()
                    log_key = f"profit_halt_{self.strategy_id}"
                    if now_ts - getattr(self, "_profit_halt_log_ts", 0.0) >= 60.0:
                        self._profit_halt_log_ts = now_ts
                        self.logger.info(
                            "⛔ [매수차단] %s (보유 %d, 후보 %d)",
                            halt_msg or "일일익절", active_cnt, len(candidates),
                        )
                    return
            except Exception as ex:
                self.logger.debug("일일익절 가드 예외(매수 계속): %s", ex)

        self.logger.info(
            "🔍 [매수체크] 후보 %d (보유 %d/%d)",
            len(candidates), active_cnt, max_stocks,
        )
        for c in candidates:
            if not self._running:
                return
            code = c.get("code") or c.get("stk_cd", "")
            name = c.get("name") or c.get("stk_nm", code)
            if not code or code in self.holdings:
                continue
            if code in self.untradable_skip:
                continue
            if not self._candidate_filter(c):
                continue
            guard = self._live_portfolio_entry_guard(code, max_stocks)
            if guard:
                self.logger.info("🔍 [%s] %s(%s)", guard, name, code)
                continue
            # 재진입 쿨다운
            cooldown_sec = self._reentry_cooldown_sec()
            elapsed = time.time() - self.recently_sold.get(code, 0)
            if elapsed < cooldown_sec:
                continue

            signal = self.check_buy(code, name)
            if not signal:
                time.sleep(self._scan_sleep("reject"))
                continue

            result = self._submit_buy(signal)
            if result and result.success:
                time.sleep(self._scan_sleep("buy_ok"))
                return  # 1루프당 1매수 (포지션 과집중 방지)
            time.sleep(self._scan_sleep("buy_fail"))

    # ------------------------------------------------------------------
    # OrderManager 호출 래퍼
    # ------------------------------------------------------------------
    def _submit_buy(self, signal: Dict):
        from ..execution.order_manager import OrderRequest

        req = OrderRequest(
            strategy_id=self.strategy_id,
            code=signal["code"],
            name=signal.get("name", signal["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=bool(signal.get("use_limit_buy")),
        )
        result = self.order_mgr.place(req)
        if result.success and not signal.get("use_limit_buy"):
            # 로컬 holdings 갱신 (DB 는 OrderManager 가 이미 upsert 함)
            fp = float(result.filled_avg_price)
            self.holdings[req.code] = {
                "buy_price": fp,
                "qty": result.filled_qty,
                "stop_price": req.stop_price,
                "target_price": req.target_price,
                "max_price": float(signal.get("max_price", fp) or fp),
                "session_low": float(signal.get("session_low", fp) or fp),
                "atr_entry": req.atr_entry,
                "buy_time": dt.now().strftime("%Y-%m-%d %H:%M:%S"),
                "name": req.name,
                "size_class": req.size_class or "",
            }
            self._capture_runtime_overlay()
        elif result.success and signal.get("use_limit_buy"):
            self.on_limit_buy_submitted(signal, result)
        else:
            # 매매불가 종목은 당일 제외
            if result.reason and "order_reject" in result.reason:
                if any(k in (result.reason or "") for k in ("매매불가", "40070000")):
                    self.untradable_skip.add(req.code)
        return result

    def _submit_sell(self, signal: Dict):
        from ..execution.order_manager import OrderRequest

        req = OrderRequest(
            strategy_id=self.strategy_id,
            code=signal["code"],
            name=signal.get("name", signal["code"]),
            side="SELL",
            qty=int(signal.get("qty", 0)),
            price_ref=float(signal.get("current_price", signal.get("price", 0))),
            reason=signal.get("reason", ""),
            buy_price=float(signal.get("buy_price", 0)),
            profit_pct=float(signal.get("profit_pct", 0)),
        )
        result = self.order_mgr.place(req)
        if result.success:
            self.recently_sold[req.code] = time.time()
            self._drop_local_position(req.code)
        elif result.reason == "broker_no_position":
            if req.code in self.holdings:
                self.logger.info(
                    "🧹 [유령정리] %s %s — 로컬 holdings 제거 (%s)",
                    req.name, req.code, result.reason,
                )
            self._drop_local_position(req.code)
        return result

    # ------------------------------------------------------------------
    # 서브클래스 공통 헬퍼
    # ------------------------------------------------------------------
    def _session_time_bounds(self) -> Tuple[int, int]:
        """실매 **매도·EOD** 세션 (HHMM). ``{SID}_TIME_END``(매수 종료)와 별도.

        종료 우선순위:
          - SHORT: ``TAIL_TIME_START`` / ``TAIL_TIME_END``
          - 그 외: ``{SID}_SELL_TIME_END`` → ``MARKET_SESSION_END_HM`` → 기본 **1530**
        """
        sid = self.strategy_id.upper()
        if sid == "SHORT":
            from ..engine.tail_env_keys import tail_market_time_hm
            return tail_market_time_hm()
        start = get_env_int(f"{sid}_TIME_START", 0) or get_env_int("TIME_START", 0) or 900
        sell_end = (
            get_env_int(f"{sid}_SELL_TIME_END", 0)
            or get_env_int("MARKET_SESSION_END_HM", 0)
            or 1530
        )
        return start, sell_end

    def _buy_time_bounds(self) -> Tuple[int, int]:
        """신규 **매수** 허용 구간 (HHMM). ``{SID}_TIME_END`` / ``TIME_END`` = 매수 종료만."""
        sid = self.strategy_id.upper()
        if sid == "SHORT":
            return self._session_time_bounds()
        start = get_env_int(f"{sid}_TIME_START", 0) or get_env_int("TIME_START", 0) or 900
        buy_end = get_env_int(f"{sid}_TIME_END", 0) or get_env_int("TIME_END", 0)
        if buy_end <= 0:
            _, sell_end = self._session_time_bounds()
            buy_end = sell_end
        return start, buy_end

    def check_market_status(self) -> bool:
        """매도·EOD 포함 실매 세션이 열려 있는지 (정규장 마감까지).

        ``{STRATEGY_ID}_TIME_END`` 는 **매수 종료** 전용 — 여기서는 사용하지 않는다.
        ``FORCE_MARKET_OPEN=true`` 면 모든 시간 통과 (백테스트/디버그용).
        """
        if get_env_bool("FORCE_MARKET_OPEN", False):
            return True
        now = dt.now()
        if now.weekday() >= 5:  # 토/일
            return False
        hhmm_now = now.hour * 100 + now.minute
        start, end = self._session_time_bounds()
        return start <= hhmm_now <= end

    def check_buy_allowed(self) -> bool:
        """신규 매수 허용 시간 — ``{SID}_TIME_END`` / ``TIME_END`` 기준 (매수 종료)."""
        if get_env_bool("FORCE_MARKET_OPEN", False):
            return True
        if not self.check_market_status():
            return False
        now = dt.now()
        hhmm_now = now.hour * 100 + now.minute
        start, buy_end = self._buy_time_bounds()
        return start <= hhmm_now <= buy_end

    def _max_stocks(self) -> int:
        """전략별 동시 보유 한도.

        우선순위:
          1. ``{STRATEGY_ID}_MAX_STOCKS`` (예: ``SCALP_MAX_STOCKS``)
          2. ``MAX_STOCKS`` (글로벌, 미설정/구버전 호환)
          3. 3 (최후 fallback)
        """
        sid = self.strategy_id.upper()
        if sid == "SHORT":
            per_strategy = (
                get_env_int("TAIL_MAX_STOCKS", 0)
                or get_env_int("SHORT_MAX_STOCKS", 0)
            )
        else:
            per_strategy = get_env_int(f"{sid}_MAX_STOCKS", 0)
        if per_strategy > 0:
            return per_strategy
        return get_env_int("MAX_STOCKS", 3)

    def _reentry_cooldown_sec(self) -> int:
        sid = self.strategy_id.upper()
        cd = get_env_int(f"{sid}_COOLDOWN_SEC", 0)
        if cd > 0:
            return cd
        if sid == "SHORT":
            tail_cd = get_env_int("TAIL_COOLDOWN_SEC", 0)
            if tail_cd > 0:
                return tail_cd
        return get_env_int("REENTRY_COOLDOWN_SEC", 300)

    # DB sync 시 holdings 에 합치지 않고 _runtime 만 유지하는 장중 오버레이 키
    _RUNTIME_OVERLAY_KEYS: Tuple[str, ...] = (
        "max_price", "session_low",
        "updow_entry_bar_key", "box_low", "box_high",
    )

    def _load_holdings_from_db(self, *, log_restore: bool = False) -> None:
        """DB → holdings 동기화 (지정가 체결 등 이벤트 시 호출)."""
        self._sync_holdings_from_db(log_restore=log_restore)

    def _drop_local_position(self, code: str) -> None:
        """매도·유령정리 후 메모리 보유·런타임 오버레이 제거."""
        self.holdings.pop(code, None)
        self._runtime.pop(code, None)

    def _merge_runtime_overlay(
        self, code: str, avg_bp: float, db_max: float, db_sess_low: float,
    ) -> Tuple[float, float]:
        """DB 행 + _runtime → max_price/session_low (퇴행 방지)."""
        rt = self._runtime.get(code) or {}
        max_p = max(
            avg_bp,
            float(db_max or 0),
            float(rt.get("max_price") or 0),
        )
        sess_candidates = [
            v for v in (
                avg_bp,
                float(db_sess_low or 0),
                float(rt.get("session_low") or 0),
            ) if v > 0
        ]
        sess_low = min(sess_candidates) if sess_candidates else avg_bp
        return max_p, sess_low

    def _apply_runtime_extra_fields(self, code: str, holding: Dict[str, Any]) -> None:
        """UPDOW 등 전략 부가 필드를 _runtime → holdings 로 복원."""
        rt = self._runtime.get(code) or {}
        for k in self._RUNTIME_OVERLAY_KEYS:
            if k in ("max_price", "session_low"):
                continue
            if k in rt and rt[k] is not None:
                holding[k] = rt[k]

    def _capture_runtime_overlay(self) -> None:
        """매도 판단 루프가 갱신한 고점·저점을 _runtime 에 저장."""
        for code, h in self.holdings.items():
            rt = self._runtime.setdefault(code, {})
            mp = float(h.get("max_price") or 0)
            if mp > float(rt.get("max_price") or 0):
                rt["max_price"] = mp
            sl = float(h.get("session_low") or 0)
            if sl > 0:
                prev = float(rt.get("session_low") or 0)
                rt["session_low"] = sl if prev <= 0 else min(prev, sl)
            for k in self._RUNTIME_OVERLAY_KEYS:
                if k in ("max_price", "session_low"):
                    continue
                if k in h and h[k] is not None:
                    rt[k] = h[k]

    def _after_holdings_sync(self) -> None:
        """서브클래스 훅 — DB sync 직후 (UPDOW entry_bar_key 등)."""
        return None

    def _sync_holdings_from_db(self, *, log_restore: bool = False) -> None:
        """DB active_trades → holdings (진실의 원천). 장중 고점은 _runtime 과 merge.

        - DB에 없는 종목은 holdings·_runtime 에서 제거 (양방향 정합)
        - poll_pending 매수 체결·재시작 후에도 다음 루프에 자동 반영
        - ETN/ETF/스팩 등 비본주는 가격 API 불가 → 자동 제외
        """
        try:
            prefix = self.strategy_id.split("_")[0] if "_" in self.strategy_id else self.strategy_id
            rows = self.db.get_active_trades(strategy_prefix=prefix)
            skipped_non_stock: list[str] = []
            new_holdings: Dict[str, dict] = {}
            for code, t in rows.items():
                if t.get("strategy") and t["strategy"] != self.strategy_id:
                    continue
                if get_env_bool("EXCLUDE_NON_STOCK", True):
                    name = (t.get("name") or "").strip()
                    if _is_non_stock(name, code):
                        skipped_non_stock.append(f"{code}({name})")
                        continue
                avg_bp = float(t.get("avg_buy_price", 0) or t.get("buy_price", 0) or 0)
                qty = int(t.get("current_qty", 0) or t.get("qty", 0) or 0)
                if qty <= 0 or avg_bp <= 0:
                    continue
                db_max = float(t.get("max_price") or 0)
                db_sess = float(t.get("session_low") or 0)
                max_p, sess_low = self._merge_runtime_overlay(code, avg_bp, db_max, db_sess)
                holding = {
                    "buy_price": avg_bp,
                    "qty": qty,
                    "stop_price": t.get("stop_price", 0),
                    "target_price": t.get("target_price", 0),
                    "max_price": max_p,
                    "session_low": sess_low,
                    "atr_entry": t.get("atr_at_entry", t.get("atr_entry", 0)),
                    "buy_time": t.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                    "name": t.get("name", code),
                    "size_class": t.get("size_class", ""),
                }
                self._apply_runtime_extra_fields(code, holding)
                new_holdings[code] = holding

            db_codes = set(new_holdings.keys())
            for code in list(self._runtime.keys()):
                if code not in db_codes:
                    del self._runtime[code]

            prev_codes = set(self.holdings.keys())
            self.holdings.clear()
            self.holdings.update(new_holdings)
            self._after_holdings_sync()

            if log_restore and self.holdings:
                self.logger.info(
                    "📂 [DB 복원] 보유 %d종목 (%s)",
                    len(self.holdings), self.strategy_id,
                )
            elif not log_restore:
                added = db_codes - prev_codes
                if added:
                    self.logger.info(
                        "📂 [DB동기화] +%d종목 (%s) poll/체결 반영: %s",
                        len(added), self.strategy_id, ",".join(sorted(added)[:5]),
                    )
                removed = prev_codes - db_codes
                if removed:
                    self.logger.debug(
                        "📂 [DB동기화] -%d종목 (%s) 청산 반영: %s",
                        len(removed), self.strategy_id, ",".join(sorted(removed)[:5]),
                    )

            if skipped_non_stock:
                self.logger.warning(
                    "⚠️ ETN/ETF 보유 자동 제외(매수/매도 모두 봇이 안 건드림 — 한투 HTS에서 직접 처분 권장): %s",
                    ", ".join(skipped_non_stock),
                )
        except Exception as e:
            self.logger.warning("DB holdings 동기화 실패: %s", e)

    def _load_candidates(self) -> List[Dict]:
        """
        후보 로드 우선순위:
          1) universe_source == "ranking"           → VolumeRankManager
          2) universe_source == "condition"         → ConditionSearchManager (KIS REST)
          3) universe_source == "kiwoom_condition"    → KiwoomConditionSearchManager (키움 WS)

        ⚡ 운영 스위치는 **{SID}_UNIVERSE_SOURCE 하나** 만 바꾸면 됨.
          - condition         + CONDITION_{SID}_NAME/SEQ     → KIS 조건 (REST 폴링)
          - kiwoom_condition  + CONDITION_{SID}_NAME         → 키움 조건 (WS 실시간, seq 자동)
          - ranking           → 거래량/거래대금 순위
          (키움 seq 를 고정하려면 CONDITION_{SID}_KIWOOM_SEQ 선택 설정)
        """
        key = f"{self.strategy_id}_UNIVERSE_SOURCE"
        default = self.DEFAULT_UNIVERSE_SOURCES.get(self.strategy_id, "ranking")
        current_src = (get_env_from_db(key, default) or default).strip().lower()
        if current_src not in ("ranking", "condition", "kiwoom_condition"):
            current_src = default
        if current_src != self.universe_source:
            self.logger.info(
                "🔄 [유니버스 소스 전환] %s: %s → %s (재시작 없이 즉시 반영)",
                self.strategy_id, self.universe_source, current_src,
            )
            self.universe_source = current_src

        # 소스명 → 매니저 매핑. primary(선택 소스) 먼저, 나머지는 폴백 순.
        mgr_by_src = {
            "ranking": self.ranking_mgr,
            "condition": self.condition_mgr,
            "kiwoom_condition": self.kiwoom_condition_mgr,
        }
        order = [self.universe_source] + [
            s for s in ("condition", "kiwoom_condition", "ranking")
            if s != self.universe_source
        ]

        candidates: List[Dict] = []
        for tag in order:
            mgr = mgr_by_src.get(tag)
            if mgr is None:
                continue
            if not self._is_strategy_registered(mgr):
                continue
            try:
                universe = mgr.get_universe_for(self.strategy_id)
                # 매니저에 등록은 돼 있으나 아직 비어있음 → [] 이 정답 (폴백 금지).
                # (설정된 유니버스가 비어있을 수 있는 상태: 장 마감 후 등)
                if universe:
                    candidates = mgr.get_candidates_for(self.strategy_id) or []
                else:
                    candidates = []
                return self._post_filter_candidates(candidates)
            except Exception as e:
                self.logger.debug("%s 유니버스 로드 실패: %s", tag, e)

        # 두 매니저 모두 본 전략 설정 없음 → 레거시 DB 경로
        try:
            candidates = self.db.get_target_candidates() or []
        except Exception as e:
            self.logger.debug("target_candidates 조회 실패: %s", e)
            candidates = []
        return self._post_filter_candidates(candidates)

    def _post_filter_candidates(self, candidates: List[Dict]) -> List[Dict]:
        """매니저 결과 → ETN/비본주 자동 제외 + 전략별 후보 하드캡.

        - ``EXCLUDE_NON_STOCK=true`` (기본 true): ETN/ETF/스팩/우선주 등 비본주 자동 제외.
          KISClient.filter_rank_rows 와 동일 규칙 (`_is_non_stock`).
        - ``{STRATEGY_ID}_CAND_LIMIT`` (기본 0=무제한): 매니저 원본 순서 보존하며 상위 N개만 사용.
          WS 구독 41 한도 안전 + cond/ranking 폭주 시 매수 체크 분당 회전율 보장.
        """
        if not candidates:
            return []

        # 1) 비본주 필터
        if get_env_bool("EXCLUDE_NON_STOCK", True):
            filtered: List[Dict] = []
            dropped = 0
            for c in candidates:
                code = (c.get("code") or c.get("stk_cd") or "").strip()
                name = (c.get("name") or c.get("stk_nm") or "").strip()
                if _is_non_stock(name, code):
                    dropped += 1
                    continue
                filtered.append(c)
            if dropped:
                self.logger.debug("🛡 비본주 자동 제외 %d종목 (ETN/ETF/스팩/우선주)", dropped)
            candidates = filtered

        # 2) 전략별 후보 하드캡
        sid = self.strategy_id.upper()
        cap = get_env_int(f"{sid}_CAND_LIMIT", 0)
        if cap > 0 and len(candidates) > cap:
            self.logger.debug(
                "✂ 후보 하드캡 적용: %d → %d (%s_CAND_LIMIT=%d)",
                len(candidates), cap, sid, cap,
            )
            candidates = candidates[:cap]

        # 3) 백테 유니버스 슬롯 정합 — target_candidates_history 스냅샷 ∩ 실시간 후보
        from ..utils.live_portfolio_common import (
            filter_candidates_by_history_universe,
            live_universe_slot_align_enabled,
        )
        if live_universe_slot_align_enabled(sid):
            before = len(candidates)
            candidates, dropped = filter_candidates_by_history_universe(
                candidates, self.db, sid,
            )
            if dropped > 0:
                now_ts = time.time()
                last = getattr(self, "_universe_slot_log_ts", 0.0)
                if now_ts - last >= 120:
                    self._universe_slot_log_ts = now_ts
                    self.logger.info(
                        "🔍 [유니버스슬롯] %s 후보 %d→%d (history 교집합, 제외 %d)",
                        sid, before, len(candidates), dropped,
                    )

        return candidates

    def _is_strategy_registered(self, mgr) -> bool:
        """매니저의 _configs 에 본 전략이 들어있는지."""
        if mgr is None:
            return False
        try:
            cfgs = getattr(mgr, "_configs", [])
            return any(c.get("strategy_id") == self.strategy_id for c in cfgs)
        except Exception:
            return False

    def on_new_day(self) -> None:
        """날짜 변경 훅 (서브클래스 오버라이드 가능)."""
        pass

    def reload_config(self) -> None:
        """설정 리로드 훅 (서브클래스 오버라이드)."""
        pass

    # ------------------------------------------------------------------
    # 서브클래스 구현 필수 메서드
    # ------------------------------------------------------------------
    @abstractmethod
    def check_buy(self, code: str, name: str) -> Optional[Dict]:
        """매수 시그널. 성공 시 dict(code/name/price/qty/stop_price/...)."""
        raise NotImplementedError

    @abstractmethod
    def check_sell_signals(self) -> List[Dict]:
        """보유 종목 순회 → 매도 시그널 리스트."""
        raise NotImplementedError

    def _candidate_filter(self, candidate: Dict) -> bool:
        """후보 중 본 전략이 관심 있는 것만 True. 기본 True."""
        return True
