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

전략 스위치 (DB env_config 또는 OS env):
  - ``STRATEGY_SCALP_ENABLED``     (기본 true)
  - ``STRATEGY_SHORT_ENABLED``     (기본 true)
  - ``STRATEGY_BREAKOUT_ENABLED``  (기본 false)
  - ``STRATEGY_SAME_CODE_POLICY`` (block|allow, 기본 block — OrderManager 에서 사용)

유니버스 소스 스위치 (전략별, 런타임 교체 가능):
  - ``SCALP_UNIVERSE_SOURCE``     (기본 "ranking")
  - ``SHORT_UNIVERSE_SOURCE``     (기본 "ranking")
  - ``BREAKOUT_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)
    - 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``

  공통:
    - ``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

# 패키지로 실행되지 않은 경우(스크립트 직접 실행) 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 .database.db_manager import get_db
from .execution.kis_client import KISClient
from .execution.order_manager import OrderManager
from .network.condition_manager import ConditionSearchManager
from .network.market_guard import MarketGuard
from .network.ranking_manager import VolumeRankManager
from .network.ws_manager import WSManager
from .strategies import BreakoutStrategy, ScalpingStrategy, TailCatchStrategy
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

logger = get_logger("kis_trader.main")


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

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

    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.order_mgr = OrderManager(client=self.client, db=self.db)
        self.ws = WSManager(db=self.db, kis_client=self.market_client)
        self.condition_mgr: ConditionSearchManager | None = None
        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 = ""
        # 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

    # ------------------------------------------------------------------
    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,
            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_BREAKOUT_ENABLED", False):
            self.strategies.append(BreakoutStrategy(**shared))
            logger.info("✅ [전략 등록] BREAKOUT (%s)",
                        self._universe_tag("BREAKOUT"))
        else:
            logger.info("⏸ [전략 비활성] BREAKOUT")

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

    def _resolve_source(self, strategy_id: str) -> str:
        """{STRATEGY}_UNIVERSE_SOURCE env → 'ranking' | 'condition'."""
        key = f"{strategy_id}_UNIVERSE_SOURCE"
        default = self._DEFAULT_SOURCE.get(strategy_id, "ranking")
        src = (get_env_from_db(key, default) or default).strip().lower()
        if src not in ("ranking", "condition"):
            src = default
        return src

    def _universe_tag(self, strategy_id: str) -> str:
        """로그용 — 전략이 어떤 유니버스를 쓰는지 한눈에."""
        src = self._resolve_source(strategy_id)
        mgr = self.ranking_mgr if src == "ranking" else self.condition_mgr
        if mgr is None:
            return f"universe={src}(unavailable→DB)"
        try:
            cfgs = getattr(mgr, "_configs", [])
            for c in cfgs:
                if c.get("strategy_id") == strategy_id:
                    if src == "ranking":
                        return (
                            f"universe=rank[{c.get('sort')} x{c.get('limit')}]"
                        )
                    return f"universe=cond[{c.get('name') or c.get('seq')}]"
        except Exception:
            pass
        return f"universe={src}(unmapped→DB)"

    # ------------------------------------------------------------------
    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)

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

        # 시장 급락 서킷브레이커 매니저 기동 (활성/비활성 무관, 토글로 즉시 전환)
        self._start_market_guard()

        # 시세 WS 마이그레이션 검증 (WS_PROVIDER 토글에 따라 키움 WS 동시 기동)
        self._start_ws_validator()

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

        # 알림 (시작)
        try:
            msg_mm("🚀 kis_trader 통합 봇 시작 — 전략: " +
                   ", ".join([s.strategy_id for s in self.strategies]))
            msg_tg("🚀 kis_trader 통합 봇 시작 — 전략: " +
                   ", ".join([s.strategy_id for s in self.strategies]))
        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 기동.
        두 매니저 모두 전략에 주입되고, 전략은 본인 소스 선택 값으로 조회함.
        """
        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),
        }

        ranking_configs: List[dict] = []
        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"),
        }
        rank_env_keys = {
            "SCALP":    ("RANK_SCALP_SORT",    "RANK_SCALP_LIMIT"),
            "SHORT":    ("RANK_SHORT_SORT",    "RANK_SHORT_LIMIT"),
            "BREAKOUT": ("RANK_BREAKOUT_SORT", "RANK_BREAKOUT_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 쪽 설정 수집 (항상 등록 — 기본값 fallback 존재) ─────
            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)
            ranking_configs.append({
                "strategy_id": sid,
                "sort": sort_val,
                "limit": limit_val,
                "market": "J",
            })

            # ── condition 쪽 설정 수집 (NAME 또는 SEQ 가 있을 때만 등록) ────
            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)
            if has_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,
                )

            logger.info(
                "🔀 [%s] 시작소스=%s (매니저등록: ranking=✓ condition=%s) "
                "— 런타임 DB %s_UNIVERSE_SOURCE 변경 시 재시작 없이 전환",
                sid, src, ("✓" if has_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 소스 쓰는 전략 없음 → 조건검색 매니저 비활성")

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

        - ``MARKET_GUARD_ENABLED=false`` (기본값) 여도 thread 자체는 띄움
          → 운영 중 DB 토글로 즉시 켤 수 있음 (재시작 불필요).
        - 지수 조회 (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 마이그레이션 검증 — 키움 WS + Validator 백그라운드 기동.

        ``WS_PROVIDER`` env 토글:
            kis_only             : 미기동 (기본). 운영 100% 동일.
            kis_with_validation  : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음).
            kiwoom_only          : 추후 단계 (검증 통과 후 시세 키움 전환).

        매매·시세 의사결정은 항상 KIS WS 만 사용. 키움 WS 는 ``ws_price_validation``
        테이블에 기록만 함. 1~2주 누적 후 통계 분석으로 마이그레이션 결정.
        """
        from .utils.env import get_env_from_db

        provider = (get_env_from_db("WS_PROVIDER", "kis_only") or "kis_only").strip().lower()
        if provider == "kis_only":
            logger.info("ℹ️ WS_PROVIDER=kis_only → 키움 WS 검증 비활성")
            return
        if provider not in ("kis_with_validation",):
            logger.warning(
                "⚠️ WS_PROVIDER='%s' 알 수 없음 → kis_only 로 폴백 (검증 비활성)",
                provider,
            )
            return

        # 키움 키 로드 (kis_ws._get_kiwoom_creds 재사용)
        try:
            from kis_ws import _get_kiwoom_creds  # type: ignore
            app_key, app_secret, is_mock = _get_kiwoom_creds(self.db)
        except Exception as e:
            logger.warning("키움 자격증명 로드 실패 → 검증 비활성: %s", e)
            return
        if not app_key or not app_secret:
            logger.warning("키움 키 미설정 → WS 검증 비활성")
            return

        # 키움 WS 인스턴스
        try:
            from kiwoom_ws import KiwoomWebSocketPriceCache  # type: ignore
            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 핸들 가져오기 (WSManager.ws_cache)
        kis_ws_handle = getattr(self.ws, "ws_cache", None)
        if kis_ws_handle is None:
            logger.warning("KIS WS 핸들 미발견 → Validator 비활성 (검증 불가)")
            return

        # Validator 기동
        try:
            from .network.ws_validator import WSPriceValidator
            self.ws_validator = WSPriceValidator(
                kis_ws=kis_ws_handle,
                kiwoom_ws=self.kiwoom_ws,
                db=self.db,
            )
            self.ws_validator.start()
            logger.info("🔬 [시세 검증 모드] WS_PROVIDER=%s — KIS↔키움 동시 운영", provider)
        except Exception as e:
            logger.warning("Validator 기동 실패: %s", e)
            self.ws_validator = None

    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
        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
            # 장중 자산/리포트 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"))
        # 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

        cash = d2 if d2 > 0 else dnca
        snap = {
            "cash": cash,          # 주문가능(= D+2 or 예수금총액)
            "d2": d2,              # D+2 예수금
            "dnca": dnca,          # 예수금총액
            "total_asset": tot_evlu,
            "hold_count": hold_count,
        }
        self._asset_cache = snap
        self._asset_cache_ts = t
        return dict(snap)

    def _asset_line_for_notify(self, side: str = "", extra: Optional[Dict] = None) -> str:
        """
        체결 알림에 덧붙일 2줄 요약:
          예수금 XXX원 | 총자산 XXX원 | 보유 N종목
          당일손익 ±XXX원 (±X.XX%) | 누적손익 ±XXX원 (±X.XX%)
        - start_day_asset 이 아직 0 이면 현재 총자산을 기준점으로 '최초 설정'
        - total_deposit 이 없으면 누적손익 줄은 생략
        """
        try:
            snap = self._fetch_asset_snapshot()
            cash = snap.get("cash", 0.0)
            total = snap.get("total_asset", 0.0)
            hold = snap.get("hold_count", 0)

            if self.start_day_asset <= 0 and total > 0:
                self.start_day_asset = total
                logger.info("📌 [당일 시작 자산] %s원 최초 기록", f"{total:,.0f}")

            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

            line1 = f"예수금 {cash:,.0f}원 | 총자산 {total:,.0f}원 | 보유 {hold}종목"
            line2 = f"당일손익 {day_pnl:+,.0f}원 ({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
                line2 += f" | 누적손익 {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)"

            return line1 + "\n" + line2
        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  # 재시도 백오프도 같이 리셋
        # 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.start_day_asset = float(snap["total_asset"])
                        logger.info(
                            "📌 [당일 시작 자산] %s원 기록",
                            f"{self.start_day_asset:,.0f}",
                        )
                    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,
                    )

        # 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)

    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)
        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

        lines = [
            "📈 **[장마감 전 현황 - 15:15]**",
            "",
            f"- 당일 손익: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
            f"- 현재 자산: {total:,.0f}원",
            f"- 보유 종목: {hold}개",
            f"- 예수금(주문가능): {cash:,.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)
        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

        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"- 시작: {self.start_day_asset:,.0f}원",
            f"- 종료: {total:,.0f}원",
            f"- 손익: {day_pnl:+,.0f}원 ({day_pct:+.2f}%)",
            "",
            "💰 **누적 손익 (총 입금액 대비)**",
            f"- 총 입금: {self.total_deposit:,.0f}원",
            f"- 현재 자산: {total:,.0f}원",
            f"- 누적 손익: {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)",
            "",
            "📊 **거래 현황**",
            f"- 오늘 매매: {trade_cnt}건",
            f"- 보유 종목: {hold}개",
            f"- 예수금(주문가능): {cash:,.0f}원 | D+2예수금: {d2:,.0f}원",
            "━━━━━━━━━━━━━━━━━━━━",
        ]
        return "\n".join(lines)

    def _send_daily_report(self, title: str, body: str) -> None:
        """리포트를 SCALP·SHORT(TAIL) 양쪽 MM 채널 + Telegram 에 전송."""
        scalp_ch = str(get_env_from_db("KIS_SCALP_MM_CHANNEL", "scalping") or "scalping")
        short_ch = str(get_env_from_db("KIS_SHORT_MM_CHANNEL", "stock") or "stock")
        try:
            msg_mm(body, channel_alias=scalp_ch, jitter=False)
        except Exception as e:
            logger.debug("리포트 MM(scalp) 전송 실패: %s", e)
        # SCALP 와 SHORT 채널이 동일하면 중복 발송 방지
        if short_ch != scalp_ch:
            try:
                msg_mm(body, channel_alias=short_ch, jitter=False)
            except Exception as e:
                logger.debug("리포트 MM(short) 전송 실패: %s", 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 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.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:
            self.ws.stop()
        except Exception:
            pass
        try:
            msg_mm("🛑 kis_trader 종료")
        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()
