"""
kis_trader/main.py — 통합 오케스트레이터
==========================================
구조:
  [공유 인프라]  KISClient, TradeDBExt, WSManager, OrderManager
  [전략 쓰레드]   ScalpingStrategy(독립 Thread), TailCatchStrategy(독립 Thread)

전략 스위치 (DB env_config 또는 OS env):
  - ``STRATEGY_SCALP_ENABLED``     (기본 true)   — SCALP=reversal(고정)
  - ``STRATEGY_SHORT_ENABLED``     (기본 true)
  - ``STRATEGY_BREAKOUT_ENABLED``  (기본 false)
  - ``STRATEGY_MOMENTUM_ENABLED``  (기본 false)  — 키움 'scalp' 조건검색 추종(추세 전략)
  - ``STRATEGY_UPDOW_ENABLED``     (기본 false)  — ``holding_stock_config`` 관심종목만 직전봉 몸통 하락·다음봉 시가 매수
  - ``STRATEGY_SAME_CODE_POLICY`` (allow|block, 기본 allow — OrderManager 에서 사용)

유니버스 소스 스위치 (전략별, 런타임 교체 가능):
  - ``SCALP_UNIVERSE_SOURCE``     (기본 "condition")
  - ``SHORT_UNIVERSE_SOURCE``     (기본 "kiwoom_condition")
  - ``BREAKOUT_UNIVERSE_SOURCE``  (기본 "condition")
  - ``MOMENTUM_UNIVERSE_SOURCE``  (기본 "kiwoom_condition" — ranking | condition | kiwoom_condition)
  - ``UPDOW_UNIVERSE_SOURCE``     (기본 "condition")
  값: "ranking" | "condition"

  ── "ranking" (KIS 거래량/체결강도 상위 REST) ──
    - ``RANKING_POLL_INTERVAL_SEC``  : 폴링 주기 (기본 10)
    - 전략별 세부 설정:
        SCALP    : ``RANK_SCALP_SORT`` (기본 volume)    / ``RANK_SCALP_LIMIT`` (기본 100)
        SHORT    : ``RANK_SHORT_SORT`` (기본 volume)    / ``RANK_SHORT_LIMIT`` (기본 100)
        BREAKOUT : ``RANK_BREAKOUT_SORT`` (기본 trading_value) / ``RANK_BREAKOUT_LIMIT`` (기본 100)
        MOMENTUM : ``RANK_MOMENTUM_SORT`` (기본 volume) / ``RANK_MOMENTUM_LIMIT`` (기본 20)
        UPDOW    : ``RANK_UPDOW_SORT`` (기본 volume)    / ``RANK_UPDOW_LIMIT`` (기본 100)
    - sort 값: volume | trading_value | strength | fluct_up | fluct_down

  ── "condition" (HTS 조건식 REST) ──
    - ``KIS_HTS_ID``                 : 조건식이 저장된 HTS 로그인 ID (필수)
    - ``CONDITION_POLL_INTERVAL_SEC``: 폴링 주기 초 (기본 10)
    - 전략별:
        SCALP   : ``CONDITION_SCALP_NAME``    / ``CONDITION_SCALP_SEQ``
        SHORT   : ``CONDITION_SHORT_NAME``    / ``CONDITION_SHORT_SEQ``
        BREAKOUT: ``CONDITION_BREAKOUT_NAME`` / ``CONDITION_BREAKOUT_SEQ``
        MOMENTUM: ``CONDITION_MOMENTUM_NAME`` / ``CONDITION_MOMENTUM_SEQ``
        UPDOW   : ``CONDITION_UPDOW_NAME``    / ``CONDITION_UPDOW_SEQ``

  공통:
    - ``UNIVERSE_HISTORY_SAVE`` : 변동 감지 tick 마다 target_candidates_history 에
      초단위 event_time 으로 **즉시** INSERT (기본 true).  버퍼/배치 flush 없음.

실행:
  $ cd ~/kis_bot
  $ python -m kis_trader.main
"""
from __future__ import annotations

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

# 패키지로 실행되지 않은 경우(스크립트 직접 실행) sys.path 보정
if __package__ in (None, ""):
    _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    if _ROOT not in sys.path:
        sys.path.insert(0, _ROOT)
    __package__ = "kis_trader"  # noqa: F841

from .engine.daily_profit_halt import DailyProfitHaltGuard
from .database.db_manager import get_db
from .execution.kis_client import KISClient
from .execution.account_cash import AccountCashLedger
from .execution.order_manager import OrderManager
from .network.condition_manager import ConditionSearchManager
from .network.kiwoom_condition_manager import KiwoomConditionSearchManager
from .network.market_guard import MarketGuard
from .network.ranking_manager import VolumeRankManager
from .network.ws_manager import WSManager
from .strategies import (
    DbBandStrategy,
    BreakoutStrategy,
    MomentumStrategy,
    RangeBreakStrategy,
    ScalpingStrategy,
    TailCatchStrategy,
    UpdowStrategy,
)
from .strategies.base import BaseStrategy
from .utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int
from .utils.logger import get_logger, msg_mm, msg_tg
from .utils.strategy_ids import EXCLUDED_STRATEGY_IDS, canonical_strategy_id

logger = get_logger("kis_trader.main")

# kv_store — 당일 시작 총자산 (재시작해도 09:00 baseline 유지)
_KV_START_ASSET_DATE = "kis_trader.start_day_asset.date"
_KV_START_ASSET_VALUE = "kis_trader.start_day_asset.value"
_LEGACY_STRATEGY_SQL = ", ".join(["%s"] * len(EXCLUDED_STRATEGY_IDS))

# HTS 저장 조건식 목록과 매핑 (seq:name — 기동 시 DB 비어 있으면 자동 채움)
# 0:breakout 1:momentum 2:reversal 3:scalp 4:tail
_DEFAULT_CONDITION_BY_STRATEGY: Dict[str, Tuple[str, str]] = {
    "SCALP":    ("scalp", "3"),
    "SHORT":    ("tail", "4"),
    "MOMENTUM": ("momentum", "1"),
    "BREAKOUT": ("breakout", "0"),
    "RANGE_BREAK": ("momentum", "1"),
    "UPDOW":    ("reversal", "2"),
    "DBBAND":  ("dbband", "5"),
}
_CONDITION_ENV_KEYS: Dict[str, Tuple[str, str]] = {
    "SCALP":    ("CONDITION_SCALP_NAME", "CONDITION_SCALP_SEQ"),
    "SHORT":    ("CONDITION_SHORT_NAME", "CONDITION_SHORT_SEQ"),
    "BREAKOUT": ("CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ"),
    "RANGE_BREAK": ("CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ"),
    "MOMENTUM": ("CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ"),
    "UPDOW":    ("CONDITION_UPDOW_NAME", "CONDITION_UPDOW_SEQ"),
    "DBBAND":  ("CONDITION_DBBAND_NAME", "CONDITION_DBBAND_SEQ"),
}
# 키움 전용 조건식 seq (선택). 이름은 CONDITION_{SID}_NAME 공통 사용 — KIS/키움 HTS 이름이 같으면 NAME 하나로 충분.
# UNIVERSE_SOURCE=kiwoom_condition 일 때만 KIWOOM_SEQ 가 쓰임 (비어 있으면 CNSRLST 로 name→seq 자동 해결).
_STARTUP_NOTIFY_STRATEGY_ORDER: Tuple[str, ...] = (
    "SCALP", "SHORT", "MOMENTUM", "BREAKOUT", "RANGE_BREAK", "UPDOW", "DBBAND",
)


def _mask_account_no(raw: str) -> str:
    s = str(raw or "").strip()
    if not s:
        return "-"
    if len(s) <= 5:
        return s[0] + "***" + s[-1]
    return s[:3] + "***" + s[-2:]


class TradingOrchestrator:
    """전체 라이프사이클 관리 (공유 인프라 초기화 → 전략 쓰레드 기동 → 정리)."""

    # 전략별 UNIVERSE_SOURCE 기본값 (BaseStrategy 와 동기화)
    _DEFAULT_SOURCE = {
        "SCALP":    "condition",
        "SHORT":    "kiwoom_condition",
        "BREAKOUT": "kiwoom_condition",
        "RANGE_BREAK": "condition",
        "MOMENTUM": "kiwoom_condition",
        "UPDOW":    "condition",
        "DBBAND":  "condition",
    }
    _DEFAULT_RANK_SORT = {
        "SCALP":    "volume",
        "SHORT":    "volume",
        "BREAKOUT": "trading_value",
        "RANGE_BREAK": "trading_value",
        "MOMENTUM": "volume",
        "UPDOW":    "volume",
        "DBBAND":  "volume",
    }

    def __init__(self):
        self.db = get_db()
        # ── KIS 클라이언트 분리 정책 ────────────────────────────────────
        # 한투 모의 서버는 시세/조회 API (분봉 inquire-time-itemchartprice,
        # 조건검색 psearch-*, 거래량순위 volume-rank 등) 다수가 미지원/HTTP 500.
        # 정책: **조회는 무조건 실키, 매매·계좌만 KIS_MOCK 따름.**
        #
        #   self.client        → 매매/계좌 (KIS_MOCK 따름) → OrderManager 주입
        #                        예: 주문 place, get_account_balance, 체결조회
        #   self.market_client → 시세/조회 (항상 실키) → 전략·WS·조건·랭킹 주입
        #                        예: inquire_price, get_minute_chart, condition,
        #                             volume_rank 등 GET 류
        # 실키(KIS_APP_KEY_REAL/_SECRET_REAL) 미설정 시 market_client = self.client
        # 폴백 + 워닝. 그 경우 모의 도메인으로 시세 호출 가능 → 일부 500 발생.
        self.client = KISClient()  # 거래용 (mock or real)
        self._market_data_client: KISClient | None = None  # lazy 실키
        self.market_client: KISClient = self._build_market_client()
        self.cash_ledger = AccountCashLedger(self.db)
        self.order_mgr = OrderManager(
            client=self.client, db=self.db, cash_ledger=self.cash_ledger,
        )
        self.ws = WSManager(db=self.db, kis_client=self.market_client)
        # 해외(US) 실시간(지연)체결가 수신기 — 국내 WS와 분리된 별도 인스턴스(야간 가동).
        # permanent_subscriptions 테이블의 US 종목을 HDFSCNT0 로 구독. start()에서 기동.
        self.overseas_ws = None
        self.condition_mgr: ConditionSearchManager | None = None
        self.kiwoom_condition_mgr: KiwoomConditionSearchManager | None = None
        self._pending_kiwoom_condition_configs: List[dict] = []
        self.ranking_mgr: VolumeRankManager | None = None
        # 시장 급락 서킷브레이커 (KOSPI/KOSDAQ 지수 폭락 시 신규 매수 전면 차단).
        # 지수 조회는 모의 도메인 미지원 가능성 → 실키 우선, 폴백 self.client.
        self.market_guard: MarketGuard | None = None
        # 시세 마이그레이션 검증 (WS_PROVIDER=kis_with_validation 시만 기동)
        self.kiwoom_ws = None
        self.ws_validator = None
        self.strategies: List[BaseStrategy] = []
        self._stop = False

        # ── [자산/리포트 상태] ─────────────────────────────────────────
        # - start_day_asset: 장 시작 시점 총자산(예수금+평가액).
        #   매수/매도 알림의 '당일손익', 장마감 리포트 계산 기준.
        # - total_deposit: 총 입금액 (env_config.TOTAL_DEPOSIT). 누적손익 기준.
        # - _asset_cache: get_account_balance() 캐시 (API 호출 폭주 방지)
        # - _closing_report_date / _final_report_date: 당일 리포트 중복 발송 가드
        self.start_day_asset: float = 0.0
        self.total_deposit: float = get_env_float("TOTAL_DEPOSIT", 0.0) or 0.0
        self._asset_cache: Optional[Dict[str, Any]] = None
        self._asset_cache_ts: float = 0.0
        self._asset_cache_ttl: float = float(get_env_int("ASSET_CACHE_TTL_SEC", 2))
        self._closing_report_date: str = ""
        self._final_report_date: str = ""
        # 09:00 장 시작 리포트 중복 발송 가드 (당일 1회)
        self._market_open_report_date: str = ""
        # 15:36~ 장마감 고아복구 중복 실행 가드 (당일 1회)
        self._orphan_reconcile_date: str = ""
        # start_day_asset 조회가 모의 서버 500 등으로 실패할 때 무한 재시도 방지.
        # 다음 시도 가능 epoch (0 = 즉시 가능). 실패 시 N초 백오프.
        # 한투 모의 inquire-balance 가 간헐 500 → 20초 hb 마다 폭주하던 이슈 방지.
        self._start_asset_retry_at: float = 0.0
        self._start_asset_backoff_sec: int = get_env_int(
            "START_ASSET_RETRY_BACKOFF_SEC", 300
        )

        # OrderManager 에 체결 알림용 '자산 요약 라인 생성기' 주입.
        # (None 이면 기존 간단 문구만 나감)
        try:
            self.order_mgr.asset_line_provider = self._asset_line_for_notify  # type: ignore[attr-defined]
        except Exception:
            pass
        # 체결 알림에 '전략별 당일 손익' 줄을 넣기 위한 생성기 주입.
        try:
            self.order_mgr.strategy_pnl_provider = self._strategy_daily_pnl_for_notify  # type: ignore[attr-defined]
        except Exception:
            pass
        try:
            if hasattr(self.ws, "get_orderbook"):
                self.order_mgr.orderbook_provider = self.ws.get_orderbook  # type: ignore[attr-defined]
        except Exception:
            pass

    # ------------------------------------------------------------------
    def _register_strategies(self) -> None:
        # 전략 BaseStrategy.client = 시세/조회 전용 (항상 실키 또는 폴백).
        # 매매는 self.order_mgr 경유라서 trade client (self.client) 가 별도로 들어감.
        shared = dict(
            db=self.db, client=self.market_client, ws=self.ws, order_mgr=self.order_mgr,
            condition_mgr=self.condition_mgr,
            ranking_mgr=self.ranking_mgr,
            kiwoom_condition_mgr=self.kiwoom_condition_mgr,
            market_guard=self.market_guard,
        )

        if get_env_bool("STRATEGY_SCALP_ENABLED", True):
            self.strategies.append(ScalpingStrategy(**shared))
            logger.info("✅ [전략 등록] SCALP (%s)", self._universe_tag("SCALP"))
        else:
            logger.info("⏸ [전략 비활성] SCALP")

        if get_env_bool("STRATEGY_SHORT_ENABLED", True):
            self.strategies.append(TailCatchStrategy(**shared))
            logger.info("✅ [전략 등록] SHORT (Tail Catch) (%s)",
                        self._universe_tag("SHORT"))
        else:
            logger.info("⏸ [전략 비활성] SHORT")

        if get_env_bool("STRATEGY_MOMENTUM_ENABLED", False):
            self.strategies.append(MomentumStrategy(**shared))
            logger.info("✅ [전략 등록] MOMENTUM (%s)",
                        self._universe_tag("MOMENTUM"))
        else:
            logger.info("⏸ [전략 비활성] MOMENTUM")

        if get_env_bool("STRATEGY_BREAKOUT_ENABLED", False):
            self.strategies.append(BreakoutStrategy(**shared))
            logger.info("✅ [전략 등록] BREAKOUT (%s)",
                        self._universe_tag("BREAKOUT"))
        else:
            logger.info("⏸ [전략 비활성] BREAKOUT")

        if get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False):
            self.strategies.append(RangeBreakStrategy(**shared))
            logger.info("✅ [전략 등록] RANGE_BREAK (%s)",
                        self._universe_tag("RANGE_BREAK"))
        else:
            logger.info("⏸ [전략 비활성] RANGE_BREAK")

        if get_env_bool("STRATEGY_UPDOW_ENABLED", False):
            self.strategies.append(UpdowStrategy(**shared))
            logger.info("✅ [전략 등록] UPDOW (%s)",
                        self._universe_tag("UPDOW"))
        else:
            logger.info("⏸ [전략 비활성] UPDOW")

        if get_env_bool("STRATEGY_DBBAND_ENABLED", False):
            self.strategies.append(DbBandStrategy(**shared))
            logger.info("✅ [전략 등록] DBBAND (%s)",
                        self._universe_tag("DBBAND"))
        else:
            logger.info("⏸ [전략 비활성] DBBAND")

        if not self.strategies:
            logger.error("전략이 하나도 활성화되지 않음 → 종료")
            sys.exit(1)

        self._setup_daily_profit_halt()

    def _setup_daily_profit_halt(self) -> None:
        """일일 익절 목표 가드 — 전략 쓰레드에 공통 주입."""
        guard = DailyProfitHaltGuard(
            global_pnl_fn=self._bot_daily_realized_pnl,
            strategy_pnl_fn=self._bot_daily_realized_pnl_by_strategy,
            active_strategies_fn=lambda: [s.strategy_id for s in self.strategies],
            notify_fn=self._daily_profit_notify_mm,
        )
        for strat in self.strategies:
            strat.daily_profit_halt = guard
        from .engine.daily_profit_halt import (
            describe_profit_guard_startup,
            load_global_profit_target,
        )
        cfg = load_global_profit_target()
        logger.info(describe_profit_guard_startup(cfg, scope="마스터"))

    def _daily_profit_notify_mm(
        self, body: str, strategy_id: Optional[str] = None,
    ) -> None:
        """일일 익절 달성 Mattermost 알림."""
        ch_map = {
            "SCALP": ("KIS_SCALP_MM_CHANNEL", "scalping"),
            "SHORT": ("KIS_SHORT_MM_CHANNEL", "stock"),
            "MOMENTUM": ("KIS_MOMENTUM_MM_CHANNEL", "stock"),
            "BREAKOUT": ("KIS_BREAKOUT_MM_CHANNEL", "stock"),
            "RANGE_BREAK": ("KIS_RANGE_BREAK_MM_CHANNEL", "stock"),
            "UPDOW": ("KIS_UPDOW_MM_CHANNEL", "stock"),
            "DBBAND": ("KIS_DBBAND_MM_CHANNEL", "stock"),
        }
        if strategy_id:
            env_key, default = ch_map.get(
                strategy_id.upper(),
                ("KIS_SHORT_MM_CHANNEL", "stock"),
            )
            ch = str(get_env_from_db(env_key, default) or default)
        else:
            ch = str(
                get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock",
            )
        msg_mm(body, channel_alias=ch, jitter=False)

    def _resolve_source(self, strategy_id: str) -> str:
        """{STRATEGY}_UNIVERSE_SOURCE env → 'ranking' | 'condition' | 'kiwoom_condition'."""
        from .utils.universe_source import resolve_universe_source
        default = self._DEFAULT_SOURCE.get(strategy_id, "ranking")
        return resolve_universe_source(strategy_id, default=default)

    def _universe_tag(self, strategy_id: str) -> str:
        """로그용 — 전략이 어떤 유니버스를 쓰는지 한눈에."""
        src = self._resolve_source(strategy_id)
        if src == "ranking":
            mgr = self.ranking_mgr
        elif src == "kiwoom_condition":
            mgr = self.kiwoom_condition_mgr
        else:
            mgr = self.condition_mgr
        if mgr is None:
            return f"{src}(매니저없음→DB폴백)"
        try:
            cfgs = getattr(mgr, "_configs", [])
            for c in cfgs:
                if c.get("strategy_id") == strategy_id:
                    if src == "ranking":
                        return (
                            f"ranking · {c.get('sort')} x{c.get('limit')}"
                        )
                    nm = c.get("name") or ""
                    sq = c.get("seq") or ""
                    label = "kiwoom_cond" if src == "kiwoom_condition" else "condition"
                    if nm and sq:
                        return f"{label} · {nm}(seq={sq})"
                    return f"{label} · {nm or sq or '?'}"
        except Exception:
            pass
        if src == "condition":
            nk, sk = _CONDITION_ENV_KEYS.get(strategy_id, ("", ""))
            nm = (get_env_from_db(nk, "") or "").strip()
            sq = (get_env_from_db(sk, "") or "").strip()
            if nm or sq:
                return f"condition · {nm or '?'}(seq={sq or '?'})"
        return f"{src}(미매핑→DB)"

    def _ensure_all_universe_condition(self) -> None:
        """전략별 UNIVERSE_SOURCE=condition + CONDITION_* 기본값을 config_* DB에 반영."""
        try:
            snap = self.db.get_merged_env_snapshot()
        except Exception as e:
            logger.warning("유니버스 condition 기본값 스킵(DB): %s", e)
            return
        patch: Dict[str, str] = {}
        for sid, (dname, dseq) in _DEFAULT_CONDITION_BY_STRATEGY.items():
            # ⚠️ 미설정/유효하지 않은 값일 때만 기본값(condition) 주입.
            #    사용자가 명시적으로 고른 소스(ranking/condition/kiwoom_condition)는
            #    재시작해도 덮어쓰지 않는다 (런타임 스위치 유지 — 이 함수가 매 기동
            #    무조건 condition 으로 리셋하면 스위치가 무의미해지므로).
            cur_src = (snap.get(f"{sid}_UNIVERSE_SOURCE") or "").strip().lower()
            if cur_src not in ("ranking", "condition", "kiwoom_condition"):
                patch[f"{sid}_UNIVERSE_SOURCE"] = "condition"
            nk, sk = _CONDITION_ENV_KEYS.get(sid, ("", ""))
            if nk and not (snap.get(nk) or "").strip():
                patch[nk] = dname
            if sk and not (snap.get(sk) or "").strip():
                patch[sk] = str(dseq)
        if not patch:
            return
        merged = dict(snap)
        merged.update(patch)
        try:
            env_id = self.db.insert_env_snapshot(merged)
            logger.info(
                "📌 유니버스 condition 기본값 DB 반영 (env_id=%s): %s",
                env_id, ", ".join(sorted(patch.keys())),
            )
        except Exception as e:
            logger.error("유니버스 condition DB 저장 실패: %s", e)

    def _build_startup_notify_body(
        self,
        startup_cash_line: str = "",
        startup_cash_ok: bool = False,
    ) -> str:
        """기동 알림 — 전략별 유니버스(ranking/condition) 줄바꿈."""
        lines: List[str] = [
            "🚀 kis_trader 통합 봇 시작",
            f"활성 전략: {', '.join(s.strategy_id for s in self.strategies)}",
            "",
            "【유니버스】",
        ]
        enabled = {s.strategy_id for s in self.strategies}
        for sid in _STARTUP_NOTIFY_STRATEGY_ORDER:
            if sid not in enabled:
                continue
            src = self._resolve_source(sid)
            detail = self._universe_tag(sid)
            lines.append(f"{sid}: {src}")
            lines.append(f"  └ {detail}")
        if startup_cash_line:
            lines.append("")
            prefix = "✅" if startup_cash_ok else "⚠️"
            lines.append(f"{prefix} [기동 계좌] {startup_cash_line}")
        return "\n".join(lines)

    def _system_mm_channel(self) -> str:
        """장시작·장마감 리포트 단일 MM 채널 alias.

        기본 'default'(mm_config.json → 키스명령봇 채널). DB env 로 변경 가능.
        """
        return str(
            get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "default") or "default",
        ).strip() or "default"

    def _lifecycle_mm_channels(self) -> List[str]:
        """시작/종료 알림 채널 목록 — 시스템 채널(키스명령봇) + stock 채널.

        stock 채널에서 시작/종료를 짝으로 봐야 재시작/종료 구분이 되므로,
        시스템 채널과 KIS_LIFECYCLE_MM_CHANNEL(기본 'stock') 양쪽에 발송한다.
        (중복 alias 는 1회만)
        """
        chans: List[str] = []
        for ch in (
            self._system_mm_channel(),
            str(get_env_from_db("KIS_LIFECYCLE_MM_CHANNEL", "stock") or "").strip(),
        ):
            if ch and ch not in chans:
                chans.append(ch)
        return chans

    def _send_startup_notify(
        self,
        startup_cash_line: str = "",
        startup_cash_ok: bool = False,
    ) -> None:
        """기동 알림 — 시스템 채널(키스명령봇) + stock 채널 + Telegram.

        시작/종료는 stock 채널에도 보내 재시작/종료를 짝으로 확인 가능하게 한다.
        """
        body = self._build_startup_notify_body(startup_cash_line, startup_cash_ok)
        for ch in self._lifecycle_mm_channels():
            try:
                msg_mm(body, channel_alias=ch, jitter=False)
            except Exception as e:
                logger.debug("기동 MM(%s) 전송 실패: %s", ch, e)
        try:
            msg_tg(body, jitter=False)
        except Exception as e:
            logger.debug("기동 TG 전송 실패: %s", e)

    # ------------------------------------------------------------------
    def start(self) -> None:
        logger.info("=" * 70)
        logger.info("🚀 kis_trader 통합 봇 시작")
        logger.info("=" * 70)

        # WS 허브 먼저 기동 (전략이 구독 요청하기 전에)
        ws_ok = self.ws.start()
        logger.info("WSManager active=%s", ws_ok)

        # 해외(US) 실시간 WS 기동 (permanent_subscriptions 테이블의 US 종목)
        self._start_overseas_ws()

        # 유니버스 매니저 (ranking/condition) 기동
        self._start_universe_managers()

        # 시장 급락 서킷브레이커 (ENABLED=true 일 때만 폴링·봉 저장·PANIC 판정)
        self._start_market_guard()
        if self.market_guard is not None:
            # 전략 매수 루프 전 1회: ws_candles MG* + kv PANIC 복원 후 즉시 재판정
            self.market_guard.bootstrap_sync()

        # 시세 WS 마이그레이션 검증 / WS_SUBSCRIBE_KIS_MINIMAL 시 키움 WS 기동
        self._start_ws_validator()
        # 키움 조건검색은 시세 WS 와 **동일 세션** 공유 → 시세 WS 기동 후에만 등록
        self._start_kiwoom_condition_manager()
        self._wire_ws_split_feed_if_needed()

        # 예수금 캐시: 전략 기동 전 API 선동기화 (재시작 직후 stale kv 사용 방지)
        startup_cash_line = ""
        startup_cash_ok = False
        try:
            snap = self._fetch_asset_snapshot(force=True)
            cash = float(snap.get("cash", 0) or 0)
            dnca = float(snap.get("dnca", 0) or 0)
            d2 = float(snap.get("d2", 0) or 0)
            ord_psbl = float(snap.get("ord_psbl", 0) or 0)
            total_asset = float(snap.get("total_asset", 0) or 0)
            hold_count = int(snap.get("hold_count", 0) or 0)
            basis = str(snap.get("cash_basis", "dnca") or "dnca")
            if cash > 0:
                startup_cash_ok = True
                logger.info("💰 [예수금캐시] 기동 API 선동기화 완료: %s원", f"{cash:,.0f}")
                startup_cash_line = (
                    f"계좌[{('모의' if self.client.mock else '실전')}] "
                    f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | "
                    f"보유 {hold_count}종목 / 총자산 {total_asset:,.0f}원 | "
                    f"cash_basis={basis} | "
                    f"예수금 {dnca:,.0f} / D+2 {d2:,.0f} / 주문가능 {ord_psbl:,.0f} / 사용기준 {cash:,.0f}"
                )
            else:
                logger.warning(
                    "⚠️ [예수금캐시] 기동 API 응답 cash=0 → stale kv baseline 비활성"
                )
                self.cash_ledger.invalidate_baseline("startup_cash_zero")
                startup_cash_line = (
                    f"계좌[{('모의' if self.client.mock else '실전')}] "
                    f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | "
                    "기동 예수금 동기화 실패(cash=0)"
                )
        except Exception as e:
            logger.warning(
                "⚠️ [예수금캐시] 기동 API 선동기화 실패: %s → stale kv baseline 비활성", e
            )
            self.cash_ledger.invalidate_baseline("startup_sync_failed")
            startup_cash_line = (
                f"계좌[{('모의' if self.client.mock else '실전')}] "
                f"{_mask_account_no(self.client.account_no)}-{self.client.account_code} | "
                f"기동 예수금 동기화 예외: {e}"
            )

        # 전략 등록 후 쓰레드 시작 (각자 독립 쓰레드)
        self._register_strategies()
        for strat in self.strategies:
            strat.start()  # threading.Thread.start()
            logger.info("▶ 쓰레드 기동: %s", strat.name)

        self._wire_short_holding_peak_provider()

        # 당일 시작 총자산 — kv 복원 또는 09:00 이후 최초 1회 기록
        self._restore_start_day_asset()
        try:
            boot_snap = self._fetch_asset_snapshot()
            if self.start_day_asset <= 0 and float(boot_snap.get("total_asset") or 0) > 0:
                now_hm = dt.now()
                if now_hm.hour > 9 or (now_hm.hour == 9 and now_hm.minute >= 0):
                    self._save_start_day_asset(float(boot_snap["total_asset"]))
        except Exception as e:
            logger.debug("start_day_asset 기동 설정 스킵: %s", e)

        # 알림 (시작) — stock MM + TG, 전략별 ranking/condition 줄바꿈
        try:
            self._send_startup_notify(startup_cash_line, startup_cash_ok)
        except Exception:
            pass

        # 시그널 핸들러 (SIGINT/SIGTERM)
        signal.signal(signal.SIGINT, self._on_signal)
        try:
            signal.signal(signal.SIGTERM, self._on_signal)
        except Exception:
            pass

        # 메인 쓰레드는 heartbeat 만 찍으며 대기
        self._heartbeat_loop()

    # ------------------------------------------------------------------
    def _start_universe_managers(self) -> None:
        """
        전략별 UNIVERSE_SOURCE 에 따라 VolumeRankManager / ConditionSearchManager 기동.
        두 매니저 모두 전략에 주입되고, 전략은 본인 소스 선택 값으로 조회함.
        """
        self._ensure_all_universe_condition()

        strategy_switch = {
            "SCALP":    get_env_bool("STRATEGY_SCALP_ENABLED", True),
            "SHORT":    get_env_bool("STRATEGY_SHORT_ENABLED", True),
            "BREAKOUT": get_env_bool("STRATEGY_BREAKOUT_ENABLED", False),
            "RANGE_BREAK": get_env_bool("STRATEGY_RANGE_BREAK_ENABLED", False),
            "MOMENTUM": get_env_bool("STRATEGY_MOMENTUM_ENABLED", False),
            "UPDOW":    get_env_bool("STRATEGY_UPDOW_ENABLED", False),
        }

        ranking_configs: List[dict] = []
        condition_configs: List[dict] = []
        # 키움 조건검색(WS 실시간) 소스 — KIS 와 별개. KIWOOM_CONDITION_{SID}_NAME/SEQ.
        kiwoom_condition_configs: List[dict] = []

        cond_env_keys = {
            "SCALP":    ("CONDITION_SCALP_NAME",    "CONDITION_SCALP_SEQ"),
            "SHORT":    ("CONDITION_SHORT_NAME",    "CONDITION_SHORT_SEQ"),
            "BREAKOUT": ("CONDITION_BREAKOUT_NAME", "CONDITION_BREAKOUT_SEQ"),
            "RANGE_BREAK": ("CONDITION_RANGE_BREAK_NAME", "CONDITION_RANGE_BREAK_SEQ"),
            "MOMENTUM": ("CONDITION_MOMENTUM_NAME", "CONDITION_MOMENTUM_SEQ"),
            "UPDOW":    ("CONDITION_UPDOW_NAME",    "CONDITION_UPDOW_SEQ"),
        }
        rank_env_keys = {
            "SCALP":    ("RANK_SCALP_SORT",    "RANK_SCALP_LIMIT"),
            "SHORT":    ("RANK_SHORT_SORT",    "RANK_SHORT_LIMIT"),
            "BREAKOUT": ("RANK_BREAKOUT_SORT", "RANK_BREAKOUT_LIMIT"),
            "RANGE_BREAK": ("RANK_RANGE_BREAK_SORT", "RANK_RANGE_BREAK_LIMIT"),
            "MOMENTUM": ("RANK_MOMENTUM_SORT", "RANK_MOMENTUM_LIMIT"),
            "UPDOW":    ("RANK_UPDOW_SORT",    "RANK_UPDOW_LIMIT"),
        }

        # ⚡ 런타임 스위치 지원:
        #   기존에는 시작 시점의 {SID}_UNIVERSE_SOURCE 값 기준으로 한쪽 매니저에만 등록했지만,
        #   운영 중 DB 값만 바꿔도 즉시 전환되도록 **설정이 존재하는 모든 소스에 미리 등록** 한다.
        #   실제 어느 쪽을 쓸지는 BaseStrategy._load_candidates() 가 매 틱 DB 를 재조회하여 결정.
        #   (단, 조건식 이름/seq 자체 변경은 매니저 내부 _configs 정적 바인딩이라 여전히 재시작 필요)
        for sid, enabled in strategy_switch.items():
            if not enabled:
                continue
            src = self._resolve_source(sid)  # 시작 시 '주 소스' (로그/기본값용)

            # ── ranking 쪽 설정 수집 (UNIVERSE_SOURCE=ranking 일 때만 KIS REST 폴링) ─────
            #    kiwoom_condition / condition 소스 전략은 거래량순위 REST 폴링에 등록하지 않는다.
            sort_key, limit_key = rank_env_keys[sid]
            sort_default = self._DEFAULT_RANK_SORT.get(sid, "volume")
            sort_val = (
                get_env_from_db(sort_key, sort_default) or sort_default
            ).strip().lower()
            limit_val = get_env_int(limit_key, 20)
            register_ranking = src == "ranking"
            if register_ranking:
                ranking_configs.append({
                    "strategy_id": sid,
                    "sort": sort_val,
                    "limit": limit_val,
                    "market": "J",
                })

            # ── condition 쪽 설정 수집 (UNIVERSE_SOURCE=condition 일 때만 KIS REST 폴링) ────
            #    CONDITION_{SID}_NAME 은 키움 조건식과 HTS 동일명 공유용으로도 쓰임.
            #    kiwoom_condition / ranking 소스 전략은 KIS psearch REST 폴링에 등록하지 않는다.
            name_key, seq_key = cond_env_keys[sid]
            nm = (get_env_from_db(name_key, "") or "").strip()
            sq = (get_env_from_db(seq_key, "") or "").strip()
            has_cond = bool(nm or sq)
            register_kis_cond = has_cond and src == "condition"
            if register_kis_cond:
                condition_configs.append({
                    "strategy_id": sid,
                    "name": nm or None,
                    "seq": sq or None,
                })
            elif src == "condition":
                logger.warning(
                    "⚠️ [%s] UNIVERSE_SOURCE=condition 인데 "
                    "CONDITION_%s_NAME/SEQ 가 비어있음 → ranking 으로 자동 폴백 동작. "
                    "(조건식 세팅 후엔 봇 재시작 필요)",
                    sid, sid,
                )

            # ── 키움 조건: UNIVERSE_SOURCE=kiwoom_condition 일 때만 사용 ─────
            #    이름 → CONDITION_{SID}_NAME 공통 (KIS/키움 HTS 동일명이면 필드 1개로 충분)
            #    seq   → CONDITION_{SID}_KIWOOM_SEQ (선택, 비면 CNSRLST 자동 해결)
            kw_nm = (get_env_from_db(f"CONDITION_{sid}_KIWOOM_NAME", "") or "").strip() or nm
            kw_sq = (get_env_from_db(f"CONDITION_{sid}_KIWOOM_SEQ", "") or "").strip()
            has_kw_cond = bool(kw_nm or kw_sq)
            if has_kw_cond:
                kiwoom_condition_configs.append({
                    "strategy_id": sid,
                    "name": kw_nm or None,
                    "seq": kw_sq or None,
                })
            elif src == "kiwoom_condition":
                logger.warning(
                    "⚠️ [%s] UNIVERSE_SOURCE=kiwoom_condition 인데 "
                    "CONDITION_%s_NAME(또는 KIWOOM_SEQ) 가 비어있음 → 다른 소스로 폴백.",
                    sid, sid,
                )

            logger.info(
                "🔀 [%s] 시작소스=%s (매니저등록: ranking=%s condition=%s kiwoom=%s) "
                "— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환 "
                "(KIS ranking/condition REST 는 시작소스 일치 시만)",
                sid, src, ("✓" if register_ranking else "✗"),
                ("✓" if register_kis_cond else "✗"),
                ("✓" if has_kw_cond else "✗"), sid,
            )

        # ── ranking 매니저 기동 ────────────────────────────────
        # ⚠️ KIS 거래량순위(volume-rank, FHPST01710000)도 **모의투자 미지원** (HTTP 500).
        #    조건검색과 같은 정책: KIS_MOCK 와 무관하게 실전 도메인으로 호출.
        if ranking_configs:
            mkt_client = self._get_market_data_client()
            if mkt_client is None:
                logger.warning(
                    "⚠️ KIS_APP_KEY_REAL/KIS_APP_SECRET_REAL 미설정 → "
                    "거래량순위는 모의 미지원이라 실키 필수. 랭킹 매니저 비활성 "
                    "(%d개 전략 DB 폴백)", len(ranking_configs),
                )
            else:
                try:
                    self.ranking_mgr = VolumeRankManager(
                        client=mkt_client,
                        configs=ranking_configs,
                        db=self.db,
                    )
                    if not self.ranking_mgr.start():
                        self.ranking_mgr = None
                except Exception as e:
                    logger.error("랭킹 매니저 기동 실패: %s", e)
                    self.ranking_mgr = None
        else:
            logger.info("ℹ️ ranking 소스 쓰는 전략 없음 → 랭킹 매니저 비활성")

        # ── condition 매니저 기동 ──────────────────────────────
        # ⚠️ KIS 종목조건검색(psearch-title/psearch-result)은 **모의투자 미지원**.
        #    따라서 KIS_MOCK 값과 무관하게 **실전 키 전용 KISClient 를 별도 생성**해
        #    조건검색 매니저에만 주입한다. (WSManager 가 시세를 실키로 받는 정책과 동일)
        #    거래용 self.client 는 그대로 KIS_MOCK 스위치를 따른다 → 모의 매매 안전성 유지.
        if condition_configs:
            user_id = (get_env_from_db("KIS_HTS_ID", "") or "").strip()
            mkt_client = self._get_market_data_client()
            if not user_id:
                logger.warning(
                    "⚠️ KIS_HTS_ID 미설정 → 조건검색 매니저 비활성 "
                    "(%d개 전략 DB 폴백)", len(condition_configs),
                )
            elif mkt_client is None:
                logger.warning(
                    "⚠️ KIS_APP_KEY_REAL/KIS_APP_SECRET_REAL 미설정 → "
                    "조건검색은 모의 미지원이라 실키 필수. (%d개 전략 DB 폴백)",
                    len(condition_configs),
                )
            else:
                try:
                    self.condition_mgr = ConditionSearchManager(
                        client=mkt_client,
                        user_id=user_id,
                        configs=condition_configs,
                        db=self.db,
                    )
                    if not self.condition_mgr.start():
                        self.condition_mgr = None
                except Exception as e:
                    logger.error("조건검색 매니저 기동 실패: %s", e)
                    self.condition_mgr = None
        else:
            logger.info("ℹ️ condition 소스 쓰는 전략 없음 → 조건검색 매니저 비활성")

        # ── 키움 조건검색: 시세 WS 기동 후 _start_kiwoom_condition_manager() 에서 등록 ──
        self._pending_kiwoom_condition_configs = kiwoom_condition_configs
        if not kiwoom_condition_configs:
            logger.info("ℹ️ kiwoom_condition 소스 쓰는 전략 없음 → 키움 조건검색 매니저 비활성")

    # ------------------------------------------------------------------
    def _start_market_guard(self) -> None:
        """
        시장 급락 서킷브레이커 매니저 기동.

        - ``MARKET_GUARD_ENABLED=false`` (기본값) 면 지수 API·봉 저장 없음(쓰레드는 대기만).
          → DB 에서 true 로 바꾸면 재시작 없이 감시 시작.
        - ``bootstrap_sync()`` 는 ``start()`` 직후·전략 기동 전 1회 (재시작 PANIC 구멍 방지).
        - 지수 조회 (inquire-index-price) 는 모의 도메인 미지원 가능성
          → market_client (실키 우선) 를 사용. 실키 없으면 self.client 폴백.
        - PANIC 진입 시 BaseStrategy._scan_and_buy() 가 매수 차단.
        """
        try:
            self.market_guard = MarketGuard(client=self.market_client, db=self.db)
            self.market_guard.start()
        except Exception as e:
            logger.error("MarketGuard 기동 실패 (전략은 가드 없이 동작): %s", e)
            self.market_guard = None

    # ------------------------------------------------------------------
    def _start_ws_validator(self) -> None:
        """키움 WS 기동 + (선택) KIS↔키움 가격 검증기.

        - ``WS_PROVIDER=kis_with_validation`` : 검증기 + 키움 WS
        - ``WS_SUBSCRIBE_KIS_MINIMAL=true``   : KIS 구독 최소화용 키움 WS (검증기는 provider 에 따라)
        """
        from .utils.env import get_env_from_db

        minimal = get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False)
        provider = (get_env_from_db("WS_PROVIDER", "kis_only") or "kis_only").strip().lower()

        need_kiwoom = bool(minimal or provider == "kis_with_validation")
        need_validator = provider == "kis_with_validation"

        if not need_kiwoom:
            if provider not in ("kis_only", "kis_with_validation"):
                logger.warning(
                    "⚠️ WS_PROVIDER='%s' 알 수 없음 → kis_only 로 취급",
                    provider,
                )
            logger.info("ℹ️ 키움 WS 미기동 (WS_PROVIDER=kis_only & WS_SUBSCRIBE_KIS_MINIMAL=false)")
            return

        if minimal and not need_validator:
            logger.info(
                "ℹ️ WS_SUBSCRIBE_KIS_MINIMAL=true → 키움 WS 기동 (WS_PROVIDER=kis_only: 검증기 비활성)",
            )

        # 키움 키 로드 — 검증/분리 시세 모두 실키·실전 권장
        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")

        app_key = app_secret = ""
        is_mock = False
        try:
            if force_real:
                row = self.db.conn.execute(
                    "SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
                ).fetchone()
                if row:
                    r = dict(row)
                    app_key = (r.get("KIWOOM_APP_KEY_REAL") or "").strip()
                    app_secret = (r.get("KIWOOM_APP_SECRET_REAL") or "").strip()
                    if not app_key or not app_secret:
                        app_key = (r.get("KIWOOM_APP_KEY") or "").strip()
                        app_secret = (r.get("KIWOOM_APP_SECRET") or "").strip()
                is_mock = False
                logger.info("🔑 키움 WS 자격증명: 실키/실전 도메인 (KIWOOM_WS_FORCE_REAL=true)")
            else:
                from .ws.kis_ws import _get_kiwoom_creds
                app_key, app_secret, is_mock = _get_kiwoom_creds(self.db)
                logger.info(
                    "🔑 키움 WS 자격증명: KIS_MOCK 자동 (mock=%s) — 디버그 모드",
                    is_mock,
                )
        except Exception as e:
            logger.warning("키움 자격증명 로드 실패 → 키움 WS 비활성: %s", e)
            return
        if not app_key or not app_secret:
            logger.warning(
                "키움 %s 키 미설정 → 키움 WS 비활성 (KIWOOM_APP_KEY%s 확인)",
                "실키" if force_real else "키",
                "_REAL" if force_real else "",
            )
            return

        if self.kiwoom_ws is None:
            try:
                from .ws.kiwoom_ws import KiwoomWebSocketPriceCache
                self.kiwoom_ws = KiwoomWebSocketPriceCache(
                    app_key, app_secret, is_mock=is_mock,
                )
                if not self.kiwoom_ws.start():
                    logger.warning("키움 WS 시작 실패")
                    self.kiwoom_ws = None
                    return
            except Exception as e:
                logger.warning("키움 WS 인스턴스 생성 실패: %s", e)
                self.kiwoom_ws = None
                return

        kis_ws_handle = getattr(self.ws, "ws_cache", None)

        if need_validator and kis_ws_handle:
            try:
                from .network.ws_validator import WSPriceValidator
                self.ws_validator = WSPriceValidator(
                    kis_ws=kis_ws_handle,
                    kiwoom_ws=self.kiwoom_ws,
                    db=self.db,
                    sync_kiwoom_to_kis=not minimal,
                )
                self.ws_validator.start()
                logger.info("🔬 [시세 검증] WS_PROVIDER=%s — ws_price_validation 기록", provider)
            except Exception as e:
                logger.warning("Validator 기동 실패: %s", e)
                self.ws_validator = None
        elif need_validator and not kis_ws_handle:
            logger.warning("KIS WS 핸들 미발견 → Validator 비활성")

    def _start_kiwoom_condition_manager(self) -> None:
        """키움 조건검색 — 시세 WS(KiwoomWebSocketPriceCache) 와 단일 세션 공유.

        키움은 동일 OAuth 토큰으로 WS 2접속 시 먼저 붙은 쪽에 Bye(1000)를 보내 끊는다.
        조건검색 전용 소켓을 따로 열면 시세 WS 가 5초마다 끊기는 증상이 발생한다.
        """
        configs = self._pending_kiwoom_condition_configs or []
        if not configs:
            return

        kw_key = (get_env_from_db("KIWOOM_APP_KEY_REAL", "") or "").strip()
        kw_secret = (get_env_from_db("KIWOOM_APP_SECRET_REAL", "") or "").strip()
        kw_key = kw_key or (get_env_from_db("KIWOOM_APP_KEY", "") or "").strip()
        kw_secret = kw_secret or (get_env_from_db("KIWOOM_APP_SECRET", "") or "").strip()
        if not (kw_key and kw_secret):
            logger.warning(
                "⚠️ KIWOOM_APP_KEY_REAL/SECRET_REAL 미설정 → 키움 조건검색 매니저 비활성 "
                "(%d개 전략 다른 소스 폴백)", len(configs),
            )
            return

        shared = self.kiwoom_ws
        if shared is None:
            logger.warning(
                "⚠️ 키움 시세 WS 미기동 → 조건검색 단독 접속 시도 "
                "(WS_PROVIDER=kis_with_validation 또는 WS_SUBSCRIBE_KIS_MINIMAL 권장)"
            )

        try:
            self.kiwoom_condition_mgr = KiwoomConditionSearchManager(
                app_key=kw_key,
                app_secret=kw_secret,
                is_mock=False,
                configs=configs,
                db=self.db,
                shared_ws=shared,
            )
            if not self.kiwoom_condition_mgr.start():
                self.kiwoom_condition_mgr = None
        except Exception as e:
            logger.error("키움 조건검색 매니저 기동 실패: %s", e)
            self.kiwoom_condition_mgr = None

    def _wire_short_holding_peak_provider(self) -> None:
        """SHORT 보유 종목 max_price → ws_candles.holding_peak (백테·tail_engine 정합)."""
        ca = getattr(self.ws, "candle_agg", None)
        if ca is None or not hasattr(ca, "set_peak_provider"):
            return
        short = next(
            (s for s in self.strategies if getattr(s, "strategy_id", "") == "SHORT"),
            None,
        )
        if short is None:
            return
        ca.set_peak_provider(
            lambda code: (short.holdings.get(code) or {}).get("max_price"),
        )
        logger.info("📌 SHORT holding_peak → CandleAggregator (ws_candles 정합)")

    def _start_overseas_ws(self) -> None:
        """
        해외(US) 실시간(지연)체결가 WS(HDFSCNT0) 기동.
        - permanent_subscriptions 테이블에서 market_type='US' (enabled) 종목을 구독.
        - 국내 WS(WSManager.ws_cache)와 완전히 분리된 별도 인스턴스 → 야간(미국장)만 가동.
        - 실패해도 봇 본체엔 영향 없음(REST 폴백/조회로 동작).
        ※ OVERSEAS_WS_ENABLED=false (env/DB) 로 끌 수 있음.
        ※ HDFSCNT0 는 '해외 실시간시세' 미신청 시 15분 지연으로 수신됨.
        """
        if not get_env_bool("OVERSEAS_WS_ENABLED", True):
            logger.info("ℹ️ OVERSEAS_WS_ENABLED=false → 해외 WS 비활성")
            return
        # 해외 실시간시세는 실전(real) 자격증명 전용
        real_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
        real_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
        if not real_key or not real_secret:
            logger.warning("⚠️ KIS_APP_KEY_REAL/_SECRET_REAL 미설정 → 해외 WS 비활성")
            return
        try:
            import permanent_subs as ps
            us_rows = ps.codes_by_market(self.db, "US", enabled_only=True)
        except Exception as e:
            logger.warning("해외 영구구독 목록 로드 실패 → 해외 WS 비활성: %s", e)
            return
        if not us_rows:
            logger.info("ℹ️ 해외(US) 영구구독 종목 없음 → 해외 WS 미기동")
            return
        try:
            from .ws.kis_ws_overseas import KISOverseasWebSocket
            self.overseas_ws = KISOverseasWebSocket(real_key, real_secret)
            for row in us_rows:
                self.overseas_ws.add_symbol(
                    row.get("code", ""),
                    row.get("exchange", "NASD"),
                    row.get("symbol") or row.get("code", ""),
                )
            # 국내 WS 와 동일한 CandleAggregator 에 해외 틱도 흘려 봉 정합 유지
            if getattr(self.ws, "candle_agg", None):
                self.overseas_ws.attach_candle_aggregator(self.ws.candle_agg)
            if getattr(self.ws, "tick_recorder", None):
                self.overseas_ws.attach_tick_recorder(self.ws.tick_recorder)
            ok = self.overseas_ws.start()
            logger.info(
                "🌎 해외 WS active=%s (US %d종목: %s)",
                ok, len(us_rows), ", ".join(r.get("code", "") for r in us_rows),
            )
            if not ok:
                self.overseas_ws = None
        except Exception as e:
            logger.error("해외 WS 기동 실패 (봇은 정상 동작): %s", e)
            self.overseas_ws = None

    def _wire_ws_split_feed_if_needed(self) -> None:
        """``WS_SUBSCRIBE_KIS_MINIMAL`` : 후보 틱→CandleAggregator + KIS/키움 분리 구독."""
        if not get_env_bool("WS_SUBSCRIBE_KIS_MINIMAL", False):
            return
        if not self.kiwoom_ws:
            logger.warning(
                "WS_SUBSCRIBE_KIS_MINIMAL=true 이지만 키움 WS 미기동 — "
                "KIWOOM_APP_KEY_REAL 등 확인 (WS_PROVIDER=kis_with_validation 병행 권장)",
            )
            return
        ca = getattr(self.ws, "candle_agg", None)
        if not ca:
            return
        try:
            self.kiwoom_ws.attach_candle_aggregator(ca)
            tr = getattr(self.ws, "tick_recorder", None)
            if tr is not None:
                self.kiwoom_ws.attach_tick_recorder(tr)
            tsr = getattr(self.ws, "trigger_snapshot_recorder", None)
            if tsr is not None:
                self.kiwoom_ws.attach_trigger_snapshot_recorder(tsr)
            self.ws.set_kiwoom_ws(self.kiwoom_ws)
            self.ws.activate_split_feed(True)
        except Exception as e:
            logger.warning("WS 분리 시세 연결 실패: %s", e)

    def _build_market_client(self) -> KISClient:
        """
        시세/조회 전용 실전 KISClient 생성 (init 단계).

        정책 (`__init__` 주석 참고):
          - KIS_MOCK 와 무관하게 **항상 실전 도메인** 으로 시세 조회.
          - 거래용 self.client 가 이미 실전(KIS_MOCK=false)이면 토큰·세션 공유 위해
            그대로 재사용 → 폴백.
          - 실키(KIS_APP_KEY_REAL/_SECRET_REAL) 미설정이면 self.client 폴백 + 워닝
            (이 경우 모의 도메인으로 시세 호출 → inquire-price/balance 일부 500 가능).
        """
        # 거래 client 가 실전이면 그대로 재사용
        if not self.client.mock:
            self._market_data_client = self.client
            return self.client

        real_app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or "").strip()
        real_app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or "").strip()
        if not real_app_key or not real_app_secret:
            logger.warning(
                "⚠️ KIS_APP_KEY_REAL/_SECRET_REAL 미설정 → 시세 client 도 모의로 폴백. "
                "조건검색·분봉 등 모의 미지원 API 가 HTTP 500 폭주할 수 있음."
            )
            self._market_data_client = self.client
            return self.client

        try:
            mc = KISClient(
                mock=False,
                app_key=real_app_key,
                app_secret=real_app_secret,
                account_no=(get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or "").strip(),
                account_code=(get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01") or "01").strip(),
            )
            logger.info(
                "🔑 시세 전용 실전 KISClient 생성 (모의 미지원 API 대응 — 매매는 KIS_MOCK 따름)"
            )
            self._market_data_client = mc
            return mc
        except Exception as e:
            logger.error(
                "시세 전용 실전 KISClient 생성 실패 → 거래용 client 폴백: %s", e
            )
            self._market_data_client = self.client
            return self.client

    def _get_market_data_client(self) -> KISClient | None:
        """
        condition/ranking 매니저 호환 헬퍼.
        실키 모드 client 가 확보돼 있으면 반환, 아니면 None (= 매니저 비활성 신호).
        """
        mc = self.market_client
        if mc is not None and not mc.mock:
            return mc
        return None

    def _heartbeat_loop(self) -> None:
        last_hb = 0.0
        last_daily_tick = 0.0
        last_pending_fill = 0.0
        pending_interval = float(get_env_int("PENDING_FILL_POLL_INTERVAL_SEC", 10))
        while not self._stop:
            now = time.time()
            if now - last_hb >= 60:
                alive = [s.name for s in self.strategies if s.is_alive()]
                dead = [s.name for s in self.strategies if not s.is_alive()]
                ws_session = "OPEN" if self.ws.is_active else "IDLE"
                rank_desc = self._mgr_heartbeat_desc(self.ranking_mgr)
                cond_desc = self._mgr_heartbeat_desc(self.condition_mgr)
                logger.info(
                    "❤️ heartbeat ws=%s rank[%s] cond[%s] alive=%s dead=%s",
                    ws_session, rank_desc, cond_desc, alive, dead,
                )
                if dead:
                    logger.warning("⚠️ 죽은 전략 쓰레드 감지: %s → 재기동", dead)
                    self._restart_dead()
                last_hb = now
            if now - last_pending_fill >= pending_interval:
                try:
                    n = self.order_mgr.poll_pending_fills()
                    if n:
                        logger.info("🔄 미체결 재조회 처리 %d건", n)
                except Exception as e:
                    logger.debug("poll_pending_fills 예외: %s", e)
                last_pending_fill = now
            # 장중 자산/리포트 tick (20초 주기)
            if now - last_daily_tick >= 20:
                try:
                    self._daily_report_tick()
                except Exception as e:
                    logger.debug("daily_report_tick 예외: %s", e)
                last_daily_tick = now
            time.sleep(1)

    # ------------------------------------------------------------------
    # 자산 스냅샷 / 당일·누적 손익 / 장마감 리포트
    # ------------------------------------------------------------------
    def _fetch_asset_snapshot(self, force: bool = False) -> Dict[str, Any]:
        """
        KIS `inquire-balance` 호출하여 예수금/D+2/총자산/보유종목수를 dict 로 반환.
        TTL 캐시(기본 2초)로 API 부하 절감.
        """
        t = time.time()
        if (
            not force
            and self._asset_cache is not None
            and (t - self._asset_cache_ts) < self._asset_cache_ttl
        ):
            return dict(self._asset_cache)

        def _parse_amt(v) -> float:
            try:
                return float(str(v or 0).replace(",", "").strip())
            except Exception:
                return 0.0

        balance = {}
        try:
            balance = self.client.get_account_balance() or {}
        except Exception as e:
            logger.debug("get_account_balance 예외: %s", e)

        out2_raw = balance.get("output2") or []
        if isinstance(out2_raw, dict):
            out2 = out2_raw
        elif isinstance(out2_raw, list) and out2_raw:
            out2 = out2_raw[0]
        else:
            out2 = {}
        out1_raw = balance.get("output1") or []
        if isinstance(out1_raw, dict):
            out1 = [out1_raw]
        elif isinstance(out1_raw, list):
            out1 = out1_raw
        else:
            out1 = []

        dnca = _parse_amt(out2.get("dnca_tot_amt"))
        d2 = _parse_amt(out2.get("prvs_rcdl_excc_amt"))
        # 브로커 응답에 주문가능금액 키가 계좌/상품별로 다를 수 있어 후보를 넓게 본다.
        ord_psbl = 0.0
        for k in (
            "ord_psbl_cash", "ord_psbl_amt", "ord_psbl",
            "buy_psbl_cash", "buy_psbl_amt",
        ):
            v = _parse_amt(out2.get(k))
            if v > 0:
                ord_psbl = v
                break
        # output2.tot_evlu_amt = 예수금+평가액 (없을 경우 output1 로 합산)
        tot_evlu = _parse_amt(out2.get("tot_evlu_amt"))
        if tot_evlu <= 0:
            holdings_value = 0.0
            for it in out1:
                qty = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY"))
                if qty <= 0:
                    continue
                evlu = _parse_amt(it.get("evlu_amt") or it.get("EVLU_AMT"))
                if evlu > 0:
                    holdings_value += evlu
                else:
                    price = _parse_amt(it.get("prpr") or it.get("PRPR"))
                    holdings_value += price * qty
            tot_evlu = (d2 if d2 > 0 else dnca) + holdings_value

        hold_count = 0
        for it in out1:
            q = _parse_amt(it.get("hldg_qty") or it.get("HLDG_QTY"))
            if q > 0:
                hold_count += 1

        # 주문 상한 기준 (증거금/미수 사용 제어)
        # - dnca    : 예수금총액만 사용 (증거금 미사용 권장, 기본값)
        # - d2      : D+2 예수금 우선
        # - ord_psbl: 브로커 주문가능금액 우선
        # - min     : dnca/d2/ord_psbl 중 양수 최솟값 (가장 보수적)
        cash_basis = str(get_env_from_db("ACCOUNT_CASH_BASIS", "dnca") or "dnca").strip().lower()
        if cash_basis == "d2":
            cash = d2 if d2 > 0 else dnca
        elif cash_basis == "ord_psbl":
            cash = ord_psbl if ord_psbl > 0 else dnca
        elif cash_basis == "min":
            cands = [x for x in (dnca, d2, ord_psbl) if x > 0]
            cash = min(cands) if cands else 0.0
        else:
            # 기본: 증거금/미수 사용 방지
            cash = dnca if dnca > 0 else (d2 if d2 > 0 else ord_psbl)
        snap = {
            "cash": cash,          # 주문가능(= D+2 or 예수금총액)
            "d2": d2,              # D+2 예수금
            "dnca": dnca,          # 예수금총액
            "ord_psbl": ord_psbl,  # 주문가능금액 (브로커 제공 시)
            "cash_basis": cash_basis,
            "total_asset": tot_evlu,
            "hold_count": hold_count,
        }
        self._asset_cache = snap
        self._asset_cache_ts = t
        try:
            self.cash_ledger.sync_from_snapshot(snap, source="api")
        except Exception as e:
            logger.debug("cash_ledger API 동기화 스킵: %s", e)
        return dict(snap)

    def _trade_db_raw(self):
        """TradeDBExt → raw TradeDB (kv_store 등)."""
        return getattr(self.db, "raw", self.db)

    def _is_forced_ghost_trade_row(self, row: Dict[str, Any]) -> bool:
        reason = str(row.get("sell_reason") or "")
        try:
            sell_price = float(row.get("sell_price") or 0)
        except Exception:
            sell_price = 0.0
        is_forced = ("잔고없음(강제정리)" in reason) or ("잔고동기화(외부매도)" in reason)
        return is_forced and sell_price <= 0

    def _bot_daily_realized_pnl(self, day_yyyy_mm_dd: str) -> tuple[float, int]:
        """
        당일 봇 trade_history 실현손익 합 — 웹 「오늘 운영」·실거래 분석과 동일.
        Returns: (순실현손익 원, 청산 건수)
        """
        day = (day_yyyy_mm_dd or "")[:10]
        if len(day) != 10:
            day = dt.now().strftime("%Y-%m-%d")
        day_start = f"{day} 00:00:00"
        day_end = f"{day} 23:59:59"
        ex = list(EXCLUDED_STRATEGY_IDS)
        sql = (
            "SELECT realized_pnl, sell_reason, sell_price FROM trade_history "
            f"WHERE strategy NOT IN ({_LEGACY_STRATEGY_SQL}) "
            "AND sell_date >= %s AND sell_date <= %s"
        )
        try:
            rows = self._trade_db_raw().conn.execute(
                sql, ex + [day_start, day_end],
            ).fetchall()
        except Exception as e:
            logger.debug("bot_daily_realized_pnl 조회 실패: %s", e)
            return 0.0, 0
        total = 0.0
        cnt = 0
        for raw in rows or []:
            row = dict(raw)
            if self._is_forced_ghost_trade_row(row):
                continue
            total += float(row.get("realized_pnl") or 0)
            cnt += 1
        return total, cnt

    def _bot_daily_realized_pnl_by_strategy(
        self, day_yyyy_mm_dd: str, strategy_id: str,
    ) -> tuple[float, int]:
        """
        당일 '특정 전략' 실현손익 합 — 체결 알림의 전략별 손익 줄용.
        _bot_daily_realized_pnl 과 동일 기준(유령거래 제외, trade_history)에
        strategy 필터만 추가한다.
        Returns: (순실현손익 원, 청산 건수)
        """
        day = (day_yyyy_mm_dd or "")[:10]
        if len(day) != 10:
            day = dt.now().strftime("%Y-%m-%d")
        day_start = f"{day} 00:00:00"
        day_end = f"{day} 23:59:59"
        # trade_history.strategy 는 canonical id 로 저장되므로 정규화 후 비교
        sid = canonical_strategy_id(strategy_id)
        sql = (
            "SELECT realized_pnl, sell_reason, sell_price FROM trade_history "
            "WHERE strategy = %s "
            "AND sell_date >= %s AND sell_date <= %s"
        )
        try:
            rows = self._trade_db_raw().conn.execute(
                sql, [sid, day_start, day_end],
            ).fetchall()
        except Exception as e:
            logger.debug("bot_daily_realized_pnl_by_strategy 조회 실패: %s", e)
            return 0.0, 0
        total = 0.0
        cnt = 0
        for raw in rows or []:
            row = dict(raw)
            if self._is_forced_ghost_trade_row(row):
                continue
            total += float(row.get("realized_pnl") or 0)
            cnt += 1
        return total, cnt

    def _strategy_daily_pnl_for_notify(self, strategy_id: str):
        """
        OrderManager 체결 알림 주입용 — 해당 전략의 '당일' (실현손익, 청산건수) 반환.
        실패/예외 시 None 을 돌려 알림 줄을 생략한다.
        """
        try:
            return self._bot_daily_realized_pnl_by_strategy(
                dt.now().strftime("%Y-%m-%d"), strategy_id,
            )
        except Exception:
            return None

    def _restore_start_day_asset(self) -> bool:
        """kv 에 저장된 당일 시작 총자산 복원."""
        today = dt.now().strftime("%Y-%m-%d")
        try:
            db = self._trade_db_raw()
            kv_date = (db.get_kv(_KV_START_ASSET_DATE) or "").strip()
            if kv_date != today:
                return False
            val_raw = (db.get_kv(_KV_START_ASSET_VALUE) or "").strip()
            if not val_raw:
                return False
            self.start_day_asset = float(val_raw)
            logger.info(
                "📌 [당일 시작 자산] kv 복원 %s원",
                f"{self.start_day_asset:,.0f}",
            )
            return True
        except Exception as e:
            logger.debug("start_day_asset kv 복원 실패: %s", e)
            return False

    def _save_start_day_asset(self, value: float) -> None:
        """당일 시작 총자산 1회 기록 — 이미 있으면 덮어쓰지 않음 (재시작 baseline 유지)."""
        today = dt.now().strftime("%Y-%m-%d")
        try:
            db = self._trade_db_raw()
            kv_date = (db.get_kv(_KV_START_ASSET_DATE) or "").strip()
            if kv_date == today:
                val_raw = (db.get_kv(_KV_START_ASSET_VALUE) or "").strip()
                if val_raw:
                    self.start_day_asset = float(val_raw)
                    return
            if value <= 0:
                return
            self.start_day_asset = float(value)
            db.set_kv(_KV_START_ASSET_DATE, today)
            db.set_kv(_KV_START_ASSET_VALUE, str(int(round(self.start_day_asset))))
            logger.info(
                "📌 [당일 시작 자산] %s원 기록 (kv)",
                f"{self.start_day_asset:,.0f}",
            )
        except Exception as e:
            logger.debug("start_day_asset kv 저장 실패: %s", e)

    def _asset_line_for_notify(self, side: str = "", extra: Optional[Dict] = None) -> str:
        """
        체결 알림 하단 요약 (줄바꿈 정리):
          - 봇 실현손익: trade_history 당일 합 (웹과 동일, 수수료·세금 반영)
          - 계좌평가변동: 현재 총자산 − 당일 시작 총자산 (전체 계좌 MTM)
        """
        try:
            self._restore_start_day_asset()
            snap = self._fetch_asset_snapshot()
            cash = snap.get("cash", 0.0)
            total = snap.get("total_asset", 0.0)
            hold = snap.get("hold_count", 0)
            today = dt.now().strftime("%Y-%m-%d")

            acct_day_pnl = (
                (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0
            )
            acct_day_pct = (
                (acct_day_pnl / self.start_day_asset * 100)
                if self.start_day_asset > 0 else 0.0
            )

            bot_pnl, bot_cnt = self._bot_daily_realized_pnl(today)

            lines = [
                "━━━━━━━━━━━━━━━━━━━━",
                "💼 **계좌**",
                f"예수금 {cash:,.0f}원",
                f"총자산 {total:,.0f}원 · 보유 {hold}종목",
                "",
                "📊 **손익 (당일)**",
                f"🤖 봇 실현 {bot_pnl:+,.0f}원 · 청산 {bot_cnt}건",
                f"📈 계좌평가 {acct_day_pnl:+,.0f}원 ({acct_day_pct:+.2f}%)",
            ]

            if self.total_deposit and self.total_deposit > 0:
                cum_pnl = total - self.total_deposit
                cum_pct = cum_pnl / self.total_deposit * 100
                lines.extend([
                    "",
                    "💰 **누적 (입금 대비)**",
                    f"{cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)",
                ])

            lines.append("━━━━━━━━━━━━━━━━━━━━")
            return "\n".join(lines)
        except Exception as e:
            logger.debug("asset line 생성 실패: %s", e)
            return ""

    def _daily_report_tick(self) -> None:
        """
        장중 주기적으로 호출되어:
          1) 최초 1회 start_day_asset 기록 (09:00 이후 첫 조회)
          2) 15:15 장마감 전 리포트 발송 (하루 1회)
          3) 15:35 장마감 최종 리포트 발송 (하루 1회)
        """
        now = dt.now()
        h, m = now.hour, now.minute
        today = now.strftime("%Y-%m-%d")
        today_ymd = now.strftime("%Y%m%d")

        # 날짜가 바뀌면 상태 리셋
        if self._closing_report_date and self._closing_report_date != today:
            self.start_day_asset = 0.0  # 새 영업일 기준 재설정
            self._start_asset_retry_at = 0.0  # 재시도 백오프도 같이 리셋
        try:
            kv_date = (self._trade_db_raw().get_kv(_KV_START_ASSET_DATE) or "").strip()
            if kv_date and kv_date != today:
                self.start_day_asset = 0.0
        except Exception:
            pass

        if self.start_day_asset <= 0:
            self._restore_start_day_asset()
        # env 에 TOTAL_DEPOSIT 가 있을 수도 있으므로 매일 한번 리로드
        try:
            latest_td = get_env_float("TOTAL_DEPOSIT", 0.0) or 0.0
            if latest_td > 0:
                self.total_deposit = latest_td
        except Exception:
            pass

        # 09:00 이후에만 start_day_asset 확보 (장 시작 전 예탁금 왜곡 방지).
        # 실패하면 START_ASSET_RETRY_BACKOFF_SEC (기본 300s) 동안 재시도 차단 →
        # 모의 서버 500 시 hb 20초마다 inquire-balance 폭주하던 이슈 방지.
        if self.start_day_asset <= 0 and (h > 9 or (h == 9 and m >= 0)):
            now_t = time.time()
            if now_t >= self._start_asset_retry_at:
                try:
                    snap = self._fetch_asset_snapshot(force=True)
                    if snap.get("total_asset", 0) > 0:
                        self._save_start_day_asset(float(snap["total_asset"]))
                    else:
                        # snap 자체는 받았으나 total=0 (잔고 0 또는 모의 응답 깨짐)
                        self._start_asset_retry_at = now_t + self._start_asset_backoff_sec
                        logger.debug(
                            "start_day_asset 조회 결과 total=0 → %ds 후 재시도",
                            self._start_asset_backoff_sec,
                        )
                except Exception as e:
                    self._start_asset_retry_at = now_t + self._start_asset_backoff_sec
                    logger.debug(
                        "start_day_asset 설정 실패: %s → %ds 후 재시도",
                        e, self._start_asset_backoff_sec,
                    )

        # 09:00 장 시작 알림 (09:00~09:10 윈도우, 하루 1회)
        # 봇 기동 시점과 무관하게 매일 장 시작에 1회 발송 (아침 알람 용도).
        if (h == 9 and 0 <= m <= 10 and self._market_open_report_date != today):
            try:
                body = self._build_market_open_report(today_ymd)
                if body:
                    self._send_daily_report("장 시작 알림", body)
                self._market_open_report_date = today
            except Exception as e:
                logger.error("장 시작 알림 실패: %s", e)

        # 15:15 장마감 전 (15:15~15:20 윈도우, 하루 1회)
        if (h == 15 and 15 <= m <= 20 and self._closing_report_date != today):
            try:
                body = self._build_closing_report(today_ymd)
                if body:
                    self._send_daily_report("장마감 전 현황", body)
                self._closing_report_date = today
            except Exception as e:
                logger.error("장마감 전 리포트 실패: %s", e)

        # 15:35 장마감 최종 (15:34~15:45 윈도우, 하루 1회)
        if (h == 15 and 34 <= m <= 45 and self._final_report_date != today):
            try:
                body = self._build_final_report(today_ymd)
                if body:
                    self._send_daily_report("장마감 최종 보고", body)
                self._final_report_date = today
            except Exception as e:
                logger.error("장마감 최종 리포트 실패: %s", e)

        # 15:36~16:00 장마감 고아복구 (하루 1회 — 매매 없을 때 REST 잔고↔DB 대조)
        if self._orphan_reconcile_date != today:
            in_reconcile_window = (h == 15 and 36 <= m <= 59) or (h == 16 and m < 30)
            if in_reconcile_window:
                try:
                    self._run_orphan_reconcile(today)
                except Exception as e:
                    logger.error("장마감 고아복구 실패: %s", e)

    def _run_orphan_reconcile(self, today: str) -> None:
        """장마감 후 봇 고아(active_trades 미기록) 1회 복구."""
        from .execution.orphan_reconcile import reconcile_orphan_positions

        result = reconcile_orphan_positions(self.order_mgr)
        self._orphan_reconcile_date = today
        n = int(result.get("reconciled_count") or 0)
        if n <= 0 and not result.get("error"):
            logger.debug("🧩 [고아복구] 복구 대상 없음")
            return
        if result.get("error"):
            logger.warning("🧩 [고아복구] 중단: %s", result["error"])
            return
        lines = [
            "🧩 **[장마감 고아복구]**",
            f"- 복구: {n}종목",
            f"- 실패: {int(result.get('failed_count') or 0)}건",
        ]
        for it in (result.get("reconciled") or [])[:8]:
            lines.append(
                f"  · {it.get('name')}({it.get('code')}) "
                f"[{it.get('strategy')}] {it.get('qty')}주"
            )
        if n > 8:
            lines.append(f"  …외 {n - 8}종목")
        body = "\n".join(lines)
        try:
            from .utils.logger import msg_mm
            msg_mm(body, channel_alias=self._system_mm_channel(), jitter=False)
        except Exception as e:
            logger.debug("고아복구 MM 전송 실패: %s", e)
        logger.info("🧩 [고아복구] %d종목 active_trades 반영", n)

    def _build_market_open_report(self, today_ymd: str) -> str:
        """09:00 장 시작 알림 메시지 — 보유종목·예수금·주문가능금액 현황."""
        snap = self._fetch_asset_snapshot(force=True)
        total = snap.get("total_asset", 0.0)
        cash = snap.get("cash", 0.0)
        d2 = snap.get("d2", 0.0)
        dnca = snap.get("dnca", 0.0)
        ord_psbl = snap.get("ord_psbl", 0.0)
        basis = str(snap.get("cash_basis", "dnca") or "dnca")
        hold = snap.get("hold_count", 0)

        active_sids = ", ".join(s.strategy_id for s in self.strategies) or "(없음)"

        lines = [
            "🌅 **[장 시작 알림 - 09:00]**",
            "━━━━━━━━━━━━━━━━━━━━",
            f"- 🤖 활성 전략: {active_sids}",
            (
                f"- 계좌: {('모의' if self.client.mock else '실전')} "
                f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
            ),
            f"- 현재 자산: {total:,.0f}원",
            f"- 보유 종목: {hold}개",
            (
                f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
                f"주문가능금액: {ord_psbl:,.0f}원 | "
                f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원"
            ),
            "━━━━━━━━━━━━━━━━━━━━",
            "📈 오늘도 안전 매매! 손절 라인 준수.",
        ]
        return "\n".join(lines)

    def _build_closing_report(self, today_ymd: str) -> str:
        """15:15 장마감 전 현황 메시지 (기존 kis_short_ver3.py 포맷 계승)."""
        snap = self._fetch_asset_snapshot(force=True)
        total = snap.get("total_asset", 0.0)
        cash = snap.get("cash", 0.0)
        d2 = snap.get("d2", 0.0)
        dnca = snap.get("dnca", 0.0)
        ord_psbl = snap.get("ord_psbl", 0.0)
        basis = str(snap.get("cash_basis", "dnca") or "dnca")
        hold = snap.get("hold_count", 0)

        day_pnl = (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0
        day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0
        bot_pnl, bot_cnt = self._bot_daily_realized_pnl(
            f"{today_ymd[:4]}-{today_ymd[4:6]}-{today_ymd[6:8]}"
        )

        lines = [
            "📈 **[장마감 전 현황 - 15:15]**",
            "",
            f"- 🤖 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)",
            f"- 📈 계좌평가: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
            f"- 현재 자산: {total:,.0f}원",
            f"- 보유 종목: {hold}개",
            (
                f"- 계좌: {('모의' if self.client.mock else '실전')} "
                f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
            ),
            (
                f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
                f"주문가능금액: {ord_psbl:,.0f}원 | "
                f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원"
            ),
        ]
        return "\n".join(lines)

    def _build_final_report(self, today_ymd: str) -> str:
        """15:35 장마감 최종 보고 메시지 (당일/누적 손익 + 매매건수)."""
        snap = self._fetch_asset_snapshot(force=True)
        total = snap.get("total_asset", 0.0)
        cash = snap.get("cash", 0.0)
        d2 = snap.get("d2", 0.0)
        dnca = snap.get("dnca", 0.0)
        ord_psbl = snap.get("ord_psbl", 0.0)
        basis = str(snap.get("cash_basis", "dnca") or "dnca")
        hold = snap.get("hold_count", 0)

        day_pnl = (total - self.start_day_asset) if self.start_day_asset > 0 else 0.0
        day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0.0
        bot_pnl, bot_cnt = self._bot_daily_realized_pnl(
            f"{today_ymd[:4]}-{today_ymd[4:6]}-{today_ymd[6:8]}"
        )

        cum_pnl = (total - self.total_deposit) if self.total_deposit > 0 else 0.0
        cum_pct = (cum_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0.0

        try:
            today_trades = self.db.get_trades_by_date(today_ymd) or []
        except Exception:
            today_trades = []
        trade_cnt = len(today_trades)

        lines = [
            "🏁 **[장마감 최종 보고 - 15:35]**",
            "━━━━━━━━━━━━━━━━━━━━",
            "📅 **당일 손익**",
            f"- 🤖 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)",
            f"- 📈 계좌평가: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
            f"- 시작 총자산: {self.start_day_asset:,.0f}원",
            f"- 종료 총자산: {total:,.0f}원",
            "",
            "💰 **누적 (입금 대비)**",
            f"- 총 입금: {self.total_deposit:,.0f}원",
            f"- 현재 자산: {total:,.0f}원",
            f"- 누적 손익: {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)",
            "",
            "📊 **거래 현황**",
            f"- 오늘 매매: {trade_cnt}건",
            f"- 보유 종목: {hold}개",
            (
                f"- 계좌: {('모의' if self.client.mock else '실전')} "
                f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
            ),
            (
                f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
                f"주문가능금액: {ord_psbl:,.0f}원 | "
                f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원"
            ),
            "━━━━━━━━━━━━━━━━━━━━",
        ]
        return "\n".join(lines)

    def _send_daily_report(self, title: str, body: str) -> None:
        """장시작/장마감 리포트 — 시스템 알림 단일 채널(키스명령봇) + Telegram."""
        sys_ch = self._system_mm_channel()
        try:
            msg_mm(body, channel_alias=sys_ch, jitter=False)
        except Exception as e:
            logger.debug("리포트 MM(%s) 전송 실패: %s", sys_ch, e)
        try:
            msg_tg(body, jitter=False)
        except Exception as e:
            logger.debug("리포트 TG 전송 실패: %s", e)
        logger.info("📢 %s 발송 완료", title)

    @staticmethod
    def _mgr_heartbeat_desc(mgr) -> str:
        if mgr is None:
            return "off"
        try:
            cfgs = getattr(mgr, "_configs", [])
            parts = [
                f"{c['strategy_id']}={len(mgr.get_universe_for(c['strategy_id']))}"
                for c in cfgs
            ]
            return ",".join(parts) if parts else "0"
        except Exception:
            try:
                return str(len(mgr.get_universe()))
            except Exception:
                return "?"

    def _restart_dead(self) -> None:
        new_list = []
        for s in self.strategies:
            if s.is_alive():
                new_list.append(s)
                continue
            # 같은 타입으로 새 쓰레드 생성 (공유 인프라 주입)
            cls = type(s)
            ns = cls(
                db=self.db, client=self.market_client, ws=self.ws,
                order_mgr=self.order_mgr,
                condition_mgr=self.condition_mgr,
                ranking_mgr=self.ranking_mgr,
            )
            ns.start()
            new_list.append(ns)
            logger.info("🔁 전략 재기동: %s", ns.name)
            try:
                msg_mm(f"🔁 전략 재기동: {ns.strategy_id}")
            except Exception:
                pass
        self.strategies = new_list

    # ------------------------------------------------------------------
    def _on_signal(self, *_):
        logger.info("🛑 종료 신호 수신 → 정리 시작")
        self._stop = True
        self.stop()
        sys.exit(0)

    def _build_shutdown_report(self) -> str:
        """종료 알림 메시지 — 보유종목·예수금·주문가능금액 + 당일 봇 실현손익.

        종료는 장중/장후 어느 시점에나 발생할 수 있으므로 잔고 조회가 실패해도
        최소 정보는 출력되도록 방어한다. (호출부에서 예외 시 단순 메시지로 폴백)
        """
        now = dt.now()
        today_ymd = now.strftime("%Y%m%d")
        today_dash = now.strftime("%Y-%m-%d")

        lines = ["🛑 **kis_trader 종료**", "━━━━━━━━━━━━━━━━━━━━"]
        try:
            snap = self._fetch_asset_snapshot(force=True)
            total = snap.get("total_asset", 0.0)
            cash = snap.get("cash", 0.0)
            d2 = snap.get("d2", 0.0)
            dnca = snap.get("dnca", 0.0)
            ord_psbl = snap.get("ord_psbl", 0.0)
            basis = str(snap.get("cash_basis", "dnca") or "dnca")
            hold = snap.get("hold_count", 0)

            # 당일 봇 실현손익 (청산 완료분만)
            try:
                bot_pnl, bot_cnt = self._bot_daily_realized_pnl(today_dash)
            except Exception:
                bot_pnl, bot_cnt = 0.0, 0

            lines += [
                f"- 🕒 종료 시각: {now.strftime('%Y-%m-%d %H:%M:%S')}",
                f"- 🤖 당일 봇 실현: {bot_pnl:+,.0f}원 (청산 {bot_cnt}건)",
                (
                    f"- 계좌: {('모의' if self.client.mock else '실전')} "
                    f"{_mask_account_no(self.client.account_no)}-{self.client.account_code}"
                ),
                f"- 현재 자산: {total:,.0f}원",
                f"- 보유 종목: {hold}개",
                (
                    f"- 예수금(선택기준:{basis}): {cash:,.0f}원 | "
                    f"주문가능금액: {ord_psbl:,.0f}원 | "
                    f"예수금총액: {dnca:,.0f}원 | D+2예수금: {d2:,.0f}원"
                ),
            ]
        except Exception as e:
            logger.debug("종료 리포트 잔고 조회 실패: %s", e)
            lines.append(f"- 🕒 종료 시각: {now.strftime('%Y-%m-%d %H:%M:%S')}")
            lines.append("- ⚠️ 잔고 조회 실패 (네트워크/장후 등)")
        lines.append("━━━━━━━━━━━━━━━━━━━━")
        return "\n".join(lines)

    def stop(self) -> None:
        for s in self.strategies:
            s.stop_loop()
        for s in self.strategies:
            try:
                s.join(timeout=5)
            except Exception:
                pass
        try:
            if self.condition_mgr:
                self.condition_mgr.stop()
        except Exception:
            pass
        try:
            if self.kiwoom_condition_mgr:
                self.kiwoom_condition_mgr.stop()
        except Exception:
            pass
        try:
            if self.ranking_mgr:
                self.ranking_mgr.stop()
        except Exception:
            pass
        try:
            if self.ws_validator:
                self.ws_validator.stop()
        except Exception:
            pass
        try:
            if self.kiwoom_ws:
                self.kiwoom_ws.stop()
        except Exception:
            pass
        try:
            if self.overseas_ws:
                self.overseas_ws.stop()
        except Exception:
            pass
        try:
            self.ws.stop()
        except Exception:
            pass
        try:
            body = self._build_shutdown_report()
            for ch in self._lifecycle_mm_channels():
                try:
                    msg_mm(body, channel_alias=ch, jitter=False)
                except Exception as e:
                    logger.debug("종료 MM(%s) 전송 실패: %s", ch, e)
        except Exception:
            # 종료 시 잔고 조회 실패 등 어떤 예외라도 최소 종료 메시지는 보낸다.
            for ch in self._lifecycle_mm_channels():
                try:
                    msg_mm("🛑 kis_trader 종료", channel_alias=ch, jitter=False)
                except Exception:
                    pass


def main() -> None:
    orch = TradingOrchestrator()
    try:
        orch.start()
    except KeyboardInterrupt:
        orch.stop()
    except Exception as e:
        logger.exception("오케스트레이터 치명적 예외: %s", e)
        orch.stop()
        sys.exit(1)


if __name__ == "__main__":
    main()
