"""
kis_trader/database/db_manager.py — 통합 DB 관리자
==================================================
기존 ``database.TradeDB``(MariaDB 2,000줄)를 **그대로 재사용**하고,
본 모듈에서는 주문(order_id=ODNO) 단위 추적을 위한 ``orders`` 테이블을
추가·관리한다.

핵심 설계:
  1. ODNO(한투 주문번호)를 PK 로 사용 → DB 레벨에서 중복 주문 차단.
  2. UNIQUE(strategy_id, code, order_date, side) 인덱스 → 같은 전략이
     같은 날 같은 종목·같은 방향으로 중복 주문 시도 시 서버단 차단.
  3. 전략별 filter → 꼬리잡기봇과 스캘핑봇의 "남의 집 물건 팔기" 방지.

본 모듈은 TradeDB 를 수정하지 않고 래핑한다 (원본 코드 존중 규칙).
"""
from __future__ import annotations

import datetime
import logging
from typing import Dict, List, Optional

from database import TradeDB  # 프로젝트 루트 모듈

logger = logging.getLogger("kis_trader.db")

# 싱글톤: 프로세스 내 TradeDBExt 재사용 (연결 낭비 방지)
_SINGLETON: Optional["TradeDBExt"] = None


def get_db() -> "TradeDBExt":
    global _SINGLETON
    if _SINGLETON is None:
        _SINGLETON = TradeDBExt()
    return _SINGLETON


class TradeDBExt:
    """
    기존 TradeDB 를 합성(Composition)한 래퍼. ``self.raw`` 로 원본 객체 접근.
    ``orders`` 테이블 생성/마이그레이션을 책임지고, 주문 이력을 기록/조회하는
    메서드를 추가로 제공한다.
    """

    def __init__(self):
        self.raw = TradeDB()
        self.conn = self.raw.conn
        self._ensure_orders_table()

    # ------------------------------------------------------------------
    # 테이블 마이그레이션: orders
    # ------------------------------------------------------------------
    def _ensure_orders_table(self) -> None:
        """
        orders 테이블:
            ord_no       : 한투 주문번호(ODNO). PK — 서버단 중복 차단.
            strategy_id  : SCALP_RSI_REVERSAL, SHORT_ANT_SHAKING 등
            code/name    : 종목코드/명
            side         : BUY / SELL
            qty          : 주문 수량
            price        : 주문 시 참고가 (시장가=0 일 수 있음)
            filled_qty   : 실제 체결 수량
            filled_avg_price : 평균 체결가
            status       : SUBMITTED / FILLED / PARTIAL / REJECTED / CANCELLED
            msg_cd/msg1  : 한투 응답 메시지(실패 원인 추적)
            submitted_at : 주문 접수 시각
            filled_at    : 체결 확인 시각

        중복 차단은 ord_no(PK) 만 사용.
        (구) UNIQUE(strategy,code,side,ord_date) 는 당일 재매수·부분체결 재주문 시
        HTS 에는 체결됐는데 orders/active_trades 미기록 버그 유발 → 제거.
        """
        try:
            self.conn.execute("""
                CREATE TABLE IF NOT EXISTS orders (
                    ord_no            VARCHAR(30)  NOT NULL PRIMARY KEY,
                    strategy_id       VARCHAR(40)  NOT NULL,
                    code              VARCHAR(20)  NOT NULL,
                    name              VARCHAR(100) NOT NULL DEFAULT '',
                    side              VARCHAR(8)   NOT NULL,
                    qty               INT          NOT NULL,
                    price             DOUBLE       DEFAULT 0,
                    filled_qty        INT          NOT NULL DEFAULT 0,
                    filled_avg_price  DOUBLE       DEFAULT 0,
                    status            VARCHAR(16)  NOT NULL DEFAULT 'SUBMITTED',
                    msg_cd            VARCHAR(20)  DEFAULT NULL,
                    msg1              VARCHAR(300) DEFAULT NULL,
                    ord_date          VARCHAR(10)  NOT NULL,
                    submitted_at      VARCHAR(30)  NOT NULL,
                    filled_at         VARCHAR(30)  DEFAULT NULL,
                    raw_json          MEDIUMTEXT   DEFAULT NULL,
                    INDEX idx_strategy_date (strategy_id, ord_date),
                    INDEX idx_code_date     (code, ord_date)
                ) CHARACTER SET utf8mb4
            """)
            self._migrate_orders_drop_daily_side_unique()
            logger.info("📊 orders 테이블 확인/생성 완료")
        except Exception as e:
            logger.warning("orders 테이블 생성 실패(무시·폴백): %s", e)

    def _migrate_orders_drop_daily_side_unique(self) -> None:
        """당일 1회 매수 UNIQUE 제거 — 재진입·삼성전자 누적매수 DB 미기록 방지."""
        try:
            rows = self.conn.execute(
                "SHOW INDEX FROM orders WHERE Key_name = %s",
                ("uq_strategy_code_side_date",),
            ).fetchall()
            if rows:
                self.conn.execute(
                    "ALTER TABLE orders DROP INDEX uq_strategy_code_side_date"
                )
                logger.info(
                    "✅ orders.uq_strategy_code_side_date 제거 "
                    "(당일 재매수 시 DB 동기화 가능)"
                )
        except Exception as e:
            logger.debug("orders UNIQUE 마이그레이션 스킵: %s", e)

    # ------------------------------------------------------------------
    # orders CRUD
    # ------------------------------------------------------------------
    def insert_order(
        self,
        *,
        ord_no: str,
        strategy_id: str,
        code: str,
        name: str,
        side: str,
        qty: int,
        price: float = 0.0,
        status: str = "SUBMITTED",
        msg_cd: Optional[str] = None,
        msg1: Optional[str] = None,
        raw_json: Optional[str] = None,
    ) -> bool:
        """
        주문 기록 INSERT.  PK(ord_no) 중복이면 False 반환 (서버단 차단).
        side: 'BUY' | 'SELL'
        """
        side = (side or "").upper()
        assert side in ("BUY", "SELL"), f"side must be BUY|SELL, got {side}"
        now = datetime.datetime.now()
        try:
            with self.conn:
                self.conn.execute(
                    """
                    INSERT INTO orders (
                        ord_no, strategy_id, code, name, side, qty, price,
                        status, msg_cd, msg1, ord_date, submitted_at, raw_json
                    ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                    """,
                    (
                        ord_no, strategy_id, code, name, side, qty, price,
                        status, msg_cd, msg1,
                        now.strftime("%Y-%m-%d"),
                        now.strftime("%Y-%m-%d %H:%M:%S"),
                        raw_json,
                    ),
                )
            return True
        except Exception as e:
            # 중복 PK(1062) / 유니크 인덱스 위반은 정상적 차단으로 간주
            msg = str(e)
            if "1062" in msg or "Duplicate entry" in msg:
                logger.warning(
                    "⚠️ [주문중복차단] strategy=%s code=%s side=%s ord_no=%s (이미 DB 존재)",
                    strategy_id, code, side, ord_no,
                )
                return False
            logger.error("insert_order 실패 (ord_no=%s): %s", ord_no, e)
            return False

    def update_order_fill(
        self,
        *,
        ord_no: str,
        filled_qty: int,
        filled_avg_price: float,
        status: str = "FILLED",
    ) -> bool:
        """주문 체결 확인 후 체결가/체결수량/상태 갱신."""
        now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        try:
            with self.conn:
                self.conn.execute(
                    """
                    UPDATE orders
                       SET filled_qty=%s, filled_avg_price=%s, status=%s, filled_at=%s
                     WHERE ord_no=%s
                    """,
                    (filled_qty, filled_avg_price, status, now, ord_no),
                )
            return True
        except Exception as e:
            logger.error("update_order_fill 실패 (%s): %s", ord_no, e)
            return False

    def mark_order_rejected(
        self, *, ord_no: str, msg_cd: str = "", msg1: str = ""
    ) -> None:
        """주문 실패/거부 시 상태 REJECTED 처리."""
        try:
            with self.conn:
                self.conn.execute(
                    """
                    UPDATE orders SET status='REJECTED', msg_cd=%s, msg1=%s
                     WHERE ord_no=%s
                    """,
                    (msg_cd, msg1, ord_no),
                )
        except Exception as e:
            logger.debug("mark_order_rejected 실패 (%s): %s", ord_no, e)

    def update_order_status(self, *, ord_no: str, status: str) -> bool:
        """체결 대기 등 — filled_qty 없이 status 만 갱신."""
        try:
            with self.conn:
                self.conn.execute(
                    "UPDATE orders SET status=%s WHERE ord_no=%s",
                    (status, ord_no),
                )
            return True
        except Exception as e:
            logger.error("update_order_status 실패 (%s): %s", ord_no, e)
            return False

    def get_pending_fill_orders(self) -> List[Dict]:
        """
        당일 미확인·부분체결 주문 (체결 qty < 주문 qty).
        heartbeat ``poll_pending_fills`` 재조회용.
        """
        today = datetime.datetime.now().strftime("%Y-%m-%d")
        try:
            rows = self.conn.execute(
                """
                SELECT * FROM orders
                 WHERE ord_date=%s
                   AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
                   AND COALESCE(filled_qty, 0) < qty
                 ORDER BY submitted_at ASC
                """,
                (today,),
            ).fetchall()
            return [dict(r) for r in rows]
        except Exception as e:
            logger.error("get_pending_fill_orders 실패: %s", e)
            return []

    def get_pending_sell_order(
        self, strategy_id: str, code: str
    ) -> Optional[Dict]:
        """동일 전략·종목 미체결 매도 1건 (중복 주문 방지용)."""
        today = datetime.datetime.now().strftime("%Y-%m-%d")
        try:
            row = self.conn.execute(
                """
                SELECT * FROM orders
                 WHERE ord_date=%s
                   AND strategy_id=%s
                   AND code=%s
                   AND side='SELL'
                   AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
                   AND COALESCE(filled_qty, 0) < qty
                 ORDER BY submitted_at DESC
                 LIMIT 1
                """,
                (today, strategy_id, code),
            ).fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error("get_pending_sell_order 실패 (%s/%s): %s", strategy_id, code, e)
            return None

    def get_pending_buy_order(
        self, strategy_id: str, code: str
    ) -> Optional[Dict]:
        """동일 전략·종목 미체결 매수 1건 (중복 주문 방지용).

        체결확인 API(inquire-daily-ccld) 장애로 fill 미확인(PENDING_FILL) 상태일 때,
        전략 루프가 같은 종목을 매 턴 재주문하는 폭주를 막는다.
        만료 시 poll_pending_fills 가 status 를 CANCELLED 로 바꿔 자동 해제된다.
        """
        today = datetime.datetime.now().strftime("%Y-%m-%d")
        try:
            row = self.conn.execute(
                """
                SELECT * FROM orders
                 WHERE ord_date=%s
                   AND strategy_id=%s
                   AND code=%s
                   AND side='BUY'
                   AND status IN ('SUBMITTED', 'PENDING_FILL', 'PARTIAL')
                   AND COALESCE(filled_qty, 0) < qty
                 ORDER BY submitted_at DESC
                 LIMIT 1
                """,
                (today, strategy_id, code),
            ).fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error("get_pending_buy_order 실패 (%s/%s): %s", strategy_id, code, e)
            return None

    def get_order_by_odno(self, ord_no: str) -> Optional[Dict]:
        try:
            row = self.conn.execute(
                "SELECT * FROM orders WHERE ord_no=%s", (ord_no,)
            ).fetchone()
            return dict(row) if row else None
        except Exception as e:
            logger.error("get_order_by_odno 실패: %s", e)
            return None

    def get_orders_today(
        self, strategy_id: Optional[str] = None
    ) -> List[Dict]:
        today = datetime.datetime.now().strftime("%Y-%m-%d")
        try:
            if strategy_id:
                rows = self.conn.execute(
                    """
                    SELECT * FROM orders
                     WHERE ord_date=%s AND strategy_id=%s
                     ORDER BY submitted_at DESC
                    """,
                    (today, strategy_id),
                ).fetchall()
            else:
                rows = self.conn.execute(
                    """
                    SELECT * FROM orders
                     WHERE ord_date=%s
                     ORDER BY submitted_at DESC
                    """,
                    (today,),
                ).fetchall()
            return [dict(r) for r in rows]
        except Exception as e:
            logger.error("get_orders_today 실패: %s", e)
            return []

    # ------------------------------------------------------------------
    # 조건검색 유니버스 스냅샷 (백테스트용 이력)
    # ------------------------------------------------------------------
    # 설계 원칙:
    #   - 5분 슬롯 스냅샷은 정보 손실이 크다(그 사이 들락날락한 종목 유실).
    #   - 따라서 **변동(ENTER/EXIT) 발생 tick 마다 풀 스냅샷**을 저장한다.
    #   - 변동 없는 tick 은 저장하지 않는다 (공간 절약).
    #   - 각 스냅샷은 같은 ``event_time`` 을 공유하는 N 행으로 표현.
    #   - 백테스트 시 특정 시점 유니버스 = 그 시점 이전 최근 event_time.
    _HISTORY_MIGRATED = False

    def _ensure_history_columns(self) -> None:
        """
        target_candidates_history 에 필요한 확장 컬럼을 자동 추가:
          * strategy_id  — 전략 구분 (기존 키움 스캐너 행은 NULL)
          * event_time   — 스냅샷 시각 (초 단위 'YYYY-MM-DD HH:MM:SS')
        """
        if TradeDBExt._HISTORY_MIGRATED:
            return
        try:
            cols = self.conn.get_columns("target_candidates_history")
            if "strategy_id" not in cols:
                self.conn.execute(
                    "ALTER TABLE target_candidates_history "
                    "ADD COLUMN strategy_id VARCHAR(30) DEFAULT NULL"
                )
                logger.info("📌 target_candidates_history.strategy_id 컬럼 추가")
            if "event_time" not in cols:
                self.conn.execute(
                    "ALTER TABLE target_candidates_history "
                    "ADD COLUMN event_time VARCHAR(19) DEFAULT NULL"
                )
                logger.info("📌 target_candidates_history.event_time 컬럼 추가")
            # 인덱스 — 백테스트 조회 속도 확보
            for idx_name, idx_def in (
                ("idx_strategy_event",
                 "(strategy_id, event_time)"),
            ):
                try:
                    self.conn.execute(
                        f"CREATE INDEX {idx_name} "
                        f"ON target_candidates_history {idx_def}"
                    )
                except Exception as e:
                    # 이미 존재 / 버전 미지원 → 무시
                    if "1061" not in str(e) and "Duplicate" not in str(e):
                        logger.debug("idx %s 생성 실패: %s", idx_name, e)
        except Exception as e:
            logger.debug("history 컬럼 마이그레이션: %s", e)
        TradeDBExt._HISTORY_MIGRATED = True

    def insert_condition_universe_snapshot(
        self,
        *,
        strategy_id: str,
        event_time: str,
        items: List[Dict],
        slot_key: Optional[str] = None,
    ) -> int:
        """
        조건검색 변동(ENTER/EXIT) 발생 tick 마다 호출되는 **풀 스냅샷** 저장.
        같은 ``event_time`` 으로 들어온 N 행 = 그 시점의 유니버스 전체.

        Args:
            strategy_id : 'SCALP' | 'SHORT' | 'BREAKOUT' ...
            event_time  : 'YYYY-MM-DD HH:MM:SS' (초 단위). 백테스트 기준 시각.
            items       : [{"code": "...", "name": "..."}, ...] — 현재 유니버스 전체
            slot_key    : (선택) 기존 5분 슬롯 키. 과거 대시보드/쿼리 호환용.

        Returns: 실제 INSERT 된 행수
        """
        if not items or not event_time:
            return 0
        self._ensure_history_columns()

        # slot_key 기본값 — 기존 스키마 NOT NULL 이므로 최소한 값 채움
        if not slot_key:
            # event_time 'YYYY-MM-DD HH:MM:SS' → 'YYYYMMDDHHMM' (5분 단위 반올림 X, 그대로)
            try:
                dp = event_time.replace("-", "").replace(":", "").replace(" ", "")
                slot_key = dp[:12]  # YYYYMMDDHHMM
            except Exception:
                slot_key = event_time[:12]

        inserted = 0
        try:
            with self.conn:
                # 동일 (전략, event_time) 키로 재호출되면 이전 것 지우고 재기록
                # (사실상 중복 호출 방지용 — ConditionSearchManager 가 tick 단위 유니크)
                self.conn.execute(
                    "DELETE FROM target_candidates_history "
                    "WHERE strategy_id=%s AND event_time=%s",
                    (strategy_id, event_time),
                )
                for it in items:
                    code = (it.get("code") or "").strip()
                    if not code:
                        continue
                    name = (it.get("name") or code)[:100]
                    try:
                        self.conn.execute(
                            """
                            INSERT INTO target_candidates_history
                                (slot_key, scan_time, code, name, score, price,
                                 market, sector, theme, strategy_id, event_time)
                            VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
                            """,
                            (slot_key, event_time, code, name, 0.0, 0.0,
                             "Q", "", "", strategy_id, event_time),
                        )
                        inserted += 1
                    except Exception as e:
                        logger.debug("history insert 실패(%s/%s): %s",
                                     strategy_id, code, e)
        except Exception as e:
            logger.warning("history 스냅샷 저장 실패: %s", e)
        return inserted

    # 범용 별칭 — 유니버스 소스가 condition 이든 ranking 이든 같은 테이블을 공유
    # (함수명 혼동 줄이기 위한 alias. 기존 호출부는 그대로 동작)
    def insert_universe_snapshot(
        self,
        *,
        strategy_id: str,
        event_time: str,
        items: List[Dict],
        slot_key: Optional[str] = None,
    ) -> int:
        return self.insert_condition_universe_snapshot(
            strategy_id=strategy_id, event_time=event_time,
            items=items, slot_key=slot_key,
        )

    # ------------------------------------------------------------------
    # 백테스트 헬퍼: 특정 시점의 유니버스 복원
    # ------------------------------------------------------------------
    def get_universe_at(
        self, *, strategy_id: str, at_time: str
    ) -> List[Dict]:
        """
        ``at_time`` ('YYYY-MM-DD HH:MM:SS') 시점에 봇이 보던 유니버스를 복원.
        = 그 시각 이전의 가장 최근 event_time 스냅샷.
        """
        self._ensure_history_columns()
        try:
            row = self.conn.execute(
                """
                SELECT MAX(event_time) AS et
                FROM target_candidates_history
                WHERE strategy_id=%s AND event_time <= %s
                """,
                (strategy_id, at_time),
            ).fetchone()
            et = (row or {}).get("et") if row else None
            if not et:
                return []
            rows = self.conn.execute(
                """
                SELECT code, name FROM target_candidates_history
                WHERE strategy_id=%s AND event_time=%s
                ORDER BY code
                """,
                (strategy_id, et),
            ).fetchall()
            return [{"code": r["code"], "name": r["name"]} for r in rows]
        except Exception as e:
            logger.error("get_universe_at 실패: %s", e)
            return []

    def iter_universe_events(
        self,
        *,
        strategy_id: str,
        start_time: str,
        end_time: str,
        preserve_insert_order: bool = False,
    ) -> List[Dict]:
        """
        ``[start_time, end_time]`` 구간의 모든 스냅샷 이벤트를 시간순 반환.
        각 이벤트 = {"event_time": ..., "codes": [...]}.
        백테스트 루프에서 시점별 유니버스를 순회할 때 사용.

        ``preserve_insert_order=True``: 스냅샷 내 종목 순서를 DB insert(id) 순으로
        유지 (백테 매수 우선순위 정합). 기본 False 는 code 가나다순.
        """
        self._ensure_history_columns()
        order_clause = "event_time, id" if preserve_insert_order else "event_time, code"
        try:
            rows = self.conn.execute(
                f"""
                SELECT event_time, code, name
                FROM target_candidates_history
                WHERE strategy_id=%s
                  AND event_time BETWEEN %s AND %s
                ORDER BY {order_clause}
                """,
                (strategy_id, start_time, end_time),
            ).fetchall()
            grouped: Dict[str, List[Dict]] = {}
            for r in rows:
                et = r["event_time"]
                grouped.setdefault(et, []).append(
                    {"code": r["code"], "name": r["name"]}
                )
            return [
                {"event_time": et, "items": items}
                for et, items in sorted(grouped.items())
            ]
        except Exception as e:
            logger.error("iter_universe_events 실패: %s", e)
            return []

    @staticmethod
    def _add_minutes_to_candle_slot(candle_slot: str, minutes: int) -> str:
        """YYYYMMDDHHMM 슬롯에 분 단위 가산."""
        from datetime import datetime, timedelta
        dt = datetime.strptime(candle_slot, "%Y%m%d%H%M")
        return (dt + timedelta(minutes=int(minutes))).strftime("%Y%m%d%H%M")

    def get_universe_by_candle_time(
        self,
        *,
        strategy_id: str,
        start_ymd: str,
        end_ymd: str,
        strict: bool = False,
        strict_lag_minutes: int = 1,
        exit_debounce_sec: int = 0,
    ) -> Dict[str, List[str]]:
        """
        백테스트 편의용 — 1분봉 캔들 시각(YYYYMMDDHHMM)을 키로 하는 유니버스 dict.

        실매매는 10초 주기로 REST 를 돌려 변동 tick 마다 초단위 ``event_time``
        (YYYY-MM-DD HH:MM:SS) 으로 ``target_candidates_history`` 에 적재한다.
        반면 백테스트 엔진은 1분봉 단위로 돌아가므로, 각 캔들에 대해
        **그 캔들의 종가 형성 시각 (HH:MM:59) 이전의 가장 최근 스냅샷** 을
        선택해 "그 시점에 봇이 보던 유니버스" 를 재현한다.

        ``strict=True`` (모멘텀 백테 실매 정합):
          분 전체에 스냅샷을 미리 적용하는 lookahead 를 제거한다.
          이전 스냅샷 대비 **신규 편입** 종목만 ``event_time`` 분 + lag 이후 분봉부터 포함.
          (예: 09:42:25 편입 + lag=1 → 09:43 분봉부터, 09:42 분봉에서는 제외)

        Args:
            strategy_id: 'SCALP' | 'SHORT' | 'BREAKOUT' | 'MOMENTUM'
            start_ymd:   시작일 YYYYMMDD
            end_ymd:     종료일 YYYYMMDD
            strict:      종목별 첫 event_time 기준 편입 지연 적용
            strict_lag_minutes: 첫 편입 분 이후 추가 대기 분 (기본 1)
            exit_debounce_sec: 짧은 EXIT→재편입 무시(초). 0=OFF.

        Returns:
            ``{candle_time(YYYYMMDDHHMM): [code, ...]}`` —
            해당 1분 캔들 종료 시점에 유효했던 유니버스 코드 리스트.
            스냅샷이 하나도 없으면 빈 dict.
        """
        self._ensure_history_columns()

        # 조회 범위: 전일 마지막 스냅샷도 포함하기 위해 시작일 00:00:00 이전 1건은
        # 엔진 쪽에서 "이전에 유효했던 유니버스" 로 물려받는 게 자연스럽다.
        # 단순화를 위해 start_ymd 00:00:00 ~ end_ymd 23:59:59 범위로 쿼리.
        start_time = (
            f"{start_ymd[:4]}-{start_ymd[4:6]}-{start_ymd[6:8]} 00:00:00"
        )
        end_time = (
            f"{end_ymd[:4]}-{end_ymd[4:6]}-{end_ymd[6:8]} 23:59:59"
        )

        events = self.iter_universe_events(
            strategy_id=strategy_id,
            start_time=start_time,
            end_time=end_time,
            preserve_insert_order=True,
        )
        if not events:
            return {}

        # 각 스냅샷의 event_time 을 'YYYYMMDDHHMMSS' (14자리 정수비교용) 으로 정규화.
        # event_time 문자열은 이미 시간순 정렬됨 (iter_universe_events).
        normalized: List[tuple] = []
        for ev in events:
            et = str(ev.get("event_time") or "")
            if len(et) < 19:
                continue
            et_key = (
                et[0:4] + et[5:7] + et[8:10]
                + et[11:13] + et[14:16] + et[17:19]
            )  # YYYYMMDDHHMMSS
            codes = [it["code"] for it in ev.get("items", []) if it.get("code")]
            normalized.append((et_key, codes))

        if not normalized:
            return {}

        if exit_debounce_sec > 0:
            try:
                from kis_trader.backtest.momentum_universe_timeline import (
                    debounce_universe_snapshots,
                )
                normalized = debounce_universe_snapshots(
                    normalized, int(exit_debounce_sec),
                )
            except Exception as e:
                logger.warning("유니버스 EXIT 디바운스 실패(원본 사용): %s", e)

        out: Dict[str, List[str]] = {}
        # candle_time 은 분단위 (YYYYMMDDHHMM). 각 캔들의 "종가 형성 시점" 은
        # 그 분의 59초로 본다 → candle_end_key = candle_time + "59".
        # 스냅샷 포인터를 전진하며 O(N+M) 으로 매칭.
        ev_idx = 0
        n_ev = len(normalized)
        active_codes: List[str] = []

        # 첫 이벤트 시각을 분단위로 내림 → 그 이전 캔들은 유니버스 없음
        first_ev_key = normalized[0][0]
        strict_lag = max(0, int(strict_lag_minutes))

        prev_codes_set: set = set()
        code_avail: Dict[str, str] = {}
        if strict:
            try:
                prev_items = self.get_universe_at(
                    strategy_id=strategy_id, at_time=start_time,
                )
                prev_codes_set = {
                    str(it["code"]) for it in prev_items if it.get("code")
                }
                code_avail = {c: "000000000000" for c in prev_codes_set}
            except Exception as e:
                logger.warning("strict 유니버스 초기 스냅샷 실패: %s", e)

        # 날짜별 장중 시간대 (09:00~15:30) 1분 단위로 캔들 시각 생성.
        # DB 에 없는 시각도 dict 에 key 가 생기면 메모리 낭비이므로, 엔진이 실제
        # 요청하는 캔들 시각에 대해서만 값을 반환하도록 **lazy** 하게 넘기고 싶지만,
        # 엔진 쪽은 dict lookup 만 하므로 여기서 미리 만들어 주는 편이 단순.
        from datetime import datetime, timedelta
        d0 = datetime.strptime(start_ymd, "%Y%m%d").date()
        d1 = datetime.strptime(end_ymd, "%Y%m%d").date()
        day = d0
        while day <= d1:
            # 장중 대략 범위: 08:00 ~ 16:00 (pre/post 여유)
            t = datetime.combine(day, datetime.min.time()).replace(hour=8)
            t_end = datetime.combine(day, datetime.min.time()).replace(hour=16)
            while t <= t_end:
                candle_time = t.strftime("%Y%m%d%H%M")
                candle_end_key = candle_time + "59"  # YYYYMMDDHHMMSS
                # 그 이전 또는 같은 시각의 가장 최근 event_time 까지 포인터 전진
                while ev_idx < n_ev and normalized[ev_idx][0] <= candle_end_key:
                    et_key, codes = normalized[ev_idx]
                    if strict:
                        cur_set = {str(c) for c in codes if c}
                        et_slot = et_key[:12]
                        avail_slot = (
                            self._add_minutes_to_candle_slot(et_slot, strict_lag)
                            if strict_lag > 0 else et_slot
                        )
                        for c in cur_set - prev_codes_set:
                            code_avail[c] = avail_slot
                        for c in prev_codes_set - cur_set:
                            code_avail.pop(c, None)
                        prev_codes_set = cur_set
                    active_codes = codes
                    ev_idx += 1
                if active_codes and candle_end_key >= first_ev_key:
                    if strict:
                        filtered = [
                            c for c in active_codes
                            if candle_time >= code_avail.get(str(c), "000000000000")
                        ]
                        if filtered:
                            out[candle_time] = filtered
                    else:
                        out[candle_time] = active_codes
                t += timedelta(minutes=1)
            day += timedelta(days=1)

        return out

    # ------------------------------------------------------------------
    # TradeDB 위임 (원본 API 그대로 사용)
    # ------------------------------------------------------------------
    def __getattr__(self, name):
        """TradeDBExt 에 없는 속성/메서드는 self.raw 에 위임."""
        return getattr(self.raw, name)
