"""
KIS Short Trading Bot Ver1 - 단타용 한투 API 트레이딩 시스템
- 한국투자증권(KIS) Open API 사용
- 개미털기(눌림목) 전략 기반 단타 매매
- 빠른 손절/익절 로직
- kiwoom_trader_ver2.py의 단타 전략을 한투 API로 변환
"""

import os
import re
import json
import time
import random
import logging
import datetime
import hashlib
import hmac
import base64
import warnings
import asyncio
from datetime import datetime as dt
from pathlib import Path
from typing import List, Dict, Optional

import pandas as pd
import requests

from database import TradeDB, ENV_CONFIG_KEYS

# 로깅 설정
logging.basicConfig(
    format='[%(asctime)s] %(message)s',
    datefmt='%H:%M:%S',
    level=logging.INFO,
)
logger = logging.getLogger("KISShortBot")

# 로그 색상 (ANSI) - 탈락/통과 구분
LOG_RED = "\033[91m"     # 탈락
LOG_YELLOW = "\033[93m"  # 탈락 (Pass-조건)
LOG_GREEN = "\033[92m"   # 통과
LOG_CYAN = "\033[96m"    # 강조
LOG_RESET = "\033[0m"

# DB 초기화 (스크립트所在 디렉터리 기준 경로)
SCRIPT_DIR = Path(__file__).resolve().parent
db = TradeDB(db_path=str(SCRIPT_DIR / "quant_bot.db"))

# DB에서 환경변수 로드
def get_env_from_db(key, default=""):
    """DB에서 환경변수 읽기"""
    env_data = db.get_latest_env()
    if env_data and env_data.get("snapshot"):
        return env_data["snapshot"].get(key, default)
    return default

def get_env_float(key, default):
    """환경변수를 float로 변환 (DB 우선)"""
    value = get_env_from_db(key, str(default))
    if isinstance(value, str) and "#" in value:
        value = value.split("#")[0].strip()
    try:
        return float(value) if value else default
    except (ValueError, TypeError):
        return default

def get_env_int(key, default):
    """환경변수를 int로 변환 (DB 우선)"""
    value = get_env_from_db(key, str(default))
    if isinstance(value, str) and "#" in value:
        value = value.split("#")[0].strip()
    try:
        return int(value) if value else default
    except (ValueError, TypeError):
        return default

def get_env_bool(key, default=False):
    """환경변수를 bool로 변환 (DB 우선)"""
    value = get_env_from_db(key, str(default)).lower()
    return value in ("true", "1", "yes")

# Mattermost 설정
MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org")
MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip()
MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json"
# 기본 채널(alias) + 단타 봇 전용 채널(alias)
MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "stock")
MM_CHANNEL_SHORT = get_env_from_db("KIS_SHORT_MM_CHANNEL", MM_CHANNEL_DEFAULT)

# Gemini API (AI 리포트용) - google.genai 신규 SDK (Client 사용, configure 없음)
try:
    import google.genai as genai
    GEMINI_AVAILABLE = True
except ImportError:
    GEMINI_AVAILABLE = False
    logger.warning("⚠️ google-genai 미설치 - AI 리포트 기능 사용 불가")

GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip()
GEMINI_MODEL_ID = "gemini-2.5-flash"  # 또는 gemini-2.5-flash (모델명 확인)
gemini_client = None
if GEMINI_API_KEY and GEMINI_AVAILABLE:
    try:
        gemini_client = genai.Client(api_key=GEMINI_API_KEY)
    except Exception as e:
        logger.warning(f"⚠️ Gemini 초기화 실패: {e}")
        gemini_client = None
else:
    gemini_client = None

# ML 예측 (선택적)
try:
    from ml_predictor import MLPredictor
    ML_AVAILABLE = True
except ImportError:
    ML_AVAILABLE = False
    logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가")

# RiskManager (변동성 기반 리스크 관리)
try:
    from risk_manager import RiskManager
    RISK_MANAGER_AVAILABLE = True
except ImportError:
    RISK_MANAGER_AVAILABLE = False
    logger.warning("⚠️ risk_manager 미설치 - 변동성 역가중 매수 금액 계산 불가")

# ============================================================
# 한투(KIS) API 클라이언트 (kis_long_term_checker.py 참고)
# ============================================================
# 모의계좌용 토큰 캐시 경로
KIS_TOKEN_CACHE_PATH_MOCK = SCRIPT_DIR / ".kis_token_cache_mock.json"
# 실계좌용 토큰 캐시 경로
KIS_TOKEN_CACHE_PATH_REAL = SCRIPT_DIR / ".kis_token_cache_real.json"



# 한투 접근 토큰 유효기간 24시간. 자주 발급하면 영구 제명될 수 있으므로 캐시 철저 재사용.
# 만료 1분 전에만 재발급 (불필요한 발급 최소화)
KIS_TOKEN_EXPIRE_MARGIN_SEC = 60


def _parse_kis_token_expired(expired_str):
    """한투 API 만료시간 문자열 파싱. 'YYYY-MM-DD HH:MM:SS' 또는 'YYYY-MM-DDTHH:MM:SS' 등 지원."""
    if not expired_str or not isinstance(expired_str, str):
        return None
    s = expired_str.strip().replace("T", " ")[:19]
    if len(s) < 19:
        return None
    try:
        return dt.strptime(s, "%Y-%m-%d %H:%M:%S")
    except ValueError:
        return None


def _load_kis_token_cache(mock):
    """캐시 파일에서 토큰 로드. 만료 1분 전까지 유효하면 재사용 (24h 토큰 자주 발급 시 영구 제명 주의)."""
    if mock:
        path = KIS_TOKEN_CACHE_PATH_MOCK
    else:
        path = KIS_TOKEN_CACHE_PATH_REAL
    if not path.exists():
        logger.info("한투 토큰 캐시 없음 → API 발급 예정 (캐시 경로: %s)", path)
        return None
    try:
        logger.info("패스 %s", path)
        with open(path, "r", encoding="utf-8") as f:
            cache = json.load(f)
        if cache.get("mock") != mock:
            logger.info("한투 토큰 캐시 모의/실전 불일치 → API 발급 예정")
            return None
        token = cache.get("access_token")
        expired_str = cache.get("access_token_token_expired") or cache.get("expires_at")
        if not token or not expired_str:
            logger.info("한투 토큰 캐시 내용 불완전 → API 발급 예정")
            return None
        expired_dt = _parse_kis_token_expired(expired_str)
        if expired_dt is None:
            logger.info("한투 토큰 캐시 만료시간 파싱 실패(%s) → API 발급 예정", expired_str[:30])
            return None
        if dt.now() >= expired_dt - datetime.timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC):
            logger.info("한투 토큰 캐시 만료 임박(%s) → API 발급 예정", expired_str[:19])
            return None
        return token
    except Exception as e:
        logger.warning("한투 토큰 캐시 로드 실패(%s): %s", path, e)
        return None


def _save_kis_token_cache(access_token, access_token_token_expired, mock):
    """발급받은 토큰을 캐시 파일에 저장."""
    try:
        if mock:
            path = KIS_TOKEN_CACHE_PATH_MOCK
        else:
            path = KIS_TOKEN_CACHE_PATH_REAL
        with open(path, "w", encoding="utf-8") as f:
            json.dump({
                "access_token": access_token,
                "access_token_token_expired": access_token_token_expired,
                "mock": mock,
            }, f, ensure_ascii=False, indent=2)
        logger.info("한투 토큰 캐시 저장 완료: %s", path)
    except Exception as e:
        logger.warning("한투 토큰 캐시 저장 실패: %s", e)


def _invalidate_kis_token_cache(mock):
    """토큰 만료(EGW00123) 시 캐시 파일 삭제 → 다음 _auth()에서 새 토큰 발급."""
    path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL
    try:
        if path.exists():
            path.unlink()
            logger.info("한투 토큰 캐시 삭제 (만료 감지): %s", path)
    except Exception as e:
        logger.warning("한투 토큰 캐시 삭제 실패(%s): %s", path, e)


def _is_token_expired_response(j):
    """응답이 '기간이 만료된 token' 오류(EGW00123)인지 여부."""
    if not j or not isinstance(j, dict):
        return False
    msg_cd = j.get("msg_cd") or ""
    msg1 = str(j.get("msg1", ""))
    return msg_cd == "EGW00123" or "만료된 token" in msg1 or "만료" in msg1


class KISClient:
    """한국투자증권 Open API 클라이언트"""
    def __init__(self, mock=None):

        # 모의 여부 결정
        if mock is not None:
            use_mock = mock
        else:
            use_mock = get_env_bool("KIS_MOCK", True)

        # 모의투자는 MOCK 전용 키만 사용(실전 키로 폴백 안 함 → 토큰/캐시가 실전이랑 섞이지 않도록)
        if use_mock:
            self.app_key = get_env_from_db("KIS_APP_KEY_MOCK", "").strip()
            self.app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "").strip()
            if not self.app_key or not self.app_secret:
                logger.error("❌ 모의투자용 APP KEY/SECRET이 DB에 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 설정 필요.")
                raise ValueError("모의투자 KIS_APP_KEY_MOCK / KIS_APP_SECRET_MOCK 미설정")
        else:
            self.app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or get_env_from_db("KIS_APP_KEY", "")).strip()
            self.app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or get_env_from_db("KIS_APP_SECRET", "")).strip()

        # 계좌번호: 모의/실전 분리
        if use_mock:
            raw_no   = get_env_from_db("KIS_ACCOUNT_NO_MOCK",   "").strip()
            raw_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip()
            if not raw_code:
                raw_code = "01"
        else:
            raw_no   = (get_env_from_db("KIS_ACCOUNT_NO_REAL",   "") or get_env_from_db("KIS_ACCOUNT_NO",   "")).strip()
            raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_REAL", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip()
            if not raw_code:
                raw_code = "01"

        # 10자리면 앞 8 / 뒤 2 분리
        if len(raw_no) >= 10:
            self.acc_no   = raw_no[:8]
            self.acc_code = raw_no[8:10]
        else:
            self.acc_no   = raw_no
            self.acc_code = raw_code[:2] if len(raw_code) >= 2 else "01"
            if len(self.acc_no) != 8:
                logger.warning("⚠️ 계좌번호 CANO 8자리 아님: '%s'(%s자리). DB 확인.", self.acc_no, len(self.acc_no))

        if len(self.acc_no) != 8 or len(self.acc_code) != 2:
            logger.error(
                "❌ 계좌번호 형식 오류: CANO=%s(%s자리), ACNT_PRDT_CD=%s(%s자리) → OPSQ2000 발생. "
                "모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO/KIS_ACCOUNT_CODE 확인.",
                self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code)
            )
        else:
            logger.info("✅ 한투 계좌 CANO=%s, ACNT_PRDT_CD=%s (모의=%s)", self.acc_no, self.acc_code, use_mock)

        self.mock = use_mock

        if self.mock is True:
            self.base_url = "https://openapivts.koreainvestment.com:29443"
        else:
            self.base_url = "https://openapi.koreainvestment.com:9443"

        self.access_token = None
        # 매수 주문 실패 시 사유 저장 (매매불가 종목 당일 제외용)
        self._last_order_msg_cd = None
        self._last_order_msg1 = None
        # 현재가 API 캐시 (레이트리밋·지연 완화용)
        # {종목코드: (timestamp, output_dict)}
        self._price_cache = {}
        logger.info("한투 API 연결: 모의=%s → %s", self.mock, self.base_url)
        self._auth()


    def _auth(self):
        """접근 토큰 발급"""
        if not self.app_key or not self.app_secret:
            if self.mock:
                key_hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK"
            else:
                key_hint = "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)"
            logger.error("한투 API 키가 없습니다. DB env_config에 설정 필요: %s", key_hint)
            raise ValueError("KIS 앱키/시크릿 설정 필요 (모의=%s)" % self.mock)

        # ✅ path를 먼저 정의 (발급 성공/실패 양쪽에서 사용)
        path = KIS_TOKEN_CACHE_PATH_MOCK if self.mock else KIS_TOKEN_CACHE_PATH_REAL
        mode_str = "모의" if self.mock else "실전"

        cached = _load_kis_token_cache(self.mock)
        if cached:
            self.access_token = cached
            token_head = (cached[:8] + "…") if cached and len(cached) > 8 else "(없음)"
            logger.info("한투 토큰 캐시 사용 (%s) | 파일=%s | 토큰앞8=%s", mode_str, path, token_head)
            return

        # 캐시 없음/만료 → API로 새 토큰 발급 (캐시 파일 없어도 자동 발급)
        appkey_tail = (self.app_key[-4:] if len(self.app_key) >= 4 else self.app_key) or "????"
        logger.info(
            "한투 토큰 발급 요청 (%s) | 앱키 끝4자리=%s | 저장할 캐시=%s",
            mode_str, appkey_tail, path,
        )
        url  = f"{self.base_url}/oauth2/tokenP"
        body = {"grant_type": "client_credentials", "appkey": self.app_key, "appsecret": self.app_secret}
        try:
            r    = requests.post(url, json=body, timeout=10)
            data = r.json()
            if "access_token" in data:
                self.access_token = data["access_token"]
                expired = data.get("access_token_token_expired") or ""
                _save_kis_token_cache(self.access_token, expired, self.mock)
                token_head = (self.access_token[:8] + "…") if self.access_token and len(self.access_token) > 8 else "(없음)"
                logger.info(
                    "한투 토큰 발급 완료 (%s) | 캐시=%s | 앱키끝4=%s | 토큰앞8=%s",
                    mode_str, path, appkey_tail, token_head,
                )
            else:
                logger.error("한투 토큰 발급 실패: %s", data)
                if isinstance(data, dict) and data.get("error_code") == "EGW00133":
                    logger.warning("한투 1분당 1회 제한. 1분 후 재시도하거나 캐시 사용: %s", path)
                raise RuntimeError("한투 토큰 발급 실패")
        except Exception as e:
            logger.error("한투 인증 예외: %s", e)
            raise

    def _get_hashkey(self, body):
        """해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용"""
        try:
            url = f"{self.base_url}/uapi/hashkey"
            headers = {
                "content-type": "application/json",
                "appkey": self.app_key,
                "appsecret": self.app_secret,
            }
            r = requests.post(url, headers=headers, json=body, timeout=5)
            if r.status_code == 200:
                data = r.json()
                if data.get("rt_cd") == "0":
                    return data.get("HASH")
            return None
        except Exception as e:
            logger.debug(f"해시키 발급 실패: {e}")
            return None

    def _headers(self, tr_id, hashkey=None):
        """API 호출용 헤더 생성"""
        headers = {
            "content-type": "application/json; charset=utf-8",
            "authorization": f"Bearer {self.access_token}",
            "appkey": self.app_key,
            "appsecret": self.app_secret,
            "tr_id": tr_id,
        }
        if hashkey:
            headers["hashkey"] = hashkey
        return headers

    def _get(self, path, tr_id, params, max_retries=5, tr_cont=None):
        """
        GET 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 후 재시도 (200/500 공통).
        EGW00123(기간이 만료된 token) 시 캐시 삭제 후 새 토큰 발급·1회 재시도.
        - 한투 API 제한: 초당 20개 (실제로는 더 엄격, 모의투자는 초당 2~3회 권장)
        - 기본 호출 간격: 0.5초 이상 권장
        """
        url = f"{self.base_url}{path}"
        if tr_cont:
            headers_extra = {"tr_cont": tr_cont}
        else:
            headers_extra = {}
        logger.debug(f"[API호출] GET {path} TR_ID={tr_id} params={params} tr_cont={tr_cont}")
        time.sleep(0.5)

        token_refreshed = False
        for attempt in range(max_retries):
            try:
                headers = self._headers(tr_id)
                for k, v in headers_extra.items():
                    headers[k] = v
                r = requests.get(url, headers=headers, params=params, timeout=15)

                # HTTP 429 (Too Many Requests)
                if r.status_code == 429:
                    wait_time = 1 + (attempt * 1)
                    logger.warning(
                        f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 "
                        f"({attempt+1}/{max_retries}) path={path}"
                    )
                    time.sleep(wait_time)
                    continue

                # 200/500 응답에서 EGW00201(초당 거래건수 초과)이면 대기 후 재시도
                if r.status_code in (200, 500):
                    try:
                        j = r.json()
                    except Exception:
                        j = {}
                    if r.status_code == 200 and j.get("rt_cd") == "0":
                        logger.debug(f"[API성공] GET {path} TR_ID={tr_id} status=200 rt_cd=0")
                        return r
                    if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
                        wait_time = 1.5 + (attempt * 1.0)
                        logger.warning(
                            f"⏳ API 과부하 (EGW00201) GET {path} TR_ID={tr_id} -> {wait_time:.1f}초 대기 후 재시도 "
                            f"({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}"
                        )
                        time.sleep(wait_time)
                        continue
                    # EGW00123: 기간이 만료된 token → 캐시 삭제 후 새 토큰 발급, 1회만 재시도
                    if _is_token_expired_response(j) and not token_refreshed:
                        token_refreshed = True
                        _invalidate_kis_token_cache(self.mock)
                        self._auth()
                        logger.info("한투 토큰 만료(EGW00123) 감지 → 캐시 삭제 후 재발급, GET 재시도")
                        time.sleep(0.5)
                        continue
                # HTTP 200이 아니거나 rt_cd != "0"인 경우
                try:
                    body_preview = (r.text or "")[:500]
                except Exception:
                    body_preview = ""
                logger.warning(
                    f"[API실패] GET {path} TR_ID={tr_id} status={r.status_code} "
                    f"params={params} body={body_preview}"
                )
                return r
            except requests.exceptions.RequestException as e:
                if attempt < max_retries - 1:
                    wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
                    logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}")
                    time.sleep(wait_time)
                else:
                    logger.error(f"❌ GET 요청 실패 ({path}): {e}")
        return r

    def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3):
        """
        POST 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 후 재시도.
        EGW00123(기간이 만료된 token) 시 캐시 삭제 후 새 토큰 발급·1회 재시도.
        - 한투 API 제한: 초당 20개 (실제로는 더 엄격)
        """
        url = f"{self.base_url}{path}"
        body_preview = str(body)[:200] if body else "{}"
        logger.debug(f"[API호출] POST {path} TR_ID={tr_id} body={body_preview}...")
        time.sleep(0.5)

        token_refreshed = False
        for attempt in range(max_retries):
            try:
                hashkey = self._get_hashkey(body) if use_hashkey else None
                if use_hashkey and not hashkey:
                    logger.debug("해시키 발급 실패, 해시키 없이 진행")
                r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15)

                # HTTP 429 (Too Many Requests)
                if r.status_code == 429:
                    wait_time = 5 + (attempt * 1)
                    logger.warning(
                        f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 "
                        f"({attempt+1}/{max_retries}) path={path}"
                    )
                    time.sleep(wait_time)
                    continue

                if r.status_code in (200, 500):
                    try:
                        j = r.json()
                    except Exception:
                        j = {}
                    if r.status_code == 200 and j.get("rt_cd") == "0":
                        logger.debug(f"[API성공] POST {path} TR_ID={tr_id} status=200 rt_cd=0")
                        return r
                    if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")):
                        wait_time = 5 + (attempt * 1)
                        logger.warning(
                            f"⏳ API 과부하 (EGW00201) POST {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 "
                            f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
                        )
                        time.sleep(wait_time)
                        continue
                    # EGW00123: 기간이 만료된 token → 캐시 삭제 후 새 토큰 발급, 1회만 재시도
                    if _is_token_expired_response(j) and not token_refreshed:
                        token_refreshed = True
                        _invalidate_kis_token_cache(self.mock)
                        self._auth()
                        logger.info("한투 토큰 만료(EGW00123) 감지 → 캐시 삭제 후 재발급, POST 재시도")
                        time.sleep(0.5)
                        continue
                try:
                    body_preview = (r.text or "")[:500]
                except Exception:
                    body_preview = ""
                logger.warning(
                    f"[API실패] POST {path} TR_ID={tr_id} status={r.status_code} "
                    f"body={body_preview}"
                )
                return r
            except requests.exceptions.RequestException as e:
                if attempt < max_retries - 1:
                    wait_time = (2 ** attempt) + random.uniform(0.5, 1.5)
                    logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}")
                    time.sleep(wait_time)
                else:
                    logger.error(f"❌ POST 요청 실패 ({path}): {e}")
        return r

    def inquire_price(self, stock_code):
        """
        주식 현재가 시세 조회 [v1_국내주식-008] (단건).
        output: stck_prpr(현재가) ✅, stck_oprc(시가), stck_hgpr(고가), stck_lwpr(저가) 등 당일 OHLC 포함.
        실패 시 오류코드(rt_cd, msg_cd, msg1) 로깅.
        """
        # 짧은 TTL 캐시로 동일 종목 반복 호출 시 레이트리밋·지연 완화
        cache_ttl = get_env_float("KIS_PRICE_CACHE_TTL_SEC", 1.0)
        if cache_ttl > 0:
            now_ts = time.time()
            cached = self._price_cache.get(stock_code)
            if cached:
                ts, output = cached
                if now_ts - ts <= cache_ttl:
                    logger.debug(f"[현재가API-캐시히트] code={stock_code} ttl={cache_ttl}s")
                    return output

        path = "/uapi/domestic-stock/v1/quotations/inquire-price"
        tr_id = "FHKST01010100"
        params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code}
        logger.debug(f"[현재가API] 호출 code={stock_code} path={path} TR_ID={tr_id}")
        r = self._get(path, tr_id, params)
        if r.status_code != 200:
            try:
                body_preview = (r.text or "")[:300]
            except Exception:
                body_preview = ""
            logger.warning(
                f"[현재가API] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} "
                f"status={r.status_code} body={body_preview}"
            )
            return None
        try:
            j = r.json()
        except Exception as e:
            logger.warning(
                f"[현재가API] JSON 파싱 실패 code={stock_code} path={path} TR_ID={tr_id} exception={e}"
            )
            return None
        if j.get("rt_cd") != "0":
            logger.warning(
                f"[현재가API] 오류 code={stock_code} path={path} TR_ID={tr_id} "
                f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
            )
            return None
        output = j.get("output")
        if cache_ttl > 0 and output is not None:
            try:
                self._price_cache[stock_code] = (time.time(), output)
            except Exception:
                pass
        return output

    def inquire_multprice(self, stock_codes: List[str], max_per_call: int = 20):
        """
        다중 종목 현재가 조회 [intstock-multprice]
        - 한투 API: /uapi/domestic-stock/v1/quotations/intstock-multprice
        - 성공 시 {종목코드: output딕셔너리} 반환, 실패 시 None (오류 시 rt_cd/msg_cd/msg1 로깅)
        - TR_ID: FHKST01010600
        - ⚠️ 배치 응답에는 stck_oprc(시가)/stck_hgpr(고가)/stck_lwpr(저가)가 없을 수 있음(API 스펙).
          시가·고가·저가 필요 시 단건 inquire_price() 사용.
        """
        if not stock_codes:
            return None
        codes = list(stock_codes)[: max_per_call * 10]
        result = {}
        for i in range(0, len(codes), max_per_call):
            chunk = codes[i : i + max_per_call]
            iscd = ",".join(chunk)
            path = "/uapi/domestic-stock/v1/quotations/intstock-multprice"
            tr_id = "FHKST01010600"
            params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": iscd}
            r = self._get(path, tr_id, params)
            if r.status_code != 200:
                try:
                    body_preview = (r.text or "")[:300]
                except Exception:
                    body_preview = ""
                logger.warning(
                    f"[다중시세API] HTTP 실패 status={r.status_code} body={body_preview}"
                )
                continue
            try:
                j = r.json()
            except Exception as e:
                logger.warning(f"[다중시세API] JSON 파싱 실패 exception={e}")
                continue
            if j.get("rt_cd") != "0":
                logger.warning(
                    f"[다중시세API] 오류 rt_cd={j.get('rt_cd')} "
                    f"msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
                )
                continue
            out = j.get("output")
            if out is None:
                continue
            if isinstance(out, list):
                for item in out:
                    if isinstance(item, dict):
                        code = (
                            item.get("stck_shrn_iscd")
                            or item.get("rsym")
                            or item.get("FID_INPUT_ISCD")
                            or item.get("mksc_shrn_iscd")
                        )
                        if code:
                            result[code] = item
            elif isinstance(out, dict):
                code = (
                    out.get("stck_shrn_iscd")
                    or out.get("rsym")
                    or out.get("FID_INPUT_ISCD")
                )
                if code:
                    result[code] = out
            time.sleep(random.uniform(0.2, 0.5))
        return result if result else None

    def inquire_prices_batch(self, stock_codes: List[str]):
        """
        다중 종목 현재가 일괄 조회
        - intstock-multprice API 우선 시도 후, 실패 시 순차 조회(inquire_price)로 fallback
        - 순차 조회 시 종목당 0.3~0.6초 딜레이
        - 배치 응답에는 stck_prpr(현재가)는 있으나, stck_oprc/stck_hgpr/stck_lwpr 는 없을 수 있음.
          시가·고가·저가 필요 시 배치 대신 종목별 inquire_price() 사용(개미털기 스캔은 이미 단건 사용).
        """
        if not stock_codes:
            return {}
        multi = self.inquire_multprice(stock_codes)
        if multi:
            return multi
        result = {}
        for code in stock_codes:
            try:
                price_data = self.inquire_price(code)
                if price_data:
                    result[code] = price_data
                time.sleep(random.uniform(0.3, 0.6))
            except Exception as e:
                logger.warning(f"종목 조회 실패({code}) exception={e!r}")
                continue
        return result

    def get_account_balance(self):
        """계좌 잔고 조회 [v1_국내주식-010]. 모의/실전에 따라 TR ID 분기 (EGW2004 방지)."""
        if self.mock:
            tr_id = "VTTC8434R"
        else:
            tr_id = "TTTC8434R"
        params = {
            "CANO": self.acc_no,
            "ACNT_PRDT_CD": self.acc_code,
            "AFHR_FLPR_YN": "N",
            "OFL_YN": "",
            "INQR_DVSN": "01",   # 01: 예수금/잔고 요약 (output2에 예수금) - 블로그·한투 문서 기준
            "UNPR_DVSN": "01",
            "FUND_STTL_ICLD_YN": "N",
            "FNCG_AMT_AUTO_RDPT_YN": "N",
            "PRCS_DVSN": "00",   # 00: 조회 (블로그 기준)
            "CTX_AREA_FK100": "",
            "CTX_AREA_NK100": "",
        }
        try:
            logger.info(f"💵 [예수금] 잔고 조회 요청: TR={tr_id}, CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}")
            r = self._get(
                "/uapi/domestic-stock/v1/trading/inquire-balance",
                tr_id,
                params,
            )
            if r.status_code != 200:
                logger.warning(
                    f"💵 [예수금] 잔고 API HTTP 오류: status={r.status_code}, body={getattr(r, 'text', '')[:200]} | "
                    f"TR={tr_id} (모의={self.mock}), CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}. "
                    f"EGW2004 시 모의면 VTTC8434R/실전이면 TTTC8434R 확인"
                )
                return None
            j = r.json()
            if j.get("rt_cd") != "0":
                msg1 = (j.get("msg1") or "")[:150]
                msg_cd = j.get("msg_cd", "")
                logger.error(
                    f"💵 [예수금] 잔고 API 응답 오류: rt_cd={j.get('rt_cd')}, msg_cd={msg_cd}, msg1={msg1} | "
                    f"요청 파라미터: TR={tr_id}, CANO={self.acc_no}({len(self.acc_no)}자리), "
                    f"ACNT_PRDT_CD={self.acc_code}({len(self.acc_code)}자리), 모의={self.mock} | "
                    f"전체 응답: {j}"
                )
                if "OPSQ2000" in str(msg_cd) or "INVALID_CHECK_ACNO" in msg1:
                    logger.error(
                        "💵 [예수금] OPSQ2000 = 계좌번호 검증 실패. "
                        f"모의투자 서버({self.base_url})에 계좌번호 CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}가 등록되어 있는지 확인. "
                        f"한투 모의투자 앱/웹에서 계좌번호 확인 필요. DB의 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK 값 확인."
                    )
                return None
            logger.debug(f"💵 [예수금] 잔고 조회 성공: output2 keys={list(j.get('output2', [{}])[0].keys()) if isinstance(j.get('output2'), list) and j.get('output2') else []}")
            return j
        except Exception as e:
            logger.error(f"💵 [예수금] 잔고 조회 예외: {e} | CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}")
            return None
    
    def get_account_evaluation(self):
        """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R."""
        if self.mock:
            tr_id = "VTTC8494R"
        else:
            tr_id = "TTTC8494R"
        try:
            logger.info(tr_id)
            r = self._get(
                "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl",
                tr_id,
                {
                    "CANO": self.acc_no,
                    "ACNT_PRDT_CD": self.acc_code,
                    "AFHR_FLPR_YN": "N",
                    "OFL_YN": "",
                    "INQR_DVSN": "01",
                    "UNPR_DVSN": "01",
                    "FUND_STTL_ICLD_YN": "N",
                    "FNCG_AMT_AUTO_RDPT_YN": "N",
                    "PRCS_DVSN": "01",
                    "CTX_AREA_FK100": "",
                    "CTX_AREA_NK100": "",
                },
            )
            if r.status_code != 200:
                return None
            j = r.json()
            if j.get("rt_cd") != "0":
                return None
            return j
        except Exception as e:
            logger.error(f"계좌 평가 조회 실패: {e}")
            return None
    
    def get_order_history(self, start_date=None, end_date=None):
        """주문 체결 내역 조회 [v1_국내주식-012] (모의=VTTC8001R, 실전=TTTC8001R)"""
        try:
            if self.mock:
                tr_id = "VTTC8001R"
            else:
                tr_id = "TTTC8001R"
            if not start_date:
                start_date = dt.now().strftime("%Y%m%d")
            if not end_date:
                end_date = dt.now().strftime("%Y%m%d")
            
            r = self._get(
                "/uapi/domestic-stock/v1/trading/inquire-daily-ccld",
                tr_id,
                {
                    "CANO": self.acc_no,
                    "ACNT_PRDT_CD": self.acc_code,
                    "INQR_STRT_DT": start_date,
                    "INQR_END_DT": end_date,
                    "SLL_BUY_DVSN_CD": "00",  # 00:전체
                    "INQR_DVSN": "00",  # 00:역순
                    "PDNO": "",
                    "CCLD_DVSN": "00",  # 00:전체
                    "ORD_GNO_BRNO": "",
                    "ODNO": "",
                    "INQR_DVSN_3": "00",
                    "INQR_DVSN_1": "",
                    "CTX_AREA_FK100": "",
                    "CTX_AREA_NK100": "",
                },
            )
            if r.status_code != 200:
                return None
            j = r.json()
            if j.get("rt_cd") != "0":
                return None
            return j
        except Exception as e:
            logger.error(f"주문 내역 조회 실패: {e}")
            return None

    def get_execution_by_odno(self, odno, code=None, wait_sec=2):
        """
        주문번호(ODNO)로 당일 체결 내역 조회. 시장가 매수 후 실제 체결가/체결수량 확인용.
        [v1_국내주식-012] inquire-daily-ccld 응답 output2에서 해당 주문 찾아 체결정보 반환.
        
        Args:
            odno: 주문번호 (buy_order 성공 시 반환값)
            code: 종목코드 (선택, 일치 행 필터용)
            wait_sec: 조회 전 대기 초 (체결 반영 지연 고려, 기본 2초)
        
        Returns:
            성공 시 {"filled_qty": int, "avg_price": float}, 미체결/조회실패 시 None
        """
        if not odno:
            return None
        time.sleep(max(0, wait_sec))
        try:
            j = self.get_order_history()
            if not j:
                return None
            out2 = j.get("output2", [])
            if isinstance(out2, dict):
                out2 = [out2]
            for row in out2:
                row_odno = str(row.get("ODNO") or row.get("ord_no") or "").strip()
                row_pdno = str(row.get("PDNO") or row.get("pdno") or "").strip()
                if row_odno != str(odno).strip():
                    continue
                if code and row_pdno and row_pdno != str(code).strip():
                    continue
                # 총체결수량 / 체결평균가 (한투 문서 필드명 다양하므로 후보 나열)
                filled = row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY") or row.get("ccld_qty") or row.get("ord_qty") or row.get("ORD_QTY")
                avg_pr = row.get("avg_prvs") or row.get("AVG_PRVS") or row.get("rjct_avg_prvs") or row.get("RJCT_AVG_PRVS") or row.get("ord_unpr") or row.get("ORD_UNPR")
                if filled is not None and avg_pr is not None:
                    qty = int(float(str(filled).replace(",", "")))
                    price = float(str(avg_pr).replace(",", ""))
                    if qty > 0 and price > 0:
                        logger.debug(f"[체결조회] ODNO={odno} -> 체결수량={qty}, 체결평균가={price:,.0f}")
                        return {"filled_qty": qty, "avg_price": price}
            logger.debug(f"[체결조회] ODNO={odno} 해당 주문 체결 내역 없음 (output2 건수={len(out2)})")
            return None
        except Exception as e:
            logger.warning(f"주문번호 체결 조회 예외 ODNO={odno}: {e}")
            return None

    def get_volume_surge_stocks(self, market="J", min_volume_rate="50", limit=50):
        """거래량 급증 종목 조회 [v1_국내주식-023]"""
        try:
            r = self._get(
                "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
                "FHKST03010200",
                {
                    "FID_COND_MRKT_DIV_CODE": market,
                    "FID_INPUT_ISCD": "",
                    "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"),
                    "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"),
                    "FID_PW_DATA_INCU_YN": "Y",
                },
            )
            # 실제로는 거래량 급증 API를 사용해야 하지만, 여기서는 예시로 현재가 조회 활용
            # 실제 구현 시: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice 사용
            return []
        except Exception as e:
            logger.error(f"거래량 급증 종목 조회 실패: {e}")
            return []
    
    def get_top_price_movers(self, market="J", sort_type="1", limit=50):
        """등락률 상위 종목 조회 [v1_국내주식-027]"""
        try:
            r = self._get(
                "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
                "FHKST03010200",
                {
                    "FID_COND_MRKT_DIV_CODE": market,
                    "FID_INPUT_ISCD": "",
                    "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"),
                    "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"),
                    "FID_PW_DATA_INCU_YN": "Y",
                },
            )
            # 실제 구현 필요
            return []
        except Exception as e:
            logger.error(f"등락률 상위 조회 실패: {e}")
            return []
    
    def get_investor_trend(self, stock_code, days=5):
        """외국인/기관 매매 동향 조회"""
        path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
        tr_id = "FHKST03010100"
        try:
            # 일봉 데이터에서 외국인/기관 정보 추출
            end_dt = dt.now()
            start_dt = end_dt - datetime.timedelta(days=days + 10)
            start_ymd = start_dt.strftime("%Y%m%d")
            end_ymd = end_dt.strftime("%Y%m%d")
            
            params = {
                "FID_COND_MRKT_DIV_CODE": "J",
                "FID_INPUT_ISCD": stock_code,
                "FID_INPUT_DATE_1": start_ymd,
                "FID_INPUT_DATE_2": end_ymd,
                "FID_PERIOD_DIV_CODE": "D",
                "FID_ORG_ADJ_PRC": "1",
            }
            logger.debug(f"[투자자동향] 호출 code={stock_code} path={path} TR_ID={tr_id}")
            r = self._get(path, tr_id, params)
            if r.status_code != 200:
                logger.warning(f"[투자자동향] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} status={r.status_code}")
                return None
            
            j = r.json()
            if j.get("rt_cd") != "0":
                logger.warning(
                    f"[투자자동향] 오류 code={stock_code} path={path} TR_ID={tr_id} "
                    f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
                )
                return None
            
            out2 = j.get("output2", [])
            if not out2:
                return None
            
            # 최근 N일 외국인/기관 순매수 합계
            foreign_sum = 0
            org_sum = 0
            
            for item in out2[:days]:
                try:
                    foreign_raw = item.get("frgn_ntby_qty") or item.get("frgn_ntby_shnu") or "0"
                    foreign_net = int(float(str(foreign_raw).replace(",", "").replace("+", "").replace("-", "")))
                    if str(foreign_raw).startswith("-"):
                        foreign_net = -foreign_net
                    
                    org_raw = item.get("orgn_ntby_qty") or "0"
                    org_net = int(float(str(org_raw).replace(",", "").replace("+", "").replace("-", "")))
                    if str(org_raw).startswith("-"):
                        org_net = -org_net
                    
                    foreign_sum += foreign_net
                    org_sum += org_net
                except:
                    continue
            
            return {
                "foreign_net_buy": foreign_sum,
                "org_net_buy": org_sum,
                "total_net_buy": foreign_sum + org_sum,
            }
        except Exception as e:
            logger.error(f"외국인/기관 동향 조회 실패({stock_code}): {e}")
            return None

    def get_daily_chart(self, code, limit=10):
        """일봉 차트 조회 [v1_국내주식-017] - 거래대금(대/중/소형) 계산용"""
        path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
        tr_id = "FHKST03010100"
        try:
            end_dt = dt.now()
            start_dt = end_dt - datetime.timedelta(days=limit + 30)
            start_ymd = start_dt.strftime("%Y%m%d")
            end_ymd = end_dt.strftime("%Y%m%d")
            params = {
                "FID_COND_MRKT_DIV_CODE": "J",
                "FID_INPUT_ISCD": code,
                "FID_INPUT_DATE_1": start_ymd,
                "FID_INPUT_DATE_2": end_ymd,
                "FID_PERIOD_DIV_CODE": "D",
                "FID_ORG_ADJ_PRC": "1",
            }
            logger.debug(f"[일봉차트] 호출 code={code} path={path} TR_ID={tr_id}")
            r = self._get(path, tr_id, params)
            if r.status_code != 200:
                logger.warning(f"[일봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}")
                return pd.DataFrame()
            j = r.json()
            if j.get("rt_cd") != "0":
                logger.warning(
                    f"[일봉차트] 오류 code={code} path={path} TR_ID={tr_id} "
                    f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
                )
                return pd.DataFrame()
            data = j.get("output2", [])
            if not data:
                return pd.DataFrame()
            rows = []
            for item in data:
                try:
                    rows.append({
                        "date": item.get("stck_bsop_date", ""),
                        "open": abs(float(item.get("stck_oprc", 0))),
                        "high": abs(float(item.get("stck_hgpr", 0))),
                        "low": abs(float(item.get("stck_lwpr", 0))),
                        "close": abs(float(item.get("stck_clpr", 0))),
                        "volume": int(item.get("acml_vol", 0)),
                    })
                except Exception:
                    continue
            if not rows:
                return pd.DataFrame()
            df = pd.DataFrame(rows)
            df = df.sort_values("date").reset_index(drop=True)
            return df.tail(limit)
        except Exception as e:
            logger.error(f"일봉 조회 실패({code}): {e}")
            return pd.DataFrame()

    def buy_order(self, code, qty, price=0, order_type="01"):
        """
        매수 주문 (모의=VTTC0802U, 실전=TTTC0802U)
        
        Args:
            code: 종목코드
            qty: 수량
            price: 가격 (0이면 시장가)
            order_type: 주문구분
                - "01": 시장가
                - "00": 지정가
                - "05": 조건부지정가
                - "06": 최유리지정가
                - "07": 최우선지정가
                - "10": 보통(IOC)
                - "13": 시장가(IOC)
                - "16": 최유리(IOC)
                - "20": 보통(FOK)
                - "23": 시장가(FOK)
                - "26": 최유리(FOK)
        """
        try:
            if self.mock:
                tr_id = "VTTC0802U"
            else:
                tr_id = "TTTC0802U"
            if price > 0:
                ord_unpr = str(price)
            else:
                ord_unpr = "0"
            path = "/uapi/domestic-stock/v1/trading/order-cash"
            body = {
                "CANO": self.acc_no,
                "ACNT_PRDT_CD": self.acc_code,
                "PDNO": code,
                "ORD_DVSN": order_type,
                "ORD_QTY": str(qty),
                "ORD_UNPR": ord_unpr,
            }
            logger.debug(f"[매수주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}")
            r = self._post(path, tr_id, body, use_hashkey=True)
            if r.status_code != 200:
                logger.error(f"[매수주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}")
                return False
            j = r.json()
            if j.get("rt_cd") == "0":
                self._last_order_msg_cd = None
                self._last_order_msg1 = None
                ord_no = j.get("output", {}).get("ODNO", "")
                logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})")
                # 체결 확인용으로 주문번호 반환 (실패 시 False)
                return ord_no if ord_no else True
            else:
                # 매매불가 등 실패 시 bot에서 당일 제외용으로 구분할 수 있도록 저장
                self._last_order_msg_cd = j.get("msg_cd", "")
                self._last_order_msg1 = str(j.get("msg1", "") or "")
                logger.error(
                    f"[매수주문] 실패 code={code} path={path} TR_ID={tr_id} "
                    f"rt_cd={j.get('rt_cd')} msg_cd={self._last_order_msg_cd} msg1={self._last_order_msg1}"
                )
                return False
        except Exception as e:
            self._last_order_msg_cd = None
            self._last_order_msg1 = None
            logger.error(f"매수 주문 예외({code}): {e}")
            return False

    def buy_market_order(self, code, qty):
        """
        시장가 매수 주문.
        - 모의투자: FOK/IOC 미지원이므로 무조건 "01" 일반 시장가.
        - 실전: USE_MARKET_IOC=true면 "13" 시장가 IOC, false면 "01" 일반 시장가.
        """
        if self.mock:
            order_type = "01"
        else:
            use_ioc = get_env_from_db("USE_MARKET_IOC", "true").strip().lower() in ("true", "1", "yes")
            order_type = "13" if use_ioc else "01"
        return self.buy_order(code, qty, price=0, order_type=order_type)

    def sell_order(self, code, qty, price=0, order_type="01"):
        """
        매도 주문 (모의=VTTC0801U, 실전=TTTC0801U)
        
        Args:
            code: 종목코드
            qty: 수량
            price: 가격 (0이면 시장가)
            order_type: 주문구분 (buy_order와 동일)
        """
        try:
            if self.mock:
                tr_id = "VTTC0801U"
            else:
                tr_id = "TTTC0801U"
            if price > 0:
                ord_unpr = str(price)
            else:
                ord_unpr = "0"
            path = "/uapi/domestic-stock/v1/trading/order-cash"
            body = {
                "CANO": self.acc_no,
                "ACNT_PRDT_CD": self.acc_code,
                "PDNO": code,
                "ORD_DVSN": order_type,
                "ORD_QTY": str(qty),
                "ORD_UNPR": ord_unpr,
            }
            logger.debug(f"[매도주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}")
            r = self._post(path, tr_id, body, use_hashkey=True)
            if r.status_code != 200:
                logger.error(f"[매도주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}")
                return False
            j = r.json()
            if j.get("rt_cd") == "0":
                ord_no = j.get("output", {}).get("ODNO", "")
                logger.info(f"✅ 매도 주문 성공: {code} {qty}주 (주문번호: {ord_no})")
                return True
            else:
                logger.error(
                    f"[매도주문] 실패 code={code} path={path} TR_ID={tr_id} "
                    f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
                )
                return False
        except Exception as e:
            logger.error(f"매도 주문 예외({code}): {e}")
            return False

    def sell_market_order(self, code, qty):
        """시장가 매도 주문 (간편 메서드)"""
        return self.sell_order(code, qty, price=0, order_type="01")

    def get_minute_chart(self, code, period="3", limit=100):
        """분봉 차트 조회 [v1_국내주식-017]"""
        path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice"
        tr_id = "FHKST03010200"
        try:
            end_dt = dt.now()
            start_dt = end_dt - datetime.timedelta(days=1)
            start_ymd = start_dt.strftime("%Y%m%d")
            end_ymd = end_dt.strftime("%Y%m%d")
            
            # 분봉 코드: 1분=1, 3분=3, 5분=5, 10분=10, 30분=30, 60분=60
            period_map = {"1": "1", "3": "3", "5": "5", "10": "10", "30": "30", "60": "60"}
            period_code = period_map.get(str(period), "3")
            
            params = {
                "FID_COND_MRKT_DIV_CODE": "J",
                "FID_INPUT_ISCD": code,
                "FID_INPUT_HOUR_1": start_ymd,
                "FID_INPUT_HOUR_2": end_ymd,
                "FID_PW_DATA_INCU_YN": "Y",
                "FID_ETC_CLS_CODE": "",  # 기타분류코드 (필수 파라미터, 빈 값 가능)
            }
            logger.debug(f"[분봉차트] 호출 code={code} period={period} path={path} TR_ID={tr_id}")
            r = self._get(path, tr_id, params)
            if r.status_code != 200:
                logger.warning(f"[분봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}")
                return pd.DataFrame()
            j = r.json()
            if j.get("rt_cd") != "0":
                logger.warning(
                    f"[분봉차트] 오류 code={code} path={path} TR_ID={tr_id} "
                    f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}"
                )
                return pd.DataFrame()
            
            data = j.get("output2", [])
            if not data:
                return pd.DataFrame()
            
            rows = []
            for item in data:
                try:
                    # 정렬용: 영업일자+체결시각(있으면) → 마지막 봉이 실제 최신봉이 되도록
                    date_str = str(item.get("stck_bsop_date", "") or "")
                    time_str = str(item.get("stck_cntg_hour", "") or item.get("cntg_hour", "") or "000000")
                    sort_key = date_str + time_str
                    rows.append({
                        "time": sort_key,
                        "open": abs(float(item.get("stck_oprc", 0))),
                        "high": abs(float(item.get("stck_hgpr", 0))),
                        "low": abs(float(item.get("stck_lwpr", 0))),
                        "close": abs(float(item.get("stck_clpr", 0))),
                        "volume": int(item.get("acml_vol", 0)),
                    })
                except Exception:
                    continue
            
            if not rows:
                return pd.DataFrame()
            
            df = pd.DataFrame(rows)
            df = df.sort_values("time").reset_index(drop=True)
            
            # 기술적 지표 추가
            if len(df) >= 14:
                delta = df["close"].diff(1)
                gain = delta.where(delta > 0, 0).rolling(window=14).mean()
                loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean()
                rs = gain / loss.replace(0, float("nan"))
                df["RSI"] = 100 - (100 / (1 + rs))
            
            if len(df) >= 20:
                df["MA20"] = df["close"].rolling(window=20).mean()
            
            return df.tail(limit)
        except Exception as e:
            logger.error(f"분봉 조회 실패({code}): {e}")
            return pd.DataFrame()
    
    def get_orderbook(self, stock_code):
        """호가 조회 [v1_국내주식-009]"""
        try:
            r = self._get(
                "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
                "FHKST01010200",
                {
                    "FID_COND_MRKT_DIV_CODE": "J",
                    "FID_INPUT_ISCD": stock_code,
                },
            )
            if r.status_code != 200:
                return None
            j = r.json()
            if j.get("rt_cd") != "0":
                return None
            return j.get("output")
        except Exception as e:
            logger.error(f"호가 조회 실패({stock_code}): {e}")
            return None
    
    # ============================================================
    # 순위분석 API (키움 봇과 동일한 로직 + 레버리지/스팩/ETN 제외 옵션)
    # ============================================================
    
    @staticmethod
    def _is_valid_stock(name: str, code: str) -> bool:
        """
        종목 필터링 (키움 kiwoom_trader_ver2와 동일, ETF는 포함)
        - 스팩, ETN, 우선주, 레버리지, 인버스 등만 제외 (ETF는 위험도 낮아 포함)
        """
        if not code or len(code) != 6 or not code.isdigit():
            return False
        name = (name or "").strip()
        exclude = [
            "스팩", "SPAC", "ETN", "W", "ELW", "채권",
            "레버리지", "인버스", "곱버스", "선물", "콜", "풋",
            "2X", "3X", "합성", "H", "B",
        ]
        # ETF는 exclude 목록에 없음 → 일반 주식·ETF 모두 통과
        if any(k in name for k in exclude):
            return False
        if name.endswith("우") or name.endswith("우B"):
            return False
        return True

    def _filter_rank_by_valid_stock(self, rank_list: list) -> list:
        """랭크 API 응답 리스트에서 스팩/ETN/레버리지 등 제외 (키움 옵션과 동일)"""
        if not rank_list:
            return []
        filtered = []
        for item in rank_list:
            code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip()
            name = (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip()
            if self._is_valid_stock(name, code):
                filtered.append(item)
        return filtered

    def _fetch_volume_rank_paged(
        self,
        market: str,
        blng_cls_code: str,
        limit: int,
        exclude_spec_etn_leverage: bool,
    ) -> list:
        """
        거래량순위 API 1회 조회 (한투 volume-rank API는 다음 페이지 tr_cont 미지원 → 1회만 호출).
        """
        path = "/uapi/domestic-stock/v1/quotations/volume-rank"
        tr_id = "FHPST01710000"
        params = {
            "FID_COND_MRKT_DIV_CODE": market,
            "FID_COND_SCR_DIV_CODE": "20171",
            "FID_INPUT_ISCD": "0000",
            "FID_DIV_CLS_CODE": "0",
            "FID_BLNG_CLS_CODE": blng_cls_code,
            "FID_TRGT_CLS_CODE": "111111111",
            "FID_TRGT_EXLS_CLS_CODE": "0000000000",
            "FID_INPUT_PRICE_1": "0",
            "FID_INPUT_PRICE_2": "0",
            "FID_VOL_CNT": "0",
            "FID_INPUT_DATE_1": "",
        }
        try:
            time.sleep(0.5)
            r = self._get(path, tr_id, params, tr_cont=None)
            if r.status_code != 200:
                return []
            j = r.json()
            if j.get("rt_cd") != "0":
                return []
            output = j.get("output", [])
            if exclude_spec_etn_leverage:
                output = self._filter_rank_by_valid_stock(output)
            logger.info(f"   📡 [순위API] 수신 {len(output)}건 (다음페이지 미지원 → 1회만 호출)")
            return output[:limit]
        except Exception as e:
            logger.debug(f"거래량순위 조회 실패: {e}")
            return []

    def get_volume_rank(
        self,
        market: str = "J",
        limit: int = 50,
        exclude_spec_etn_leverage: bool = True,
    ):
        """
        거래량순위 조회 [v1_국내주식-047] (1회 호출, API가 반환한 건수만큼 수집)
        """
        try:
            output = self._fetch_volume_rank_paged(
                market=market,
                blng_cls_code="0",
                limit=limit,
                exclude_spec_etn_leverage=exclude_spec_etn_leverage,
            )
            return output
        except Exception as e:
            logger.debug(f"거래량순위 조회 실패: {e}")
            return []
    
    def get_price_change_rank(
        self,
        market: str = "J",
        sort_type: str = "1",
        limit: int = 50,
        exclude_spec_etn_leverage: bool = True,
    ):
        """
        등락률순위 조회 (동일 volume-rank API, FID_BLNG_CLS_CODE로 등락 구분)
        sort_type: "1"=상승률 상위, "2"=하락률 상위(낙폭 큰 종목, N자 망치봉 스캔에 유리)
        """
        # 한투 volume-rank API: 4=등락률(상승), 5=등락률(하락). 미지원 시 빈값/에러 가능.
        blng = "5" if sort_type == "2" else "4"
        try:
            out = self._fetch_volume_rank_paged(
                market=market,
                blng_cls_code=blng,
                limit=limit,
                exclude_spec_etn_leverage=exclude_spec_etn_leverage,
            )
            if out:
                return out
        except Exception as e:
            logger.debug(f"등락률순위(blng={blng}) 조회 실패: {e}")
        # API가 4/5 미지원이면 기존처럼 거래량순위로 fallback (상승 위주 후보 확보)
        if sort_type != "2":
            return self.get_volume_rank(market=market, limit=limit, exclude_spec_etn_leverage=exclude_spec_etn_leverage)
        return []

    def get_price_decline_rank(
        self,
        market: str = "J",
        limit: int = 100,
        exclude_spec_etn_leverage: bool = True,
    ):
        """하락률 순위(낙폭 큰 종목) 조회. N자 망치봉/개미털기 스캔 유니버스 확대용."""
        return self.get_price_change_rank(
            market=market,
            sort_type="2",
            limit=limit,
            exclude_spec_etn_leverage=exclude_spec_etn_leverage,
        )

    def get_trading_value_rank(
        self,
        market: str = "J",
        limit: int = 50,
        exclude_spec_etn_leverage: bool = True,
    ):
        """거래대금순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=3"""
        try:
            return self._fetch_volume_rank_paged(
                market=market,
                blng_cls_code="3",
                limit=limit,
                exclude_spec_etn_leverage=exclude_spec_etn_leverage,
            )
        except Exception as e:
            logger.debug(f"거래대금순위 조회 실패: {e}")
            return []

    def get_turnover_rank(
        self,
        market: str = "J",
        limit: int = 50,
        exclude_spec_etn_leverage: bool = True,
    ):
        """회전율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=2"""
        try:
            return self._fetch_volume_rank_paged(
                market=market,
                blng_cls_code="2",
                limit=limit,
                exclude_spec_etn_leverage=exclude_spec_etn_leverage,
            )
        except Exception as e:
            logger.debug(f"회전율순위 조회 실패: {e}")
            return []

    def get_volume_growth_rank(
        self,
        market: str = "J",
        limit: int = 50,
        exclude_spec_etn_leverage: bool = True,
    ):
        """거래증가율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=1"""
        try:
            return self._fetch_volume_rank_paged(
                market=market,
                blng_cls_code="1",
                limit=limit,
                exclude_spec_etn_leverage=exclude_spec_etn_leverage,
            )
        except Exception as e:
            logger.debug(f"거래증가율순위 조회 실패: {e}")
            return []

    def get_execution_strength_rank(
        self,
        market: str = "J",
        limit: int = 200,
        exclude_spec_etn_leverage: bool = True,
    ):
        """체결강도 상위 순위 조회 (FHPST01710000, FID_BLNG_CLS_CODE=6). 매수세 강한 종목 필터용."""
        try:
            return self._fetch_volume_rank_paged(
                market=market,
                blng_cls_code="6",
                limit=limit,
                exclude_spec_etn_leverage=exclude_spec_etn_leverage,
            )
        except Exception as e:
            logger.debug(f"체결강도순위 조회 실패: {e}")
            return []

    def get_execution_strength_map(self, market: str = "J", limit: int = 200):
        """
        체결강도 상위 API 조회 후 종목코드 -> 체결강도 값 매핑 반환.
        output 필드: cntr_str(체결강도) 등 한투 문서 기준으로 파싱. 미제공 시 0.
        """
        strength_map = {}
        try:
            rows = self.get_execution_strength_rank(market=market, limit=limit, exclude_spec_etn_leverage=True)
            for item in (rows or []):
                code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip()
                if not code or len(code) != 6:
                    continue
                # 한투 volume-rank 체결강도: cntr_str 또는 유사 필드 (문서 확인 후 조정)
                raw = item.get("cntr_str") or item.get("exec_str") or item.get("strg_rt") or item.get("prdy_ctrt") or ""
                try:
                    strength = float(str(raw).replace(",", "").strip()) if raw else 0
                except (ValueError, TypeError):
                    strength = 0
                strength_map[code] = strength
        except Exception as e:
            logger.debug(f"체결강도 맵 조회 실패: {e}")
        return strength_map


# ============================================================
# Mattermost 봇 클래스
# ============================================================
class MattermostBot:
    """Mattermost 알림 봇"""
    def __init__(self):
        self.api_url = f"{MM_SERVER_URL.rstrip('/')}/api/v4/posts"
        self.headers = {
            "Authorization": f"Bearer {MM_BOT_TOKEN}",
            "Content-Type": "application/json"
        }
        self.channels = self._load_channels()

    def _load_channels(self):
        """채널 설정 로드"""
        try:
            if MM_CONFIG_FILE.exists():
                with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f:
                    return json.load(f).get("channels", {})
            return {}
        except Exception as e:
            logger.error(f"⚠️ MM 설정 로드 실패: {e}")
            return {}

    def send(self, channel_alias, message):
        """메시지 전송"""
        channel_id = self.channels.get(channel_alias)
        if not channel_id:
            logger.warning(f"❌ '{channel_alias}' 채널 ID 없음")
            return False

        payload = {"channel_id": channel_id, "message": message}
        try:
            res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3)
            res.raise_for_status()
            return True
        except Exception as e:
            logger.error(f"❌ MM 전송 에러: {e}")
            return False


def _save_ai_recommendations_from_text(db_instance, analysis_text: str):
    """AI 분석문에서 'KEY=값' 추천 줄만 추출해 DB에 저장 (!적용 시 사용)."""
    if not analysis_text or not db_instance:
        return
    valid_keys = set(ENV_CONFIG_KEYS)
    lines = []
    for line in analysis_text.splitlines():
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        m = re.match(r"^([A-Z][A-Z0-9_]*)=(.+)$", line)
        if m and m.group(1) in valid_keys:
            lines.append(f"{m.group(1)}={m.group(2).strip()}")
    if lines:
        db_instance.set_last_ai_recommendations("\n".join(lines))


# ============================================================
# 단타 트레이딩 봇
# ============================================================
class ShortTradingBot:
    """단타용 트레이딩 봇 - 개미털기(눌림목) 전략"""
    def __init__(self):
        self.db = db
        self.client = KISClient()
        
        # Mattermost 초기화
        self.mm = MattermostBot()
        # 단타 봇 전용 채널(alias) 우선 사용, 없으면 기본 채널 사용
        self.mm_channel = MM_CHANNEL_SHORT
        
        # ML 예측 초기화 (선택적)
        self.ml_predictor = None
        if ML_AVAILABLE:
            try:
                self.ml_predictor = MLPredictor(db_path=str(SCRIPT_DIR / "quant_bot.db"))
                if self.ml_predictor.should_retrain():
                    self.ml_predictor.train_model(retrain=True)
            except Exception as e:
                logger.warning(f"⚠️ ML 예측 초기화 실패: {e}")
        
        # RiskManager 초기화 (뼈대만 생성 → reload_config에서 DB 값으로 실시간 갱신)
        self.risk_mgr = None
        if RISK_MANAGER_AVAILABLE:
            self.risk_mgr = RiskManager(
                risk_pct_per_trade=get_env_float("RISK_PCT_PER_TRADE", 0.01),
                max_position_pct=get_env_float("MAX_POSITION_PCT", 0.15),
                min_position_amount=get_env_int("MIN_POSITION_AMOUNT", 50000),
                use_kelly=get_env_bool("USE_KELLY_FORMULA", True),
                kelly_multiplier=get_env_float("KELLY_MULTIPLIER", 0.25),
                slot_base_amount_cap=get_env_int("SLOT_BASE_AMOUNT_CAP", 0),
            )
            logger.info("✅ RiskManager 뼈대 생성 완료")
        else:
            logger.warning("⚠️ RiskManager 미사용: 고정 슬롯 금액 방식으로 폴백")

        # ★ [실시간 리로드] DB에서 최신 설정값을 불러와 봇·RiskManager에 반영 (재시작 없이 적용)
        self.reload_config()

        # 리포트 플래그 (reload_config 이후 유지)
        self.morning_report_sent = False
        self.closing_report_sent = False
        self.final_report_sent = False
        self.ai_report_sent = False
        
        # 자산 추적
        self.today_date = dt.now().strftime("%Y-%m-%d")
        self.start_day_asset = 0
        self.current_total_asset = 0
        self.current_cash = 0
        self.d2_excc_amt = 0   # D+2 예수금 (output2 prvs_rcdl_excc_amt)
        self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)
        
        # DB에서 활성 트레이드 로드 (단타만: SHORT_% - 늘림목과 섞이지 않도록)
        self.holdings = {}
        # 당일 매매불가로 확인된 종목 (같은 종목 반복 주문 방지 → 다음 후보로 넘어감)
        self.untradable_skip_set = set()
        active_trades = self.db.get_active_trades(strategy_prefix="SHORT")
        for code, trade in active_trades.items():
            self.holdings[code] = {
                "buy_price": trade.get("avg_buy_price", 0),
                "qty": trade.get("current_qty", 0),
                "buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")),
                "name": trade.get("name", code),
            }
        
        # ★ 계좌 잔고 API와 동기화 (폰/타 앱으로 산 종목 반영)
        balance = self.client.get_account_balance()
        if balance is not None:
            self._sync_holdings_from_balance(balance)
            self._update_assets(balance=balance)  # 이미 받은 잔고로 자산 갱신 (API 1회만)
        else:
            self._update_assets()
        
        # 비동기 태스크 관리
        self._universe_task = None
        self._report_task = None
        self._asset_task = None
        self.is_first_run = True

    def reload_config(self):
        """[실시간 리로드] DB(env) 설정을 봇에 반영. 메인 루프마다 호출 시 재시작 없이 적용."""
        # [손절/익절 설정]
        self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04)
        self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05)
        self.max_stocks = get_env_int("MAX_STOCKS", 3)
        self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03)
        self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5)

        self.stop_atr_multiplier = get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5)
        self.target_atr_multiplier = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 8.0)
        self.min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0)

        # [리스크 관리 설정]
        self.risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.01)
        self.kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25)
        self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.15)
        self.min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 50000)
        use_kelly = get_env_bool("USE_KELLY_FORMULA", True)

        if self.risk_mgr is not None:
            self.risk_mgr.risk_pct = self.risk_pct_per_trade
            self.risk_mgr.max_pos_pct = self.max_position_pct
            self.risk_mgr.min_amount = self.min_position_amount
            self.risk_mgr.use_kelly = use_kelly
            self.risk_mgr.kelly_mult = self.kelly_multiplier

        # ML 신호 필터링
        self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False)
        self.ml_min_probability = get_env_float("ML_MIN_PROBABILITY", 0.57)

        # 자산 기준 (리포트용)
        self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0)

    def _get_effective_slot_cap(self) -> float:
        """
        슬롯당 실투입 상한 금액 계산.
        - env: SLOT_BASE_AMOUNT_CAP (직접 지정)
        - env: MAX_LOSS_PER_TRADE_KRW + STOP_LOSS_PCT 로부터 역산
          (MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT|)
        둘 다 설정되어 있으면 더 작은 값 사용.
        설정이 없으면 0 리턴(제한 없음).
        """
        slot_cap_env = get_env_float("SLOT_BASE_AMOUNT_CAP", 0.0)
        max_loss_krw = get_env_float("MAX_LOSS_PER_TRADE_KRW", 0.0)
        stop_abs = abs(self.stop_loss_pct) if self.stop_loss_pct != 0 else 0.0
        derived_cap = 0.0
        if max_loss_krw > 0 and stop_abs > 0:
            try:
                derived_cap = max_loss_krw / stop_abs
            except Exception:
                derived_cap = 0.0
        candidates = [c for c in (slot_cap_env, derived_cap) if c and c > 0]
        if not candidates:
            return 0.0
        return float(min(candidates))

    def _seconds_until_next_5min(self):
        """다음 5분 정각까지 남은 초 계산"""
        now = dt.now()
        next_min = ((now.minute // 5) + 1) * 5
        if next_min >= 60:
            next_time = now.replace(hour=now.hour + 1, minute=0, second=0, microsecond=0)
        else:
            next_time = now.replace(minute=next_min, second=0, microsecond=0)
        return (next_time - now).total_seconds()
    
    def update_universe(self):
        """
        유니버스 업데이트 (5분마다 호출) - 키움 봇과 동일한 복합 스캔 로직
        - 개미털기 우선 (원본 점수 유지)
        - 외국인/거래량/상승률/기관 추가 (보너스 점수)
        - 강도 순으로 정렬 → Top 30
        """
        logger.info(f"🔄 [유니버스 업데이트] 시작 | 예수금: {self.current_cash:,.0f}원")
        logger.info("📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스")
        
        try:
            # 매수 가능 금액 계산 (수치: env/DB)
            if self.max_stocks > 0:
                slot_money = int(self.current_cash * 0.9 / self.max_stocks)
            else:
                slot_money = int(get_env_float("SLOT_MONEY_DEFAULT", 100000.0))
            # 슬롯 캡(손절 기반 상한) 함께 고려 → 유니버스 단계에서부터 가격 상한 필터
            effective_slot_cap = self._get_effective_slot_cap()
            price_cap = effective_slot_cap if effective_slot_cap > 0 else slot_money
            
            all_candidates = {}  # {code: {name, price, base_score, bonus_score, total_score}}
            
            # 이번 스캔 주기용으로 후보 테이블 비움 (통과 시마다 add_target_candidate로 채워짐)
            self.db.update_target_candidates([])
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 1. 개미털기 (눌림목) - 원본 점수 100% 유지! (통과 시마다 즉시 add_target_candidate)
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            try:
                ant_shaking = self.scan_ant_shaking_candidates(max_candidates=50)
                logger.info(f"   ✅ [개미털기] {len(ant_shaking)}개 수집")
                # 키움과 동일: 강도 = 낙폭률×100. 단, 분봉 대신 단일 시세(inquire_price) 기반으로 당일 전체 낙폭 계산.
                drop_delay = max(0.3, get_env_float("API_DELAY_DROP_SCORE_SEC", 0.5))
                max_drop_score_calls = min(get_env_int("SCAN_ANT_DROP_SCORE_MAX", 30), len(ant_shaking))
                scan_min_price = get_env_float("SCAN_MIN_PRICE", get_env_float("MIN_PRICE_TAIL", 1000.0))
                scan_min_drop = get_env_float("SCAN_ANT_MIN_DROP_RATE", 0.03)
                scan_max_change = get_env_float("SCAN_MAX_DAILY_CHANGE_PCT", get_env_float("MAX_DAILY_CHANGE_PCT", 20.0))
                real_drop_count = 0
                for idx, item in enumerate(ant_shaking):
                    if idx >= max_drop_score_calls:
                        break
                    code = item.get('code')
                    if not code:
                        continue
                    base = float(item.get('score', 0)) if item.get('score', 0) else 0.0
                    drop_rate = item.get('drop_rate', 0)
                    recovery = item.get('recovery', 0)
                    price = item.get('price', 0) or 0
                    # 이미 점수가 있는 경우(외부 계산 값)는 그대로 두고, 없는 경우에만 한투 시세로 강도 재계산
                    if base <= 0:
                        try:
                            time.sleep(drop_delay)
                            price_data = self.client.inquire_price(code)
                            if not price_data:
                                continue
                            day_open = abs(float(str(price_data.get("stck_oprc", 0)).replace(",", "").strip() or 0))
                            day_high = abs(float(str(price_data.get("stck_hgpr", 0)).replace(",", "").strip() or 0))
                            day_low = abs(float(str(price_data.get("stck_lwpr", 0)).replace(",", "").strip() or 0))
                            day_close = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", "").strip() or 0))
                            if not price and day_close > 0:
                                price = day_close
                            # 가격·급등 필터 (키움 개미털기 스캔과 유사하게 동전주/상한가 근처 제외)
                            price_for_filter = price or day_close
                            if price_for_filter > 0 and price_for_filter < scan_min_price:
                                logger.info(
                                    f"{LOG_YELLOW}🔍 [탈락-가격(스캔)] {item.get('name', code)} {code}: "
                                    f"현재가 {price_for_filter:,.0f}원 < 최소 {scan_min_price:,.0f}원{LOG_RESET}"
                                )
                                continue
                            if day_open <= 0 or day_low <= 0:
                                continue
                            drop_rate = (day_open - day_low) / day_open
                            day_range = day_high - day_low
                            recovery = (day_close - day_low) / day_range if day_range > 0 else 0
                            drop_rate = max(0.0, min(1.0, drop_rate))
                            recovery = max(0.0, min(1.0, recovery))
                            change_pct = (day_close - day_open) / day_open * 100.0 if day_open > 0 else 0.0
                            if change_pct > scan_max_change:
                                logger.info(
                                    f"{LOG_YELLOW}🔍 [탈락-급등(스캔)] {item.get('name', code)} {code}: "
                                    f"일간 등락 {change_pct:.1f}% > 허용 {scan_max_change:.1f}%{LOG_RESET}"
                                )
                                continue
                            if drop_rate >= scan_min_drop:
                                base = drop_rate * 100.0
                                real_drop_count += 1
                            else:
                                # 낙폭이 기준 미만이면 개미털기 강도는 0으로 두고, 다른 랭킹 보너스만 사용
                                base = 0.0
                        except Exception as e:
                            logger.debug(f"개미털기 강도 조회 실패({code}): {e}")
                            continue
                    # ant_shaking 쪽도 슬롯 캡 기반 가격 상한 적용 (day_close 기준)
                    if price_cap > 0 and price and price > price_cap:
                        logger.info(
                            f"{LOG_YELLOW}🔍 [탈락-가격(슬롯캡)] {item.get('name', code)} {code}: "
                            f"현재가 {price:,.0f}원 > 슬롯캡 {price_cap:,.0f}원{LOG_RESET}"
                        )
                        continue
                    all_candidates[code] = {
                        'code': code,
                        'name': item.get('name', code),
                        'price': price,
                        'base_score': base,
                        'bonus_score': 0.0,
                        'from_ant': True,
                        'drop_rate': max(0.0, min(1.0, drop_rate)),
                        'recovery': max(0.0, min(1.0, recovery)),
                    }
                if max_drop_score_calls > 0:
                    logger.info(f"   📊 [개미털기 강도] 낙폭 실계산 {real_drop_count}명, fallback 없음 (기준 미달/조회 실패는 강도 0 처리)")
            except Exception as e:
                logger.warning(f"   ⚠️ [개미털기] 수집 실패: {e}")
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 2. 거래량순위 - 보너스 +0.3 (순위별 가산점)
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            try:
                volume_rank = self.client.get_volume_rank(market="J", limit=50)
                logger.info(f"   ✅ [거래량순위] {len(volume_rank)}개 수집")
                for idx, item in enumerate(volume_rank):
                    code = item.get('stk_cd', '').strip() or item.get('code', '').strip()
                    if not code or len(code) != 6:
                        continue
                    bonus = (50 - idx) / 50.0 * 0.3  # 순위별 보너스 (최대 +0.3)
                    
                    if code in all_candidates:
                        all_candidates[code]['bonus_score'] += bonus
                    else:
                        # 신규 추가 (가격 확인 필요)
                        price_data = self.client.inquire_price(code)
                        if price_data:
                            current_price = abs(float(price_data.get("stck_prpr", 0)))
                            if current_price > 0 and (price_cap <= 0 or current_price <= price_cap):
                                all_candidates[code] = {
                                    'code': code,
                                    'name': item.get('stk_nm', code),
                                    'price': current_price,
                                    'base_score': 0.0,
                                    'bonus_score': bonus,
                                    'from_ant': False,
                                }
            except Exception as e:
                logger.warning(f"   ⚠️ [거래량순위] 수집 실패: {e}")
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 3. 등락률순위 (상승률) - 보너스 +0.2
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            try:
                price_movers = self.client.get_price_change_rank(market="J", sort_type="1", limit=30)
                logger.info(f"   ✅ [등락률순위] {len(price_movers)}개 수집")
                for idx, item in enumerate(price_movers):
                    code = item.get('stk_cd', '').strip() or item.get('code', '').strip()
                    if not code or len(code) != 6:
                        continue
                    bonus = (30 - idx) / 30.0 * 0.2  # 순위별 보너스 (최대 +0.2)
                    
                    if code in all_candidates:
                        all_candidates[code]['bonus_score'] += bonus
                    else:
                        price_data = self.client.inquire_price(code)
                        if price_data:
                            current_price = abs(float(price_data.get("stck_prpr", 0)))
                            if current_price > 0 and (price_cap <= 0 or current_price <= price_cap):
                                all_candidates[code] = {
                                    'code': code,
                                    'name': item.get('stk_nm', code),
                                    'price': current_price,
                                    'base_score': 0.0,
                                    'bonus_score': bonus,
                                    'from_ant': False,
                                }
            except Exception as e:
                logger.warning(f"   ⚠️ [등락률순위] 수집 실패: {e}")
            
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 4. 거래대금순위 - 보너스 +0.2
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            try:
                value_rank = self.client.get_trading_value_rank(market="J", limit=30)
                logger.info(f"   ✅ [거래대금순위] {len(value_rank)}개 수집")
                for idx, item in enumerate(value_rank):
                    code = item.get('stk_cd', '').strip() or item.get('code', '').strip()
                    if not code or len(code) != 6:
                        continue
                    bonus = (30 - idx) / 30.0 * 0.2  # 순위별 보너스 (최대 +0.2)
                    
                    if code in all_candidates:
                        all_candidates[code]['bonus_score'] += bonus
            except Exception as e:
                logger.warning(f"   ⚠️ [거래대금순위] 수집 실패: {e}")
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 4-2. 테마/인기 종목 (단타 묘미 - 테마별로 빵 뜨는 종목 보너스)
            # 상승률·거래증가율 상위 = 테마성 수요 대리 지표 (추후 테마 API 연동 시 교체 가능)
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            try:
                theme_hot = self.client.get_price_change_rank(market="J", sort_type="1", limit=20)
                if theme_hot:
                    for idx, item in enumerate(theme_hot):
                        code = item.get("stk_cd", "").strip() or item.get("code", "").strip()
                        if not code or len(code) != 6:
                            continue
                        bonus = (20 - idx) / 20.0 * 0.15  # 순위별 보너스 최대 +0.15
                        if code in all_candidates:
                            all_candidates[code]["bonus_score"] += bonus
                        else:
                            price_data = self.client.inquire_price(code)
                            if price_data:
                                current_price = abs(float(price_data.get("stck_prpr", 0) or 0))
                                if current_price > 0 and (price_cap <= 0 or current_price <= price_cap):
                                    all_candidates[code] = {
                                        "code": code,
                                        "name": item.get("stk_nm", code),
                                        "price": current_price,
                                        "base_score": 0.0,
                                        "bonus_score": bonus,
                                        "from_ant": False,
                                    }
                    logger.info(f"   ✅ [테마/인기] 상승률 상위 20개 보너스 반영 (테마성 수요 대리)")
            except Exception as e:
                logger.warning(f"   ⚠️ [테마/인기] 수집 실패: {e}")
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반)
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 개미털기 후보에 대해서만 투자자 동향 확인 (API 호출 간격 넉넉히 → 초당 거래건수 초과 방지)
            investor_check_count = 0
            investor_delay = max(0.6, get_env_float("API_DELAY_INVESTOR_SEC", 0.8))  # 종목당 최소 0.8초
            for code, candidate in list(all_candidates.items()):
                if candidate.get('from_ant') and investor_check_count < 10:  # 최대 10개만 체크
                    try:
                        time.sleep(investor_delay)
                        investor_trend = self.client.get_investor_trend(code, days=2)
                        investor_check_count += 1
                        if investor_trend:
                            total_net = investor_trend.get("total_net_buy", 0)
                            if total_net > 20000:
                                candidate['bonus_score'] += 0.3  # 강한 매수세
                            elif total_net > 5000:
                                candidate['bonus_score'] += 0.15  # 매수세
                    except Exception as e:
                        logger.debug(f"투자자 동향 조회 실패({code}): {e}")
            
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            # 최종 점수 계산 및 정렬
            # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
            final_candidates = []
            for code, item in all_candidates.items():
                total_score = item['base_score'] + item['bonus_score']
                final_candidates.append({
                    'code': code,
                    'name': item['name'],
                    'price': item['price'],
                    'score': total_score,
                    'base_score': item['base_score'],
                    'bonus_score': item['bonus_score'],
                    'from_ant': item.get('from_ant', False),
                    'drop_rate': item.get('drop_rate', 0),
                    'recovery': item.get('recovery', 0),
                })
            
            # 강도 순 정렬 (total_score 기준)
            final_candidates.sort(key=lambda x: x['score'], reverse=True)
            
            min_score = get_env_float("UPDATE_UNIVERSE_MIN_SCORE", 4.0)
            fallback_top = get_env_int("UPDATE_UNIVERSE_FALLBACK_TOP_N", 30)
            filtered = [c for c in final_candidates if c['score'] >= min_score]
            if not filtered:
                logger.warning(f"   ⚠️ 강도 {min_score} 이상 후보 없음 (전체: {len(final_candidates)}개)")
                filtered = final_candidates[:fallback_top]

            # 전일 대비 하락률 하한 필터 (너무 박살난 종목 제외)
            min_prev_day_pct = get_env_float("SCAN_MIN_PREV_DAY_PCT", -100.0)
            if min_prev_day_pct > -100.0 and filtered:
                logger.info(f"   📉 [전일하락 하한] {min_prev_day_pct:.1f}% 미만 종목 제외")
                filtered_prev = []
                for c in filtered:
                    code = c["code"]
                    name = c.get("name", code)
                    try:
                        price_data = self.client.inquire_price(code)
                        if not price_data:
                            filtered_prev.append(c)
                            continue
                        cur = float(str(price_data.get("stck_prpr", 0)).replace(",", "") or 0)
                        prev = float(str(price_data.get("stck_prdy_clpr", 0)).replace(",", "") or 0)
                        if prev <= 0 or cur <= 0:
                            filtered_prev.append(c)
                            continue
                        change_pct = (cur - prev) / prev * 100.0
                        if change_pct < min_prev_day_pct:
                            logger.info(
                                f"{LOG_YELLOW}🔍 [탈락-전일하락] {name} {code}: 전일대비 {change_pct:.1f}% < {min_prev_day_pct:.1f}%{LOG_RESET}"
                            )
                            continue
                        filtered_prev.append(c)
                    except Exception as e:
                        logger.debug(f"전일하락 필터 조회 실패({name} {code}): {e}")
                        filtered_prev.append(c)
                filtered = filtered_prev

            # 최소 후보 수 확보 (강도 기준을 통과한 종목이 적을 때, 나머지는 점수순으로 채움)
            min_candidates = get_env_int("UPDATE_UNIVERSE_MIN_CANDIDATES", 0)
            if min_candidates > 0 and len(filtered) < min_candidates and final_candidates:
                need = min_candidates - len(filtered)
                if need > 0:
                    existing = {c["code"] for c in filtered}
                    extras = []
                    for c in final_candidates:
                        if c["code"] in existing:
                            continue
                        extras.append(c)
                        existing.add(c["code"])
                        if len(extras) >= need:
                            break
                    if extras:
                        logger.info(f"   📋 [후보보강] 강도컷 통과 {len(filtered)}개 + 추가 {len(extras)}개 → 최소 {min_candidates}개 확보")
                        filtered.extend(extras)
            
            # 강도순 상위 N명만 DB 저장 (매수체크는 이 목록만 사용 → 잡주 제거)
            top_n_db = get_env_int("UPDATE_UNIVERSE_TOP_N", 20)
            top_for_db = filtered[:top_n_db]
            db_payload = [
                {"code": c["code"], "name": c["name"], "score": c["score"], "price": c.get("price", 0)}
                for c in top_for_db
            ]
            self.db.update_target_candidates(db_payload)
            logger.info(f"   💾 매수 후보군: 강도순 상위 {len(db_payload)}명 DB 반영 완료")
            
            top_log = get_env_int("UPDATE_UNIVERSE_TOP_LOG", 5)
            logger.info(f"   🔝 [유니버스 Top {top_log}] (강도 순)")
            
            # 상위 N종목의 전일 대비 정보 조회 (API 문서: stck_prdy_clpr = 전일 종가)
            def _safe_float(val):
                if not val:
                    return 0.0
                try:
                    return abs(float(str(val).replace(",", "").strip() or 0))
                except:
                    return 0.0
            
            # 전일대비 로그용 API 호출 간격 넉넉히 (초당 거래건수 초과 방지)
            prev_day_delay = max(0.5, get_env_float("API_DELAY_PREV_DAY_SEC", 1.0))  # 종목당 최소 1초
            prev_day_map = {}
            for x in filtered[:top_log]:
                code = x["code"]
                name = x.get("name", code)
                try:
                    time.sleep(prev_day_delay)
                    price_data = self.client.inquire_price(code)
                    if not price_data:
                        logger.info(f"   ⚠️ 전일대비: {name} {code} API응답없음")
                        continue
                    current_price = _safe_float(price_data.get("stck_prpr")) or _safe_float(x.get("price", 0))
                    prev_close = _safe_float(price_data.get("stck_prdy_clpr"))  # API 문서 필드명 그대로 사용
                    if prev_close <= 0:  # 없으면 일봉에서 전일 close
                        time.sleep(prev_day_delay)
                        df_daily = self.client.get_daily_chart(code, limit=3)
                        if not df_daily.empty and len(df_daily) >= 2:
                            prev_close = _safe_float(df_daily["close"].iloc[-2])
                            logger.info(f"   📊 전일대비: {name} {code} 일봉에서 전일종가 조회 성공 ({prev_close:,.0f}원)")
                        else:
                            logger.info(f"   ⚠️ 전일대비: {name} {code} 일봉데이터없음 (len={len(df_daily) if not df_daily.empty else 0})")
                    if prev_close > 0 and current_price > 0:
                        change = current_price - prev_close
                        change_pct = (change / prev_close) * 100
                        prev_day_map[code] = (change, change_pct)
                    else:
                        logger.info(f"   ⚠️ 전일대비: {name} {code} prev_close={prev_close}, current={current_price}")
                except Exception as e:
                    logger.warning(f"   ⚠️ 전일대비 조회 실패({name} {code}): {e}")
            
            for i, x in enumerate(filtered[:top_log], 1):
                base = x.get('base_score', 0)
                bonus = x.get('bonus_score', 0)
                total = x['score']
                drop_pct = x.get('drop_rate', 0) * 100
                recovery_pct = x.get('recovery', 0) * 100
                if x.get('from_ant'):
                    source = "개미털기"
                else:
                    source = "랭킹"
                if x.get('ml_probability'):
                    ml_info = f" | ML {x.get('ml_probability', 0):.1%}"
                else:
                    ml_info = ""
                
                # 전일 대비 등락률 표시
                prev_day_info = ""
                code = x['code']
                if code in prev_day_map:
                    change, change_pct = prev_day_map[code]
                    sign = "+" if change >= 0 else ""
                    prev_day_info = f" | 어제보다 {sign}{change:,.0f}원 ({sign}{change_pct:.2f}%)"
                
                logger.info(
                    f"      {i}. {x['name']} {x['code']}: "
                    f"강도 {total:.1f} (기본 {base:.1f} + 보너스 {bonus:.1f}) | "
                    f"낙폭 {drop_pct:.1f}% | 회복 {recovery_pct:.0f}% | {source}{ml_info}{prev_day_info}"
                )
            
            logger.info(f"   ✅ 최종 후보: {len(filtered)}개 (강도 {min_score} 이상: {len([c for c in final_candidates if c['score'] >= min_score])}개)")
            # [매터모스트 5분 주기 후보 알림] filtered 있으면 발송, 없으면 미발송
            if filtered:
                mm_msg = f"🐜 **개미털기 후보 TOP{top_log}** (스캔 {len(final_candidates)}개 중 {len(filtered)}개 통과)\n\n"
                for i, x in enumerate(filtered[:top_log], 1):
                    code_str = x['code']
                    name_str = x['name']
                    total = x['score']
                    
                    prev_info = ""
                    if code_str in prev_day_map:
                        change, change_pct = prev_day_map[code_str]
                        sign = "+" if change >= 0 else ""
                        prev_info = f" ({sign}{change_pct:.2f}%)"
                        
                    mm_msg += f"강도 {total:.1f} {name_str}{prev_info}\n"
                
                logger.info(f"   📤 [MM] 5분 주기 후보 알림 발송 중 (채널={self.mm_channel})")
                ok = self.send_mm(mm_msg)
                if ok:
                    logger.info(f"   📤 [MM] 후보 알림 발송 완료")
                else:
                    logger.warning(f"   📤 [MM] 후보 알림 발송 실패 (채널/토큰 확인)")
            else:
                logger.info(f"   📤 [MM] 후보 0개 → 알림 미발송")
        except Exception as e:
            logger.error(f"❌ 유니버스 업데이트 실패: {e}")
            import traceback
            logger.error(traceback.format_exc())
    
    async def _universe_scan_scheduler(self):
        """5분마다 정각에 유니버스 스캔 실행 (비동기 백그라운드)"""
        loop = asyncio.get_event_loop()
        while True:
            try:
                if self.is_first_run:
                    wait_sec = 0  # 첫 실행은 즉시
                else:
                    wait_sec = max(0, self._seconds_until_next_5min())
                if wait_sec > 0:
                    await asyncio.sleep(wait_sec)
                now = dt.now()
                logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}")
                # 동기 함수를 executor에서 실행 (메인 루프 블로킹 방지)
                await loop.run_in_executor(None, self.update_universe)
                self.is_first_run = False
                await asyncio.sleep(5)  # 스캔 직후 5초 대기 (과부하 방지)
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"❌ [스캔 스케줄러] 에러: {e}")
                await asyncio.sleep(60)
    
    async def _report_scheduler(self):
        """리포트 전송 스케줄러 (비동기 백그라운드)"""
        while True:
            try:
                await asyncio.sleep(60)  # 1분마다 체크
                now = dt.now()
                
                # 13:00 - 오전 리포트 + AI 리포트
                if now.hour == 13 and now.minute == 0 and not self.morning_report_sent:
                    loop = asyncio.get_event_loop()
                    await loop.run_in_executor(None, self.send_morning_report)
                    await loop.run_in_executor(None, self.send_ai_report)
                
                # 15:15 - 장마감 전 리포트
                elif now.hour == 15 and now.minute == 15 and not self.closing_report_sent:
                    loop = asyncio.get_event_loop()
                    await loop.run_in_executor(None, self.send_closing_report)
                
                # 15:35 - 최종 리포트
                elif now.hour == 15 and now.minute == 35 and not self.final_report_sent:
                    loop = asyncio.get_event_loop()
                    await loop.run_in_executor(None, self.send_final_report)
                
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"❌ [리포트 스케줄러] 에러: {e}")
                await asyncio.sleep(60)
    
    async def _asset_update_scheduler(self):
        """자산 정보 업데이트 스케줄러 (30분마다, 비동기 백그라운드)"""
        while True:
            try:
                await asyncio.sleep(60)  # 1분마다 체크
                now = dt.now()
                
                # 30분마다 자산 업데이트
                if now.minute % 30 == 0:
                    loop = asyncio.get_event_loop()
                    await loop.run_in_executor(None, self._update_assets)
                    await asyncio.sleep(60)  # 업데이트 후 1분 대기 (중복 방지)
                
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}")
                await asyncio.sleep(60)
        
    def _sync_holdings_from_balance(self, balance):
        """
        계좌 잔고 API 응답(output1)으로 self.holdings 동기화.
        폰·타 앱으로 매수한 종목이 계좌에 있으면 holdings에 반영하고, 매도한 종목은 제거.
        """
        try:
            output1 = balance.get("output1") or []
            if isinstance(output1, dict):
                output1 = [output1]
            if not isinstance(output1, list):
                return
            api_codes = set()
            for item in output1:
                code = (item.get("pdno") or item.get("PDNO") or "").strip()
                if not code:
                    continue
                # 보유수량 (한투: hldg_qty 등)
                qty_val = item.get("hldg_qty") or item.get("HLDG_QTY") or item.get("ord_qty") or item.get("ORD_QTY") or 0
                qty = int(float(str(qty_val).replace(",", ""))) if qty_val is not None else 0
                if qty <= 0:
                    continue
                api_codes.add(code)
                # 매입평균가 (한투 pchs_avg_pric). 없거나 0이면 평가금액-평가손익으로 역산
                avg_pr_raw = item.get("pchs_avg_pric") or item.get("PCHS_AVG_PRIC")
                api_buy_price = 0.0
                if avg_pr_raw is not None:
                    avg_pr_str = str(avg_pr_raw).replace(",", "").strip()
                    if avg_pr_str not in ("", "0", "0.0"):
                        try:
                            api_buy_price = abs(float(avg_pr_str))
                        except Exception:
                            api_buy_price = 0.0
                if api_buy_price <= 0:
                    try:
                        evlu_amt = float(item.get("evlu_amt") or item.get("EVLU_AMT") or 0)
                        evlu_pfls = float(item.get("evlu_pfls_amt") or item.get("EVLU_PFLS_AMT") or 0)
                        cost = evlu_amt - evlu_pfls
                        api_buy_price = cost / qty if qty and cost > 0 else 0.0
                    except Exception:
                        api_buy_price = 0.0
                if api_buy_price <= 0:
                    api_buy_price = 0.0
                # 매수일시 (API에 있으면 그때로, 없으면 현재)
                buy_time_str = item.get("fstc_pchs_dt") or item.get("FSTC_PCHS_DT") or item.get("pchs_dt") or item.get("PCHS_DT") or item.get("ord_dt") or ""
                buy_time_str = (buy_time_str or "").strip().replace("-", "").replace(" ", "").replace(":", "")
                if len(buy_time_str) >= 14:
                    try:
                        buy_time = dt.strptime(buy_time_str[:14], "%Y%m%d%H%M%S").strftime("%Y-%m-%d %H:%M:%S")
                    except Exception:
                        buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
                elif len(buy_time_str) >= 8:
                    try:
                        buy_time = dt.strptime(buy_time_str[:8], "%Y%m%d").strftime("%Y-%m-%d %H:%M:%S")
                    except Exception:
                        buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
                else:
                    buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
                name = (item.get("prdt_name") or item.get("PRDT_NAME") or item.get("prdt_name_eng") or code or "").strip()
                if not name:
                    name = code
                existing = self.holdings.get(code)
                # ⇒ buy_price 결정: API > 기존 holdings > DB 순서로 폴백
                buy_price = api_buy_price
                existing_buy_price = 0.0
                if existing:
                    try:
                        existing_buy_price = float(existing.get("buy_price", 0) or 0)
                    except Exception:
                        existing_buy_price = 0.0
                    if buy_price <= 0 and existing_buy_price > 0:
                        buy_price = existing_buy_price
                if buy_price <= 0:
                    db_trade = None
                    try:
                        if hasattr(self.db, "get_active_trade"):
                            db_trade = self.db.get_active_trade(code)
                    except Exception as e:
                        logger.debug(f"잔고동기화 DB 평단 조회 실패({code}): {e}")
                    if db_trade:
                        try:
                            db_buy_price = float(
                                db_trade.get("avg_buy_price")
                                or db_trade.get("buy_price")
                                or 0
                            )
                        except Exception:
                            db_buy_price = 0.0
                        if db_buy_price > 0:
                            buy_price = db_buy_price
                if existing:
                    if buy_price <= 0:
                        # 매입가를 신뢰할 수 없으면 기존 매입가를 보존하고 수량/이름만 갱신
                        logger.warning(
                            f"⚠️ [잔고동기화] {name} ({code}) 매입가 복원 실패 → 기존 매입가 유지"
                        )
                        existing["qty"] = qty
                        if name:
                            existing["name"] = name
                        continue
                    # 수량/매입가 API·DB 기준으로 갱신 (폰에서 추가 매수/일부 매도 반영)
                    existing["qty"] = qty
                    existing["buy_price"] = buy_price
                    if name:
                        existing["name"] = name
                    continue
                # API에만 있는 종목(폰 등 외부 매수) → holdings에 추가, 기본 손절/목표가 적용
                if buy_price <= 0:
                    logger.warning(
                        f"⚠️ [잔고동기화] {name} ({code}) 매입가 0/없음 → holdings 추가 스킵"
                    )
                    continue
                stop_price = buy_price * (1 + self.stop_loss_pct) if buy_price > 0 else 0
                target_price = buy_price * (1 + self.take_profit_pct) if buy_price > 0 else 0
                self.holdings[code] = {
                    "buy_price": buy_price,
                    "qty": qty,
                    "buy_time": buy_time,
                    "name": name,
                    "max_price": buy_price,
                    "atr_entry": buy_price * 0.01 if buy_price > 0 else 0,
                    "stop_price": stop_price,
                    "target_price": target_price,
                }
                self.db.upsert_trade({
                    "code": code,
                    "name": name,
                    "strategy": "SHORT_ANT_SHAKING",
                    "avg_buy_price": buy_price,
                    "current_price": buy_price,
                    "target_qty": qty,
                    "current_qty": qty,
                    "status": "HOLDING",
                    "buy_date": buy_time,
                    "stop_price": stop_price,
                    "target_price": target_price,
                    "atr_entry": self.holdings[code]["atr_entry"],
                })
                logger.info(f"📲 [잔고동기화] 외부 매수 반영: {name} ({code}) {qty}주 @ {buy_price:,.0f}원")
            # API에 없는 종목 제거 (다른 경로에서 매도된 경우). output1이 리스트일 때만 적용
            if isinstance(output1, list):
                for code in list(self.holdings.keys()):
                    if code not in api_codes:
                        name = self.holdings[code].get("name", code)
                        del self.holdings[code]
                        try:
                            self.db.close_trade(code=code, sell_price=0, sell_reason="잔고동기화(외부매도)")
                        except Exception as e:
                            logger.debug(f"잔고동기화 close_trade 스킵 {code}: {e}")
                        logger.info(f"📲 [잔고동기화] 보유 제거: {name} ({code}) - 계좌에 없음")
        except Exception as e:
            logger.warning(f"잔고 동기화 예외: {e}")

    def _update_assets(self, balance=None):
        """자산 정보 업데이트 (잔고 동기화 포함). balance 생략 시 API 호출."""
        try:
            if balance is None:
                balance = self.client.get_account_balance()
            if balance is None:
                logger.warning(
                    "💵 [예수금] get_account_balance가 None 반환 → 예수금 갱신 스킵 "
                    "(토큰·계좌·TR ID 확인. 모의=VTTC8434R, 실전=TTTC8434R)"
                )
                return
            # 폰/타 앱 매매 반영: 잔고 API 기준으로 holdings 동기화
            self._sync_holdings_from_balance(balance)
            # 한투 API: output1=주식 잔고(종목별), output2=예수금 관련(dnca_tot_amt 등) - 블로그·문서 기준
            def _parse_amt(v):
                if v is None or str(v).strip() == "":
                    return None
                return float(str(v).replace(",", "").strip())
            def _cash_block(obj):
                if not obj:
                    return {}
                if isinstance(obj, list) and obj:
                    return obj[0]
                if isinstance(obj, dict):
                    return obj
                return {}
            out2 = _cash_block(balance.get("output2"))
            if isinstance(balance.get("output1"), dict):
                out1 = balance.get("output1", {})
            else:
                out1 = {}
            ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash"))
            dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0
            # 거래가능 = D+2 예수금 (prvs_rcdl_excc_amt). 없으면 예수금총액 fallback
            prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt"))
            if prvs_rcdl is not None:
                self.d2_excc_amt = prvs_rcdl
            else:
                self.d2_excc_amt = 0
            if prvs_rcdl is not None and prvs_rcdl > 0:
                self.current_cash = prvs_rcdl
                logger.info(f"💵 [예수금] 거래가능=D+2예수금={self.current_cash:,.0f}원")
            else:
                self.current_cash = dnca_tot_val
                logger.info(f"💵 [예수금] 거래가능=예수금총액={self.current_cash:,.0f}원 (D+2 없음)")
            # 보유 종목 평가액 계산
            holdings_value = 0
            for code, holding in self.holdings.items():
                price_data = self.client.inquire_price(code)
                if price_data:
                    current_price = abs(float(price_data.get("stck_prpr", 0)))
                    holdings_value += current_price * holding["qty"]
            self.current_total_asset = self.current_cash + holdings_value
            if self.start_day_asset == 0:
                self.start_day_asset = self.current_total_asset
        except Exception as e:
            logger.error(f"자산 정보 업데이트 실패: {e}")
    
    def _update_account_light(self, profit_val=0):
        """
        경량 계좌 갱신 (매수/매도 직후 즉시 호출!)
        - API 부하를 줄이기 위해 예수금 + 보유 종목 평가액만 빠르게 계산
        - 총자산 = 예수금 + 보유 종목 평가액 (+ 손익 반영)
        """
        try:
            balance = self.client.get_account_balance()
            if balance is None:
                logger.warning("💵 [예수금-경량] get_account_balance None → 예수금 갱신 스킵")
                return False
            def _parse_amt_light(v):
                if v is None or str(v).strip() == "":
                    return None
                return float(str(v).replace(",", "").strip())
            def _cash_block_light(obj):
                if not obj:
                    return {}
                if isinstance(obj, list) and obj:
                    return obj[0]
                if isinstance(obj, dict):
                    return obj
                return {}
            out2 = _cash_block_light(balance.get("output2"))
            if isinstance(balance.get("output1"), dict):
                out1 = balance.get("output1", {})
            else:
                out1 = {}
            ord_psbl_val = _parse_amt_light(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash"))
            dnca_tot_val = _parse_amt_light(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0
            prvs_rcdl = _parse_amt_light(out2.get("prvs_rcdl_excc_amt"))
            if prvs_rcdl is not None:
                self.d2_excc_amt = prvs_rcdl
            if prvs_rcdl is not None and prvs_rcdl > 0:
                new_cash = prvs_rcdl
            else:
                new_cash = dnca_tot_val
            logger.info(
                f"💵 [예수금-경량] 거래가능(D+2)={new_cash:,.0f}원 (이전={self.current_cash:,.0f})"
            )
            if new_cash > 0 or self.current_cash == 0:
                self.current_cash = new_cash
            # 보유 종목 평가액: output1=주식 잔고(종목별), output2=예수금 요약 (블로그 기준)
            output1_list = balance.get("output1", [])
            if isinstance(output1_list, dict):
                output1_list = [output1_list]
            holdings_value = 0
            for code, holding in self.holdings.items():
                for item in output1_list:
                    if (item.get("pdno") or "").strip() == code:
                        evlu_amt = float(item.get("evlu_amt", 0))
                        holdings_value += evlu_amt
                        break
                else:
                    price_data = self.client.inquire_price(code)
                    if price_data:
                        current_price = abs(float(price_data.get("stck_prpr", 0)))
                        holdings_value += current_price * holding["qty"]
            self.current_total_asset = self.current_cash + holdings_value
            if profit_val != 0:
                self.current_total_asset += profit_val
            logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원")
            return True
        except Exception as e:
            logger.error(f"❌ 경량 갱신 실패: {e}")
        return False
    
    def _update_cash_only(self):
        """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)"""
        return self._update_account_light(profit_val=0)
    
    def send_mm(self, msg):
        """Mattermost 알림 전송. 성공 시 True, 실패 시 False."""
        try:
            return self.mm.send(self.mm_channel, msg)
        except Exception as e:
            logger.error(f"❌ MM 전송 에러: {e}")
            return False
    
    def check_market_status(self):
        """장 운영 시간 체크"""
        # FORCE_MARKET_OPEN 플래그 확인 (테스트용)
        force_open = get_env_bool("FORCE_MARKET_OPEN", False)
        if force_open:
            logger.debug("🔓 FORCE_MARKET_OPEN=true - 장 상태 무시하고 계속 진행")
            return True
        
        # 정상 장 운영 시간 체크
        now = dt.now()
        if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)):
            return False
        if now.weekday() >= 5:  # 주말
            return False
        return True
    
    def send_morning_report(self):
        """오전 장 뜸할 때 리포트 (13:00)"""
        if self.morning_report_sent:
            return
        
        self._update_assets()
        day_pnl = self.current_total_asset - self.start_day_asset
        if self.start_day_asset > 0:
            day_pnl_pct = day_pnl / self.start_day_asset * 100
        else:
            day_pnl_pct = 0
        
        msg = f"""📊 **[오전 장 현황 - 13:00]**
- 당일 시작: {self.start_day_asset:,.0f}원
- 현재 자산: {self.current_total_asset:,.0f}원
- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
- 보유 종목: {len(self.holdings)}개"""
        
        self.send_mm(msg)
        self.morning_report_sent = True
        logger.info("📊 오전 리포트 전송 완료")
    
    def send_closing_report(self):
        """장마감 전 리포트 (15:15)"""
        if self.closing_report_sent:
            return
        
        self._update_assets()
        day_pnl = self.current_total_asset - self.start_day_asset
        if self.start_day_asset > 0:
            day_pnl_pct = day_pnl / self.start_day_asset * 100
        else:
            day_pnl_pct = 0
        
        msg = f"""📈 **[장마감 전 현황 - 15:15]**
- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
- 현재 자산: {self.current_total_asset:,.0f}원
- 보유 종목: {len(self.holdings)}개
- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원"""
        
        self.send_mm(msg)
        self.closing_report_sent = True
        logger.info("📈 장마감 전 리포트 전송 완료")
    
    def send_final_report(self):
        """장마감 후 최종 리포트 (15:35)"""
        if self.final_report_sent:
            return
        
        self._update_assets()
        
        # 당일 손익
        day_pnl = self.current_total_asset - self.start_day_asset
        if self.start_day_asset > 0:
            day_pnl_pct = day_pnl / self.start_day_asset * 100
        else:
            day_pnl_pct = 0
        
        # 누적 손익
        cumulative_pnl = self.current_total_asset - self.total_deposit
        if self.total_deposit > 0:
            cumulative_pnl_pct = cumulative_pnl / self.total_deposit * 100
        else:
            cumulative_pnl_pct = 0
        
        # 오늘 거래 내역
        today_trades = self.db.get_trades_by_date(self.today_date)
        
        msg = f"""🏁 **[장마감 최종 보고 - 15:35]**
━━━━━━━━━━━━━━━━━━━━
📅 **당일 손익**
- 시작: {self.start_day_asset:,.0f}원
- 종료: {self.current_total_asset:,.0f}원
- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)

💰 **누적 손익 (총 입금액 대비)**
- 총 입금: {self.total_deposit:,.0f}원
- 현재 자산: {self.current_total_asset:,.0f}원
- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%)

📊 **거래 현황**
- 오늘 매매: {len(today_trades)}건
- 보유 종목: {len(self.holdings)}개
- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원
━━━━━━━━━━━━━━━━━━━━"""
        
        self.send_mm(msg)
        self.final_report_sent = True
        logger.info("🏁 장마감 최종 리포트 전송 완료")
    
    def _get_env_numeric_snapshot(self):
        """DB 최신 env에서 계좌/키/토큰/URL 제외한 수치·설정만 반환 (키=값 줄 단위)."""
        EXCLUDE = {
            "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", "GEMINI_API_KEY",
            "KIS_APP_KEY_REAL", "KIS_APP_SECRET_REAL", "KIS_APP_KEY_MOCK", "KIS_APP_SECRET_MOCK",
            "KIS_ACCOUNT_NO_REAL", "KIS_ACCOUNT_CODE_REAL", "KIS_ACCOUNT_NO_MOCK", "KIS_ACCOUNT_CODE_MOCK",
            "KIS_SHORT_MM_CHANNEL", "KIS_LONG_MM_CHANNEL",
        }
        latest = self.db.get_latest_env()
        if not latest or not latest.get("snapshot"):
            return ""
        snap = latest["snapshot"]
        lines = []
        for k, v in sorted(snap.items()):
            if k in EXCLUDE or v is None:
                continue
            v = (v or "").strip()
            if "#" in v:
                v = v.split("#")[0].strip()
            if not v:
                continue
            lines.append(f"{k}={v}")
        return "\n".join(lines)

    def send_ai_report(self):
        """AI 분석 리포트 (13:00) - DB 수치만 보여주고, 거래 내역으로 승률 분석·추천(설정수치=값 형식)."""
        if self.ai_report_sent or not gemini_client:
            return

        try:
            # DB에서 수치 설정만 (계좌/키/토큰 제외)
            env_lines = self._get_env_numeric_snapshot()

            # 최근 거래 내역 조회
            recent_trades = []
            try:
                conn = self.db.conn
                cursor = conn.execute("""
                    SELECT code, name, buy_price, sell_price, qty, profit_rate,
                           realized_pnl, strategy, sell_reason, buy_date, sell_date, hold_minutes
                    FROM trade_history
                    ORDER BY id DESC
                    LIMIT 10
                """)
                for row in cursor.fetchall():
                    recent_trades.append({
                        'code': row[0],
                        'name': row[1],
                        'buy_price': row[2],
                        'sell_price': row[3],
                        'qty': row[4],
                        'profit_rate': row[5],
                        'realized_pnl': row[6],
                        'strategy': row[7],
                        'sell_reason': row[8],
                        'buy_date': row[9],
                        'sell_date': row[10],
                        'hold_minutes': row[11] or 0
                    })
            except Exception as e:
                logger.error(f"거래 내역 조회 실패: {e}")
                return

            db_candidates = self.db.get_target_candidates()
            candidate_count = len(db_candidates)

            # 거래 내역 없을 때: 수치만 보여주고 유니버스·추천
            if not recent_trades:
                summary = f"""📊 **현재 상태**
- 유니버스 후보: {candidate_count}개
- 최근 거래: 없음"""
                prompt = f"""당신은 퀀트 트레이딩 전문가입니다.

**현재 상태**
- 유니버스 후보: {candidate_count}개
- 최근 거래: 없음

**현재 DB 설정 수치 (일부만 표시됨, 계좌/키 등 제외)**
```
{env_lines}
```

**당신의 임무**
1. 위 설정과 후보 수({candidate_count}개)를 보고 문제점 분석 (필터가 너무 까다로운지 등).
2. **추천**: 반드시 아래 형식으로만 한 줄에 하나씩 작성. 이유·주석 붙이지 말 것. 그대로 DB에 복붙해 적용할 수 있어야 함.
   - 예: MAX_STOCKS=4
   - 예: MIN_DROP_RATE=0.025
   - 예: MIN_RECOVERY_RATIO_SHORT=0.4
3. 예상 효과 한두 줄.

**출력 형식 (반드시 준수)**
## 🔍 문제점
1. [구체적 문제 1]
2. [구체적 문제 2]

## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용 가능하게)
MAX_STOCKS=4
MIN_DROP_RATE=0.025
(필요한 것만, 변수명은 위 설정 목록에 있는 것만 사용)

## 📈 예상 효과
- [효과 1]
"""

                response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt)
                analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "")

                # 마지막 AI 추천문 저장 (!적용 시 매터모스트 원격 조종용)
                _save_ai_recommendations_from_text(self.db, analysis)

                message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]**

{summary}

{analysis}

---
💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다.
"""
                self.send_mm(message)
                self.ai_report_sent = True
                logger.info("🤖 AI 리포트 전송 완료 (거래 내역 없음)")
                return

            # 통계 계산
            total = len(recent_trades)
            wins = sum(1 for t in recent_trades if t['profit_rate'] > 0)
            losses = total - wins
            win_rate = (wins / total * 100) if total > 0 else 0
            avg_profit = sum(t['profit_rate'] for t in recent_trades) / total
            total_pnl = sum(t['realized_pnl'] for t in recent_trades)
            avg_hold = sum(t['hold_minutes'] for t in recent_trades) / total

            trades_text = ""
            for i, t in enumerate(recent_trades, 1):
                trades_text += f"""
[거래 {i}] {t['name']} ({t['strategy']})
- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 | 매도: {t['sell_price']:,.0f}원
- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) | 보유: {t['hold_minutes']}분
- 사유: {t['sell_reason']}
"""

            prompt = f"""당신은 퀀트 트레이딩 전문가입니다.

**현재 상태**
- 유니버스 후보: {candidate_count}개
- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패)
- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:,.0f}원 | 평균 보유: {avg_hold:.0f}분

**최근 거래 내역**
{trades_text}

**현재 DB 설정 수치 (계좌/키 등 제외, 수치만)**
```
{env_lines}
```

**당신의 임무**
1. **승률이 왜 떨어졌는지** 거래 내역과 설정 수치를 보고 구체적으로 3가지 진단 (손절/진입/보유 등).
2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. 그대로 DB에 복붙해 적용할 수 있어야 함.
   - 예: MAX_STOCKS=4
   - 예: MIN_DROP_RATE=0.025
   - 예: MIN_RECOVERY_RATIO_SHORT=0.4
   변수명은 위 설정 목록에 있는 것만 사용.
3. 예상 효과 한두 줄.

**출력 형식 (반드시 준수)**
## 🔍 문제점 (승률 하락 원인)
1. [구체적 문제 1]
2. [구체적 문제 2]
3. [구체적 문제 3]

## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용)
MAX_STOCKS=4
MIN_DROP_RATE=0.025
(필요한 것만)

## 📈 예상 효과
- [효과 1]
"""

            response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt)
            analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "")

            # 마지막 AI 추천문 저장 (!적용 시 매터모스트 원격 조종용)
            _save_ai_recommendations_from_text(self.db, analysis)

            summary = f"""📊 **현재 상태**
- 유니버스 후보: {candidate_count}개
- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패)
- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:+,.0f}원 | 평균 보유: {avg_hold:.0f}분"""

            message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]**

{summary}

{analysis}

---
💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다.
"""
            self.send_mm(message)
            self.ai_report_sent = True
            logger.info("🤖 AI 리포트 전송 완료")

        except Exception as e:
            logger.error(f"AI 리포트 생성 실패: {e}")

    def _fetch_scan_universe_from_api(self, max_codes=500):
        """
        KIS API로 스캔 대상 종목 리스트 조회 (6소스 각 100건 → 최대 500개).
        - 거래량·거래대금·회전율·등락률(상승)·등락률(하락)·거래증가율 각 100건 합산 후 중복 제거.
        - 리스트는 DB에 저장하지 않음. 스캔 끝난 뒤 후보만 DB에 한 번에 인서트.
        Returns:
            list[dict]: [{"code": "006자리", "name": "종목명"}, ...] (중복 제거, 최대 max_codes개)
        """
        def _code_from_item(item):
            code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip()
            return code if code and len(code) == 6 else None

        def _name_from_item(item):
            return (
                (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip()
                or ""
            )

        scan_list = []
        seen = set()

        # 1) 거래량순위 100개 (키움은 거래대금+회전율만 사용, KIS는 거래량+거래대금+회전율 3가지로 풀 확대)
        try:
            time.sleep(random.uniform(0.5, 1.0))
            vol_list = self.client.get_volume_rank(market="J", limit=100)
            for item in (vol_list or []):
                c = _code_from_item(item)
                if c and c not in seen:
                    seen.add(c)
                    scan_list.append({"code": c, "name": _name_from_item(item) or ""})
            logger.info(f"   📡 [스캔유니버스] 거래량순위 API → {len(vol_list)}건 수신, 누적 {len(scan_list)}종목")
        except Exception as e:
            logger.warning(f"   ⚠️ [스캔유니버스] 거래량순위 조회 실패: {e}")

        # 2) 거래대금순위 100개 (키움과 동일 소스)
        try:
            time.sleep(random.uniform(0.5, 1.0))
            val_list = self.client.get_trading_value_rank(market="J", limit=100)
            for item in (val_list or []):
                c = _code_from_item(item)
                if c and c not in seen:
                    seen.add(c)
                    scan_list.append({"code": c, "name": _name_from_item(item) or ""})
            logger.info(f"   📡 [스캔유니버스] 거래대금순위 API → {len(val_list)}건 수신, 누적 {len(scan_list)}종목")
        except Exception as e:
            logger.warning(f"   ⚠️ [스캔유니버스] 거래대금순위 조회 실패: {e}")

        # 3) 회전율순위 100개 (키움 개미털기 2번째 소스와 동일)
        try:
            time.sleep(random.uniform(0.5, 1.0))
            turn_list = self.client.get_turnover_rank(market="J", limit=100)
            for item in (turn_list or []):
                c = _code_from_item(item)
                if c and c not in seen:
                    seen.add(c)
                    scan_list.append({"code": c, "name": _name_from_item(item) or ""})
            logger.info(f"   📡 [스캔유니버스] 회전율순위 API → {len(turn_list)}건 수신, 누적 {len(scan_list)}종목")
        except Exception as e:
            logger.warning(f"   ⚠️ [스캔유니버스] 회전율순위 조회 실패: {e}")

        # 4) 등락률순위(상승) 100개 (기존 후보군 풀 확대)
        try:
            time.sleep(random.uniform(0.5, 1.0))
            chg_list = self.client.get_price_change_rank(market="J", sort_type="1", limit=100)
            for item in (chg_list or []):
                c = _code_from_item(item)
                if c and c not in seen:
                    seen.add(c)
                    scan_list.append({"code": c, "name": _name_from_item(item) or ""})
            logger.info(f"   📡 [스캔유니버스] 등락률순위(상승) API → {len(chg_list)}건 수신, 누적 {len(scan_list)}종목")
        except Exception as e:
            logger.warning(f"   ⚠️ [스캔유니버스] 등락률순위(상승) 조회 실패: {e}")

        # 4-2) 등락률순위(하락) 100개 — 낙폭 큰 종목 직접 조회 → N자 망치봉 스캔 효율·Pass-낙폭 포착
        try:
            time.sleep(random.uniform(0.5, 1.0))
            decline_list = self.client.get_price_decline_rank(market="J", limit=100)
            for item in (decline_list or []):
                c = _code_from_item(item)
                if c and c not in seen:
                    seen.add(c)
                    scan_list.append({"code": c, "name": _name_from_item(item) or ""})
            logger.info(f"   📡 [스캔유니버스] 등락률순위(하락) API → {len(decline_list)}건 수신, 누적 {len(scan_list)}종목")
        except Exception as e:
            logger.warning(f"   ⚠️ [스캔유니버스] 등락률순위(하락) 조회 실패: {e}")

        # 5) 거래증가율순위 100개 (거래량/대금/회전율과 다른 풀 → 후보 다양화)
        try:
            time.sleep(random.uniform(0.5, 1.0))
            growth_list = self.client.get_volume_growth_rank(market="J", limit=100)
            for item in (growth_list or []):
                c = _code_from_item(item)
                if c and c not in seen:
                    seen.add(c)
                    scan_list.append({"code": c, "name": _name_from_item(item) or ""})
            logger.info(f"   📡 [스캔유니버스] 거래증가율순위 API → {len(growth_list)}건 수신, 누적 {len(scan_list)}종목 (6소스 합산 → 개미털기 필터)")
        except Exception as e:
            logger.warning(f"   ⚠️ [스캔유니버스] 거래증가율순위 조회 실패: {e}")

        if not scan_list:
            logger.warning("   ⚠️ [스캔유니버스] API에서 0건 수신 → 스캔 불가 (권한/계정/시간 확인)")
            return []

        scan_list = scan_list[:max_codes]
        # 종목명 비어 있으면 시세 배치로 채우기 (KIS volume-rank는 종목명 미제공 시 많음)
        need_name_codes = [x["code"] for x in scan_list[:20] if not (x.get("name") or "").strip()]
        if need_name_codes:
            try:
                time.sleep(random.uniform(0.2, 0.4))
                batch = self.client.inquire_prices_batch(need_name_codes[:20])
                name_map = {}
                _market_names = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"}
                for code, out in (batch or {}).items():
                    n = (out.get("stck_kor_isnm") or out.get("rprs_mrkt_kor_name") or "").strip()
                    if n and n not in _market_names:
                        name_map[code] = n
                for x in scan_list:
                    if not (x.get("name") or "").strip() and x["code"] in name_map:
                        x["name"] = name_map[x["code"]]
            except Exception as e:
                logger.debug(f"   스캔 종목명 배치 조회 스킵: {e}")
        for x in scan_list:
            if not (x.get("name") or "").strip():
                x["name"] = x["code"]

        logger.info(
            f"   📋 스캔 대상: {len(scan_list)}개 종목 (거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율 각 100건 합산 → 개미털기 필터)"
        )
        if scan_list:
            part = ", ".join(f"{x['code']} {x.get('name') or x['code']}" for x in scan_list[:15])
            logger.info(f"   📋 스캔 대상(일부): {part}{' ...' if len(scan_list) > 15 else ''}")
        return scan_list

    def scan_ant_shaking_candidates(self, max_candidates=20):
        """
        개미털기(눌림목) 후보 종목 스캔 - kiwoom_trader_dual 방식: 유니버스 리스트만 빠르게 채움.
        - 스캔에서는 종목별 API 호출·낙폭/회복 필터·탈락 로그 없음.
        - 낙폭/회복/3분봉/RSI/피뢰침/ML 전부 메인 루프의 check_buy_signal_tail_catch에서만 체크.
        """
        logger.info("🐜 [개미털기] 스캔 시작 (유니버스 리스트만 등록 → 매수 시 한곳에서 전 조건 체크)")
        top_n_light = get_env_int("CANDIDATE_LIST_TOP_N_LIGHT", 20)
        max_codes = get_env_int("SCAN_UNIVERSE_MAX_CODES", 150)

        scan_list = self._fetch_scan_universe_from_api(max_codes=max_codes)
        if not scan_list:
            logger.warning("   ⚠️ [개미털기] 스캔 대상 0개 → API에서 리스트를 받지 못함")
            return []

        n_save = min(top_n_light, len(scan_list))
        result = []
        for i, x in enumerate(scan_list[:n_save]):
            name = (x.get("name") or x["code"]).strip() or x["code"]
            # DB 저장은 update_universe()에서 강도 계산·정렬 후 강도순 상위 N명만 함 (여기서는 리스트만 반환)
            result.append({
                "code": x["code"],
                "name": name,
                "price": 0,
                "score": 0,
                "drop_rate": 0,
                "recovery": 0,
            })

        part = ", ".join(f"{x.get('name') or x['code']}({x['code']})" for x in scan_list[:5])
        logger.info(f"   ✅ [개미털기] 후보 {n_save}명 수집 (강도·정렬·DB 반영은 유니버스 업데이트에서 일괄 처리) | 일부: {part}{' ...' if n_save > 5 else ''}")
        return result

    def calculate_atr(self, df, period=14):
        """
        ATR (Average True Range) 계산 - 변동성 지표
        - TR(True Range) = max(고가-저가, |고가-전일종가|, |저가-전일종가|)
        - ATR = TR의 14일 이동평균
        """
        try:
            if df is None or len(df) < period:
                return 0
            
            df = df.copy()
            # True Range 계산
            high_low = df['high'] - df['low']
            high_close = (df['high'] - df['close'].shift()).abs()
            low_close = (df['low'] - df['close'].shift()).abs()
            df['tr'] = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1)
            
            # ATR = TR의 14일 이동평균
            atr = df['tr'].rolling(window=period).mean().iloc[-1]
            return float(atr) if not pd.isna(atr) and atr > 0 else 0
        except Exception as e:
            logger.debug(f"ATR 계산 실패: {e}")
            return 0

    def check_buy_signal_tail_catch(self, code: str, name: str):
        """
        [3분봉 망치봉 무릎 타점] N자 차트에서 밑꼬리 망치봉이 나온 뒤 무릎~어깨 구간에서만 매수.
        - 피뢰침(고점 추격·급등주)은 여기서만 적용 (스캔에서는 후보만 넓게 수집).
        - 수치는 전부 get_env_float / get_env_int 등 env·DB 사용.
        """
        try:
            # [테스트용] DB에서 FORCE_BUY_TEST=true 시 조건 무시하고 현재가로 시그널 반환 (매수 체결 테스트 후 반드시 false로 복구)
            if get_env_bool("FORCE_BUY_TEST", False):
                try:
                    price_data = self.client.inquire_price(code)
                    current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) if price_data else 0
                    if current_price <= 0:
                        return None
                    logger.info(f"{LOG_CYAN}🧪 [테스트] FORCE_BUY_TEST=true → 조건 건너뛰고 시그널 반환 {name} {code} @ {current_price:,.0f}원{LOG_RESET}")
                    return {
                        "code": code,
                        "name": name,
                        "price": current_price,
                        "score": 5.0,
                        "entry_features": {},
                    }
                except Exception as e:
                    logger.warning(f"🧪 FORCE_BUY_TEST 현재가 조회 실패: {e}")
                    return None

            min_candle_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14)
            min_price_tail = get_env_float("MIN_PRICE_TAIL", 1000.0)
            df = self.client.get_minute_chart(code, period="3", limit=20)
            if df is None or df.empty or len(df) < min_candle_len:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: 봉수 부족 (len={len(df) if df is not None and not df.empty else 0}, 기준 {min_candle_len}){LOG_RESET}")
                return None
            if "close" not in df.columns or "high" not in df.columns or "low" not in df.columns or "open" not in df.columns:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: OHLC 컬럼 없음{LOG_RESET}")
                return None

            current_price = float(df["close"].iloc[-1])
            candle = df.iloc[-1]
            candle_open = float(candle["open"])
            candle_high = float(candle["high"])
            candle_low = float(candle["low"])
            candle_close = float(candle["close"])

            # 분봉 마지막 봉 close=0인 경우 (장 마감/미체결 봉 등) → 현재가 API로 보정
            if current_price <= 0 or candle_close <= 0:
                try:
                    price_data = self.client.inquire_price(code)
                    if price_data:
                        current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", "")))
                        if current_price > 0:
                            candle_close = current_price
                            df = df.copy()
                            df.loc[df.index[-1], "close"] = current_price
                except Exception as e:
                    logger.debug(f"현재가 보정 실패({code}): {e}")
            if candle_open <= 0 and len(df) >= 2:
                candle_open = float(df["open"].iloc[-2])
                if candle_open <= 0:
                    candle_open = float(df["open"].iloc[0])

            if candle_open <= 0 or current_price < min_price_tail:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-가격] {name} {code}: 시가/현재가 부적절 (현재 {current_price:,.0f}원, 최소 {min_price_tail:,.0f}){LOG_RESET}")
                return None

            # [수정된 부분: 매수체크 시 당일 진짜 시가, 고가, 저가를 API로 호출해와서 낙폭 계산]
            day_open = float(df["open"].iloc[0])
            day_high = float(df["high"].max())
            # 저가가 0인 분봉(비정상 값) 때문에 낙폭이 100%로 계산되는 것을 방지
            try:
                lows = df["low"]
                valid_lows = lows[lows > 0]
                day_low = float(valid_lows.min()) if not valid_lows.empty else float(lows.min())
            except Exception:
                day_low = float(df["low"].min())
            
            try:
                # 3분봉 20개(1시간) 한계를 넘어 하루 전체 기준을 잡기 위해 현재가 API 호출
                today_price_data = self.client.inquire_price(code)
                if today_price_data:
                    api_open = abs(float(str(today_price_data.get("stck_oprc", 0)).replace(",", "")))
                    api_high = abs(float(str(today_price_data.get("stck_hgpr", 0)).replace(",", "")))
                    api_low = abs(float(str(today_price_data.get("stck_lwpr", 0)).replace(",", "")))
                    
                    if api_open > 0: day_open = api_open
                    if api_high > 0: day_high = api_high
                    if api_low > 0: day_low = api_low
            except Exception as e:
                logger.debug(f"일일 시고저 보정 실패({code}): {e}")

            if day_open > 0 and day_low > 0:
                drop_rate = (day_open - day_low) / day_open
            else:
                drop_rate = 0
            day_range = day_high - day_low
            recovery_pos_day = (current_price - day_low) / day_range if day_range > 0 else 0
            if drop_rate < self.min_drop_rate:
                logger.info(
                    f"{LOG_YELLOW}🔍 [탈락-낙폭] {name} {code}: 낙폭 {drop_rate*100:.2f}% < {self.min_drop_rate*100:.1f}% "
                    f"(당일 시가 {day_open:,.0f}원 → 저점 {day_low:,.0f}원){LOG_RESET}"
                )
                return None
            if recovery_pos_day < self.min_recovery_ratio:
                logger.info(
                    f"{LOG_YELLOW}🔍 [탈락-회복률] {name} {code}: 회복률 {recovery_pos_day*100:.1f}% < {self.min_recovery_ratio*100:.0f}% "
                    f"(저점 {day_low:,.0f}원 → 현재 {current_price:,.0f}원 / 범위 {day_range:,.0f}원){LOG_RESET}"
                )
                return None

            logger.info(f"{LOG_GREEN}🔍 [통과-낙폭·회복] {name} {code} → 꼬리/피뢰침/RSI 체크{LOG_RESET}")

            # [핵심] 망치봉(밑꼬리) 계산: 꼬리 길이 / 몸통 길이
            # 장 외에는 마지막 봉이 전일 마감봉(open=high=low=close)이라 꼬리 0 → 역순으로 밑꼬리 있는 최근 봉 사용
            tail_lookback = get_env_int("TAIL_CANDLE_LOOKBACK", 5)
            body_top = max(candle_open, candle_close)
            body_bottom = min(candle_open, candle_close)
            body_length = body_top - body_bottom
            tail_length = body_bottom - candle_low
            if tail_length < 0:
                tail_length = 0.0
            if body_length <= 0:
                body_length = 1.0
            tail_ratio = tail_length / body_length
            tail_pct = (tail_length / candle_low) if (candle_low > 0 and tail_length > 0) else 0

            # 마지막 봉에 밑꼬리가 없으면(장외/도지) 최근 N개 봉 중 밑꼬리 있는 봉으로 재계산
            if tail_length <= 0 and len(df) >= 2:
                for idx in range(len(df) - 2, max(-1, len(df) - 1 - tail_lookback), -1):
                    c = df.iloc[idx]
                    o, h, l, cl = float(c["open"]), float(c["high"]), float(c["low"]), float(c["close"])
                    if l <= 0:
                        continue
                    bt = max(o, cl)
                    bb = min(o, cl)
                    bl = bt - bb
                    tl = bb - l
                    if tl <= 0:
                        continue
                    if bl <= 0:
                        bl = 1.0
                    tail_ratio = tl / bl
                    tail_pct = (tl / l) if l > 0 else 0
                    tail_length = tl
                    break

            tail_ratio_min = get_env_float("TAIL_RATIO_MIN", 1.5)
            tail_pct_min = get_env_float("TAIL_PCT_MIN", 0.003)
            if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-꼬리] {name} {code}: 꼬리비율 {tail_ratio:.2f} (기준 {tail_ratio_min}) 또는 꼬리% {tail_pct*100:.2f}% (기준 {tail_pct_min*100:.2f}%){LOG_RESET}")
                return None

            # [무릎 타점] 그 3분 봉 안에서의 회복률 (무릎~어깨 직전만) — 항상 마지막 봉 기준
            total_range = candle_high - candle_low
            recovery_pos = (current_price - candle_low) / total_range if total_range > 0 else 0
            max_rec_3m = get_env_float("MAX_RECOVERY_RATIO_3M", 0.8)
            if not (self.min_recovery_ratio <= recovery_pos <= max_rec_3m):
                logger.info(f"{LOG_YELLOW}🔍 [탈락-회복3분] {name} {code}: 3분봉 회복률 {recovery_pos*100:.1f}% (기준 {self.min_recovery_ratio*100:.0f}~{max_rec_3m*100:.0f}%){LOG_RESET}")
                return None

            # [매수체크 전용] 피뢰침 - 고점 추격 방지 (당일 API 보정된 day_high/day_low 사용)
            high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96)
            if current_price >= day_high * high_chase_threshold:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침 고점추격] {name} {code}: 현재가 {current_price:,.0f} ≥ 고점대비 {high_chase_threshold*100:.0f}%{LOG_RESET}")
                return None
            # [매수체크 전용] 피뢰침 - 급등주 제외 (당일 API 보정된 day_high/day_low 사용)
            if day_low > 0:
                range_change_pct = (day_high - day_low) / day_low * 100
            else:
                range_change_pct = 0
            max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)
            if range_change_pct > max_daily_change:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침 급등주] {name} {code}: 일일 변동폭 {range_change_pct:.1f}% > {max_daily_change:.0f}%{LOG_RESET}")
                return None

            # RSI 과열 방지 (수치: env)
            rsi_val = 50.0
            if "RSI" in df.columns and len(df) >= 14:
                rsi_val = float(df["RSI"].iloc[-1])
            rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0)
            if rsi_val >= rsi_threshold:
                logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI {rsi_val:.1f} ≥ {rsi_threshold:.0f}{LOG_RESET}")
                return None

            # MA20: 20일선 아래면 역배열로 패스 (수치: env)
            ma5_gap_pct_val, ma20_gap_pct_val, volume_ratio_val = None, None, None
            if "MA20" in df.columns and len(df) >= 20:
                ma20 = float(df["MA20"].iloc[-1])
                if current_price < ma20:
                    logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가 {current_price:,.0f} < MA20 {ma20:,.0f}{LOG_RESET}")
                    return None
                ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0)
                if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
                    logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20초과] {name} {code}: MA20 대비 {ma20_cap_pct:.0f}% 초과{LOG_RESET}")
                    return None
                ma20_gap_pct_val = (current_price - ma20) / ma20 * 100 if ma20 > 0 else None
            if "MA5" in df.columns and len(df) >= 5:
                ma5 = float(df["MA5"].iloc[-1])
                ma5_gap_pct_val = (current_price - ma5) / ma5 * 100 if ma5 and float(ma5) > 0 else None
            if "volume" in df.columns and df["volume"].mean() > 0:
                volume_ratio_val = float(df["volume"].iloc[-1] / df["volume"].mean())

            investor_trend = self.client.get_investor_trend(code, days=3)
            entry_features = {
                "rsi": rsi_val,
                "volume_ratio": volume_ratio_val,
                "tail_length_pct": tail_pct * 100,
                "ma5_gap_pct": ma5_gap_pct_val,
                "ma20_gap_pct": ma20_gap_pct_val,
                "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0,
                "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0,
                "market_hour": dt.now().hour,
            }

            # ML 승률 (설정 시 env/DB 기준)
            if self.use_ml_signal and self.ml_predictor:
                try:
                    ml_features = {
                        "rsi": entry_features["rsi"],
                        "volume_ratio": entry_features["volume_ratio"] if entry_features["volume_ratio"] is not None else 1.0,
                        "tail_length_pct": entry_features["tail_length_pct"],
                        "ma5_gap_pct": entry_features["ma5_gap_pct"] if entry_features["ma5_gap_pct"] is not None else 0.0,
                        "ma20_gap_pct": entry_features["ma20_gap_pct"] if entry_features["ma20_gap_pct"] is not None else 0.0,
                        "foreign_net_buy": entry_features["foreign_net_buy"],
                        "institution_net_buy": entry_features["institution_net_buy"],
                        "market_hour": entry_features["market_hour"],
                    }
                    ml_prob = self.ml_predictor.predict_win_probability(ml_features)
                    if ml_prob < self.ml_min_probability:
                        logger.info(f"{LOG_YELLOW}🔍 [탈락-ML] {name} {code}: 승률 {ml_prob:.2%} < {self.ml_min_probability:.0%}{LOG_RESET}")
                        return None
                except Exception:
                    pass

            score_base = get_env_float("TAIL_SCORE_BASE", 5.0)
            score_ratio_mult = get_env_float("TAIL_SCORE_RATIO_MULT", 2.0)
            score = score_base + (tail_ratio * score_ratio_mult)
            logger.info(
                f"{LOG_CYAN}🎯 [무릎 타점] {name} | 가격:{current_price:,.0f} | "
                f"꼬리비율:{tail_ratio:.1f}배 | 회복:{recovery_pos*100:.0f}% | RSI:{rsi_val:.1f}{LOG_RESET}"
            )
            return {
                "code": code,
                "name": name,
                "price": current_price,
                "score": score,
                "entry_features": entry_features,
            }
        except Exception as e:
            logger.info(f"{LOG_YELLOW}🔍 [탈락-예외] {name} {code}: {e}{LOG_RESET}")
            return None

    def check_sell_signals(self):
        """
        매도 신호 체크 (ATR 기반 변동성 매도 로직)
        - 어깨 매도 (고점 대비 3% 하락)
        - ATR 기반 스캘핑 (본절사수/익절보존)
        - 순수익 보존
        - 목표가/손절가
        """
        if not self.holdings:
            return []
        
        sell_signals = []
        min_hold_sec = get_env_float("MIN_HOLD_AFTER_BUY_SEC", 30.0)  # 매수 직후 N초 간 매도 신호 무시 (모의투자 잔고 반영 지연 대응)
        for code, holding in list(self.holdings.items()):
            try:
                name = holding.get("name", code)
                buy_price = holding["buy_price"]
                buy_time_str = holding.get("buy_time", "")
                qty = holding["qty"]
                
                # 매수 직후 최소 보유 시간 미만이면 매도 체크 스킵 (모의투자 "잔고내역이 없습니다" 방지)
                if buy_time_str and min_hold_sec > 0:
                    try:
                        buy_time = dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S")
                        if (dt.now() - buy_time).total_seconds() < min_hold_sec:
                            continue
                    except Exception:
                        pass
                
                # 현재가 조회
                price_data = self.client.inquire_price(code)
                if not price_data:
                    continue
                
                current_price = abs(float(price_data.get("stck_prpr", 0)))
                if current_price == 0:
                    continue
                
                # 고점 갱신 (holdings에 저장)
                max_price = holding.get("max_price", buy_price)
                if current_price > max_price:
                    max_price = current_price
                    self.holdings[code]["max_price"] = max_price
                
                # 손익률 계산
                profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
                profit_val = (current_price - buy_price) * qty
                
                # ATR 조회 (DB 또는 재계산)
                atr = holding.get("atr_entry", 0)
                if atr == 0:
                    # ATR 재계산 (3분봉)
                    try:
                        df = self.client.get_minute_chart(code, period="3", limit=20)
                        if not df.empty:
                            atr = self.calculate_atr(df)
                            if atr > 0:
                                self.holdings[code]["atr_entry"] = atr
                    except Exception as e:
                        logger.debug(f"ATR 조회 실패({code}): {e}")
                        atr = buy_price * 0.01  # 기본값 1%
                
                # 손절가/목표가 (ATR 기반 또는 기본값)
                stop_price = holding.get("stop_price", buy_price * (1 + self.stop_loss_pct))
                target_price_atr = holding.get("target_price", buy_price * (1 + self.take_profit_pct))
                target_price_pct = buy_price * (1 + self.take_profit_pct)  # 퍼센트 기반 목표가
                
                # ATR 기반 손절가/목표가 계산
                if atr > 0:
                    stop_price = buy_price - (atr * self.stop_atr_multiplier)
                    target_price_atr = buy_price + (atr * self.target_atr_multiplier)
                
                # 목표가: ATR 기반과 퍼센트 기반 중 더 작은 값 사용 (둘 다 체크)
                target_price = min(target_price_atr, target_price_pct)
                
                # 매도 사유 판단
                sell_reason = None
                
                # ==========================================================
                # [1] 어깨 매도 (Shoulder Cut) - 최우선!
                # 고점 대비 3% 이상 빠지면 수익/손실 불문하고 즉시 탈출
                # ==========================================================
                shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", 0.03)
                drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0
                if drop_from_high >= shoulder_cut_pct:
                    sell_reason = "어깨매도"
                
                # ==========================================================
                # [2] 금액 기준 손절 (원 단위)
                # 퍼센트는 작아도 물량이 크면 원화 손실이 커져서 걸림. 0이면 비활성화.
                # ==========================================================
                max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000)
                if max_loss_per_trade_krw > 0 and not sell_reason and profit_val <= -max_loss_per_trade_krw:
                    sell_reason = "금액손실컷"
                
                # ==========================================================
                # [3] ATR 기반 스캘핑 로직 (수치: env/DB)
                # ==========================================================
                if not sell_reason and atr > 0:
                    scalp_up = get_env_float("SCALP_ATR_UP_MULT", 1.0)
                    scalp_down = get_env_float("SCALP_ATR_DOWN_MULT", 0.2)
                    scalp_drop = get_env_float("SCALP_ATR_DROP_MULT", 1.0)
                    if (max_price >= buy_price + atr * scalp_up) and (current_price <= buy_price + atr * scalp_down):
                        sell_reason = "스캘핑_본절사수"
                    if not sell_reason and current_price < (max_price - atr * scalp_drop) and profit_pct > 0:
                        sell_reason = "스캘핑_익절보존"
                
                # 보유 시간 계산 (N자 패턴 등 다음날 상승 가능성 있는 종목 24시간 보유)
                hours_passed = 0
                if buy_time_str:
                    try:
                        buy_time = dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S")
                        hours_passed = (dt.now() - buy_time).total_seconds() / 3600
                    except:
                        pass
                
                # ==========================================================
                # [4] 빠른 익절 보호 (매수 후 N시간 이내, 수치: env/DB)
                # ==========================================================
                if not sell_reason and hours_passed > 0:
                    use_quick_profit = get_env_bool("USE_QUICK_PROFIT_PROTECTION", True)
                    quick_hours = get_env_float("QUICK_PROFIT_PROTECT_HOURS", 0.5)
                    quick_max_ratio = get_env_float("QUICK_PROFIT_MAX_RATIO", 1.005)
                    quick_current_min = get_env_float("QUICK_PROFIT_CURRENT_MIN", 1.0015)
                    if use_quick_profit and hours_passed < quick_hours:
                        if max_price >= buy_price * quick_max_ratio and current_price <= buy_price * quick_current_min:
                            sell_reason = "💨 작은수익보호"
                
                # ==========================================================
                # [5] 24시간 보유 전략 (수치: env/DB)
                # ==========================================================
                min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0)
                early_take_pct = get_env_float("MIN_HOLD_EARLY_TAKE_PCT", 0.05)
                hold_high_pct = get_env_float("MIN_HOLD_HIGH_PCT", 1.07)
                hold_drop_from_high = get_env_float("MIN_HOLD_DROP_FROM_HIGH", 0.97)
                post_hold_take_pct = get_env_float("POST_HOLD_TAKE_PCT", 0.02)
                post_hold_drop = get_env_float("POST_HOLD_DROP_FROM_HIGH", 0.97)

                if not sell_reason and hours_passed > 0:
                    if hours_passed < min_hold_hours:
                        if profit_pct > early_take_pct:
                            sell_reason = f"💰 {hours_passed:.1f}시간내 {early_take_pct*100:.0f}%+ 익절"
                        elif max_price >= buy_price * hold_high_pct and current_price <= max_price * hold_drop_from_high:
                            sell_reason = f"📈 {hours_passed:.1f}시간내 고점→하락"
                    else:
                        if profit_pct > post_hold_take_pct:
                            sell_reason = f"⏰ {hours_passed:.1f}시간 경과 {post_hold_take_pct*100:.0f}%+ 익절"
                        elif profit_pct > 0 and current_price < max_price * post_hold_drop:
                            sell_reason = f"⏰ {hours_passed:.1f}시간 경과 익절보호"
                
                # ==========================================================
                # [6] 목표가 달성 및 손절 (24시간 보유 전략과 별개로 항상 체크)
                # ==========================================================
                if not sell_reason:
                    # 목표가 달성 체크 (ATR 기반 또는 퍼센트 기반 중 먼저 도달한 것)
                    if current_price >= target_price:
                        if current_price >= target_price_atr and current_price >= target_price_pct:
                            sell_reason = "목표달성(ATR+퍼센트)"
                        elif current_price >= target_price_atr:
                            sell_reason = "목표달성(ATR)"
                        else:
                            sell_reason = "목표달성(퍼센트)"
                    elif current_price <= stop_price:
                        sell_reason = "전략손절"
                    elif profit_pct <= self.stop_loss_pct:
                        sell_reason = f"칼손절({profit_pct * 100:.1f}%)"
                    elif profit_pct >= self.take_profit_pct:
                        sell_reason = "익절(퍼센트)"
                
                # 매도 신호 추가
                if sell_reason:
                    sell_signals.append({
                        "code": code,
                        "name": name,
                        "reason": sell_reason,
                        "profit_pct": profit_pct,
                        "qty": qty,
                        "price": current_price,
                    })
                
                time.sleep(random.uniform(0.3, 0.7))
            except Exception as e:
                logger.error(f"매도 신호 체크 실패({code}): {e}")
                continue
        
        return sell_signals

    def execute_buy(self, signal):
        """
        매수 실행
        - 키움 봇과 동일하게 대형주/소형주 비율 맞추는 로직 포함
        - 대형주: 기본 금액 100% / 중형주: 85% / 소형주: 70%
        - 켈리 기반 매수 금액 + 종목당 최대 15% 제한
        """
        code = signal["code"]
        name = signal["name"]
        price = signal["price"]
        
        # 이미 보유 중이면 스킵
        if code in self.holdings:
            logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵")
            return False
        
        # 최대 보유 종목 수 체크
        if len(self.holdings) >= self.max_stocks:
            logger.warning(f"⚠️ 최대 보유 종목 수 도달 ({self.max_stocks}개)")
            return False
        
        # 🔥 매수 직전 예수금 실시간 확인
        if not self._update_account_light(profit_val=0):
            logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵")
            return False
        
        # ============================================================
        # [대/중/소형주 구분] - 일봉 거래대금 평균 (키움 봇과 동일)
        # ============================================================
        size_class = None
        try:
            df = self.client.get_daily_chart(code, limit=10)
            if not df.empty and "volume" in df.columns and "close" in df.columns:
                trade_values = df["volume"] * df["close"]
                avg_trade_value = trade_values.mean()
                large_min = get_env_float("SIZE_CLASS_LARGE_MIN", 5000000000)  # 50억 (대형주)
                mid_min = get_env_float("SIZE_CLASS_MID_MIN", 500000000)       # 5억 (중형주)
                if avg_trade_value >= large_min:
                    size_class = "대"
                elif avg_trade_value >= mid_min:
                    size_class = "중"
                else:
                    size_class = "소"
                logger.info(
                    f"📊 [{name}] 거래대금 평균 {avg_trade_value/1e8:.1f}억원 → {size_class}형주"
                )
        except Exception as e:
            logger.debug(f"대/중/소형 조회 스킵({code}): {e}")
        
        # ============================================================
        # [매수 금액] 변동성 역가중 (Volatility Inverse Weighting)
        # ============================================================
        # ATR 계산용 분봉 데이터 (변동성 계산에 필요)
        df_minute = None
        try:
            df_minute = self.client.get_minute_chart(code, period="3", limit=20)
        except Exception as e:
            logger.debug(f"분봉 조회 실패({code}): {e}")
        
        # RiskManager 사용 시: 변동성 역가중으로 매수 금액 계산
        if self.risk_mgr is not None:
            # 켈리 비율 (DB에서 계산, 없으면 None)
            kelly_fraction = None
            if self.risk_mgr.use_kelly:
                try:
                    kelly_fraction = self.db.calculate_half_kelly()
                except Exception as e:
                    logger.debug(f"켈리 비율 계산 스킵: {e}")
            
            # 변동성 역가중 매수 금액 계산
            amount = self.risk_mgr.get_position_size(
                stock_name=name,
                current_balance=self.current_cash,
                df=df_minute,  # ATR 계산용 분봉 데이터
                kelly_fraction=kelly_fraction,
                size_class=size_class,  # 대/중/소형 구분
            )
            
            if amount <= 0:
                logger.warning(f"⚠️ [{name}] RiskManager 계산 금액 0원 -> 매수 스킵")
                return False
            
            # 수량 계산 (수수료 고려)
            qty = self.risk_mgr.calculate_quantity(price, amount)
        else:
            # 폴백: 기존 고정 슬롯 방식 (RiskManager 미사용 시, 수치: env/DB)
            slot_default = int(get_env_float("SLOT_MONEY_DEFAULT", 100000.0))
            if self.max_stocks > 0:
                slot_money = int(self.current_cash * 0.9 / self.max_stocks)
            else:
                slot_money = slot_default
            base_amount = slot_money
            # 슬롯 캡(손절 기반 상한) 적용: MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT| 와 SLOT_BASE_AMOUNT_CAP 중 작은 값
            effective_slot_cap = self._get_effective_slot_cap()
            if effective_slot_cap > 0:
                base_amount = min(base_amount, int(effective_slot_cap))
            if self.stop_loss_pct != 0:
                stop_pct_abs = abs(self.stop_loss_pct)
            else:
                stop_pct_abs = 0.04
            if stop_pct_abs > 0:
                kelly_risk_amount = self.current_cash * self.risk_pct_per_trade * self.kelly_multiplier
                kelly_based_amount = int(kelly_risk_amount / stop_pct_abs)
                base_amount = min(base_amount, kelly_based_amount)
            small_ratio = get_env_float("SIZE_CLASS_SMALL_RATIO", 0.7)
            mid_ratio = get_env_float("SIZE_CLASS_MID_RATIO", 0.85)
            if size_class == "소":
                amount = int(base_amount * small_ratio)
                logger.info(f"💰 [{name}] 소형주 → 매수 금액 {small_ratio*100:.0f}%: {amount:,.0f}원")
            elif size_class == "중":
                amount = int(base_amount * mid_ratio)
                logger.info(f"💰 [{name}] 중형주 → 매수 금액 {mid_ratio*100:.0f}%: {amount:,.0f}원")
            else:
                amount = base_amount
            max_limit = int(self.current_cash * self.max_position_pct)
            if amount > max_limit:
                logger.info(f"📐 [{name}] 최대 포지션 제한: {amount:,.0f}원 → {max_limit:,.0f}원")
                amount = max_limit
            amount = max(amount, self.min_position_amount)
            qty = int(amount / price)
        if qty <= 0:
            logger.warning(f"⚠️ [{name}] 매수 수량 0 (가격: {price:,.0f}원, 금액: {amount:,.0f}원)")
            return False
        
        required_amount = price * qty * 1.05
        if self.current_cash < required_amount:
            logger.warning(
                f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / "
                f"보유 {self.current_cash:,.0f}원 -> 매수 스킵"
            )
            return False
        
        # ATR 계산 (변동성 기반 손절가/목표가 설정용)
        # df_minute는 위에서 이미 조회했으므로 재사용
        atr = 0
        stop_price = price * (1 + self.stop_loss_pct)
        target_price = price * (1 + self.take_profit_pct)
        if df_minute is not None and not df_minute.empty:
            try:
                atr = self.calculate_atr(df_minute)
                if atr > 0:
                    # ATR 기반 손절가/목표가 설정
                    stop_price = price - (atr * self.stop_atr_multiplier)
                    target_price = price + (atr * self.target_atr_multiplier)
                    logger.info(f"📊 [{name}] ATR 기반 손절가/목표가: ATR={atr:.0f}원, 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원")
            except Exception as e:
                logger.debug(f"ATR 계산 스킵({code}): {e}")
                atr = price * 0.01  # 기본값 1%
        else:
            atr = price * 0.01  # 기본값 1%
        
        odno = self.client.buy_market_order(code, qty)
        if not odno:
            # 매매불가 종목이면 당일 제외 목록에 넣고 다음 후보로 넘어가기
            msg_cd = getattr(self.client, "_last_order_msg_cd", None) or ""
            msg1 = getattr(self.client, "_last_order_msg1", "") or ""
            if msg_cd == "40070000" or "매매불가" in msg1:
                self.untradable_skip_set.add(code)
                logger.warning(f"🚫 [{name}] 매매불가 종목 -> 당일 매수 제외, 다음 후보로 넘어감")
            return False
        # 주문 접수 후 체결 조회 API로 실제 체결가/체결수량 확인 (주문만 보고 가정하지 않음)
        fill = self.client.get_execution_by_odno(odno, code=code, wait_sec=2)
        if fill:
            price = fill["avg_price"]
            qty = fill["filled_qty"]
            if qty <= 0:
                logger.warning(f"⚠️ [{name}] 체결 조회 수량 0 -> 매수 반영 스킵")
                return False
        else:
            # 체결 조회 실패 시 주문 수량/시그널가로 반영 (기존 동작, 로그로 구분)
            logger.info(f"📋 [{name}] 체결 조회 미확인 -> 주문기준으로 반영 (가격={price:,.0f}, 수량={qty})")
        self._update_account_light(profit_val=0)
        buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S")
        self.holdings[code] = {
            "buy_price": price,
            "qty": qty,
            "buy_time": buy_time,
            "name": name,
            "max_price": price,  # 고점 추적
            "atr_entry": atr,  # 매수 시점 ATR 저장
            "stop_price": stop_price,  # 손절가
            "target_price": target_price,  # 목표가
        }
        self.db.upsert_trade({
            "code": code,
            "name": name,
            "strategy": "SHORT_ANT_SHAKING",
            "avg_buy_price": price,
            "current_price": price,
            "target_qty": qty,
            "current_qty": qty,
            "status": "HOLDING",
            "buy_date": buy_time,
            "stop_price": stop_price,
            "target_price": target_price,
            "atr_entry": atr,
            "entry_features": signal.get("entry_features"),
        })
        if fill:
            logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 (API 체결 확인) | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원")
        else:
            logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 (주문기준) | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원")
        # 체결 알림 (MM)
        try:
            mm_msg = f"🟢 **매수 체결** {name} ({code})\n{price:,.0f}원 × {qty:,}주 | 손절 {stop_price:,.0f}원 / 목표 {target_price:,.0f}원"
            self.send_mm(mm_msg)
        except Exception as e:
            logger.debug(f"매수 체결 MM 발송 스킵: {e}")
        return True

    def execute_sell(self, signal):
        """매도 실행"""
        code = signal["code"]
        name = signal["name"]
        qty = signal["qty"]
        
        if code not in self.holdings:
            logger.warning(f"⚠️ [{name}] 보유 종목 아님")
            return False
        
        # 매도 주문
        success = self.client.sell_market_order(code, qty)
        if success:
            # 현재가 조회
            price_data = self.client.inquire_price(code)
            if price_data:
                sell_price = abs(float(price_data.get("stck_prpr", 0)))
            else:
                sell_price = signal.get("price", 0)
            
            # 손익 계산 (매도 후 총자산 반영용)
            holding = self.holdings.get(code, {})
            buy_price = holding.get("buy_price", sell_price)
            profit_val = (sell_price - buy_price) * qty  # 손익 금액
            
            # DB에서 매도 처리
            self.db.close_trade(
                code=code,
                sell_price=sell_price,
                sell_reason=signal['reason'],
            )
            
            del self.holdings[code]
            
            # 🔥 매도 후 예수금 + 총자산 즉시 업데이트 (손익 반영)
            self._update_account_light(profit_val=profit_val)
            
            logger.info(f"💸 [매도 체결] {name} ({code}): {qty}주 ({signal['reason']}, {signal['profit_pct']*100:+.2f}%)")
            # 체결 알림 (MM)
            try:
                pct = signal['profit_pct'] * 100
                mm_msg = f"🔴 **매도 체결** {name} ({code})\n{sell_price:,.0f}원 × {qty:,}주 | {signal['reason']} | 수익률 {pct:+.2f}%"
                self.send_mm(mm_msg)
            except Exception as e:
                logger.debug(f"매도 체결 MM 발송 스킵: {e}")
            return True
        
        return False

    def run(self):
        """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출."""
        asyncio.run(self._run_async())
    
    async def _run_async(self):
        """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행"""
        logger.info("🚀 단타 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)")
        
        # 매터모스트 원격 조종 리스너 (!적용 / !설정 → DB 반영 후 reload_config)
        try:
            from mm_remote import MattermostRemoteController
            self.mm_remote = MattermostRemoteController(
                server_url=MM_SERVER_URL,
                bot_token=MM_BOT_TOKEN,
                channel_alias=self.mm_channel,
                mm_config_path=MM_CONFIG_FILE,
                db=self.db,
            )
            self.mm_remote.start()
        except Exception as e:
            logger.warning("⚠️ MM 원격 조종 리스너 시작 실패: %s", e)
            self.mm_remote = None

        # 백그라운드 태스크 시작
        self._universe_task = asyncio.create_task(self._universe_scan_scheduler())
        self._report_task = asyncio.create_task(self._report_scheduler())
        self._asset_task = asyncio.create_task(self._asset_update_scheduler())
        logger.info("✅ 백그라운드 태스크 시작 완료 (유니버스 스캔, 리포트, 자산 업데이트)")
        
        # 동기 매매 루프는 별도 스레드에서 실행 (메인 이벤트 루프 블로킹 방지)
        loop = asyncio.get_event_loop()
        await loop.run_in_executor(None, self._sync_trading_loop)
    
    def _sync_trading_loop(self):
        """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리"""
        logger.info("📈 매매 루프 시작 (동기 모드)")
        
        while True:
            try:
                # ★ DB 설정 실시간 반영 (재시작 없이 적용)
                self.reload_config()

                now = dt.now()
                current_date = now.strftime("%Y-%m-%d")
                
                # 날짜 변경 시 리포트 플래그 리셋
                if current_date != self.today_date:
                    self.today_date = current_date
                    self.morning_report_sent = False
                    self.closing_report_sent = False
                    self.final_report_sent = False
                    self.ai_report_sent = False
                    self.start_day_asset = 0
                    logger.info(f"📅 날짜 변경: {current_date}")
                
                # 리포트 전송은 백그라운드 태스크에서 처리하므로 여기서는 제거
                # (기존 코드와의 호환성을 위해 주석 처리)
                # if now.hour == 13 and now.minute == 0:
                #     self.send_morning_report()
                #     self.send_ai_report()
                # elif now.hour == 15 and now.minute == 15:
                #     self.send_closing_report()
                # elif now.hour == 15 and now.minute == 35:
                #     self.send_final_report()
                
                # 장 상태 체크
                if not self.check_market_status():
                    logger.info("⏸ 장 시간 아님 - 대기 중...")
                    time.sleep(60)
                    continue
                
                # 자산 정보 업데이트는 백그라운드 태스크에서 처리하므로 여기서는 제거
                # (기존 코드와의 호환성을 위해 주석 처리)
                # if now.minute % 30 == 0:  # 30분마다
                #     self._update_assets()
                
                # 매도 신호 체크 (우선)
                sell_signals = self.check_sell_signals()
                for signal in sell_signals:
                    self.execute_sell(signal)
                
                # 매수: 후보 종목을 3분봉으로 실시간 타점 체크 → 그 순간에만 매수 (키움 TAIL_CATCH 방식)
                today_str = dt.now().strftime("%Y-%m-%d")
                if today_str != self.today_date:
                    self.today_date = today_str
                    self.untradable_skip_set.clear()
                    logger.debug("📅 날짜 변경 -> 매매불가 제외 목록 초기화")
                active_count = len(self.holdings)
                db_candidates = self.db.get_target_candidates()
                if db_candidates and active_count < self.max_stocks:
                    strength_preview = " | 강도순: " + ", ".join(
                        f"{c.get('name', c.get('code',''))} {c.get('score', 0):.1f}" for c in db_candidates[:5]
                    ) if db_candidates else ""
                    logger.info(f"🔍 [매수체크] 후보 {len(db_candidates)}명 순회 (보유 {active_count}/{self.max_stocks}){strength_preview}")
                    signals_this_turn = 0
                    attempts_this_turn = 0
                    for db_item in db_candidates:
                        code = db_item.get("code") or db_item.get("stk_cd", "")
                        name = db_item.get("name") or db_item.get("stk_nm", code)
                        if not code or code in self.holdings:
                            continue
                        # 매매불가 종목은 당일 재시도 안 함 → 다음 후보로
                        if code in self.untradable_skip_set:
                            continue
                        signal = self.check_buy_signal_tail_catch(code, name)
                        if signal:
                            signals_this_turn += 1
                            attempts_this_turn += 1
                            logger.info(f"🛒 [매수 시도] {name} ({code}) {attempts_this_turn}건째")
                            ok = self.execute_buy(signal)
                            if ok:
                                logger.info(f"✅ [매수체크] 이번 턴 시그널 {signals_this_turn}건, 시도 {attempts_this_turn}건, 성공 1건")
                                time.sleep(random.uniform(1, 2))
                                break
                            # 실패(매매불가, 금액0, 예수금 부족, API 오류 등) -> 다음 후보로 순회
                            time.sleep(random.uniform(0.3, 0.8))
                            continue
                        time.sleep(random.uniform(0.5, 1.0))
                    if signals_this_turn == 0 and db_candidates:
                        logger.info(f"🔍 [매수체크] 이번 순회 시그널 0건 (조건 통과한 후보 없음) → 다음 턴에 재시도")
                    elif attempts_this_turn > 0 and len(self.holdings) == active_count:
                        logger.info(f"🔍 [매수체크] 이번 턴 시그널 {signals_this_turn}건, 시도 {attempts_this_turn}건, 체결 0건 (실패 후 다음 턴)")
                elif not db_candidates and active_count == 0:
                    logger.debug("🔍 [매수] 타겟 0개 (유니버스 스캔 대기 중)")
                
                # 대기 (너무 길면 매수 타점 놓침 → 2~4초로 단축)
                time.sleep(random.uniform(2, 4))
                
            except KeyboardInterrupt:
                logger.info("⏹ 봇 종료")
                # 백그라운드 태스크 취소
                if self._universe_task:
                    self._universe_task.cancel()
                if self._report_task:
                    self._report_task.cancel()
                if self._asset_task:
                    self._asset_task.cancel()
                break
            except Exception as e:
                logger.error(f"❌ 루프 에러: {e}")
                time.sleep(5)


if __name__ == "__main__":
    bot = ShortTradingBot()
    bot.run()
