"""
kis_trader/network/ws_manager.py — 단일 WebSocket 허브 (Event Bus)
====================================================================
설계 목적:
  * 두 전략(스캘핑/꼬리잡기)이 각자 WS 연결을 띄우면 같은 종목 2번 구독 → 토큰/approval_key
    경합 + 계정 차단 위험 → 프로세스 전체에서 **단 하나의 WS 세션**만 띄운다.
  * ``kis_trader.ws.kis_ws`` — ``KISWebSocketPriceCache`` + ``CandleAggregator`` 재사용.
  * 전략별 "구독 관심 종목" 을 **레퍼런스 카운팅**으로 관리. 한 전략이 구독 해제해도
    다른 전략이 구독 중이면 WS 에서 해제되지 않는다.

공용 API:
  - start() / stop()
  - subscribe(code, owner) / unsubscribe(code, owner)  (레거시 ref-count; ``WS_SUBSCRIBE_KIS_MINIMAL`` 시 비활성)
  - sync_targets(owner, codes) : 레거시 한 덩어리 동기화 (분리 모드에선 미사용)
  - sync_targets_split(owner, candidates, holdings) : KIS 최소 구독 모드용 후보/보유 분리
  - set_kiwoom_ws / activate_split_feed : Orchestrator 가 키움 인스턴스 주입 후 분리 구독 ON
  - get_price(code) / get_candles(code, tf, n)
  - fill_gap(codes=None) : 갭 보정 (WS 연결 직후 자동 + 수동 호출)
"""
from __future__ import annotations

import queue
import random
import threading
import time
from collections import defaultdict
from typing import Any, Dict, Iterable, List, Optional, Set

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

logger = get_logger("kis_trader.ws")

# 기존 kis_ws 모듈 재사용 (검증된 로직 보존 원칙)
# 역할 분리 정책 (kis_scalping_ver2 / kis_short_ver3 와 동일):
#   - 실시간 시세(WS)         : KIS 실전키     (is_mock=False 고정)
#   - 매수/매도 주문·계좌      : KIS (mock 여부는 KIS_MOCK)
#   - 유니버스(거래량 순위)    : KIS REST volume-rank
#   - 과거 봉 웜업·갭보정      : 키움 ka10080 (1/3/15/60분 native 지원)
#                                → 키움 키 없으면 KIS 1분봉 fallback
try:
    from ..ws.kis_ws import (
        CandleAggregator,
        KISWebSocketPriceCache,
        _get_kiwoom_creds,
        get_kiwoom_candles_df,
        fetch_kiwoom_stock_meta,
        fetch_kiwoom_stock_meta_detail,
    )
    from ..ws.tick_recorder import TickRecorder
    from ..ws.trigger_snapshot_recorder import TriggerSnapshotRecorder
    from ..ws.trigger_eval_recorder import TriggerEvalRecorder
    _KIS_WS_AVAILABLE = True
except ImportError as _e:
    _KIS_WS_AVAILABLE = False
    TriggerSnapshotRecorder = None  # type: ignore[misc, assignment]
    TriggerEvalRecorder = None  # type: ignore[misc, assignment]
    # 서브심볼 import 실패 대응 (패키지만 있고 키움 함수 없는 구버전)
    try:
        from ..ws.kis_ws import CandleAggregator, KISWebSocketPriceCache  # type: ignore
        from ..ws.tick_recorder import TickRecorder  # type: ignore
        from ..ws.trigger_snapshot_recorder import TriggerSnapshotRecorder  # type: ignore
        from ..ws.trigger_eval_recorder import TriggerEvalRecorder  # type: ignore
        _KIS_WS_AVAILABLE = True
        _get_kiwoom_creds = None  # type: ignore[assignment]
        get_kiwoom_candles_df = None  # type: ignore[assignment]
        fetch_kiwoom_stock_meta = None  # type: ignore[assignment]
        fetch_kiwoom_stock_meta_detail = None  # type: ignore[assignment]
        logger.warning(
            "kis_ws 에 키움 함수 없음 → 갭보정 키움 fallback 비활성 "
            "(KIS 1분봉 전용): %s", _e,
        )
    except ImportError as _e2:
        logger.warning("kis_ws 모듈 import 실패 → WS 기능 비활성: %s", _e2)


class WSManager:
    """
    단일 WS 허브.  전략은 이 매니저를 공유하고, subscribe/unsubscribe 시 owner 를 전달한다.

    reference counting 예시:
        subscribe("005930", owner="SCALP")   # refs[005930]={SCALP}       → WS subscribe
        subscribe("005930", owner="SHORT")   # refs[005930]={SCALP,SHORT} → (already subscribed)
        unsubscribe("005930", owner="SCALP") # refs[005930]={SHORT}       → keep
        unsubscribe("005930", owner="SHORT") # refs[005930]=set()         → WS unsubscribe
    """

    def __init__(self, *, db, kis_client):
        self.db = db
        self.kis_client = kis_client

        self.ws_cache: Optional["KISWebSocketPriceCache"] = None
        self.candle_agg: Optional["CandleAggregator"] = None
        self.tick_recorder: Optional["TickRecorder"] = None
        self.trigger_snapshot_recorder: Optional["TriggerSnapshotRecorder"] = None
        self.trigger_eval_recorder: Optional["TriggerEvalRecorder"] = None
        # KIS 최소 구독 모드: 후보는 키움 WS, KIS 는 영구+보유만 (세션 41 한도 완화)
        self._kiwoom_ws: Any = None
        self._split_feed_active: bool = False

        # owner(전략ID) → 관심 코드 집합
        self._owner_codes: Dict[str, Set[str]] = defaultdict(set)
        # code → 보유 중인 owner 집합 (ref counting)
        self._code_refs: Dict[str, Set[str]] = defaultdict(set)
        # 분리 모드: 후보 vs 보유 (전략별)
        self._owner_candidates: Dict[str, Set[str]] = defaultdict(set)
        self._owner_holdings: Dict[str, Set[str]] = defaultdict(set)
        # 영구 구독(시장방향 ETF 등)
        self._permanent_codes: Set[str] = set()
        self._lock = threading.Lock()
        # 갭보정 WS 재접속 시: split 모드면 KIS∪키움 관심 종목 전체
        self._gap_refill_codes: Set[str] = set()

        # ── 갭보정 비동기 파이프라인 ─────────────────────────────
        # (전략 쓰레드에서 subscribe() 시 동기 REST 호출하면 매수 체크가
        #  수 분간 블로킹됨 → 백그라운드 워커 큐로 이관)
        self._gap_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
        self._gap_prio_q: "queue.Queue[str]" = queue.Queue(maxsize=512)
        self._gap_mode: Dict[str, str] = {}          # code → "1m" | "full"
        self._gap_filled: Set[str] = set()          # 이미 갭보정 완료한 코드
        self._gap_inflight: Set[str] = set()        # 큐에 등록/처리 중인 코드
        self._gap_retry_count: Dict[str, int] = {}  # TF 실패 시 재시도 카운터
        self._gap_tf_ok: Dict[str, Set[int]] = {}   # 종목별 성공한 TF (재시도 시 스킵)
        self._gap_lock = threading.Lock()
        self._gap_worker_threads: List[threading.Thread] = []
        self._gap_worker_boot_logged = False
        # 전체 재갭보정(재접속 시) 중복 트리거 방지
        self._bulk_refill_running = False
        self._bulk_refill_last_ts: float = 0.0
        # 키움 ka10001 유통/상장주식수 — 전략 공통 (stock_share_meta DB 동기)
        self._share_cache: Dict[str, Dict[str, int]] = {}
        self._share_q: "queue.Queue[str]" = queue.Queue(maxsize=1024)
        self._share_inflight: Set[str] = set()
        self._share_lock = threading.Lock()
        self._share_worker_thread: Optional[threading.Thread] = None

    # ------------------------------------------------------------------
    # 시작/종료
    # ------------------------------------------------------------------
    def start(self) -> bool:
        """WS 세션 시작. 실패 시 False (봇은 REST 폴백으로 동작)."""
        if not _KIS_WS_AVAILABLE:
            logger.warning("kis_ws 미설치 → WS 허브 비활성 (REST 폴백만 동작)")
            return False

        # ── [중요] WS 는 데이터 수신용이므로 무조건 실전 서버로 접속 ──
        # kis_scalping_ver2 와 동일 정책 (모의 계좌라도 시세는 실전 필요)
        ws_app_key = get_env_from_db("KIS_APP_KEY_REAL", "") or self.kis_client.app_key
        ws_app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "") or self.kis_client.app_secret
        if not ws_app_key or not ws_app_secret:
            logger.warning("KIS 실전 키 없음 → WS 허브 비활성")
            return False

        try:
            self.ws_cache = KISWebSocketPriceCache(
                app_key=ws_app_key,
                app_secret=ws_app_secret,
                is_mock=False,  # 시세는 실전 서버 고정
            )
            # 봉 타임프레임: 스캘핑(1분) + 꼬리잡기(3분) + 추세 필터(15/60분)
            # SCALP/SHORT 양쪽 전략이 쓰는 모든 TF 포함
            tfs = self._resolve_timeframes()
            self.candle_agg = CandleAggregator(db=self.db, timeframes=tfs)
            self.ws_cache.attach_candle_aggregator(self.candle_agg)

            if get_env_bool("WS_TICK_SAVE_ENABLED", True):
                try:
                    self.tick_recorder = TickRecorder(db=self.db)
                    self.ws_cache.attach_tick_recorder(self.tick_recorder)
                except Exception as tr_ex:
                    logger.warning("TickRecorder 기동 실패 (봉 집계만 동작): %s", tr_ex)
                    self.tick_recorder = None

            if get_env_bool("WS_TRIGGER_EVAL_SAVE_ENABLED", True):
                try:
                    self.trigger_eval_recorder = TriggerEvalRecorder(db=self.db)
                except Exception as te_ex:
                    logger.warning("TriggerEvalRecorder 기동 실패: %s", te_ex)
                    self.trigger_eval_recorder = None

            # 레거시: WS 3초 주기 덤프 (기본 OFF — filter_eval 판정 스냅 사용)
            if get_env_bool("WS_ORDERBOOK_SAVE_ENABLED", False) or get_env_bool(
                "WS_PROGRAM_SAVE_ENABLED", False,
            ):
                try:
                    if TriggerSnapshotRecorder is not None:
                        self.trigger_snapshot_recorder = TriggerSnapshotRecorder(db=self.db)
                except Exception as ts_ex:
                    logger.warning("TriggerSnapshotRecorder 기동 실패: %s", ts_ex)
                    self.trigger_snapshot_recorder = None

            ok = self.ws_cache.start()
            if not ok:
                logger.warning("WS start() 실패 → 비활성")
                self.ws_cache = None
                self.candle_agg = None
                self.tick_recorder = None
                return False

            # 갭보정 · 유통주식수 백그라운드 워커 (subscribe 논블로킹)
            self._load_share_cache_from_db()
            self._start_share_meta_worker()
            self._start_gap_worker()

            # 영구 구독 (KOSPI/KOSDAQ ETF 등)
            self._load_permanent_codes()
            for code in sorted(self._permanent_codes):
                self.ws_cache.subscribe(code)
                self._enqueue_gap_fill(code)
                logger.info("📡 [영구구독] %s", code)
            with self._lock:
                self._gap_refill_codes = set(self._permanent_codes)

            # 연결 성공 후 자동 갭 보정 등록 (WS 재접속 시 전체 재갭보정)
            self.ws_cache.set_on_connected_callback(self._trigger_bulk_refill_async)

            logger.info(
                "✅ WSManager 활성 (tfs=%s, permanent=%d, gap_workers=%d)",
                tfs, len(self._permanent_codes),
                max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4)),
            )
            return True
        except Exception as e:
            logger.error("WS 초기화 예외: %s", e)
            self.ws_cache = None
            self.candle_agg = None
            return False

    def stop(self) -> None:
        try:
            if self.ws_cache:
                self.ws_cache.stop(clear_subscriptions=True)
        except Exception as e:
            logger.debug("WS stop 실패: %s", e)

    @property
    def is_active(self) -> bool:
        return bool(self.ws_cache and self.ws_cache.is_active)

    # ------------------------------------------------------------------
    # 키움 분리 시세 (KIS=영구+보유, 후보=키움) — Orchestrator 가 주입
    # ------------------------------------------------------------------
    def set_kiwoom_ws(self, kiwoom_ws: Any) -> None:
        """키움 WS 인스턴스 (기동 후 주입). ``activate_split_feed`` 전에 설정."""
        self._kiwoom_ws = kiwoom_ws

    def activate_split_feed(self, active: bool) -> None:
        """``WS_SUBSCRIBE_KIS_MINIMAL`` + 키움 준비 완료 후 True → 후보 구독을 키움으로."""
        self._split_feed_active = bool(active and self._kiwoom_ws and self.ws_cache)
        if self._split_feed_active:
            logger.info("✅ WS 분리 시세 활성: KIS=PERMANENT∪보유, 후보·검증=키움")

    def sync_targets_split(
        self,
        owner: str,
        candidates: Iterable[str],
        holdings: Iterable[str],
    ) -> None:
        """전략별 후보/보유를 분리 반영. ``WS_SUBSCRIBE_KIS_MINIMAL`` 아니면 레거시와 동일."""
        cand = {str(c).strip() for c in candidates if c}
        hold = {str(h).strip() for h in holdings if h}
        if not self._split_feed_active:
            self.sync_targets(owner, cand | hold)
            return
        if not self._kiwoom_ws:
            logger.warning(
                "⚠️ WS 분리 시세 요청이나 키움 WS 없음 → KIS 전체 구독(레거시)으로 폴백",
            )
            self.sync_targets(owner, cand | hold)
            return
        with self._lock:
            self._owner_candidates[owner] = cand
            self._owner_holdings[owner] = hold
        self._reconcile_split_subscriptions()

    def _reconcile_split_subscriptions(self) -> None:
        """KIS/키움 구독 집합을 후보·보유·영구 기준으로 재동기화."""
        if not (self._split_feed_active and self.ws_cache and self._kiwoom_ws):
            return
        with self._lock:
            cand_u: Set[str] = set()
            for s in self._owner_candidates.values():
                cand_u |= s
            hold_u: Set[str] = set()
            for s in self._owner_holdings.values():
                hold_u |= s
            perm = set(self._permanent_codes)
            kis_want = perm | hold_u
            kw_want = cand_u | hold_u | perm
            tick_to_agg = set(cand_u - hold_u)
            self._gap_refill_codes = set(kis_want) | set(kw_want)

        try:
            self._kiwoom_ws.set_candle_tick_codes(tick_to_agg)
        except Exception:
            pass
        try:
            if self.candle_agg and hasattr(self.candle_agg, "set_incremental_volume_codes"):
                self.candle_agg.set_incremental_volume_codes(set(tick_to_agg))
        except Exception:
            pass

        with self._kiwoom_ws._sub_lock:
            kw_now = set(self._kiwoom_ws._subscribed)
        with self.ws_cache._sub_lock:
            kis_now = set(self.ws_cache._subscribed)

        to_kw = sorted(kw_want - kw_now)
        if to_kw:
            try:
                added_kw = self._kiwoom_ws.subscribe_many(to_kw)
            except Exception:
                added_kw = []
                for code in to_kw:
                    if self._kiwoom_ws.subscribe(code):
                        added_kw.append(code)
            for code in added_kw:
                if code in self._permanent_codes:
                    self._enqueue_gap_fill(code)
                else:
                    # 후보 종목: 1M 우선 갭보정을 큐 앞쪽에 — BREAKOUT 매수체크 즉시 가능
                    self._enqueue_gap_fill(code, priority=True, mode="1m")

        for code in sorted(kis_want - kis_now):
            self.ws_cache.subscribe(code)
            self._enqueue_gap_fill(code)

        for code in sorted(kis_now - kis_want):
            self.ws_cache.unsubscribe(code)
            if code not in kw_want and self.candle_agg:
                self.candle_agg.remove_code(code)

        with self._kiwoom_ws._sub_lock:
            kw_now2 = set(self._kiwoom_ws._subscribed)
        for code in sorted(kw_now2 - kw_want):
            self._kiwoom_ws.unsubscribe(code)
            if code not in kis_want and self.candle_agg:
                self.candle_agg.remove_code(code)
            if self.tick_recorder and code not in kw_want:
                self.tick_recorder.remove_code(code)
            if self.trigger_snapshot_recorder and code not in kw_want:
                self.trigger_snapshot_recorder.remove_code(code)

        self._sync_tick_record_codes()

    def _sync_tick_record_codes(self) -> None:
        """``WS_TICK_RECORD_SCOPE`` 에 따라 TickRecorder 저장 대상 종목 갱신."""
        if not self.tick_recorder:
            return
        scope = (get_env_from_db("WS_TICK_RECORD_SCOPE", "candidates") or "candidates").strip().lower()
        with self._lock:
            perm = set(self._permanent_codes)
            if self._split_feed_active:
                cand_u: Set[str] = set()
                for s in self._owner_candidates.values():
                    cand_u |= s
                hold_u: Set[str] = set()
                for s in self._owner_holdings.values():
                    hold_u |= s
                subscribed = perm | cand_u | hold_u
                if scope in ("subscribed", "all", "full"):
                    want = subscribed
                else:
                    # 'candidates' 스코프라도 보유(hold_u)는 항상 포함한다.
                    # 매수 후 종목이 후보 유니버스에서 이탈하면 보유 구간 틱이 끊겨
                    # 백테 '틱청산' 재현이 불가(진입틱만 있고 청산틱 없음)해진다.
                    # 실 체결(손절/익절) 정합을 위해 보유분 틱은 반드시 수집한다.
                    want = cand_u | perm | hold_u
            else:
                subscribed = set(perm)
                for refs in self._code_refs.values():
                    subscribed |= set(refs)
                want = subscribed if scope in ("subscribed", "all", "full") else subscribed
        self.tick_recorder.set_record_codes(want)
        if self.trigger_snapshot_recorder:
            self.trigger_snapshot_recorder.set_record_codes(want)

    # ------------------------------------------------------------------
    # 구독 관리 (Reference Counting)
    # ------------------------------------------------------------------
    def subscribe(self, code: str, owner: str) -> None:
        """
        한 전략(owner)이 해당 종목에 관심 등록.
        ※ 갭보정은 **백그라운드 워커 큐**로 위임하여 전략 쓰레드를 블록하지 않음.
          (예전: 여기서 REST 4개 TF 동기 호출 → 매수 체크 2~3분 지연)
        """
        if self._split_feed_active:
            return
        if not code or not owner:
            return
        with self._lock:
            self._owner_codes[owner].add(code)
            first_ref = not self._code_refs[code]
            self._code_refs[code].add(owner)

        if first_ref and self.ws_cache:
            self.ws_cache.subscribe(code)
            # 신규 구독 → 워커에게 갭보정 위임 (논블로킹)
            self._enqueue_gap_fill(code)
        self._sync_tick_record_codes()

    def unsubscribe(self, code: str, owner: str) -> None:
        """한 전략(owner)이 관심 해제. 다른 전략이 아직 들고 있으면 WS 는 유지."""
        if self._split_feed_active:
            return
        if not code or not owner:
            return
        with self._lock:
            self._owner_codes[owner].discard(code)
            if owner in self._code_refs.get(code, set()):
                self._code_refs[code].discard(owner)
            still_refs = bool(self._code_refs.get(code))
            is_permanent = code in self._permanent_codes

        if not still_refs and not is_permanent and self.ws_cache:
            self.ws_cache.unsubscribe(code)
            if self.candle_agg:
                self.candle_agg.remove_code(code)
            if self.tick_recorder:
                self.tick_recorder.remove_code(code)
            if self.trigger_snapshot_recorder:
                self.trigger_snapshot_recorder.remove_code(code)
        self._sync_tick_record_codes()

    def sync_targets(self, owner: str, codes: Iterable[str]) -> None:
        """
        한 전략의 관심 종목 목록을 통째로 동기화.
        - 기존 관심 종목 중 없어진 것은 unsubscribe
        - 새로 추가된 것은 subscribe
        """
        if self._split_feed_active:
            return
        new_set = {c for c in codes if c}
        with self._lock:
            cur = set(self._owner_codes.get(owner, set()))

        for code in sorted(cur - new_set):
            self.unsubscribe(code, owner)
        for code in sorted(new_set - cur):
            self.subscribe(code, owner)
        self._sync_tick_record_codes()

    def get_recent_ticks(self, code: str, limit: int = 100) -> list:
        """RAM 링버퍼 최근 체결 틱 (B안 봉 내 돌파 등)."""
        if self.tick_recorder:
            try:
                return self.tick_recorder.get_recent_ticks(code, limit=limit)
            except Exception:
                return []
        return []

    def get_current_candle(self, code: str, tf: int) -> Optional[dict]:
        """진행 중 봉 (RAM, is_confirmed=0) — B안 거래량·양봉 판정용."""
        if self.candle_agg:
            try:
                return self.candle_agg.get_current_candle(code, tf)
            except Exception:
                return None
        return None

    # ------------------------------------------------------------------
    # 조회 헬퍼 (전략이 쓰는 API)
    # ------------------------------------------------------------------
    def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[dict]:
        if self.ws_cache:
            try:
                p = self.ws_cache.get_price(code, max_age_sec=max_age_sec)
                if p:
                    return p
            except Exception:
                pass
        if self._split_feed_active and self._kiwoom_ws:
            try:
                return self._kiwoom_ws.get_price(code, max_age_sec=max_age_sec)
            except Exception:
                return None
        return None

    def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
        """키움 0D 호가 스냅샷 (분리 시세·키움 WS 활성 시)."""
        if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook_snapshot"):
            try:
                snap = self._kiwoom_ws.get_orderbook_snapshot(code, max_age_sec=max_age_sec)
                if snap is not None:
                    return snap
            except Exception:
                pass
        return None

    def get_orderbook(self, code: str, max_age_sec: float = 3.0) -> Optional[dict]:
        """호가 dict (KIS REST 호환 bidp*) — 키움 WS 캐시 우선."""
        if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_orderbook"):
            try:
                ob = self._kiwoom_ws.get_orderbook(code, max_age_sec=max_age_sec)
                if ob:
                    return ob
            except Exception:
                pass
        return None

    def get_program_snapshot(self, code: str, max_age_sec: float = 30.0):
        """키움 0w 프로그램매매 스냅샷 (분리 시세·키움 WS 활성 시)."""
        if self._kiwoom_ws and hasattr(self._kiwoom_ws, "get_program_snapshot"):
            try:
                snap = self._kiwoom_ws.get_program_snapshot(code, max_age_sec=max_age_sec)
                if snap is not None:
                    return snap
            except Exception:
                pass
        return None

    def get_candles(self, code: str, tf: int, n: int = 50) -> list:
        if self.candle_agg:
            try:
                return self.candle_agg.get_candles(code, tf, n)
            except Exception:
                return []
        # CandleAggregator 없으면 DB 폴백
        try:
            return self.db.get_ws_candles(code, tf, limit=n, confirmed_only=True)
        except Exception:
            return []

    def fill_gap(self, codes: Optional[Iterable[str]] = None) -> None:
        """외부에서 수동으로 갭 보정 트리거 (비동기: 큐 등록 후 즉시 리턴)."""
        if codes is None:
            self._trigger_bulk_refill_async()
        else:
            for c in codes:
                self._enqueue_gap_fill(c)

    # ------------------------------------------------------------------
    # 내부: 갭 보정 — 백그라운드 워커 파이프라인
    # ------------------------------------------------------------------
    def _start_gap_worker(self) -> None:
        """갭보정 백그라운드 워커 N개 기동 — 우선큐(후보 1M)와 일반큐 병렬 소진."""
        want = max(1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4))
        alive = [t for t in self._gap_worker_threads if t.is_alive()]
        if len(alive) >= want:
            return
        start_id = len(self._gap_worker_threads)
        for i in range(start_id, want):
            t = threading.Thread(
                target=self._gap_worker_loop,
                args=(i,),
                name=f"WS-GapFillWorker-{i}",
                daemon=True,
            )
            t.start()
            self._gap_worker_threads.append(t)
        logger.info(
            "✅ 갭보정 워커 %d개 시작 (queue 병렬, WS_GAP_FILL_WORKERS=%d)",
            want, want,
        )

    def _enqueue_gap_fill(
        self,
        code: str,
        *,
        force: bool = False,
        priority: bool = False,
        mode: str = "full",
    ) -> None:
        """구독 직후 호출 — 갭보정 큐에 논블로킹 등록.

        중복 방지:
          - 이미 완료(`_gap_filled`) → 스킵 (force=True 시 재시도)
          - 이미 큐/처리 중(`_gap_inflight`) → 스킵

        Args:
            priority: True 이면 우선 큐(후보 종목 1M 웜업 등)
            mode: ``"1m"`` = 1분봉만 먼저, ``"full"`` = 설정된 전 TF
        """
        if not code:
            return
        fill_mode = "1m" if str(mode).strip().lower() == "1m" else "full"
        with self._gap_lock:
            if code in self._gap_inflight:
                return
            if code in self._gap_filled and not force:
                return
            if force:
                self._gap_filled.discard(code)
            self._gap_inflight.add(code)
            self._gap_mode[code] = fill_mode
        target_q = self._gap_prio_q if priority else self._gap_q
        try:
            target_q.put_nowait(code)
        except queue.Full:
            # 큐가 가득 차면 inflight 해제 후 포기 (WS 틱으로 자연 누적)
            with self._gap_lock:
                self._gap_inflight.discard(code)
                self._gap_mode.pop(code, None)
            logger.warning("⚠️ 갭보정 큐 full → %s 스킵 (WS 실시간 누적으로 대체)", code)
        self._enqueue_share_meta(code)

    def _dequeue_gap_fill(self) -> tuple[Optional[str], bool]:
        """우선 큐 → 일반 큐 순으로 (code, from_priority) 반환."""
        try:
            return self._gap_prio_q.get_nowait(), True
        except queue.Empty:
            pass
        try:
            return self._gap_q.get(timeout=1.0), False
        except queue.Empty:
            return None, False

    def _start_share_meta_worker(self) -> None:
        """유통주식수(ka10001) 전담 워커 — 장외에도 동작, 전략 공통."""
        if self._share_worker_thread and self._share_worker_thread.is_alive():
            return
        t = threading.Thread(
            target=self._share_meta_worker_loop,
            name="WS-ShareMetaWorker",
            daemon=True,
        )
        t.start()
        self._share_worker_thread = t
        logger.info("✅ 유통주식수 워커 시작 (stock_share_meta / ka10001)")

    def _load_share_cache_from_db(self) -> None:
        try:
            from kis_trader.share.stock_share import (
                load_stock_share_meta_map,
                merge_share_meta_into_cache,
            )
            db_map = load_stock_share_meta_map(self.db)
            merge_share_meta_into_cache(self._share_cache, db_map)
            if db_map:
                logger.info("📊 stock_share_meta DB 캐시: %d종목", len(db_map))
        except Exception as e:
            logger.debug("stock_share_meta DB 로드 실패: %s", e)

    def _enqueue_share_meta(self, code: str) -> None:
        """구독 종목 유통주식수 큐 등록 — ``dstr_stk`` 없을 때만 ka10001 호출."""
        if not code:
            return
        try:
            from kis_trader.share.stock_share import codes_missing_dstr
            if not codes_missing_dstr(self._share_cache, [code]):
                return
        except Exception:
            pass
        with self._share_lock:
            if code in self._share_inflight:
                return
            self._share_inflight.add(code)
        try:
            self._share_q.put_nowait(code)
        except queue.Full:
            with self._share_lock:
                self._share_inflight.discard(code)
            logger.warning("⚠️ 유통주식수 큐 full → %s 스킵", code)

    def _share_meta_worker_loop(self) -> None:
        kw_key = kw_secret = None
        kw_mock = False
        kw_resolved = False
        while True:
            try:
                code = self._share_q.get(timeout=1.0)
            except queue.Empty:
                continue
            if code is None:
                return
            try:
                if not kw_resolved:
                    kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials()
                    kw_resolved = True
                if kw_key and kw_secret and fetch_kiwoom_stock_meta_detail is not None:
                    # 실매: 종목당 1회 시도 — 실패 시 다음 구독/매수체크 턴에 재큐 (백필 스크립트만 다회 재시도)
                    res = fetch_kiwoom_stock_meta_detail(
                        code, kw_key, kw_secret, is_mock=kw_mock, max_retries=1,
                    )
                    meta = res.get("meta") if res.get("ok") else None
                    if meta:
                        from kis_trader.share.stock_share import apply_fetched_meta
                        apply_fetched_meta(self._share_cache, self.db, code, meta)
                        logger.debug(
                            "📊 [ka10001] %s flo=%s dstr=%s",
                            code, meta.get("flo_stk"), meta.get("dstr_stk"),
                        )
                    elif res.get("reason") == "rate_limit_1700":
                        logger.warning(
                            "📊 [ka10001] %s 레이트리밋 — 다음 턴 재시도 (큐 유지 안 함)",
                            code,
                        )
                    elif res.get("reason"):
                        logger.debug(
                            "📊 [ka10001] %s 실패 %s %s",
                            code, res.get("reason"), res.get("return_msg"),
                        )
                meta_sleep = get_env_float("STOCK_SHARE_META_SLEEP_SEC", 1.0)
                time.sleep(random.uniform(meta_sleep * 0.9, meta_sleep * 1.1))
            except Exception as e:
                logger.debug("유통주식수 워커 예외 (%s): %s", code, e)
            finally:
                with self._share_lock:
                    self._share_inflight.discard(code)
                self._share_q.task_done()

    def ensure_share_meta(self, codes: Iterable[str]) -> None:
        """전략 공통 — 여러 종목 유통주식수 큐 일괄 등록."""
        for code in codes:
            self._enqueue_share_meta(str(code or "").strip())

    def _trigger_bulk_refill_async(self) -> None:
        """WS 재접속 시 현재 구독된 전 종목의 갭보정 완료 마커를 리셋하고 재큐잉."""
        if not (self.ws_cache and self.candle_agg):
            return
        if self._bulk_refill_running:
            return
        debounce = float(get_env_int("WS_GAP_BULK_REFILL_DEBOUNCE_SEC", 120))
        now = time.time()
        if debounce > 0 and (now - self._bulk_refill_last_ts) < debounce:
            logger.debug(
                "🔄 [갭보정-전체] 스킵 (디바운스 %.0fs, 마지막 %.0fs 전)",
                debounce, now - self._bulk_refill_last_ts,
            )
            return
        self._bulk_refill_last_ts = now
        self._bulk_refill_running = True

        def _bulk():
            try:
                if self._split_feed_active and self._gap_refill_codes:
                    codes = sorted(self._gap_refill_codes)
                else:
                    with self.ws_cache._sub_lock:
                        codes = sorted(self.ws_cache._subscribed)
                # 재접속이므로 모든 종목 갭보정 재실행
                with self._gap_lock:
                    self._gap_filled.clear()
                    self._gap_tf_ok.clear()
                logger.info(
                    "🔄 [갭보정-전체] WS 재접속 → %d종목 큐 재등록", len(codes),
                )
                for code in codes:
                    self._enqueue_gap_fill(code)
            finally:
                self._bulk_refill_running = False

        threading.Thread(target=_bulk, name="WS-BulkRefill", daemon=True).start()

    def _gap_worker_loop(self, worker_id: int = 0) -> None:
        """워커 루프: 공유 큐에서 코드 꺼내 ka10080 갭보정 (N워커 병렬)."""
        kw_key = kw_secret = None
        kw_mock = False

        while True:
            code, from_prio = self._dequeue_gap_fill()
            if not code:
                continue

            with self._gap_lock:
                gap_mode = self._gap_mode.get(code, "full")
                only_1m = gap_mode == "1m"

            # 장중만 실행 (장외면 완료 마커 찍고 다음)
            if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
                with self._gap_lock:
                    self._gap_inflight.discard(code)
                    self._gap_mode.pop(code, None)
                    self._gap_filled.add(code)
                if from_prio:
                    self._gap_prio_q.task_done()
                else:
                    self._gap_q.task_done()
                continue

            if kw_key is None:
                kw_key, kw_secret, kw_mock = self._get_kiwoom_credentials()
                with self._gap_lock:
                    if not self._gap_worker_boot_logged:
                        use_kiwoom = bool(
                            kw_key and kw_secret and get_kiwoom_candles_df is not None
                        )
                        if use_kiwoom:
                            if get_env_bool("KIWOOM_WS_FORCE_REAL", True):
                                kw_status = "✅ (실전·시세)"
                            else:
                                kw_status = f"✅ ({'모의' if kw_mock else '실전'})"
                        else:
                            kw_status = "❌"
                        n_workers = max(
                            1, min(get_env_int("WS_GAP_FILL_WORKERS", 2), 4),
                        )
                        logger.info(
                            "🔧 [갭보정-워커×%d] kiwoom=%s, KIS_fallback=%s",
                            n_workers,
                            kw_status,
                            "ON" if get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False) else "OFF",
                        )
                        self._gap_worker_boot_logged = True

            try:
                only_tfs = {1} if only_1m else None
                ok = self._fill_gap_for_code(
                    code,
                    kw_key=kw_key, kw_secret=kw_secret, kw_mock=kw_mock,
                    only_tfs=only_tfs,
                )
            except Exception as e:
                logger.debug("갭보정 워커 예외 (%s): %s", code, e)
                ok = False
            finally:
                with self._gap_lock:
                    self._gap_inflight.discard(code)
                    self._gap_mode.pop(code, None)

                    if only_1m:
                        # 1M 웜업 성공 → 나머지 TF 는 일반 큐로 이어서
                        have_1m = 1 in self._gap_tf_ok.get(code, set())
                        if have_1m:
                            need = set(self.candle_agg.timeframes)
                            have = self._gap_tf_ok.get(code, set())
                            if not need.issubset(have):
                                threading.Thread(
                                    target=lambda c=code: self._enqueue_gap_fill(
                                        c, mode="full",
                                    ),
                                    daemon=True,
                                ).start()
                            else:
                                self._gap_filled.add(code)
                                self._gap_retry_count.pop(code, None)
                        else:
                            retries = self._gap_retry_count.get(code, 0) + 1
                            max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
                            self._gap_retry_count[code] = retries
                            if retries < max_retries:
                                delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8))
                                logger.warning(
                                    "⚠️ [갭보정] %s 1M 실패 → %ds 후 우선 재시도 (%d/%d)",
                                    code, int(delay), retries, max_retries,
                                )
                                threading.Timer(
                                    delay,
                                    lambda c=code: self._enqueue_gap_fill(
                                        c, force=True, priority=True, mode="1m",
                                    ),
                                ).start()
                            else:
                                logger.warning(
                                    "⚠️ [갭보정] %s 1M 최대 재시도 초과 — WS 틱 누적으로 대체",
                                    code,
                                )
                    elif ok:
                        self._gap_filled.add(code)
                        self._gap_retry_count.pop(code, None)
                    else:
                        retries = self._gap_retry_count.get(code, 0) + 1
                        max_retries = get_env_int("WS_GAP_FILL_MAX_RETRIES", 3)
                        self._gap_retry_count[code] = retries
                        if retries < max_retries:
                            delay = float(get_env_int("WS_GAP_FILL_RETRY_DELAY_SEC", 8))
                            logger.warning(
                                "⚠️ [갭보정] %s 일부 TF 실패 → %ds 후 재시도 (%d/%d)",
                                code, int(delay), retries, max_retries,
                            )
                            threading.Timer(
                                delay,
                                lambda c=code: self._enqueue_gap_fill(c, force=True),
                            ).start()
                        else:
                            logger.warning(
                                "⚠️ [갭보정] %s 최대 재시도 초과 — WS 틱 누적으로 대체",
                                code,
                            )
                            self._gap_filled.add(code)

                if from_prio:
                    self._gap_prio_q.task_done()
                else:
                    self._gap_q.task_done()

            # 종목 간 sleep (REST 레이트리밋 완충)
            self._gap_code_sleep()

    @staticmethod
    def _is_market_hours() -> bool:
        """
        09:00~15:30 KST 평일 여부.
        KIS 모의투자 서버는 장외시간 inquire-time-itemchartprice 호출에
        HTTP 500 을 던지므로, 갭보정 REST 호출은 장중에만 시도한다.
        (키움 ka10080 은 장외에도 동작하지만, 매매 자체가 장중에만 의미 있으므로 통일)
        """
        now = time.localtime()
        if now.tm_wday >= 5:  # 토/일
            return False
        hhmm = now.tm_hour * 100 + now.tm_min
        return 900 <= hhmm <= 1530

    def _get_kiwoom_credentials(self):
        """
        키움 분봉 갭보정·유통주식수(ka10001)용 키 조회.

        시세 REST 는 **매매 KIS_MOCK 과 분리** — WS·조건검색과 동일하게 실키 우선.

        토글 결정 우선순위
        ------------------
          1) ``KIWOOM_WS_FORCE_REAL=true`` (기본) → **항상 실키·api.kiwoom.com**
             (KIS_MOCK·KIWOOM_MOCK 무시 — 갭보정/시세 전용)
          2) ``KIWOOM_WS_FORCE_REAL=false`` 일 때만:
             a) ``KIWOOM_MOCK`` 명시값
             b) 미지정 시 ``KIS_MOCK`` 폴백

        키 슬롯 매핑
        -----------
          mock=True  → ``KIWOOM_APP_KEY_MOCK``  → 없으면 ``KIWOOM_APP_KEY`` (레거시) 폴백
          mock=False → ``KIWOOM_APP_KEY_REAL``  → 없으면 ``KIWOOM_APP_KEY`` (레거시) 폴백

        키 없으면 ``(None, None, is_mock)`` 반환 → 키움 비활성
        (``WS_GAP_FILL_KIS_FALLBACK`` ON 일 때만 KIS REST 폴백)

        주의
        ----
          레거시 폴백은 키-도메인이 어긋나면 키움이 ``8030`` 으로 거부한다.
          (예: 모의 전용 키를 실전 도메인에 던지면 8030.) 폴백 사용 시 로그
          한 줄로 명시한다.

        Returns:
            (app_key, app_secret, is_mock)
        """
        if get_kiwoom_candles_df is None:
            return None, None, False
        try:
            # ── 1. 토글 결정 (시세 REST = WS 와 동일 정책) ───────────────
            force_real_str = (
                get_env_from_db("KIWOOM_WS_FORCE_REAL", "true") or "true"
            ).strip().lower()
            force_real = force_real_str in ("true", "1", "yes", "y", "on")

            if force_real:
                is_mock = False
            else:
                kw_mock_raw = (get_env_from_db("KIWOOM_MOCK", "") or "").strip().lower()
                if kw_mock_raw in ("true", "1", "yes", "y", "on"):
                    is_mock = True
                elif kw_mock_raw in ("false", "0", "no", "n", "off"):
                    is_mock = False
                else:
                    is_mock = get_env_bool("KIS_MOCK", True)

            # ── 2. 키 슬롯 선택 (모의/실전) ───────────────────────────
            if is_mock:
                kw_key = (get_env_from_db("KIWOOM_APP_KEY_MOCK", "") or "").strip()
                kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_MOCK", "") or "").strip()
            else:
                kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
                kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()

            # ── 3. 레거시 단일 필드 폴백 (KIWOOM_APP_KEY/_SECRET) ────
            if not kw_key or not kw_secret:
                legacy_key = (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
                legacy_secret = (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
                if legacy_key and legacy_secret:
                    kw_key = kw_key or legacy_key
                    kw_secret = kw_secret or legacy_secret
                    logger.info(
                        "🔧 [키움] %s 슬롯 비어있어 LEGACY KIWOOM_APP_KEY 폴백 사용 "
                        "(키-도메인 불일치 시 8030 발생 가능)",
                        "MOCK" if is_mock else "REAL",
                    )

            if not kw_key or not kw_secret:
                return None, None, is_mock

            return kw_key, kw_secret, is_mock
        except Exception as e:
            logger.debug("키움 크레덴셜 조회 예외: %s", e)
            return None, None, False

    @staticmethod
    def _parse_tf_csv(raw: str, fallback: str) -> List[int]:
        """콤마 구분 분봉 목록 파싱 (예: ``1,3`` → [1, 3])."""
        try:
            src = str(raw if str(raw or "").strip() else fallback)
            out = [int(x.strip()) for x in src.split(",") if x.strip()]
            return sorted(set(out))
        except Exception:
            return [int(x) for x in fallback.split(",")]

    def _gap_priority_tfs(self) -> Set[int]:
        """갭보정 1차 우선 TF — 기본 1M·3M (BREAKOUT·SHORT 핵심)."""
        raw = get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3")
        return set(self._parse_tf_csv(raw, "1,3"))

    def _resolve_gap_fill_tf_order(self) -> List[int]:
        """우선 TF(1M·3M) 먼저, 이후 15M/60M — 레이트리밋 시 핵심 봉 선확보."""
        all_tfs = list(self.candle_agg.timeframes)
        priority = self._gap_priority_tfs()
        ordered: List[int] = [tf for tf in self._parse_tf_csv(
            get_env_from_db("WS_GAP_FILL_PRIORITY_TFS", "1,3"), "1,3",
        ) if tf in all_tfs]
        for tf in all_tfs:
            if tf not in priority:
                ordered.append(tf)
        return ordered

    def _gap_tf_sleep(self) -> None:
        """TF 간 REST 호출 간격 — 키움 ka10080 레이트리밋 완충."""
        lo = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MIN_SEC", 0.6))
        hi = float(get_env_float("WS_GAP_FILL_TF_SLEEP_MAX_SEC", 1.2))
        if hi < lo:
            lo, hi = hi, lo
        time.sleep(random.uniform(lo, hi))

    def _gap_code_sleep(self) -> None:
        """종목 간 REST 호출 간격."""
        lo = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MIN_SEC", 0.4))
        hi = float(get_env_float("WS_GAP_FILL_CODE_SLEEP_MAX_SEC", 0.8))
        if hi < lo:
            lo, hi = hi, lo
        time.sleep(random.uniform(lo, hi))

    def _gap_tf_already_ok(self, code: str, tf: int) -> bool:
        with self._gap_lock:
            return tf in self._gap_tf_ok.get(code, set())

    def _mark_gap_tf_ok(self, code: str, tf: int) -> None:
        with self._gap_lock:
            self._gap_tf_ok.setdefault(code, set()).add(tf)

    def _all_gap_tfs_ok(self, code: str) -> bool:
        need = set(self.candle_agg.timeframes)
        with self._gap_lock:
            have = self._gap_tf_ok.get(code, set())
        return need.issubset(have)

    def _fill_gap_for_code(
        self,
        code: str,
        *,
        kw_key: Optional[str] = None,
        kw_secret: Optional[str] = None,
        kw_mock: bool = False,
        only_tfs: Optional[Set[int]] = None,
    ) -> bool:
        """
        단일 종목 갭 보정 — 워커 스레드 전용 (전략 쓰레드에서 직접 호출 금지).

        1M·3M 우선 → phase pause → 15M/60M 순.
        성공한 TF는 ``_gap_tf_ok`` 에 기록해 재시도 시 REST 중복 호출을 줄인다.

        Returns:
            True if all configured timeframes got REST data; False if any TF empty.
        """
        if not (self.ws_cache and self.candle_agg):
            return False
        if not self._is_market_hours() and not get_env_bool("WS_GAP_FILL_OFF_HOURS", False):
            return False

        if self._all_gap_tfs_ok(code):
            return True

        limit = get_env_int("WS_GAP_FILL_LIMIT", 120)
        use_kiwoom = bool(kw_key and kw_secret and get_kiwoom_candles_df is not None)
        kis_fallback_on = get_env_bool("WS_GAP_FILL_KIS_FALLBACK", False)
        priority = self._gap_priority_tfs()
        ordered_tfs = self._resolve_gap_fill_tf_order()
        if only_tfs is not None:
            ordered_tfs = [tf for tf in ordered_tfs if tf in only_tfs]
        phase_pause = float(get_env_float("WS_GAP_FILL_PHASE_PAUSE_SEC", 1.5))
        prev_tf: Optional[int] = None

        for tf in ordered_tfs:
            if self._gap_tf_already_ok(code, tf):
                prev_tf = tf
                continue

            # 우선(1M·3M) → 장기(15M·60M) 전환 전 추가 휴식
            if (
                prev_tf is not None
                and prev_tf in priority
                and tf not in priority
                and phase_pause > 0
            ):
                logger.debug(
                    "[갭보정] %s 우선TF 완료 → %ds pause 후 %dM",
                    code, int(phase_pause), tf,
                )
                time.sleep(phase_pause)

            df = None

            if use_kiwoom:
                try:
                    df = get_kiwoom_candles_df(
                        code, tf, kw_key, kw_secret,
                        is_mock=kw_mock, n=limit,
                    )
                except Exception as e:
                    logger.warning("⚠️ [갭보정] 키움 실패 (%s %dM): %s", code, tf, e)

            # KIS fallback — env 로 명시적 ON 일 때만 (1/3분봉 한정)
            if (df is None or df.empty) and kis_fallback_on and tf <= 3:
                try:
                    df = self.kis_client.get_minute_chart(
                        code, period=str(tf), limit=limit,
                    )
                except Exception as e:
                    logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e)

            if df is not None and not df.empty:
                self.candle_agg.fill_gap_from_rest(code, tf, df)
                self._mark_gap_tf_ok(code, tf)
            else:
                logger.warning("⚠️ [갭보정] %s %dM → REST 빈 응답 (재시도 대상)", code, tf)

            prev_tf = tf
            self._gap_tf_sleep()

        return self._all_gap_tfs_ok(code) if only_tfs is None else (
            all(tf in self._gap_tf_ok.get(code, set()) for tf in only_tfs)
        )

    def get_share_denom(self, code: str) -> float:
        """
        1분 회전율 분모 — 키움 ka10001 + stock_share_meta 캐시 (전략 공통).
        ``STOCK_SHARE_DENOM`` / ``BREAKOUT_SHARE_DENOM``: ``dstr_stk``(유통, 기본) | ``flo_stk``.
        """
        from kis_trader.share.stock_share import (
            codes_missing_dstr,
            share_denom_from_meta,
        )
        code = str(code or "").strip()
        if codes_missing_dstr(self._share_cache, [code]):
            self._enqueue_share_meta(code)
            return 0.0
        return share_denom_from_meta(self._share_cache.get(code, {}))

    # ------------------------------------------------------------------
    # 영구 구독 / 타임프레임 해석
    # ------------------------------------------------------------------
    def _load_permanent_codes(self) -> None:
        """
        국내 WS(H0STCNT0) 영구구독 코드 로드.
        우선순위: permanent_subscriptions 테이블(KR·enabled) → 없으면 env PERMANENT_WS_CODES.
        ※ 해외(US) 코드는 국내 WS로 받을 수 없으므로 여기서 제외한다.
          (해외는 별도 KISOverseasWebSocket(HDFSCNT0)이 담당)
        """
        codes: set = set()
        # 1) 통합 영구구독 테이블에서 KR(국내) 코드만 로드
        try:
            import permanent_subs as _ps  # 통합 영구구독 테이블 헬퍼
            for row in _ps.codes_by_market(self.db, "KR", enabled_only=True):
                c = str(row.get("code", "")).strip()
                if c:
                    codes.add(c)
        except Exception as e:
            logger.debug("permanent_subscriptions 테이블 로드 실패(env 폴백): %s", e)

        # 2) 테이블이 비어 있으면 env 폴백 (국내 6자리 숫자코드만 채택 → 해외코드 혼입 차단)
        if not codes:
            raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200")
            for c in str(raw).split(","):
                c = c.strip()
                if c.isdigit() and len(c) == 6:
                    codes.add(c)

        self._permanent_codes = codes

    def _resolve_timeframes(self) -> List[int]:
        """SCALP 1분 + SHORT 3분 + 추세 15/60분. env 로 확장 가능."""
        tf_raw = get_env_from_db("WS_TIMEFRAMES", "1,3,15,60")
        try:
            tfs = [int(x.strip()) for x in str(tf_raw).split(",") if x.strip()]
            # 최소 1분, 3분은 포함 보장 (두 전략 필수 TF)
            for must in (1, 3):
                if must not in tfs:
                    tfs.append(must)
            return sorted(set(tfs))
        except Exception:
            return [1, 3, 15, 60]
