"""
Kiwoom Trading Bot Ver2 - DB 기반 고급 트레이딩 시스템
- SQLite DB 기반 안전한 데이터 관리
- 변동성 기반 자금 관리 (Risk Manager)
- TWAP 스마트 분할 매수 (Smart Executor)
- 하프 켈리 공식 적용
- 기존 TAIL_CATCH_3M 전략 유지 및 강화
"""

import time
import json
import datetime
import asyncio
import pandas as pd
import numpy as np
import os
import logging
import requests
import random
from dotenv import load_dotenv

# 새로운 모듈 임포트
from database import TradeDB
from risk_manager import RiskManager
from smart_executor import SmartOrderExecutor

from ml_predictor import MLPredictor
from news_analyzer import NewsAnalyzer
from trend_divergence import TrendDivergenceAnalyzer
from export_sniper import ExportSniper
# ==========================================================
# [Step 0] 환경 변수 및 기본 설정
# ==========================================================
current_dir = os.path.dirname(os.path.abspath(__file__))
env_path = os.path.join(current_dir, ".env")
if not os.path.exists(env_path):
    env_path = os.path.join(os.path.dirname(current_dir), ".env")

load_dotenv(env_path)

# Mattermost 설정
MM_SERVER_URL = "https://mattermost.hoonfam.org"
MM_BOT_TOKEN = os.environ.get("MM_BOT_TOKEN_", "").strip()
MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json")

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

# 외부 라이브러리 로그 억제
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)

# ==========================================================
# [Helper 함수] 환경변수 안전 로드
# ==========================================================
def get_env_float(key, default):
    """환경변수를 float로 변환 (주석 제거)"""
    value = os.environ.get(key, default)
    # 주석 제거 (# 이후 문자열 제거)
    if isinstance(value, str) and "#" in value:
        value = value.split("#")[0].strip()
    return float(value)

def get_env_int(key, default):
    """환경변수를 int로 변환 (주석 제거)"""
    value = os.environ.get(key, default)
    # 주석 제거
    if isinstance(value, str) and "#" in value:
        value = value.split("#")[0].strip()
    return int(value)


# 키움 API 모듈 임포트
try:
    from kiwoom_rest_api.auth.token import TokenManager
    from kiwoom_rest_api.koreanstock.stockinfo import StockInfo
    from kiwoom_rest_api.koreanstock.chart import Chart
    from kiwoom_rest_api.koreanstock.order import Order
    from kiwoom_rest_api.koreanstock.rank_info import RankInfo
    from kiwoom_rest_api.koreanstock.account import Account
    from kiwoom_rest_api.koreanstock.market_condition import MarketCondition
    # 퀀트 트레이딩 필수 API 추가
    from kiwoom_rest_api.koreanstock.sector import Sector
    from kiwoom_rest_api.koreanstock.foreign_institution import ForeignInstitution
    from kiwoom_rest_api.koreanstock.theme import Theme
    from kiwoom_rest_api.koreanstock.etf import ETF
except ImportError as e:
    logger.critical(f"❌ 키움 REST API 모듈 임포트 실패: {e}")
    raise e


# ==========================================================
# [Part 0] Mattermost 봇 클래스
# ==========================================================
class MattermostBot:
    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 os.path.exists(MM_CONFIG_FILE):
                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


# ==========================================================
# [Part 1] 브로커 API (키움증권 REST API 연동)
# ==========================================================
class BrokerAPI:
    def __init__(self):
        logger.info("🔵 키움(REST) 브로커 연결 시도...")
        try:
            self.token_manager = TokenManager()
            self.stock_info = StockInfo(token_manager=self.token_manager)
            self.chart = Chart(token_manager=self.token_manager)
            self.order = Order(token_manager=self.token_manager)
            self.rank = RankInfo(token_manager=self.token_manager)
            self.account = Account(token_manager=self.token_manager)
            self.market = MarketCondition(token_manager=self.token_manager)
            # 퀀트 트레이딩 필수 API 추가
            self.sector = Sector(token_manager=self.token_manager)
            self.foreign_inst = ForeignInstitution(token_manager=self.token_manager)
            self.theme = Theme(token_manager=self.token_manager)
            self.etf = ETF(token_manager=self.token_manager)
            self.acc_no = os.environ.get("KIWOOM_ACCOUNT_NO", "")
            logger.info(f"✅ 브로커 연결 완료 (계좌: {self.acc_no})")
        except Exception as e:
            logger.critical(f"❌ 브로커 초기화 실패: {e}")
            raise e

    def _safe_request(self, func, *args, **kwargs):
        """API 호출 안전장치 (429 / 과부하 핸들링)

        - 키움 REST API의 호출 초과(429, '초과', '과부하')를 감지
        - 지수 백오프(1s → 2s → 4s)에 약간의 지터(jitter)를 섞어 재시도
        - 기존 로직과 동일하게, 최대 재시도 후에는 {} 반환
        """
        full_name = func.__name__
        api_id = full_name.split('_')[-1]
        max_retries = 3
        logger.debug(f"💀 [{api_id}] _safe_request 호출")

        for i in range(max_retries):
            try:
                # 기본 안전 대기 (서버 부하 완화용)
                time.sleep(1)
                result = func(*args, **kwargs)

                # 키움 REST는 HTTP 200 + return_code/msg1 조합으로 "호출 초과"를 알리는 경우가 있음
                if isinstance(result, dict):
                    msg1 = str(result.get('msg1', ''))
                    return_code = str(result.get('return_code', '0'))
                    # '초과', '과부하' 등의 키워드로 레이트 리밋/과부하 감지
                    if return_code != '0' and ('초과' in msg1 or '과부하' in msg1):
                        raise Exception(f"RateLimitOrOverload: {msg1}")

                return result

            except Exception as e:
                msg = str(e)
                # 429 / 호출 초과 / 과부하 계열 에러: 지수 백오프 + 지터
                if ("429" in msg) or ("RateLimit" in msg) or ("초과" in msg) or ("과부하" in msg):
                    # 1, 2, 4초 + 약간의 랜덤 지터 (0.5~1.5초)
                    base = 2 ** i
                    wait = base + random.uniform(0.5, 1.5)
                    logger.warning(
                        f"⚠️ [{api_id}] API 호출 제한 또는 과부하 감지 -> {wait:.1f}초 대기 후 재시도 "
                        f"({i + 1}/{max_retries}) | 에러: {msg}"
                    )
                    time.sleep(wait)
                else:
                    # 그 외 에러는 기존처럼 짧게 대기 후 다음 루프
                    logger.error(f"❌ [{api_id}] 호출 에러: {e}")
                    time.sleep(1)

        logger.error(f"💀 [{api_id}] 3회 재시도 실패")
        return {}

    def get_deposit_only(self):
        """예수금 조회"""
        try:
            res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2")
            d2_deposit = float(res.get('d2_entra', 0)) if res else 0
            current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0
            return 0 if d2_deposit < 0 else current_deposit
        except Exception as e:
            logger.error(f"예수금 조회 실패: {e}")
            return 0

    def get_account_info(self):
        """계좌 평가 정보 조회 (전체 자산, 주식 평가액, 보유 종목 리스트)"""
        try:
            # 예수금 조회
            deposit = self.get_deposit_only()
            
            # 잔고 조회 (보유 종목 리스트 포함!)
            res = self._safe_request(
                self.account.account_evaluation_balance_detail_request_kt00018, 
                query_type="1",              # 1:합산, 2:개별
                domestic_exchange_type="KRX" # 한국거래소
            )
            
            if not res:
                return {
                    'total_asset': deposit,
                    'deposit': deposit,
                    'stock_value': 0,
                    'holdings': {}  # 🔥 보유 종목 리스트 추가
                }
            
            # 주식평가금액
            stock_value = float(res.get('tot_evlt_amt', 0))
            
            # 총자산 = 예수금 + 주식평가금액
            total_asset = deposit + stock_value
            
            # 🔥 보유 종목 리스트 파싱 (Dual 로직 이식!)
            holdings = {}
            if 'acnt_evlt_remn_indv_tot' in res:
                for item in res['acnt_evlt_remn_indv_tot']:
                    code = item['stk_cd'].strip()
                    if code.startswith('A'):
                        code = code[1:]  # 'A005930' → '005930'
                    
                    qty = int(item.get('rmnd_qty', 0))
                    if qty <= 0:
                        continue  # 수량 0인 종목 제외
                    
                    holdings[code] = {
                        'name': item['stk_nm'].strip(),
                        'qty': qty,
                        'buy_price': abs(float(item.get('pur_pric', 0))),
                        'current_price': abs(float(item.get('cur_prc', 0))),
                        'profit_rate': float(item.get('erng_rt', 0))
                    }
            
            return {
                'total_asset': total_asset,
                'deposit': deposit,
                'stock_value': stock_value,
                'holdings': holdings  # 🔥 {code: {name, qty, buy_price, current_price, profit_rate}}
            }
        except Exception as e:
            logger.error(f"계좌 정보 조회 실패: {e}")
            import traceback
            logger.error(f"상세 오류:\n{traceback.format_exc()}")
            return {
                'total_asset': 0,
                'deposit': 0,
                'stock_value': 0,
                'holdings': {}
            }

    def check_market_status(self):
        """장 운영 시간 체크 (08:30 ~ 16:00)"""
        now = datetime.datetime.now()
        if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)):
            return False
        if now.weekday() >= 5:  # 주말
            return False
        return True

    def get_ohlcv(self, code, timeframe='3m', limit=100):
        """분봉 차트 데이터 조회"""
        tic_scope = {"1m": "1", "3m": "3", "5m": "5", "10m": "10"}.get(timeframe, "3")
        
        try:
            res = self._safe_request(
                self.chart.stock_minute_chart_request_ka10080,
                stk_cd=code, tic_scope=tic_scope, upd_stkpc_tp="1"
            )
            data = res.get('stk_min_pole_chart_qry', []) if res else []
            if not data:
                return pd.DataFrame()

            df = pd.DataFrame(data)
            df = df.rename(columns={
                'cur_prc': 'close', 'open_pric': 'open', 
                'high_pric': 'high', 'low_pric': 'low',
                'trde_qty': 'volume'
            })
            
            # 시간순 정렬 (과거->현재)
            df = df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs()
            return df.iloc[::-1].reset_index(drop=True).tail(limit)
            
        except Exception as e:
            logger.error(f"차트 조회 실패({code}): {e}")
            return pd.DataFrame()

    def get_current_price(self, code):
        """현재가 조회"""
        try:
            res = self._safe_request(
                self.stock_info.watchlist_stock_information_request_ka10095,
                stock_code=code
            )
            if res and 'atn_stk_infr' in res and len(res['atn_stk_infr']) > 0:
                item = res['atn_stk_infr'][0]
                return abs(float(item.get('cur_prc', 0)))
            return None
        except Exception as e:
            logger.error(f"현재가 조회 에러({code}): {e}")
            return None

    def buy_market_order(self, code, qty):
        """시장가 매수 주문"""
        try:
            res = self._safe_request(
                self.order.stock_buy_order_request_kt10000,
                dmst_stex_tp="KRX", stk_cd=code,
                ord_qty=str(qty), trde_tp="3", ord_uv="0"
            )
            if str(res.get('return_code')) == '0':
                return True
            logger.error(f"매수 주문 실패({code}): {res}")
            return False
        except Exception as e:
            logger.error(f"매수 주문 예외({code}): {e}")
            return False

    def sell_market_order(self, code, qty):
        """시장가 매도 주문"""
        try:
            res = self._safe_request(
                self.order.stock_sell_order_request_kt10001,
                dmst_stex_tp="KRX", stk_cd=code,
                ord_qty=str(qty), trde_tp="3", ord_uv="0"
            )
            if str(res.get('return_code')) == '0':
                return True
            logger.error(f"매도 주문 실패({code}): {res}")
            return False
        except Exception as e:
            logger.error(f"매도 주문 예외({code}): {e}")
            return False
    
    # ==========================================================
    # [퀀트 분석 메소드] - Sector (업종 분석)
    # ==========================================================
    
    def get_top_sectors(self, market="001", top_n=5):
        """상승률 상위 업종 조회"""
        try:
            res = self._safe_request(
                self.sector.all_industries_index_request_ka20003,
                mrkt_tp=market  # 001:코스피, 101:코스닥
            )
            if not res:
                return []
            
            # 업종 데이터 정렬 (상승률 기준)
            sectors = res.get('all_indx_qry', [])
            sorted_sectors = sorted(sectors, 
                                  key=lambda x: float(x.get('fluc_rt', 0)), 
                                  reverse=True)
            return sorted_sectors[:top_n]
        except Exception as e:
            logger.error(f"업종 조회 실패: {e}")
            return []
    
    def get_sector_investor_trend(self, market="001"):
        """업종별 투자자 순매수 현황"""
        try:
            res = self._safe_request(
                self.sector.industrywise_investor_net_buy_request_ka10051,
                mrkt_tp=market,  # 001:코스피, 101:코스닥
                stex_tp="1"      # 1:KRX
            )
            return res.get('indiv_nvst_netby_indu', []) if res else []
        except Exception as e:
            logger.error(f"업종 투자자 동향 조회 실패: {e}")
            return []
    
    # ==========================================================
    # [퀀트 분석 메소드] - ForeignInstitution (수급 분석)
    # ==========================================================
    
    def get_foreign_consecutive_buy(self, consecutive_days=3, market="001", limit=20):
        """외국인 연속 순매수 종목"""
        try:
            res = self._safe_request(
                self.rank.top_foreign_consecutive_net_buy_request_ka10035,
                mrkt_tp=market,
                trde_tp="2",      # 1:연속순매도, 2:연속순매수
                base_dt_tp="1",   # 0:당일기준, 1:전일기준
                stex_tp="1"       # 1:KRX
            )
            stocks = res.get('for_cont_nettrde_upper', []) if res else []
            return stocks[:limit]
        except Exception as e:
            logger.error(f"외국인 연속 순매수 조회 실패: {e}")
            return []
    
    def get_institutional_buy_stocks(self, market="001", limit=20):
        """기관 순매수 상위 종목"""
        try:
            from datetime import datetime as dt
            today = dt.now().strftime("%Y%m%d")
            res = self._safe_request(
                self.rank.same_day_net_buying_ranking_request_ka10062,
                strt_dt=today,   # 시작일자 (YYYYMMDD)
                mrkt_tp=market,  # 시장구분
                trde_tp="1",     # 1:순매수, 2:순매도
                sort_cnd="1",    # 1:수량, 2:금액
                unit_tp="1",     # 1:단주, 1000:천주
                stex_tp="1"      # 1:KRX
            )
            stocks = res.get('eql_nettrde_rank', []) if res else []
            # 기관 순매수만 필터 (orgn_nettrde_qty > 0)
            return [s for s in stocks if float(s.get('orgn_nettrde_qty', 0)) > 0][:limit]
        except Exception as e:
            logger.error(f"기관 순매수 조회 실패: {e}")
            return []
    
    def get_foreign_stock_trend(self, stock_code, period="D"):
        """특정 종목의 외국인 매매동향"""
        try:
            res = self._safe_request(
                self.foreign_inst.foreign_investor_stockwise_trading_trend_request_ka10008,
                stk_cd=stock_code,
                period=period,   # D:일, W:주, M:월
                stex_tp="1"
            )
            return res.get('forgn_nvst_stk_trd_tnd', []) if res else []
        except Exception as e:
            logger.error(f"외국인 매매동향 조회 실패({stock_code}): {e}")
            return []
    
    # ==========================================================
    # [퀀트 분석 메소드] - RankInfo (종목 스크리닝)
    # ==========================================================
    
    def get_volume_surge_stocks(self, market="001", min_volume="50", limit=20):
        """거래량 급증 종목"""
        try:
            res = self._safe_request(
                self.rank.sudden_increase_trading_volume_request_ka10023,
                mrkt_tp=market,
                sort_tp="1",         # 1:급증량, 2:급증률, 3:급감량, 4:급감률
                tm_tp="2",           # 1:분, 2:전일
                trde_qty_tp=min_volume,  # 5, 10, 50, 100, 200...
                stk_cnd="1",         # 관리종목제외
                pric_tp="0",         # 0:전체조회
                stex_tp="1"          # 1:KRX
            )
            stocks = res.get('trde_qty_sdnin', []) if res else []
            return stocks[:limit]
        except Exception as e:
            logger.error(f"거래량 급증 조회 실패: {e}")
            return []

    def get_volume_surge_stocks_full_pages(
        self,
        market="001",
        min_volume="50",
        stk_cnd="1",
        max_pages=5,
    ):
        """
        거래량 급증 종목 - 한 stk_cnd에 대해 연속조회(next_key)로 전체 페이지 수집.
        - next_key는 '같은 조건의 다음 페이지'만 가져오므로, 여러 stk_cnd를 쓰려면
          get_volume_surge_stocks_multi_stk_cnd() 사용.
        """
        collected = []
        next_key = ""
        page = 0
        try:
            while page < max_pages:
                page += 1
                res = self._safe_request(
                    self.rank.sudden_increase_trading_volume_request_ka10023,
                    mrkt_tp=market,
                    sort_tp="1",
                    tm_tp="2",
                    trde_qty_tp=min_volume,
                    stk_cnd=stk_cnd,
                    pric_tp="0",
                    stex_tp="1",
                    cont_yn="Y" if next_key else "N",
                    next_key=next_key,
                )
                if not res or "trde_qty_sdnin" not in res:
                    break
                chunk = res.get("trde_qty_sdnin", [])
                collected.extend(chunk)
                next_key = res.get("next-key", "")
                if not next_key:
                    break
                time.sleep(random.uniform(1.0, 2.0))
            return collected
        except Exception as e:
            logger.error(f"거래량 급증 연속조회 실패: {e}")
            return collected

    def get_volume_surge_stocks_multi_stk_cnd(
        self,
        stk_cnd_list=None,
        market="001",
        min_volume="50",
        limit=100,
        delay_sec=(1, 3),
    ):
        """
        여러 stk_cnd(종목조건)로 각각 API 호출 후 결과를 append.
        - next_key로는 다른 stk_cnd를 이어받을 수 없으므로, 조건마다 별도 호출 필요.
        - 호출 간 딜레이로 429(횟수 제한) 완화.
        - stk_cnd_list 예: ["0", "1", "4"] (전체, 관리제외, 관리+우선주제외)
        """
        if stk_cnd_list is None:
            stk_cnd_list = ["1"]
        out = []
        seen_codes = set()
        for stk_cnd in stk_cnd_list:
            try:
                res = self._safe_request(
                    self.rank.sudden_increase_trading_volume_request_ka10023,
                    mrkt_tp=market,
                    sort_tp="1",
                    tm_tp="2",
                    trde_qty_tp=min_volume,
                    stk_cnd=stk_cnd,
                    pric_tp="0",
                    stex_tp="1",
                    cont_yn="N",
                    next_key="",
                )
                if res and "trde_qty_sdnin" in res:
                    for item in res["trde_qty_sdnin"]:
                        code = item.get("stk_cd", "").split("_")[0]
                        if code and code not in seen_codes:
                            seen_codes.add(code)
                            out.append(item)
                if delay_sec and stk_cnd != stk_cnd_list[-1]:
                    time.sleep(random.uniform(delay_sec[0], delay_sec[1]))
            except Exception as e:
                logger.warning(f"거래량 급증 stk_cnd={stk_cnd} 조회 실패: {e}")
        return out[:limit]

    def get_top_price_movers(self, market="001", sort_type="1", limit=20):
        """등락률 상위 종목"""
        try:
            res = self._safe_request(
                self.rank.top_day_over_day_change_rate_request_ka10027,
                mrkt_tp=market,
                sort_tp=sort_type,       # 1:상승률, 2:상승폭, 3:하락률, 4:하락폭, 5:보합
                trde_qty_cnd="0000",     # 거래량조건
                stk_cnd="1",             # 1:관리종목제외
                crd_cnd="0",             # 0:전체조회
                updown_incls="1",        # 0:불포함, 1:포함
                pric_cnd="0",            # 0:전체조회
                trde_prica_cnd="0",      # 0:전체조회
                stex_tp="1"              # 1:KRX
            )
            stocks = res.get('pred_pre_flu_rt_upper', []) if res else []
            return stocks[:limit]
        except Exception as e:
            logger.error(f"등락률 조회 실패: {e}")
            return []
    
    def get_top_volume_stocks(self, market="001", limit=20):
        """당일 거래량 상위 종목"""
        try:
            res = self._safe_request(
                self.rank.top_trading_volume_today_request_ka10030,
                mrkt_tp=market,
                trde_qty_tp="0000",
                stk_cnd="1",
                stex_tp="1"
            )
            stocks = res.get('td_trde_qty_upper', []) if res else []
            return stocks[:limit]
        except Exception as e:
            logger.error(f"거래량 상위 조회 실패: {e}")
            return []
    
    # ==========================================================
    # [퀀트 분석 메소드] - Theme (테마 분석)
    # ==========================================================
    
    def get_hot_themes(self, limit=10):
        """급등 테마 조회 (Theme API ka90001: 테마그룹별 조회, 상위등락률)"""
        try:
            res = self._safe_request(
                self.theme.theme_group_list_request_ka90001,
                qry_tp="0",           # 전체검색
                date_tp="1",         # 1일 전 기준
                flu_pl_amt_tp="3",   # 3: 상위등락률 (급등 테마)
                stex_tp="1",
            )
            themes = res.get("thema_grp", []) if res else []
            # 등락률(flu_rt) 기준 정렬 후 상위 limit개
            sorted_themes = sorted(
                themes,
                key=lambda x: float(x.get("flu_rt", 0)),
                reverse=True,
            )
            return sorted_themes[:limit]
        except Exception as e:
            logger.error(f"테마 조회 실패: {e}")
            return []
    
    # ==========================================================
    # [퀀트 분석 메소드] - ETF
    # ==========================================================
    
    def get_etf_ranking(self, market="001", limit=20):
        """ETF 거래대금 상위"""
        try:
            res = self._safe_request(
                self.etf.etf_ranking_request_ka10133,
                mrkt_tp=market,
                sort_tp="4",     # 4:거래대금
                stex_tp="1"
            )
            etfs = res.get('etf_rank', []) if res else []
            return etfs[:limit]
        except Exception as e:
            logger.error(f"ETF 순위 조회 실패: {e}")
            return []
    
    # ==========================================================
    # [핵심 전략 메소드] - 개미털기 & 장중 수급
    # ==========================================================
    
    def get_intraday_investor(self, code):
        """
        장중 투자자별 매매 차트 (수급 확인용)
        - 외국인/기관의 실시간 순매수 수량을 리턴
        """
        try:
            res = self._safe_request(
                self.chart.intraday_investor_trading_chart_request_ka10064,
                mrkt_tp="000",
                amt_qty_tp="2",  # 2:수량
                trde_tp="0",     # 0:전체
                stk_cd=code
            )
            
            if not res or 'opmr_invsr_trde_chart' not in res:
                return 0, 0
            
            data_list = res['opmr_invsr_trde_chart']
            if not data_list:
                return 0, 0
            
            latest = data_list[0]
            foreigner = int(latest.get('frgnr_invsr', 0))
            institution = int(latest.get('orgn', 0))
            
            return foreigner, institution
        except Exception as e:
            logger.debug(f"장중 수급 조회 실패({code}): {e}")
            return 0, 0
    
    def scan_ant_shaking_candidates(self, max_price_limit=None):
        """
        [조건검색 대체 로직] 개미털기(눌림목) 후보 종목 스캔
        - 거래대금 및 회전율 상위 종목 중, 고점 대비 일정 비율 하락 후 반등 시도하는 종목 추출
        
        📊 수집 전략:
        1. 거래대금 상위 (sort_tp=3)
        2. 회전율 상위 (sort_tp=2)
        → 합친 후 중복 제거
        
        💎 강도 계산:
        - drop_rate = (시가 - 저가) / 시가  (낙폭 %)
        - recovery = (현재가 - 저가) / (고가 - 저가)  (회복률)
        - 조건: 낙폭 3% 이상 & 회복 50% 이상
        - 점수 = drop_rate * 100  (낙폭이 클수록 강함!)
        """
        logger.info(f"🐜 [개미털기] 스캔 시작")
        logger.info(f"   📡 수집 방식: 거래대금 + 회전율 (2가지 소스)")
        raw_codes_set = set()
        scan_strategies = [("3", "거래대금"), ("2", "회전율")]
        
        for sort_tp, desc in scan_strategies:
            logger.info(f"   🔍 [{desc}] 상위 종목 조회 중...")
            try:
                res = self._safe_request(
                    self.rank.top_trading_volume_today_request_ka10030,
                    mrkt_tp="000",
                    sort_tp=sort_tp,
                    mang_stk_incls="3",
                    pric_tp="0",
                    crd_tp="0",
                    trde_qty_tp="0",
                    trde_prica_tp="0",
                    mrkt_open_tp="0",
                    stex_tp="3",
                    cont_yn="N"
                )
                
                if not res or 'tdy_trde_qty_upper' not in res:
                    logger.warning(f"   ⚠️ [{desc}] 응답 없음")
                    continue
                
                logger.info(f"   ✅ [{desc}] {len(res['tdy_trde_qty_upper'])}개 수신")
                
                for stock in res['tdy_trde_qty_upper']:
                    code = stock['stk_cd'].split('_')[0]
                    try:
                        price = abs(float(stock['cur_prc']))
                    except:
                        continue
                    
                    open_price = abs(float(stock.get('open_pric', price)))
                    change_rate = ((price - open_price) / open_price * 100) if open_price > 0 else 0
                    
                    if price < 1000:  # 동전주 제외
                        continue
                    if change_rate > 20:  # 상한가 근처 제외
                        logger.info(f"🚫 상한가 제외: {stock['stk_nm']} (+{change_rate:.1f}%)")
                        continue
                    if price > 200000:  # 20만원 이상 제외
                        continue
                    
                    # 종목 필터링
                    name = stock['stk_nm']
                    if self._is_valid_stock(name, code):
                        raw_codes_set.add(code)
            except Exception as e:
                logger.error(f"스캔({desc}) 중 에러: {e}")
        
        raw_codes = list(raw_codes_set)
        logger.info(f"   📥 후보 수집: {len(raw_codes)}개 -> 정밀 분석")
        
        final_list = []
        chunk_size = 50
        
        try:
            for i in range(0, len(raw_codes), chunk_size):
                chunk = raw_codes[i:i + chunk_size]
                code_str = '|'.join(chunk)
                
                # 다중 종목 현재가 조회
                res = self._safe_request(
                    self.stock_info.watchlist_stock_information_request_ka10095,
                    stock_code=code_str
                )
                
                if res and 'atn_stk_infr' in res:
                    for item in res['atn_stk_infr']:
                        code = item['stk_cd'].strip()
                        if code.startswith('A'):
                            code = code[1:]
                        
                        try:
                            op = abs(float(item.get('open_pric', 0)))
                            hi = abs(float(item.get('high_pric', 0)))
                            lo = abs(float(item.get('low_pric', 0)))
                            cl = abs(float(item.get('cur_prc', 0)))
                            
                            if op == 0:
                                continue
                            
                            if max_price_limit and cl > max_price_limit:
                                continue
                            
                            # 낙폭 계산
                            drop_rate = (op - lo) / op
                            total_range = hi - lo
                            recovery_pos = (cl - lo) / total_range if total_range > 0 else 0
                            
                            # 낙폭이 3% 이상, 아래꼬리를 달고 올라온 종목
                            if total_range > 0 and drop_rate > 0.03:
                                if recovery_pos > 0.5:
                                    score = drop_rate * 100
                                    logger.info(
                                        f"      💎 {item['stk_nm'].strip()} {code}: "
                                        f"낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 점수 {score:.2f}"
                                    )
                                    final_list.append({
                                        'code': code,
                                        'name': item['stk_nm'].strip(),
                                        'price': cl,
                                        'score': score
                                    })
                        except Exception as e:
                            logger.debug(f"종목 분석 오류({code}): {e}")
                            continue
                
                time.sleep(0.5)
            
            final_list.sort(key=lambda x: x['score'], reverse=True)
            return final_list
        except Exception as e:
            logger.error(f"분석 중 치명적 에러: {e}")
            return []
    
    def _is_valid_stock(self, name, code):
        """종목 필터링 (스팩, ETN, 우선주 등 제외)"""
        if len(code) != 6 or not code.isdigit():
            return False
        
        exclude = ['스팩', 'ETN', 'W', 'ELW', '채권', '레버리지', '인버스', 
                   '곱버스', '선물', '콜', '풋', '2X', '3X', '합성', 'H', 'B']
        
        if any(k in name for k in exclude):
            return False
        
        if name.endswith('우') or name.endswith('우B'):
            return False
        
        return True


# ==========================================================
# [Part 2] 메인 트레이딩 봇 Ver2
# ==========================================================
class TradingBotV2:
    def __init__(self, broker_api):
        self.api = broker_api
        
        # Mattermost 초기화
        self.mm = MattermostBot()
        self.mm_channel = "stock"
        
        # DB 초기화
        self.db = TradeDB(db_path="quant_bot.db")
        
        # Risk Manager 초기화
        kelly_enabled = os.environ.get("USE_KELLY", "false").lower() == "true"
        self.risk_mgr = RiskManager(
            risk_pct_per_trade=get_env_float("RISK_PCT_PER_TRADE", "0.02"),
            max_position_pct=get_env_float("MAX_POSITION_PCT", "0.20"),
            min_position_amount=get_env_int("MIN_POSITION_AMOUNT", "50000"),
            use_kelly=kelly_enabled
        )
        
        # Smart Executor 초기화 (TWAP 분할 매수)
        use_twap = os.environ.get("USE_TWAP", "false").lower() == "true"
        self.use_twap = use_twap
        if use_twap:
            self.executor = SmartOrderExecutor(
                min_split_amount=get_env_int("TWAP_MIN_SPLIT", "500000"),
                max_split_amount=get_env_int("TWAP_MAX_SPLIT", "2000000"),
                min_delay_seconds=get_env_int("TWAP_MIN_DELAY", "30"),
                max_delay_seconds=get_env_int("TWAP_MAX_DELAY", "180")
            )
        else:
            self.executor = None
        
        # ML Predictor 초기화
        self.use_ml = os.environ.get("USE_ML_SIGNAL", "false").lower() == "true"
        self.ml_min_prob = get_env_float("ML_MIN_PROBABILITY", "0.65")
        if self.use_ml:
            try:
                self.ml_predictor = MLPredictor(db_path="quant_bot.db")
                if self.ml_predictor.should_retrain():
                    logger.info("🤖 ML 모델 학습 시작...")
                    self.ml_predictor.train_model(retrain=True)
            except Exception as e:
                logger.warning(f"⚠️ ML 초기화 실패: {e}")
                self.ml_predictor = None
        else:
            self.ml_predictor = None
        
        # News Analyzer 초기화
        self.use_news = os.environ.get("USE_NEWS_ANALYSIS", "false").lower() == "true"
        self.news_hour = get_env_int("NEWS_ANALYSIS_HOUR", "9")
        self.news_max = get_env_int("NEWS_MAX_COUNT", "5")
        self.news_analyzed_today = False
        if self.use_news:
            try:
                self.news_analyzer = NewsAnalyzer()
            except Exception as e:
                logger.warning(f"⚠️ News 초기화 실패: {e}")
                self.news_analyzer = None
        else:
            self.news_analyzer = None
        
        # 구글 트렌드 괴리율 분석기 초기화
        try:
            self.trend_analyzer = TrendDivergenceAnalyzer()
            logger.info("✅ 구글 트렌드 괴리율 분석기 초기화 완료")
        except Exception as e:
            logger.warning(f"⚠️ 트렌드 분석기 초기화 실패: {e}")
            self.trend_analyzer = None
        
        # 관세청 수출 호재 감지기 초기화
        try:
            self.export_sniper = ExportSniper()
            logger.info("✅ 관세청 수출 호재 감지기 초기화 완료")
        except Exception as e:
            logger.warning(f"⚠️ 수출 감지기 초기화 실패: {e}")
            self.export_sniper = None
        
        # 뉴스 감시 스레드 상태
        self.news_monitor_running = False
        self._news_monitor_task = None  # asyncio.Task (스레드 제거)
        
        # 거래 설정
        self.max_stocks = get_env_int("MAX_STOCKS", "5")
        self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", "-0.035")
        
        # 🚨 리스크 관리 (손실 한도)
        self.use_risk_check = os.environ.get("USE_RISK_CHECK", "true").lower() == "true"
        self.daily_stop_loss_pct = get_env_float("DAILY_STOP_LOSS_PCT", "-0.05")
        self.consecutive_loss_limit = get_env_int("CONSECUTIVE_LOSS_LIMIT", "4")
        self.max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", "200000")
        
        # 🚫 금지 종목 관리 (재매수 방지)
        self.use_ban_system = os.environ.get("USE_BAN_SYSTEM", "true").lower() == "true"
        self.ban_hours = get_env_int("BAN_HOURS", "24")
        
        # 🗑️ 종목 필터링 (쓰레기 종목 제외)
        self.use_stock_filter = os.environ.get("USE_STOCK_FILTER", "true").lower() == "true"
        
        # 전략 파라미터
        self.rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", "73")
        self.shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", "0.03")
        self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO", "0.5")
        self.max_recovery_ratio = get_env_float("MAX_RECOVERY_RATIO", "0.8")
        # POP/LOCK 순수익 기반 익절 파라미터 (소수 단위)
        # ROUND_TRIP_COST_PCT: 수수료+세금 왕복 비용 비율 (예: 0.0026 = 0.26%)
        # POP_NET_PCT: POP 판단용 순수익 기준 (예: 0.05 = +5% 순수익 이상 갔던 경우만)
        # LOCK_NET_PCT: 되돌림 시 지킬 최소 순수익 (예: 0.003 = +0.3% 순수익)
        self.round_trip_cost_pct = get_env_float("ROUND_TRIP_COST_PCT", "0.0026")
        self.pop_net_pct = get_env_float("POP_NET_PCT", "0.05")
        self.lock_net_pct = get_env_float("LOCK_NET_PCT", "0.003")
        
        # 상태 변수
        self.current_cash = 0
        self.start_asset = 0
        self.current_total_asset = 0
        self.prev_session_asset = 0  # 이전 세션 자산
        self.start_day_asset = 0     # 당일 시작 자산
        self.today_date = datetime.datetime.now().strftime("%Y%m%d")
        self.trading_halted = False
        # 종목별 손익 경로 상태 (순수익 POP/LOCK 판단용, 프로세스 생명주기 내에서만 유지)
        # { code: {"max_profit_pct": float, "min_profit_pct": float, "went_negative": bool} }
        self.trade_state = {}
        
        # 분할 매수 진행 중인 종목 추적 (중복 실행 방지) — asyncio.Task 사용 (스레드 제거)
        # { code: asyncio.Task }. split_buy_lock은 _run_async() 시작 시 생성 (이벤트 루프 필요)
        self.active_split_buys = {}
        self.split_buy_lock = None
        
        # 장 상태 관리
        self.was_market_open = False
        self.is_first_run = True
        self.trading_halted = False  # 일일 손실 한도 or 연속 손절로 거래 중단 여부

        # 디버그용: 장 상태 강제 오픈 (비장시간 테스트용)
        # FORCE_MARKET_OPEN=true 이면 check_market_status 결과를 무시하고 항상 장이 열린 것으로 간주
        self.force_market_open = os.environ.get("FORCE_MARKET_OPEN", "false").lower() == "true"
        
        # 계좌 조회 실패 카운트 초기화 (안전성을 위해 여기서도 초기화)
        if not hasattr(self, 'account_query_fail_count'):
            self.account_query_fail_count = 0
        if not hasattr(self, 'max_account_fail_alert'):
            self.max_account_fail_alert = get_env_int("MAX_ACCOUNT_FAIL_ALERT", "3")

    def get_env_snapshot(self):
        """매도 시점 env 스냅샷 (trade_history INSERT / env_config / 백테스트·대시보드용). 비밀/토큰 제외."""
        return {
            # 손절·목표
            "STOP_LOSS_PCT": os.environ.get("STOP_LOSS_PCT", "-0.035"),
            "SHOULDER_CUT_PCT": os.environ.get("SHOULDER_CUT_PCT", "0.03"),
            "STOP_ATR_MULTIPLIER_TAIL": os.environ.get("STOP_ATR_MULTIPLIER_TAIL", "3.5"),
            "TARGET_ATR_MULTIPLIER_TAIL": os.environ.get("TARGET_ATR_MULTIPLIER_TAIL", "8.0"),
            # 포지션·슬롯
            "MAX_POSITION_PCT": os.environ.get("MAX_POSITION_PCT", "0.20"),
            "USE_SLOT_CAP": os.environ.get("USE_SLOT_CAP", "true"),
            "SLOT_CAP_PCT": os.environ.get("SLOT_CAP_PCT", "0.9"),
            "MAX_STOCKS": os.environ.get("MAX_STOCKS", "5"),
            # 켈리·리스크
            "USE_KELLY": os.environ.get("USE_KELLY", "false"),
            "RISK_PCT_PER_TRADE": os.environ.get("RISK_PCT_PER_TRADE", "0.02"),
            "MIN_POSITION_AMOUNT": os.environ.get("MIN_POSITION_AMOUNT", "50000"),
            # 리스크 한도
            "USE_RISK_CHECK": os.environ.get("USE_RISK_CHECK", "true"),
            "DAILY_STOP_LOSS_PCT": os.environ.get("DAILY_STOP_LOSS_PCT", "-0.05"),
            "CONSECUTIVE_LOSS_LIMIT": os.environ.get("CONSECUTIVE_LOSS_LIMIT", "4"),
            "MAX_LOSS_PER_TRADE_KRW": os.environ.get("MAX_LOSS_PER_TRADE_KRW", "200000"),
            # 금지 종목
            "USE_BAN_SYSTEM": os.environ.get("USE_BAN_SYSTEM", "true"),
            "BAN_HOURS": os.environ.get("BAN_HOURS", "24"),
            # 종목 필터·전략
            "USE_STOCK_FILTER": os.environ.get("USE_STOCK_FILTER", "true"),
            "RSI_OVERHEAT_THRESHOLD": os.environ.get("RSI_OVERHEAT_THRESHOLD", "73"),
            "MIN_RECOVERY_RATIO": os.environ.get("MIN_RECOVERY_RATIO", "0.5"),
            "MAX_RECOVERY_RATIO": os.environ.get("MAX_RECOVERY_RATIO", "0.8"),
            # TWAP
            "USE_TWAP": os.environ.get("USE_TWAP", "false"),
            "TWAP_MIN_SPLIT": os.environ.get("TWAP_MIN_SPLIT", "500000"),
            "TWAP_MAX_SPLIT": os.environ.get("TWAP_MAX_SPLIT", "2000000"),
            "TWAP_MIN_DELAY": os.environ.get("TWAP_MIN_DELAY", "30"),
            "TWAP_MAX_DELAY": os.environ.get("TWAP_MAX_DELAY", "180"),
            # ML·뉴스
            "USE_ML_SIGNAL": os.environ.get("USE_ML_SIGNAL", "false"),
            "ML_MIN_PROBABILITY": os.environ.get("ML_MIN_PROBABILITY", "0.65"),
            "USE_NEWS_ANALYSIS": os.environ.get("USE_NEWS_ANALYSIS", "false"),
            "NEWS_ANALYSIS_HOUR": os.environ.get("NEWS_ANALYSIS_HOUR", "9"),
            "NEWS_MAX_COUNT": os.environ.get("NEWS_MAX_COUNT", "5"),
            # 스캔·필터
            "USE_QUICK_PROFIT_PROTECTION": os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true"),
            "HIGH_PRICE_CHASE_THRESHOLD": os.environ.get("HIGH_PRICE_CHASE_THRESHOLD", "0.96"),
            "MAX_DAILY_CHANGE_PCT": os.environ.get("MAX_DAILY_CHANGE_PCT", "20.0"),
            "MA20_MAX_ABOVE_PCT": os.environ.get("MA20_MAX_ABOVE_PCT", "3.0"),
            "VOLUME_AVG_MULTIPLIER": os.environ.get("VOLUME_AVG_MULTIPLIER", "1.0"),
            "CANDLE_OPEN_PRICE_BUFFER": os.environ.get("CANDLE_OPEN_PRICE_BUFFER", "0.995"),
            "INTRADAY_INVESTOR_NET_BUY_THRESHOLD": os.environ.get("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000"),
            # 대중소형
            "SIZE_CLASS_LARGE_MIN": os.environ.get("SIZE_CLASS_LARGE_MIN", "5000000000"),
            "SIZE_CLASS_MID_MIN": os.environ.get("SIZE_CLASS_MID_MIN", "500000000"),
            # 기타
            "USE_RANDOM_SPLIT": os.environ.get("USE_RANDOM_SPLIT", "true"),
            "FORCE_MARKET_OPEN": os.environ.get("FORCE_MARKET_OPEN", "false"),
            "TOTAL_DEPOSIT": os.environ.get("TOTAL_DEPOSIT", "0"),
        }
        
        # 리포트 플래그
        self.morning_report_sent = False
        self.closing_report_sent = False
        self.final_report_sent = False
        self.news_analyzed_today = False
        
        # 계좌 조회 실패 추적
        self.account_query_fail_count = 0
        self.max_account_fail_alert = 3  # 3회 연속 실패 시 알림
        
        # 총 입금액 (누적 손익률 계산용)
        self.total_deposit = get_env_float("TOTAL_DEPOSIT", "0")
        
        # 봇 상태 파일
        self.bot_state_file = os.path.join(current_dir, 'bot_state.json')
        
        # 이전 세션 상태 로드
        self._load_bot_state()
        
        # 초기 계좌 정보 로드
        self.refresh_account()
        
        # JSON 마이그레이션 (최초 1회)
        self._migrate_from_json_if_needed()

        # 관리자 웹에서 저장한 최신 설정을 DB에서 불러와 런타임에 적용
        # (UPDATE/스냅샷 찍기 아님: "DB 최신값 -> 봇 설정" 방향)
        try:
            latest = self.db.get_latest_env()
            if latest and isinstance(latest.get("snapshot"), dict) and latest["snapshot"]:
                applied = 0
                for k, v in latest["snapshot"].items():
                    # os.environ은 str만 저장 가능
                    os.environ[str(k)] = "" if v is None else str(v)
                    applied += 1
                logger.info(
                    f"✅ DB env_config 최신값 적용: id={latest.get('id')} keys={applied}"
                )
            else:
                logger.info("ℹ️ DB env_config 최신값 없음: .env/os.environ 사용")
        except Exception as e:
            logger.warning(f"⚠️ DB env_config 적용 실패: {e} (계속 .env/os.environ 사용)")
        
        # 시작 메시지
        self._send_startup_message(kelly_enabled, use_twap)

    def _load_bot_state(self):
        """이전 실행 시 봇 상태 로드"""
        try:
            if os.path.exists(self.bot_state_file):
                with open(self.bot_state_file, 'r', encoding='utf-8') as f:
                    state = json.load(f)
                
                self.prev_session_asset = float(state.get('start_equity', 0))
                prev_day = state.get('start_day', '')
                
                if prev_day != self.today_date:
                    # 새로운 날
                    logger.info(f"📅 새로운 거래일: {self.today_date}")
                    self.start_day_asset = 0  # 나중에 refresh_account에서 설정
                else:
                    # 같은 날 재시작
                    self.start_day_asset = float(state.get('start_day_asset', 0))
                    logger.info(f"🔄 봇 재시작 (당일 시작: {self.start_day_asset:,.0f}원)")
            else:
                logger.info("📝 최초 실행 - 새로운 bot_state.json 생성")
        except Exception as e:
            logger.error(f"⚠️ 봇 상태 로드 실패: {e}")
    
    def _save_bot_state(self):
        """현재 봇 상태 저장"""
        try:
            state = {
                'start_equity': self.current_total_asset,
                'start_day': self.today_date,
                'start_day_asset': self.start_day_asset,
                'last_update': datetime.datetime.now().isoformat()
            }
            with open(self.bot_state_file, 'w', encoding='utf-8') as f:
                json.dump(state, f, indent=2, ensure_ascii=False)
        except Exception as e:
            logger.error(f"⚠️ 봇 상태 저장 실패: {e}")
    
    def _send_startup_message(self, kelly_enabled, use_twap):
        """봇 시작 메시지 전송"""
        # 세션 대비 손익률
        session_pnl_pct = 0.0
        if self.prev_session_asset > 0:
            session_pnl_pct = ((self.current_total_asset - self.prev_session_asset) / self.prev_session_asset) * 100
        
        # 당일 손익률
        day_pnl_pct = 0.0
        if self.start_day_asset > 0:
            day_pnl_pct = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset) * 100
        
        # 누적 손익률 (총 입금액 대비)
        cumulative_pnl_pct = 0.0
        if self.total_deposit > 0:
            cumulative_pnl_pct = ((self.current_total_asset - self.total_deposit) / self.total_deposit) * 100

        # 계좌 정보가 0원으로 떨어진 경우 보호 로직
        # (예: API 장애 / 로그인 실패 등으로 예수금·자산을 못 가져온 경우)
        if self.current_total_asset <= 0:
            logger.warning(
                "⚠️ 계좌 자산이 0원으로 조회되었습니다. "
                "API 오류 / 네트워크 문제 가능성이 있어 시작 손익률을 0%로 표시합니다."
            )
            session_pnl_pct = 0.0
            day_pnl_pct = 0.0
            cumulative_pnl_pct = 0.0
        
        # 🚨 피뢰침 방지 필터 정보
        use_quick_profit = os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true").lower() == "true"
        high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", "0.96")
        max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", "20.0")
        
        msg = f"""🤖 **[트레이딩봇 Ver2 가동]**
- 현재 자산: {self.current_total_asset:,.0f}원
- 세션 손익: {session_pnl_pct:+.2f}%
- 당일 손익: {day_pnl_pct:+.2f}%
- 누적 손익: {cumulative_pnl_pct:+.2f}% (입금액: {self.total_deposit:,.0f}원)
- DB 기반 안전 관리
- 변동성 기반 자금 관리
- TWAP: {'ON' if use_twap else 'OFF'}
- 켈리 공식: {'ON' if kelly_enabled else 'OFF'}
- ML 필터: {'ON' if getattr(self, 'use_ml', False) else 'OFF'} (임계값: {getattr(self, 'ml_min_prob', 0.0):.2f})
- 뉴스 분석: {'ON' if getattr(self, 'use_news', False) else 'OFF'}
- 🛑 리스크 체크: {'ON' if self.use_risk_check else 'OFF'} (일일: {self.daily_stop_loss_pct*100:.1f}%, 연속: {self.consecutive_loss_limit}회)
- 🚫 금지 종목: {'ON' if self.use_ban_system else 'OFF'} ({self.ban_hours}시간)
- 🗑️ 종목 필터: {'ON' if self.use_stock_filter else 'OFF'}
- 💨 작은수익보호: {'ON' if use_quick_profit else 'OFF'} (30분 내)
- ⚡ 피뢰침 방지: ON (고점 {(1-high_chase_threshold)*100:.0f}%+ 조정 | 급등 {max_daily_change:.0f}% 제외)"""
        
        logger.info(msg)
        self.send_mm(msg)
    
    def _migrate_from_json_if_needed(self):
        """기존 JSON 파일에서 DB로 마이그레이션 (1회성)"""
        portfolio_file = os.path.join(current_dir, 'portfolio.json')
        if os.path.exists(portfolio_file):
            try:
                with open(portfolio_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
                if data:
                    count = self.db.migrate_from_json(data)
                    logger.info(f"📦 JSON 마이그레이션 완료: {count}개 종목")
                    # 백업 후 삭제
                    backup_path = portfolio_file + ".backup"
                    os.rename(portfolio_file, backup_path)
                    logger.info(f"💾 기존 JSON 백업: {backup_path}")
            except Exception as e:
                logger.error(f"❌ JSON 마이그레이션 실패: {e}")

    def send_mm(self, msg):
        """Mattermost 알림 전송"""
        try:
            self.mm.send(self.mm_channel, msg)
        except Exception as e:
            logger.error(f"❌ MM 전송 에러: {e}")
    
    def send_morning_report(self):
        """오전 장 뜸할 때 리포트 (13:00)"""
        if self.morning_report_sent:
            return
        
        day_pnl = self.current_total_asset - self.start_day_asset
        day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
        
        msg = f"""📊 **[오전 장 현황 - 13:00]**
- 당일 시작: {self.start_day_asset:,.0f}원
- 현재 자산: {self.current_total_asset:,.0f}원
- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
- 보유 종목: {len(self.db.get_active_trades())}개"""
        
        self.send_mm(msg)
        self.morning_report_sent = True
        logger.info("📊 오전 리포트 전송 완료")
    
    def send_closing_report(self):
        """장마감 전 리포트 (15:15)"""
        if self.closing_report_sent:
            return
        
        day_pnl = self.current_total_asset - self.start_day_asset
        day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
        
        msg = f"""📈 **[장마감 전 현황 - 15:15]**
- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)
- 현재 자산: {self.current_total_asset:,.0f}원
- 보유 종목: {len(self.db.get_active_trades())}개
- 예수금: {self.current_cash:,.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
        
        # 당일 손익
        day_pnl = self.current_total_asset - self.start_day_asset
        day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
        
        # 누적 손익
        cumulative_pnl = self.current_total_asset - self.total_deposit
        cumulative_pnl_pct = (cumulative_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 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.db.get_active_trades())}개
- 예수금: {self.current_cash:,.0f}원
━━━━━━━━━━━━━━━━━━━━"""
        
        self.send_mm(msg)
        self.final_report_sent = True
        logger.info("🏁 장마감 최종 리포트 전송 완료")
    
    def analyze_and_send_news(self):
        """뉴스 AI 분석 및 Mattermost 알림"""
        if self.news_analyzed_today:
            return
        
        logger.info("📰 [뉴스 분석] 시작")
        
        try:
            news_list = self.news_analyzer.crawl_naver_finance_news(max_news=self.news_max)
            
            if not news_list:
                logger.warning("⚠️ 크롤링된 뉴스 없음")
                self.news_analyzed_today = True
                return
            
            analysis = self.news_analyzer.analyze_news_with_claude(news_list)
            
            if not analysis:
                logger.warning("⚠️ AI 분석 실패")
                self.news_analyzed_today = True
                return
            
            message = self.news_analyzer.format_analysis_for_mattermost(analysis, news_list)
            
            if message:
                self.send_mm(message)
                logger.info("✅ 뉴스 분석 알림 전송 완료")
            
            self.news_analyzed_today = True
            
        except Exception as e:
            logger.error(f"❌ 뉴스 분석 중 에러: {e}")
            self.news_analyzed_today = True

    def refresh_account(self):
        """계좌 정보 갱신 (전체 조회 - 5분마다)"""
        # 안전성: 속성이 없으면 즉시 초기화 (초기화 순서 문제 방지)
        if not hasattr(self, 'account_query_fail_count'):
            self.account_query_fail_count = 0
        if not hasattr(self, 'max_account_fail_alert'):
            self.max_account_fail_alert = get_env_int("MAX_ACCOUNT_FAIL_ALERT", "3")
        
        try:
            info = self.api.get_account_info()
            
            # API 응답 검증
            if not info:
                if not hasattr(self, 'account_query_fail_count'):
                    self.account_query_fail_count = 0
                self.account_query_fail_count += 1
                logger.error(f"❌ 계좌 조회 실패 ({self.account_query_fail_count}회 연속) - API 응답 없음 | 이전 값 유지: {self.current_total_asset:,.0f}원")
                
                # 3회 연속 실패 시 알림
                if self.account_query_fail_count >= self.max_account_fail_alert:
                    try:
                        self.send_mm(
                            f"🚨 **[계좌 조회 실패 경고]**\n"
                            f"- 연속 실패: {self.account_query_fail_count}회\n"
                            f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}원\n"
                            f"- API 상태를 확인하세요!"
                        )
                    except:
                        pass
                return
            
            # 0원 응답 검증 (초기화 제외)
            if info.get('total_asset', 0) <= 0 and self.current_total_asset > 0:
                if not hasattr(self, 'account_query_fail_count'):
                    self.account_query_fail_count = 0
                self.account_query_fail_count += 1
                logger.error(
                    f"❌ 계좌 조회 이상 ({self.account_query_fail_count}회 연속) - "
                    f"응답: 예수금={info.get('deposit', 0):,.0f}원, 총자산={info.get('total_asset', 0):,.0f}원 | "
                    f"이전 값 유지: {self.current_total_asset:,.0f}원"
                )
                
                # 3회 연속 실패 시 알림
                if self.account_query_fail_count >= self.max_account_fail_alert:
                    try:
                        self.send_mm(
                            f"🚨 **[계좌 조회 이상 경고]**\n"
                            f"- 연속 실패: {self.account_query_fail_count}회\n"
                            f"- API 응답: 0원 (비정상)\n"
                            f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}원\n"
                            f"- 토큰 만료 or API 장애 가능성"
                        )
                    except:
                        pass
                return
            
            # ✅ 정상 응답 - 값 업데이트
            self.current_cash = info['deposit']
            self.current_total_asset = info['total_asset']
            
            # 성공 시 실패 카운트 리셋
            if hasattr(self, 'account_query_fail_count') and self.account_query_fail_count > 0:
                logger.info(f"✅ 계좌 조회 복구 (이전 {self.account_query_fail_count}회 실패)")
                self.account_query_fail_count = 0
            
            # 첫 실행 시 시작 자산 설정
            if self.start_asset == 0:
                self.start_asset = info['total_asset']
            
            # 당일 시작 자산 설정 (최초 1회)
            if self.start_day_asset == 0:
                self.start_day_asset = info['total_asset']
            
            # 상태 저장
            self._save_bot_state()
            
            logger.info(
                f"💰 예수금: {self.current_cash:,.0f}원 | "
                f"총자산: {info['total_asset']:,.0f}원 "
                f"(예수금 {self.current_cash:,.0f} + 주식 {info['stock_value']:,.0f})"
            )
            
        except Exception as e:
            if not hasattr(self, 'account_query_fail_count'):
                self.account_query_fail_count = 0
            self.account_query_fail_count += 1
            logger.error(f"❌ 계좌 정보 갱신 예외 ({self.account_query_fail_count}회 연속): {e} | 이전 값 유지")
            
            # 3회 연속 실패 시 알림
            if self.account_query_fail_count >= self.max_account_fail_alert:
                try:
                    self.send_mm(
                        f"🚨 **[계좌 조회 예외 경고]**\n"
                        f"- 연속 실패: {self.account_query_fail_count}회\n"
                        f"- 에러: {str(e)[:100]}\n"
                        f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}원"
                    )
                except:
                    pass
    
    def get_banned_codes(self):
        """금지 종목 리스트 반환 (손절 후 재매수 방지)"""
        # 🚫 금지 시스템이 꺼져있으면 빈 리스트 반환
        if not self.use_ban_system:
            return []
        
        try:
            banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json')
            if not os.path.exists(banned_file):
                return []
            
            with open(banned_file, 'r', encoding='utf-8') as f:
                data = json.load(f)
            
            active = []
            now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            for code, expire_time in data.items():
                if expire_time > now:
                    active.append(code)
            return active
        except Exception as e:
            logger.error(f"❌ 금지 종목 조회 실패: {e}")
            return []
    
    def add_ban(self, code, name):
        """종목을 금지 리스트에 추가 (손절 후 재매수 방지)"""
        # 🚫 금지 시스템이 꺼져있으면 아무것도 안 함
        if not self.use_ban_system:
            return
        
        try:
            banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json')
            
            # 기존 데이터 로드
            if os.path.exists(banned_file):
                with open(banned_file, 'r', encoding='utf-8') as f:
                    data = json.load(f)
            else:
                data = {}
            
            # 만료 시각 설정 (self.ban_hours 사용!)
            expire_time = (datetime.datetime.now() + datetime.timedelta(hours=self.ban_hours)).strftime('%Y-%m-%d %H:%M:%S')
            data[code] = expire_time
            
            # 저장
            with open(banned_file, 'w', encoding='utf-8') as f:
                json.dump(data, f, ensure_ascii=False, indent=2)
            
            logger.warning(f"🚫 [매수 금지 추가] {name} ({code}): {self.ban_hours}시간 동안 재매수 불가")
            
        except Exception as e:
            logger.error(f"❌ 금지 종목 추가 실패: {e}")
    
    def cleanup_banned_list(self):
        """만료된 금지 종목 정리"""
        try:
            banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json')
            if not os.path.exists(banned_file):
                return
            
            with open(banned_file, 'r', encoding='utf-8') as f:
                data = json.load(f)
            
            now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            new_data = {k: v for k, v in data.items() if v > now}
            
            if len(data) != len(new_data):
                with open(banned_file, 'w', encoding='utf-8') as f:
                    json.dump(new_data, f, ensure_ascii=False, indent=2)
                logger.info(f"🧹 [금지 종목 정리] {len(data) - len(new_data)}개 만료 제거")
        
        except Exception as e:
            logger.error(f"❌ 금지 종목 정리 실패: {e}")
    
    def _is_valid_stock(self, name, code):
        """
        종목 필터링 (Dual 로직 이식!)
        - 스팩, ETN, 우선주, 레버리지, 인버스 등 제외
        - "쓰레기 같은걸 자꾸 사는" 문제 방지!
        """
        # 🗑️ 필터링이 꺼져있으면 모두 통과
        if not self.use_stock_filter:
            return True
        
        try:
            # 1. 코드 유효성 검사
            if len(code) != 6 or not code.isdigit():
                return False
            
            # 2. 제외 키워드 검사
            exclude_keywords = [
                '스팩', 'SPAC', 'ETN', 'W', 'ELW', '채권', 
                '레버리지', '인버스', '곱버스', 
                '선물', '콜', '풋', 
                '2X', '3X', '합성', 'H', 'B'
            ]
            
            for keyword in exclude_keywords:
                if keyword in name:
                    logger.info(f"🔍 [Pass-필터] {name} ({code}): '{keyword}' 포함 → 제외")
                    return False
            
            # 3. 우선주 제외
            if name.endswith('우') or name.endswith('우B'):
                logger.info(f"🔍 [Pass-필터] {name} ({code}): 우선주 → 제외")
                return False
            
            return True
            
        except Exception as e:
            logger.error(f"❌ 종목 필터링 실패: {e}")
            return True  # 예외 시 통과 (보수적)
    
    def check_risk_status(self):
        """
        일일 손실 한도 체크 (Dual 로직 이식!)
        - 하루 손실 한도 도달 시 거래 중단
        - 연속 손절 한도 도달 시 거래 중단
        """
        # 🛑 리스크 체크가 꺼져있으면 아무것도 안 함
        if not self.use_risk_check:
            return False
        
        try:
            # 1. 일일 수익률 계산
            if self.start_day_asset > 0:
                daily_profit_pct = (self.current_total_asset - self.start_day_asset) / self.start_day_asset
            else:
                daily_profit_pct = 0
            
            # 2. 일일 손실 한도 체크 (self.daily_stop_loss_pct 사용!)
            if daily_profit_pct <= self.daily_stop_loss_pct and not self.trading_halted:
                self.trading_halted = True
                msg = (
                    f"🛑 **[STOP LOSS 발동]**\n"
                    f"일일 손실률: {daily_profit_pct * 100:.2f}%\n"
                    f"기준: -5.0%\n"
                    f"→ 금일 매수 중단!"
                )
                logger.critical(msg)
                self.send_mm(msg)
                return True
            
            # 3. 연속 손절 체크 (self.consecutive_loss_limit 사용!)
            # DB에서 최근 N개 거래 조회
            recent_trades = self.db.conn.execute(f"""
                SELECT sell_reason, profit_rate
                FROM trade_history
                ORDER BY id DESC
                LIMIT {self.consecutive_loss_limit}
            """).fetchall()
            
            if len(recent_trades) >= self.consecutive_loss_limit:
                # 모두 손실이고 손절 사유인지 확인
                all_stop_loss = all(
                    reason and ("손절" in reason or "어깨" in reason) and profit < 0
                    for reason, profit in recent_trades
                )
                
                if all_stop_loss and not self.trading_halted:
                    self.trading_halted = True
                    msg = (
                        f"🛑 **[연속 손절 과다]**\n"
                        f"최근 {self.consecutive_loss_limit}회 연속 손절 발생\n"
                        f"→ 금일 매수 중단!"
                    )
                    logger.critical(msg)
                    self.send_mm(msg)
                    return True
            
            return False
            
        except Exception as e:
            logger.error(f"❌ 리스크 상태 체크 실패: {e}")
            return False
    
    def sync_portfolio_from_api(self):
        """
        실제 계좌 보유 종목 ↔ DB 동기화 (Dual 로직 이식!)
        - 실제 계좌에는 있지만 DB에 없는 종목 → RECOVERED 전략으로 추가
        - DB에는 있지만 실제 계좌에 없는 종목 → DB에서 제거
        """
        try:
            logger.info("🔄 [실계좌↔DB 동기화 시작]")
            
            # 1. 실제 계좌 보유 종목 조회
            info = self.api.get_account_info()
            if not info or not info.get('holdings'):
                logger.warning("⚠️ 실제 계좌 보유 종목 조회 실패")
                return
            
            real_holdings = info['holdings']  # {code: {name, qty, buy_price, current_price, ...}}
            db_trades = self.db.get_active_trades()
            
            real_codes = set(real_holdings.keys())
            db_codes = set(db_trades.keys())
            
            # 2. 실제에는 있지만 DB에 없는 종목 (RECOVERED 전략으로 추가)
            missing_in_db = real_codes - db_codes
            if missing_in_db:
                logger.warning(f"⚠️ DB에 없는 종목 {len(missing_in_db)}개 발견!")
                for code in missing_in_db:
                    stock = real_holdings[code]
                    name = stock['name']
                    qty = stock['qty']
                    buy_price = stock['buy_price']
                    current_price = stock['current_price']
                    
                    # ATR 복구 시도
                    atr = buy_price * 0.01  # 기본값
                    try:
                        df = self.api.get_ohlcv_limit(code, timeframe='1m', limit=100)
                        if df is not None and not df.empty:
                            atr = self.calculate_atr(df)
                    except Exception as e:
                        logger.error(f"❌ [{name}] ATR 계산 실패: {e}")
                    
                    # DB에 추가
                    now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                    trade_data = {
                        'code': code,
                        'name': name,
                        'avg_buy_price': buy_price,
                        'stop_price': buy_price - (atr * 3.0),
                        'target_price': buy_price + (atr * 5.0),
                        'atr_entry': atr,
                        'target_qty': qty,
                        'current_qty': qty,
                        'total_invested': buy_price * qty,
                        'status': 'HOLDING',
                        'strategy': 'RECOVERED',
                        'buy_date': now_str,
                        'max_price': current_price
                    }
                    
                    self.db.upsert_trade(trade_data)
                    logger.info(f"♻️ [RECOVERED] {name} ({code}): {qty}주 @ {buy_price:,.0f}원 → DB 추가")
                    
                    # Mattermost 알림
                    self.send_mm(
                        f"♻️ **[DB 복구]** {name}\n"
                        f"수량: {qty}주 @ {buy_price:,.0f}원\n"
                        f"전략: RECOVERED (실제 계좌에서 발견)"
                    )
            
            # 3. DB에는 있지만 실제에는 없는 종목 (자동 매도된 종목, DB 정리)
            missing_in_real = db_codes - real_codes
            if missing_in_real:
                logger.warning(f"⚠️ 실제 계좌에 없는 종목 {len(missing_in_real)}개 발견!")
                for code in missing_in_real:
                    trade = db_trades[code]
                    name = trade['name']
                    buy_price = trade['avg_buy_price']
                    
                    # 현재가 조회 (최종 매도가 추정)
                    current_price = self.api.get_current_price(code)
                    sell_price = current_price if current_price else buy_price
                    
                    # DB에서 제거 (강제 매도 처리)
                    env_snapshot = json.dumps(self.get_env_snapshot(), ensure_ascii=False)
                    self.db.close_trade(code, sell_price, "동기화_정리(실제계좌없음)", env_snapshot=env_snapshot, size_class=trade.get('size_class'))
                    logger.info(f"🗑️ [DB 정리] {name} ({code}): 실제 계좌에 없음 → DB 제거")
                    
                    # Mattermost 알림
                    self.send_mm(
                        f"🗑️ **[DB 정리]** {name}\n"
                        f"사유: 실제 계좌에 없음\n"
                        f"추정 매도가: {sell_price:,.0f}원"
                    )
            
            # 4. 공통 종목의 수량/평단 동기화 (★ 체결 기준으로 정합성 맞추기)
            common_codes = real_codes & db_codes
            for code in common_codes:
                real = real_holdings[code]
                trade = db_trades[code]

                real_qty = real['qty']
                db_qty = trade['current_qty']

                real_buy = float(real.get('buy_price', 0.0))
                db_buy = float(trade.get('avg_buy_price') or trade.get('buy_price') or 0.0)

                real_cur = float(real.get('current_price', 0.0))

                qty_mismatch = real_qty != db_qty
                buy_mismatch = abs(real_buy - db_buy) > 1e-4

                if qty_mismatch or buy_mismatch:
                    name = trade['name']
                    logger.warning(
                        f"⚠️ [{name}] 포지션 불일치: "
                        f"수량 DB={db_qty} vs 실계좌={real_qty}, "
                        f"평단 DB={db_buy:.2f} vs 실계좌={real_buy:.2f}"
                    )

                    # --- STOP/TARGET 재계산 (실제 평단 기준) ---
                    old_stop = float(trade.get('stop_price') or 0.0)
                    old_target = float(trade.get('target_price') or 0.0)
                    atr = float(trade.get('atr_at_entry') or trade.get('atr_entry') or 0.0)

                    new_stop = old_stop
                    new_target = old_target

                    if atr > 0:
                        # env에서 ATR 배수 가져와서 새 평단 기준으로 재계산
                        atr_multiplier_stop = get_env_float("STOP_ATR_MULTIPLIER_TAIL", "3.5")
                        atr_multiplier_target = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", "8.0")
                        new_stop = real_buy - (atr * atr_multiplier_stop)
                        new_target = real_buy + (atr * atr_multiplier_target)
                    else:
                        # ATR이 없으면 기존 거리(평단~STOP/TARGET)를 유지한 채 평단만 옮김
                        if old_stop > 0 and old_target > 0 and db_buy > 0:
                            stop_delta = db_buy - old_stop          # 평단~손절 거리
                            target_delta = old_target - db_buy       # 평단~목표 거리
                            new_stop = real_buy - stop_delta
                            new_target = real_buy + target_delta

                    total_invested = real_buy * real_qty

                    # active_trades를 실계좌 기준으로 동기화 (체결 기준)
                    with self.db.conn:
                        self.db.conn.execute(
                            """
                            UPDATE active_trades
                            SET
                                current_qty = ?,
                                target_qty = ?,
                                avg_buy_price = ?,
                                total_invested = ?,
                                current_price = ?,
                                max_price = MAX(max_price, ?),
                                stop_price = ?,
                                target_price = ?
                            WHERE code = ?
                            """,
                            (real_qty, real_qty, real_buy, total_invested,
                             real_cur, real_cur,
                             new_stop, new_target,
                             code),
                        )

                    logger.info(
                        f"✅ [{name}] 동기화 완료: "
                        f"수량 {db_qty}→{real_qty}주, "
                        f"평단 {db_buy:.2f}→{real_buy:.2f}, "
                        f"투입금 {trade.get('total_invested', 0):,.0f}→{total_invested:,.0f}원"
                    )
            
            logger.info(f"✅ [동기화 완료] 실제:{len(real_codes)}개 | DB:{len(db_codes)}개 | 공통:{len(common_codes)}개")
            
        except Exception as e:
            logger.error(f"❌ 계좌↔DB 동기화 실패: {e}")
            import traceback
            logger.error(f"상세 오류:\n{traceback.format_exc()}")

    def sync_order_execution_from_api(self):
        """kt00007 / ka10076 체결 내역 보강용 INSERT (2초 간격)"""
        try:
            today_ymd = datetime.datetime.now().strftime("%Y%m%d")
            # kt00007: 계좌별 주문·체결 내역 (체결내역만)
            res = self.api._safe_request(
                self.api.account.account_order_execution_detail_request_kt00007,
                "4", "1", "0", "%",
                ord_dt=today_ymd,
            )
            if res and res.get("acnt_ord_cntr_prps_dtl"):
                for row in res["acnt_ord_cntr_prps_dtl"]:
                    self.db.insert_order_execution("kt00007", row, ord_dt=today_ymd, sell_tp="0")
                logger.info(f"📥 [주문보강] kt00007 {len(res['acnt_ord_cntr_prps_dtl'])}건 저장")
            time.sleep(2)
            # ka10076: 체결 요청
            res2 = self.api._safe_request(
                self.api.account.filled_orders_request_ka10076,
                "0", "0", "0",
            )
            if res2 and res2.get("cntr"):
                for row in res2["cntr"]:
                    self.db.insert_order_execution("ka10076", row, sell_tp="0")
                logger.info(f"📥 [주문보강] ka10076 {len(res2['cntr'])}건 저장")
        except Exception as e:
            logger.debug(f"주문 보강 조회 실패: {e}")

    def cancel_stale_orders(self, max_age_seconds: int = 10):
        """
        오늘자 미체결 주문 중 N초 이상 지난 잔량은 전량 취소.
        - 단타 목적이 아니므로, 오래 안 체결되면 '안 되면 말고' 철학으로 정리.
        - 기준: kt00007 (계좌별 주문·체결 내역)에서 ord_qty > cntr_qty 인 주문.
        """
        try:
            today_ymd = datetime.datetime.now().strftime("%Y%m%d")
            now = datetime.datetime.now()

            res = self.api._safe_request(
                self.api.account.account_order_execution_detail_request_kt00007,
                "4", "1", "0", "%",  # (키움 TR 문서 기준 파라미터)
                ord_dt=today_ymd,
            )
            if not res or not res.get("acnt_ord_cntr_prps_dtl"):
                return

            for row in res["acnt_ord_cntr_prps_dtl"]:
                ord_no = row.get("ord_no") or row.get("orig_ord_no")
                stk_cd = row.get("stk_cd")
                ord_qty = int(str(row.get("ord_qty", "0") or "0"))
                cntr_qty = int(str(row.get("cntr_qty", "0") or "0"))
                ord_tm = row.get("ord_tm", "")  # "HHMMSS"

                # 전량 체결 또는 주문수량 0이면 스킵
                if ord_qty <= 0 or cntr_qty >= ord_qty:
                    continue

                # 주문 시각 → datetime
                if len(ord_tm) == 6:
                    h, m, s = int(ord_tm[0:2]), int(ord_tm[2:4]), int(ord_tm[4:6])
                    ord_time = now.replace(hour=h, minute=m, second=s, microsecond=0)
                else:
                    continue

                age = (now - ord_time).total_seconds()
                if age < max_age_seconds:
                    continue  # 아직 기다릴 시간

                logger.warning(
                    f"🚫 [미체결 취소 후보] {stk_cd} 주문번호 {ord_no} | "
                    f"주문수량={ord_qty}, 체결수량={cntr_qty}, 잔량={ord_qty-cntr_qty}, "
                    f"경과 {age:.1f}s"
                )

                cancel_res = self.api._safe_request(
                    self.api.order.stock_cancel_order_request_kt10003,
                    "KRX",
                    orig_ord_no=ord_no,
                    stk_cd=stk_cd,
                    cncl_qty="0",  # 잔량 전부 취소
                )
                if isinstance(cancel_res, dict) and str(cancel_res.get("return_code", 0)) == "0":
                    logger.info(f"✅ [취소 성공] {stk_cd} 주문번호 {ord_no}")
                else:
                    logger.error(f"❌ [취소 실패] {stk_cd} 주문번호 {ord_no} | 응답: {cancel_res}")
        except Exception as e:
            logger.error(f"❌ 미체결 취소 처리 실패: {e}")

    def update_account_light(self, profit_val=0):
        """
        경량 계좌 갱신 (매수/매도 직후 즉시 호출!)
        - API 부하를 줄이기 위해 예수금만 빠르게 조회
        - 총자산은 로컬 계산으로 추정
        """
        try:
            new_cash = self.api.get_deposit_only()
            if new_cash > 0 or self.current_cash == 0:
                self.current_cash = new_cash
            
            # 손익 반영 (매도 시)
            if profit_val != 0:
                self.current_total_asset += profit_val
            
            logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원")
        except Exception as e:
            logger.error(f"❌ 경량 갱신 실패: {e}")

    def calculate_rsi(self, df, period=14):
        """RSI 계산"""
        try:
            if df is None or len(df) < period + 1:
                return 50
            
            delta = df['close'].diff()
            gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
            loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
            
            rs = gain / loss
            rsi = 100 - (100 / (1 + rs))
            return rsi.iloc[-1] if not np.isnan(rsi.iloc[-1]) else 50
        except:
            return 50

    def calculate_atr(self, df, period=14):
        """ATR 계산"""
        try:
            if df is None or len(df) < period:
                return 0
            
            df = df.copy()
            df['tr'] = np.maximum(
                df['high'] - df['low'],
                np.maximum(
                    np.abs(df['high'] - df['close'].shift()),
                    np.abs(df['low'] - df['close'].shift())
                )
            )
            atr = df['tr'].rolling(window=period).mean().iloc[-1]
            return atr if not np.isnan(atr) else 0
        except:
            return 0

    def check_buy_signal_tail_catch(self, code, name):
        """
        TAIL_CATCH_3M 전략: 3분봉 꼬리 잡기 (kiwoom_trader_dual.py 정식 버전)
        - RSI 과열 체크 + 고가 추격 방지 + 꼬리 필터 강화 + 상세 로그
        """
        try:
            # 🚨 0. 거래 중단 체크 (일일 손실 한도 or 연속 손절)
            if self.trading_halted:
                logger.info(f"🔍 [Pass-거래중단] {name} {code}: STOP LOSS 발동 중")
                return None
            
            # 🚨 1. 금지 종목 체크 (손절 후 24시간 재매수 방지!)
            if code in self.get_banned_codes():
                logger.info(f"🔍 [Pass-금지종목] {name} {code}: 손절 후 24시간 내 재매수 불가")
                return None
            
            # 🚨 2. 종목 필터링 (스팩, ETN, 레버리지 등 제외!)
            if not self._is_valid_stock(name, code):
                return None
            
            # 2. 데이터 조회
            df = self.api.get_ohlcv(code, timeframe='3m', limit=50)
            
            # 데이터 유효성 검사
            if df is None or len(df) < 20:
                logger.info(f"🔍 [데이터 부족] {name} {code}: DF 길이 {len(df) if df is not None else 0}")
                return None
            
            # 3. 지표 계산
            ma20 = df['close'].rolling(window=20).mean().iloc[-1]
            ma60 = df['close'].rolling(window=60).mean().iloc[-1] if len(df) >= 60 else ma20
            avg_vol = df['volume'].rolling(window=20).mean().iloc[-1]
            current_price = df['close'].iloc[-1]
            current_vol = df['volume'].iloc[-1]
            
            # ---------------------------------------------------------
            # [필터 0] MA60 골든크로스 체크 (추가)
            # MA20이 MA60을 상향 돌파 = 단타 타이밍 개선
            if len(df) >= 60:
                prev_ma20 = df['close'].rolling(window=20).mean().iloc[-2]
                prev_ma60 = df['close'].rolling(window=60).mean().iloc[-2]
                is_golden_cross = (ma20 > ma60) and (prev_ma20 <= prev_ma60)
                if is_golden_cross:
                    logger.info(f"✨ [골든크로스] {name} {code}: MA20({ma20:.1f}) > MA60({ma60:.1f}) 상향 돌파 감지")
                # 골든크로스가 아니어도 MA20 > MA60이면 통과 (추세 상승)
                if ma20 < ma60:
                    logger.info(f"🔍 [Pass-MA60] {name} {code}: MA20({ma20:.1f}) < MA60({ma60:.1f}) 하락 추세")
                    return None
            
            # ---------------------------------------------------------
            # [필터 1] RSI 과열 체크
            rsi = self.calculate_rsi(df)
            if rsi >= self.rsi_threshold:
                logger.info(f"🔍 [Pass-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {self.rsi_threshold})")
                return None
            
            # [필터 2] 🚨 피뢰침 방지 - 고점 추격 매수 방지
            daily_high = df['high'].max()
            high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", "0.96")  # 고점 대비 4% 이상 조정
            if current_price >= daily_high * high_chase_threshold:
                drop_from_high = (daily_high - current_price) / daily_high * 100
                logger.info(f"🔍 [Pass-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요)")
                return None
            
            # [필터 2-2] 🚨 피뢰침 방지 - 급등주 제외
            daily_low = df['low'].min()
            daily_change_pct = (daily_high - daily_low) / daily_low * 100
            max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", "20.0")  # 20% 이상 급등 제외
            if daily_change_pct > max_daily_change:
                logger.info(f"🔍 [Pass-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%)")
                return None
            
            # [필터 3] 20선 아래면 스킵 / 20선에서 너무 멀리 올라온 과열 구간도 스킵
            if current_price < ma20:
                logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price}) < MA20({ma20:.2f})")
                return None
            # 20선 초과 상한: MA20 대비 N% 이상 위면 고점 매수로 간주 → 스킵
            ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", "3.0")  # 기본 3% 초과 시 스킵
            if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100):
                gap_pct = (current_price - ma20) / ma20 * 100
                logger.info(f"🔍 [Pass-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%)")
                return None
            
            # [필터 4] 최소 거래량 필터 (평균의 30%도 안되면 패스)
            if current_vol < avg_vol * 0.3:
                logger.info(f"🔍 [Pass-Vol] {name} {code}: 거래량 부족 ({current_vol} < {avg_vol * 0.3:.1f})")
                return None
            
            # ---------------------------------------------------------
            # [타점 분석] 3분봉 꼬리 잡기 로직 + 캔들 패턴 검증
            candle_open = df['open'].iloc[-1]
            candle_close = df['close'].iloc[-1]
            candle_high = df['high'].iloc[-1]
            candle_low = min(df['low'].iloc[-1], current_price)
            total_tail = candle_open - candle_low
            
            if total_tail <= 0:
                return None
            
            # [캔들 패턴 검증] 망치형(Hammer) 검증
            body = abs(candle_close - candle_open)
            upper_shadow = candle_high - max(candle_open, candle_close)
            lower_shadow = min(candle_open, candle_close) - candle_low
            
            # 망치형 조건: 아래꼬리가 몸통의 2배 이상 + 윗꼬리는 매우 짧음
            is_hammer_shape = (lower_shadow >= body * 2) and (upper_shadow <= body * 0.5)
            
            # 거래량 폭발 여부 (가짜 망치 필터링)
            volume_surge_ratio = current_vol / avg_vol if avg_vol > 0 else 1.0
            is_volume_spike = volume_surge_ratio >= 1.5  # 거래량 1.5배 이상
            
            # 진짜 망치인지 검증
            if is_hammer_shape:
                if is_volume_spike:
                    logger.info(f"🔨 [진짜 망치] {name} {code}: 거래량 {volume_surge_ratio:.1f}배 폭발 + 망치형 패턴")
                else:
                    logger.info(f"⚠️ [가짜 망치] {name} {code}: 망치형이지만 거래량 부족({volume_surge_ratio:.1f}배) - 스킵")
                    return None
            
            # 회복 비율 계산
            recovery_ratio = (current_price - candle_low) / total_tail
            
            # --- 로그로 상세 수치 확인 ---
            volume_multiplier = get_env_float("VOLUME_AVG_MULTIPLIER", "1.0")
            log_msg = (
                f"🧐 분석({name} {code}): 가격{current_price} | MA20 {ma20:.1f} | "
                f"회복률 {recovery_ratio:.2f} (조건:{self.min_recovery_ratio}~{self.max_recovery_ratio}) | "
                f"거래량 {current_vol} (조건:>{avg_vol * volume_multiplier:.1f})"
            )
            
            # [조건 1] 회복 탄력성 (환경변수 사용: 기본 0.5~0.8)
            if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio):
                logger.info(f"{log_msg} -> ❌ 회복률 미달/초과")
                return None
            
            # [조건 2] 시가 근접성 (환경변수 사용: 기본 99.5%)
            candle_open_buffer = get_env_float("CANDLE_OPEN_PRICE_BUFFER", "0.995")
            if current_price < candle_open * candle_open_buffer:
                logger.info(f"{log_msg} -> ❌ 시가 회복 부족")
                return None
            
            # [조건 3] 거래량 폭발 여부 (환경변수 사용: 기본 평균 × 1.0)
            if current_vol <= avg_vol * volume_multiplier:
                logger.info(f"{log_msg} -> ❌ 거래량 파워 부족")
                return None
            
            # ---------------------------------------------------------
            # [수급 필터] 최종 관문
            foreigner, institution = self.api.get_intraday_investor(code)
            
            logger.info(f"✨ 1차 통과({name} {code}): 수급 확인 중... 외인:{foreigner}, 기관:{institution}")
            
            # 양쪽에서 동시에 대량 매도 중이면 스킵 (환경변수 사용: 기본 -1000)
            intraday_threshold = get_env_int("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000")
            if foreigner < intraday_threshold and institution < intraday_threshold:
                logger.info(f"⛔ 수급 이탈 감지({name} {code}): 외인{foreigner} / 기관{institution} -> 매수 포기")
                return None
            
            # 5. 매수 시점 대/중/소형 조회 (거래대금 기준, 변동성 구간별 적용)
            size_class = None
            try:
                base_dt = datetime.datetime.now().strftime("%Y%m%d")
                res = self.api._safe_request(
                    self.api.chart.stock_daily_chart_request_ka10081,
                    code, base_dt, "1",
                )
                if res and res.get("stk_dt_pole_chart_qry"):
                    bars = res["stk_dt_pole_chart_qry"][:10]
                    values = [abs(float(b.get("trde_prica", 0) or 0)) for b in bars]
                    avg_trade_value = sum(values) / len(values) if values else 0
                    # .env 기준: 대형/중형 최소 거래대금 (원)
                    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}형")
                time.sleep(2)  # API 부담 완화
            except Exception as e:
                logger.debug(f"대/중/소형 조회 스킵({code}): {e}")

            # 6. 변동성 계산 및 매수 금액 산출
            atr = self.calculate_atr(df)

            # 켈리 비율 가져오기
            kelly_fraction = None
            if self.risk_mgr.use_kelly:
                kelly_fraction = self.db.calculate_half_kelly()

            # Risk Manager를 통한 기본 안전 매수 금액 계산 (변동성 역가중 + 대/중/소형 반영)
            safe_amount = self.risk_mgr.get_position_size(
                stock_name=name,
                current_balance=self.current_cash,
                df=df,
                kelly_fraction=kelly_fraction,
                size_class=size_class,
            )
            
            # 종목당 금액 상한(슬롯): 비싼/싼 종목 모두 비슷한 원화 포지션 → 손익률 대비 원화 손익 균등
            use_slot_cap = os.environ.get("USE_SLOT_CAP", "true").lower() == "true"
            if use_slot_cap and self.max_stocks > 0:
                slot_cap_pct = get_env_float("SLOT_CAP_PCT", "0.9")
                slot_money = int(self.current_cash * slot_cap_pct / self.max_stocks)
                if safe_amount > slot_money:
                    logger.info(f"📐 [{name}] 슬롯 상한 적용: {safe_amount:,.0f} → {slot_money:,.0f}원")
                    safe_amount = slot_money
            
            # 🔒 금액 손실 한도 기반 추가 캡 (예: 3.5% 손절 + 20만 원 손실 한도)
            # - 손절 비율(self.stop_loss_pct)을 기준으로, 손실이 max_loss_per_trade_krw를 넘지 않도록
            #   최대 포지션 금액 = max_loss_per_trade_krw / |stop_loss_pct| 로 역산
            stop_pct_abs = abs(self.stop_loss_pct) if self.stop_loss_pct != 0 else 0.0
            if stop_pct_abs > 0 and self.max_loss_per_trade_krw > 0:
                max_position_by_loss = int(self.max_loss_per_trade_krw / stop_pct_abs)
                if safe_amount > max_position_by_loss:
                    logger.info(
                        f"🛑 [{name}] 금액손실 한도 캡 적용: {safe_amount:,.0f} → "
                        f"{max_position_by_loss:,.0f}원 (손절 {stop_pct_abs*100:.2f}% 기준, "
                        f"1트레이드 최대손실 {self.max_loss_per_trade_krw:,.0f}원)"
                    )
                    safe_amount = max_position_by_loss
            
            if safe_amount < self.risk_mgr.min_amount:
                logger.info(f"🚫 [{name}] 계산된 금액이 최소 매수액 미달")
                return None
            
            # 6. 수량 계산
            qty = self.risk_mgr.calculate_quantity(current_price, safe_amount)
            
            if qty < 1:
                return None
            
            # 손절가/목표가 설정 (ATR 기반)
            atr_multiplier_stop = get_env_float("STOP_ATR_MULTIPLIER_TAIL", "3.5")
            atr_multiplier_target = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", "8.0")
            
            stop_price = current_price - (atr * atr_multiplier_stop)
            target_price = current_price + (atr * atr_multiplier_target)
            
            # 최종 매수 시그널
            logger.info(f"🚀 [매수 신호] {name} {code}: 3분봉 꼬리 공략! (회복률: {recovery_ratio:.2f})")
            
            # ---------------------------------------------------------
            # [ML 필터] 승률 예측 (마지막 관문!)
            if self.use_ml and self.ml_predictor:
                # 피처 추출
                ml_features = {
                    'rsi': rsi,
                    'volume_ratio': current_vol / avg_vol,
                    'tail_length_pct': (total_tail / candle_open) * 100,
                    'ma5_gap_pct': ((current_price - df['close'].rolling(5).mean().iloc[-1]) / current_price) * 100,
                    'ma20_gap_pct': ((current_price - ma20) / current_price) * 100,
                    'foreign_net_buy': foreigner,
                    'institution_net_buy': institution,
                    'market_hour': datetime.datetime.now().hour
                }
                
                # ML 승률 예측
                win_prob = self.ml_predictor.predict_win_probability(ml_features)
                
                logger.info(f"🤖 [ML 예측] {name} {code}: 승률 {win_prob:.2%} (임계값: {self.ml_min_prob:.2%})")
                
                if win_prob < self.ml_min_prob:
                    logger.info(f"❌ [ML 필터] {name} {code}: 승률 부족 ({win_prob:.2%} < {self.ml_min_prob:.2%}) -> 매수 보류")
                    return None
                else:
                    logger.info(f"✅ [ML 통과] {name} {code}: 승률 충분 ({win_prob:.2%}) -> 매수 진행!")
            
            return {
                'code': code,
                'name': name,
                'price': current_price,
                'qty': qty,
                'amount': safe_amount,
                'strategy': 'TAIL_CATCH_3M',
                'stop_price': stop_price,
                'target_price': target_price,
                'atr': atr,
                'size_class': size_class,
            }
            
        except Exception as e:
            logger.error(f"⚠️ 매수 시그널 분석 중 에러({name} {code}): {e}", exc_info=True)
            return None

    async def _execute_random_split_buy_async(self, code, name, qty, price, signal):
        """
        랜덤 분할 매수 실행 (asyncio 태스크 — 스레드 제거). I/O는 run_in_executor / await sleep.
        """
        loop = asyncio.get_event_loop()
        try:
            split_count = random.randint(10, 20)
            base_qty = qty // split_count
            remainder = qty % split_count
            bought_qty = 0
            logger.info(f"🔫 [진입 시작] {name} 총 {qty}주 | {split_count}회 랜덤 분할 (async)")
            for i in range(split_count):
                qty_to_buy = base_qty + (remainder if i == split_count - 1 else 0)
                if qty_to_buy <= 0:
                    continue
                ok = await loop.run_in_executor(
                    None, lambda c=code, q=qty_to_buy: self.api.buy_market_order(c, q)
                )
                if ok:
                    bought_qty += qty_to_buy
                    logger.info(f"   ✅ {i+1}/{split_count}: {qty_to_buy}주 체결")
                    if i < split_count - 1:
                        await asyncio.sleep(random.uniform(0.8, 1.2))
                else:
                    logger.error(f"   ❌ {i+1}/{split_count}: 매수 실패")
            if bought_qty > 0:
                now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                trade_data = {
                    'code': code, 'name': name, 'avg_buy_price': price,
                    'stop_price': signal['stop_price'], 'target_price': signal['target_price'],
                    'atr_entry': signal['atr'], 'target_qty': qty, 'current_qty': bought_qty,
                    'total_invested': price * bought_qty, 'status': 'HOLDING',
                    'strategy': signal['strategy'], 'buy_date': now_str, 'max_price': price,
                    'size_class': signal.get('size_class'),
                }
                self.db.upsert_trade(trade_data)
                self.update_account_light(profit_val=0)
                total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
                msg = f"💰 **[매수 완료]** {name}\n수량: {bought_qty}주 (랜덤 {split_count}회 분할)\n전략: {signal['strategy']}\n자산변동: {total_p:+.2f}%"
                logger.info(msg)
                self.send_mm(msg)
        except Exception as e:
            logger.error(f"❌ [{name}] 분할 매수 에러: {e}", exc_info=True)
        finally:
            async with self.split_buy_lock:
                if code in self.active_split_buys:
                    del self.active_split_buys[code]
                    logger.debug(f"🧹 [{name}] 분할 매수 태스크 종료 및 정리 완료")

    async def _execute_buy_async(self, signal):
        """
        매수 실행 (async 진입점). 랜덤 분할은 asyncio 태스크로, 나머지는 executor에서 sync 실행.
        """
        code = signal['code']
        name = signal['name']
        amount = signal['amount']
        use_random_split = os.environ.get("USE_RANDOM_SPLIT", "true").lower() == "true"
        if use_random_split and amount >= 100000:
            async with self.split_buy_lock:
                if code in self.active_split_buys:
                    task = self.active_split_buys[code]
                    if not task.done():
                        logger.warning(f"⚠️ [{name}] 이미 분할 매수 진행 중 -> 스킵")
                        return False
                    del self.active_split_buys[code]
                t = asyncio.create_task(self._execute_random_split_buy_async(
                    code, name, signal['qty'], signal['price'], signal
                ))
                self.active_split_buys[code] = t
            logger.info(f"🚀 [{name}] 분할 매수 태스크 시작 (메인 루프 계속 진행)")
            return True
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, lambda: self.execute_buy(signal))

    def execute_buy(self, signal):
        """
        매수 실행 (TWAP 또는 즉시 매수). 랜덤 분할은 _execute_buy_async에서 asyncio 태스크로 처리.
        """
        try:
            code = signal['code']
            name = signal['name']
            qty = signal['qty']
            amount = signal['amount']
            price = signal['price']
            active_trades = self.db.get_active_trades()
            if code in active_trades:
                logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵")
                return False
            # ==========================================================
            # [옵션 1] TWAP 분할 매수
            # ==========================================================
            if self.use_twap and self.executor and amount >= 1000000:  # 100만원 이상만 분할
                self.executor.add_order(code, name, amount, duration_minutes=30)
                logger.info(f"📝 [{name}] TWAP 분할 매수 등록: {amount:,.0f}원")
                return True
            
            # ==========================================================
            # [옵션 3] 일반 매수 (즉시 일괄 실행)
            # ==========================================================
            else:
                success = self.api.buy_market_order(code, qty)
                
                if success:
                    now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                    trade_data = {
                        'code': code,
                        'name': name,
                        'avg_buy_price': price,
                        'stop_price': signal['stop_price'],
                        'target_price': signal['target_price'],
                        'atr_entry': signal['atr'],
                        'target_qty': qty,
                        'current_qty': qty,
                        'total_invested': price * qty,
                        'status': 'HOLDING',
                        'strategy': signal['strategy'],
                        'buy_date': now_str,
                        'max_price': price,
                        'size_class': signal.get('size_class'),
                    }
                    self.db.upsert_trade(trade_data)
                    
                    # 매수 후 즉시 예수금 갱신!
                    self.update_account_light(profit_val=0)
                    total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
                    
                    msg = f"💰 **[매수 체결]** {name}\n가격: {price:,.0f}원 × {qty}주\n자산변동: {total_p:+.2f}%"
                    logger.info(msg)
                    self.send_mm(msg)
                    
                    return True
                
                return False
            
        except Exception as e:
            logger.error(f"❌ 매수 실행 실패: {e}")
            return False

    def check_sell_signals(self):
        """
        보유 종목 매도 시그널 체크 (Dual 로직 완전 이식)
        - 어깨 매도 (최우선!)
        - 스캘핑 로직 (본절사수/익절보존)
        - 2일 보유 전략
        """
        active_trades = self.db.get_active_trades()
        
        if not active_trades:
            return
        
        for code, trade in list(active_trades.items()):
            try:
                name = trade['name']
                buy_price = trade['avg_buy_price']
                stop_price = trade['stop_price']
                target_price = trade['target_price']
                qty = trade['current_qty']
                strategy = trade.get('strategy', 'TAIL_CATCH_3M')
                buy_date_str = trade.get('buy_date', '')
                atr = trade.get('atr_entry', buy_price * 0.01)
                
                # 현재가 조회
                current_price = self.api.get_current_price(code)
                if not current_price:
                    continue
                
                # 고점 갱신
                max_price = trade.get('max_price', buy_price)
                if current_price > max_price:
                    max_price = current_price
                    self.db.update_max_price(code, current_price)
                
                # DB 현재가 업데이트
                self.db.update_current_price(code, current_price)
                
                # 손익률 계산 (소수 단위, 예: 0.01 = +1%)
                profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
                profit_val = (current_price - buy_price) * qty

                # POP/LOCK 순수익 계산을 위한 손익 경로 상태 업데이트
                state = self.trade_state.get(code, {
                    "max_profit_pct": 0.0,
                    "min_profit_pct": 0.0,
                    "went_negative": False,
                })
                max_profit_pct = max(
                    state.get("max_profit_pct", 0.0),
                    (max_price - buy_price) / buy_price if buy_price > 0 else 0,
                )
                min_profit_pct = min(state.get("min_profit_pct", 0.0), profit_pct)
                went_negative = state.get("went_negative", False) or (profit_pct < 0)

                state["max_profit_pct"] = max_profit_pct
                state["min_profit_pct"] = min_profit_pct
                state["went_negative"] = went_negative
                self.trade_state[code] = state

                # 수수료+세금 왕복 비용을 고려한 순수익률 (대략적인 추정)
                net_pct = profit_pct - self.round_trip_cost_pct
                max_net_pct = max_profit_pct - self.round_trip_cost_pct
                
                # 매도 사유 판단
                sell_reason = None

                # 금액 기준 손절 (원 단위)
                if profit_val <= -self.max_loss_per_trade_krw:
                    sell_reason = "금액손실컷"
                
                # ==========================================================
                # [필수 1] 어깨 매도 (Shoulder Cut) - 최우선!
                # 고점 대비 3% 이상 빠지면 수익/손실 불문하고 즉시 탈출
                # ==========================================================
                drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0
                if drop_from_high >= self.shoulder_cut_pct:
                    # 어깨 구간에서도 순수익 0.3% 바닥 룰을 한 번 더 체크
                    # - lock_net_pct 조건을 만족하면 "순수익0.3보존"으로 기록
                    # - 그렇지 않으면 기존대로 어깨매도
                    if (
                        max_net_pct > self.lock_net_pct
                        and not went_negative
                        and net_pct <= self.lock_net_pct
                    ):
                        sell_reason = "순수익0.3보존"
                    else:
                        sell_reason = f"어깨매도(고점대비-{drop_from_high * 100:.1f}%)"
                
                # 어깨 매도가 아니면 다른 로직 체크
                if not sell_reason:
                    # 매수 경과 시간 계산
                    hours_passed = 0
                    if buy_date_str:
                        try:
                            buy_time = datetime.datetime.strptime(buy_date_str, '%Y-%m-%d %H:%M:%S')
                            hours_passed = (datetime.datetime.now() - buy_time).total_seconds() / 3600
                        except:
                            hours_passed = 0
                    
                    # ==========================================================
                    # [통합 매도 로직] TAIL/RECOVERED 구분 없이 동일 적용
                    # ==========================================================
                    use_quick_profit = os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true").lower() == "true"
                    
                    # [빠른 익절] 작은 수익 보호 (매수 후 30분 이내)
                    if use_quick_profit and hours_passed < 0.5:
                        if max_profit_pct >= 0.005 and profit_pct <= 0.0015:
                            sell_reason = "💨 작은수익보호"
                    
                    # [스캘핑 1] 본절사수
                    if not sell_reason and (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2):
                        sell_reason = "스캘핑_본절사수"
                    
                    # [스캘핑 2] 익절보존
                    if not sell_reason and current_price < (max_price - atr * 1.0) and profit_pct > 0:
                        sell_reason = "스캘핑_익절보존"

                    # [순수익 0.3% 바닥 보존]
                    # - 진입 이후 한 번도 손실 구간을 찍지 않고 (went_negative=False)
                    # - 순수익이 lock_net_pct(기본 +0.3%)를 한 번이라도 초과했다가
                    # - 다시 lock_net_pct 이하로 되돌아오면 강제 익절
                    if (
                        not sell_reason
                        and max_net_pct > self.lock_net_pct
                        and not went_negative
                        and net_pct <= self.lock_net_pct
                    ):
                        sell_reason = "순수익0.3보존"
                    
                    # [2일 보유 전략]
                    if not sell_reason:
                        if hours_passed < 48:
                            if profit_pct > 0.05:
                                sell_reason = "💰 2일내 5%+ 익절"
                            elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97:
                                sell_reason = "📈 2일내 고점7% 찍고 3% 하락"
                        else:
                            if profit_pct > 0.02:
                                sell_reason = "⏰ 2일 경과 2%+ 익절"
                            elif profit_pct > 0 and current_price < max_price * 0.97:
                                sell_reason = "⏰ 2일 경과 익절보호"
                    
                    # [본절/트레일링] 위 조건에 안 걸렸을 때
                    if not sell_reason:
                        if max_profit_pct >= 0.015 and profit_pct <= 0.005:
                            sell_reason = "본절보호"
                        elif profit_pct > 0 and max_profit_pct >= 0.03 and current_price < max_price * 0.99:
                            sell_reason = "트레일링스탑"
                    
                    # ==========================================================
                    # [공통] 최후의 보루 (목표가 달성 및 손절)
                    # ==========================================================
                    if not sell_reason:
                        if current_price >= target_price:
                            sell_reason = "목표달성"
                        elif profit_pct <= self.stop_loss_pct:
                            sell_reason = f"칼손절({profit_pct * 100:.1f}%)"
                        elif current_price <= stop_price:
                            sell_reason = "전략손절"
                
                # ==========================================================
                # [매도 실행]
                # ==========================================================
                if sell_reason:
                    if self.api.sell_market_order(code, qty):
                        env_snapshot = json.dumps(self.get_env_snapshot(), ensure_ascii=False)
                        self.db.close_trade(code, current_price, sell_reason, env_snapshot=env_snapshot, size_class=trade.get('size_class'))
                        
                        # 🚨 손절 시 24시간 재매수 금지 추가!
                        is_loss = profit_pct < 0
                        is_stop_loss = "손절" in sell_reason or "어깨" in sell_reason or "스캘핑" in sell_reason
                        
                        if is_loss and is_stop_loss:
                            self.add_ban(code, name)
                            logger.warning(f"🚫 [{name}] 손절 발생 → {self.ban_hours}시간 재매수 금지")
                        
                        # 매도 후 즉시 예수금 갱신 및 총자산 추정!
                        self.update_account_light(profit_val)
                        
                        # 🚨 리스크 상태 체크 (손실 누적 시 거래 중단!)
                        self.check_risk_status()
                        
                        total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0
                        
                        icon = "💰" if profit_pct > 0 else "💧"
                        r_tag = "[RECOVERED] " if strategy == "RECOVERED" else ""
                        ban_tag = " 🚫24h금지" if (is_loss and is_stop_loss) else ""
                        msg = (
                            f"{icon} **[매도] {r_tag}{name}**{ban_tag}\n"
                            f"수익: {profit_pct * 100:.2f}% ({profit_val:,.0f}원)\n"
                            f"사유: {sell_reason}\n"
                            f"누적: {total_p:+.2f}%"
                        )
                        logger.info(msg)
                        self.send_mm(msg)
                
            except Exception as e:
                logger.error(f"❌ [{code}] 매도 체크 실패: {e}")

    def process_twap_orders(self):
        """TWAP 분할 매수 처리"""
        if not self.use_twap or not self.executor:
            return
        
        # 현재가 정보 수집
        active_orders = self.executor.get_status()
        if not active_orders:
            return
        
        current_prices = {}
        for code in active_orders.keys():
            price = self.api.get_current_price(code)
            if price:
                current_prices[code] = price
        
        # 매수 콜백 함수
        def buy_callback(code, name, amount, price):
            qty = int(amount / price)
            if qty < 1:
                return False
            
            success = self.api.buy_market_order(code, qty)
            
            if success:
                # DB 업데이트 (분할 매수 누적)
                active_trades = self.db.get_active_trades()
                
                if code in active_trades:
                    # 기존 보유분 있음 -> 평단가 계산
                    existing = active_trades[code]
                    old_qty = existing['current_qty']
                    old_price = existing['avg_buy_price']
                    old_invested = existing['total_invested']
                    
                    new_qty = old_qty + qty
                    new_invested = old_invested + (price * qty)
                    new_avg_price = new_invested / new_qty
                    
                    self.db.upsert_trade({
                        'code': code,
                        'name': name,
                        'avg_buy_price': new_avg_price,
                        'current_qty': new_qty,
                        'total_invested': new_invested,
                        'status': 'HOLDING',
                        **existing  # 기존 정보 유지
                    })
                else:
                    # 신규 매수
                    self.db.upsert_trade({
                        'code': code,
                        'name': name,
                        'avg_buy_price': price,
                        'target_qty': qty,  # 임시
                        'current_qty': qty,
                        'total_invested': price * qty,
                        'status': 'HOLDING',
                        'strategy': 'TAIL_CATCH_3M'
                    })
                
                return True
            
            return False
        
        # TWAP 처리
        self.executor.process_orders(current_prices, buy_callback)

    def update_universe(self):
        """
        매수 후보군 업데이트 (5분마다)
        - 개미털기 우선 (원본 점수 유지)
        - 외국인/거래량/상승률/기관 추가 (보너스 점수)
        - 강도 4 이상만 필터링 → Top 30
        """
        logger.info(f"🔄 [리스트 갱신] 예수금: {self.current_cash:,.0f}원")
        logger.info(f"📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스")
        
        # 매수 가능 금액 계산
        slot_money = int(self.current_cash * 0.9 / self.max_stocks) if self.max_stocks > 0 else 100000
        
        all_candidates = {}  # {code: {name, price, base_score, bonus_score}}
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 1. 개미털기 (눌림목) - 원본 점수 100% 유지!
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        try:
            ant_shaking = self.api.scan_ant_shaking_candidates(max_price_limit=slot_money)
            logger.info(f"   ✅ [개미털기] {len(ant_shaking)}개 수집 (강도 원본 유지)")
            for item in ant_shaking:
                code = item['code']
                all_candidates[code] = {
                    'code': code,
                    'name': item['name'],
                    'price': item['price'],
                    'base_score': item['score'],  # 개미털기 원본 점수 (100%)
                    'bonus_score': 0.0,           # 보너스 점수 (추가)
                    'from_ant': True
                }
        except Exception as e:
            logger.warning(f"   ⚠️ [개미털기] 수집 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 2. 외국인 순매수 - 보너스 +0.5 (개미털기 있으면 추가)
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        try:
            foreign_buy = self.api.get_foreign_consecutive_buy(consecutive_days=2, limit=30)
            logger.info(f"   ✅ [외국인] {len(foreign_buy)}개 수집")
            for idx, item in enumerate(foreign_buy):
                code = item['stk_cd'].strip()
                if len(code) != 6:
                    continue
                bonus = (30 - idx) / 30.0 * 0.5  # 순위별 보너스 (최대 +0.5)
                
                if code in all_candidates:
                    # 개미털기에 이미 있으면 보너스만 추가
                    all_candidates[code]['bonus_score'] += bonus
                else:
                    # 개미털기에 없으면 새로 추가 (기본 점수 3점)
                    all_candidates[code] = {
                        'code': code,
                        'name': item.get('stk_nm', '').strip(),
                        'price': 0,
                        'base_score': 3.0,  # 외국인만 있으면 기본 3점
                        'bonus_score': bonus,
                        'from_ant': False
                    }
        except Exception as e:
            logger.warning(f"   ⚠️ [외국인] 수집 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 3. 거래량 급증 - 보너스 +0.3
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        try:
            volume_surge = self.api.get_volume_surge_stocks(min_volume="50", limit=30)
            logger.info(f"   ✅ [거래량] {len(volume_surge)}개 수집")
            for idx, item in enumerate(volume_surge):
                code = item['stk_cd'].strip()
                if len(code) != 6:
                    continue
                bonus = (30 - idx) / 30.0 * 0.3
                
                if code in all_candidates:
                    all_candidates[code]['bonus_score'] += bonus
                else:
                    all_candidates[code] = {
                        'code': code,
                        'name': item.get('stk_nm', '').strip(),
                        'price': float(item.get('prpr', 0)),
                        'base_score': 2.5,
                        'bonus_score': bonus,
                        'from_ant': False
                    }
        except Exception as e:
            logger.warning(f"   ⚠️ [거래량] 수집 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 4. 상승률 상위 - 보너스 +0.2
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        try:
            price_movers = self.api.get_top_price_movers(sort_type="1", limit=30)
            logger.info(f"   ✅ [상승률] {len(price_movers)}개 수집")
            for idx, item in enumerate(price_movers):
                code = item['stk_cd'].strip()
                if len(code) != 6:
                    continue
                bonus = (30 - idx) / 30.0 * 0.2
                
                if code in all_candidates:
                    all_candidates[code]['bonus_score'] += bonus
                else:
                    all_candidates[code] = {
                        'code': code,
                        'name': item.get('stk_nm', '').strip(),
                        'price': float(item.get('prpr', 0)),
                        'base_score': 2.0,
                        'bonus_score': bonus,
                        'from_ant': False
                    }
        except Exception as e:
            logger.warning(f"   ⚠️ [상승률] 수집 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 5. 기관 순매수 - 보너스 +0.3
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        try:
            inst_buy = self.api.get_institutional_buy_stocks(limit=30)
            logger.info(f"   ✅ [기관] {len(inst_buy)}개 수집")
            for idx, item in enumerate(inst_buy):
                code = item['stk_cd'].strip()
                if len(code) != 6:
                    continue
                bonus = (30 - idx) / 30.0 * 0.3
                
                if code in all_candidates:
                    all_candidates[code]['bonus_score'] += bonus
                else:
                    all_candidates[code] = {
                        'code': code,
                        'name': item.get('stk_nm', '').strip(),
                        'price': float(item.get('prpr', 0)),
                        'base_score': 2.5,
                        'bonus_score': bonus,
                        'from_ant': False
                    }
        except Exception as e:
            logger.warning(f"   ⚠️ [기관] 수집 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 6. 구글 트렌드 괴리율 - 보너스 +0.5 (검색량 급증 vs 주가 미반영)
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        if self.trend_analyzer:
            # Rate Limit 체크: 429 에러 발생 시 스킵
            if hasattr(self.trend_analyzer, 'rate_limit_hit') and self.trend_analyzer.rate_limit_hit:
                logger.warning("   ⚠️ [트렌드 괴리율] Rate Limit 상태 - 이번 스캔 스킵")
            else:
                try:
                    # 후보 종목 리스트 준비 (Top 5만 분석 - Rate Limit 방지)
                    stock_list = [{'code': code, 'name': data['name']} for code, data in all_candidates.items()]
                    trend_scores = self.trend_analyzer.analyze_stocks(stock_list[:5])  # 최대 5개만 분석 (429 방지)
                    
                    logger.info(f"   ✅ [트렌드 괴리율] {len(trend_scores)}개 종목 분석 완료")
                    for code, score in trend_scores.items():
                        if score > 0:
                            bonus = score * 0.5  # 최대 +0.5점
                            if code in all_candidates:
                                all_candidates[code]['bonus_score'] += bonus
                                logger.info(f"      🔍 [{all_candidates[code]['name']}] 검색량 급증 보너스 +{bonus:.2f}점")
                            else:
                                # 트렌드만 있고 다른 조건 없으면 기본 점수 2.0으로 추가
                                all_candidates[code] = {
                                    'code': code,
                                    'name': next((s['name'] for s in stock_list if s['code'] == code), ''),
                                    'price': 0,
                                    'base_score': 2.0,
                                    'bonus_score': bonus,
                                    'from_ant': False
                                }
                except Exception as e:
                    logger.warning(f"   ⚠️ [트렌드 괴리율] 분석 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 7. 관세청 수출 호재 종목 추가 - 보너스 +1.0 (즉시 추가)
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        if self.export_sniper:
            try:
                hot_stocks = self.export_sniper.get_hot_stocks()
                if hot_stocks:
                    logger.info(f"   ✅ [수출 호재] {len(hot_stocks)}개 종목 포착")
                    for stock in hot_stocks:
                        code = stock['code']
                        if code in all_candidates:
                            all_candidates[code]['bonus_score'] += 1.0  # 호재는 큰 보너스
                            logger.info(f"      🔥 [{stock['name']}] 수출 호재 보너스 +1.0점: {stock['reason']}")
                        else:
                            # 호재 종목은 즉시 추가 (기본 점수 3.5)
                            all_candidates[code] = {
                                'code': code,
                                'name': stock['name'],
                                'price': 0,
                                'base_score': 3.5,
                                'bonus_score': 1.0,
                                'from_ant': False
                            }
                            logger.info(f"      🚀 [{stock['name']}] 수출 호재 즉시 추가: {stock['reason']}")
            except Exception as e:
                logger.warning(f"   ⚠️ [수출 호재] 감지 실패: {e}")
        
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        # 최종 점수 계산 및 필터링
        # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        final_candidates = []
        ant_count = 0
        bonus_count = 0
        
        for code, data in all_candidates.items():
            # 총점 = 기본 점수 + 보너스
            total_score = data['base_score'] + data['bonus_score']
            
            # 강도 4 이상만 필터링!
            if total_score >= 4.0:
                final_candidates.append({
                    'code': code,
                    'name': data['name'],
                    'score': total_score,
                    'price': data['price'],
                    'scan_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                })
                
                if data['from_ant']:
                    ant_count += 1
                else:
                    bonus_count += 1
        
        if not final_candidates:
            logger.info("   ⚠️ 스캔 결과 없음 (강도 4 이상 종목 없음)")
            return
        
        # 점수 순 정렬
        final_candidates.sort(key=lambda x: x['score'], reverse=True)
        
        # Top 30만 유지
        final_candidates = final_candidates[:30]
        
        # DB에 저장
        self.db.update_target_candidates(final_candidates)
        
        # Top 10 로그 + Mattermost 알림
        logger.info(f"📊 [스캔 완료] 총 {len(final_candidates)}개 종목 (개미털기:{ant_count} + 보너스:{bonus_count})")
        top_picks = [f"{x['name']}({x['score']:.1f})" for x in final_candidates[:10]]
        logger.info(f"   🔝 Top 10: {', '.join(top_picks)}")

        # MM에 브리핑
        try:
            msg = (
                f"📊 **[종목 스캔 완료]**\n"
                f"- 개미털기: {ant_count}개 (원본 점수 유지)\n"
                f"- 보너스 추가: {bonus_count}개 (외국인/거래량/상승률/기관)\n"
                f"- 최종 선정: {len(final_candidates)}개 (강도 4+ 필터)\n"
                f"- Top 5: {', '.join(top_picks[:5])}"
            )
            self.send_mm(msg)
        except Exception as e:
            logger.error(f"❌ 스캔 MM 전송 실패: {e}")
    
    def load_target_universe(self):
        """매수 후보군 조회 (DB에서!)"""
        return self.db.get_target_candidates()
    
    async def _news_monitor_loop(self):
        """뉴스 감시 루프 (asyncio 태스크 — 스레드 제거). I/O는 await로 대기."""
        if not self.export_sniper:
            return
        self.news_monitor_running = True
        logger.info("📡 [뉴스 감시] 비동기 태스크 시작")
        loop = asyncio.get_event_loop()
        while self.news_monitor_running:
            try:
                # 관세청 수출 호재 감지 (동기 I/O → executor에서 실행 후 await)
                hot_stocks = await loop.run_in_executor(
                    None, lambda: self.export_sniper.get_hot_stocks()
                )
                if hot_stocks:
                    candidates = self.db.get_target_candidates()
                    existing_codes = {c['code'] for c in candidates}
                    new_stocks = []
                    for stock in hot_stocks:
                        if stock['code'] not in existing_codes:
                            new_stocks.append({
                                'code': stock['code'],
                                'name': stock['name'],
                                'score': 4.5,
                                'price': 0,
                                'scan_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                            })
                    if new_stocks:
                        all_candidates = candidates + new_stocks
                        all_candidates.sort(key=lambda x: x['score'], reverse=True)
                        all_candidates = all_candidates[:30]
                        self.db.update_target_candidates(all_candidates)
                        msg = (
                            f"🔥 **[수출 호재 포착]**\n"
                            f"- {len(new_stocks)}개 종목 즉시 추가:\n"
                        )
                        for stock in new_stocks:
                            msg += f"  • {stock['name']} ({stock['code']})\n"
                        self.send_mm(msg)
                        logger.info(f"🔥 [수출 호재] {len(new_stocks)}개 종목 즉시 추가됨")
                await asyncio.sleep(60)
            except asyncio.CancelledError:
                break
            except Exception as e:
                logger.error(f"❌ 뉴스 감시 에러: {e}")
                await asyncio.sleep(60)
        logger.info("📡 [뉴스 감시] 태스크 종료")

    def start_news_monitor(self):
        """뉴스 감시 태스크는 _run_async() 내에서 create_task로 시작 (스레드 제거)"""
        pass

    @staticmethod
    def _seconds_until_next_5min():
        """다음 5분 정각(:00, :05, :10...)까지 남은 초 수. 스캔을 정확히 5분 간격으로 맞추기 위함."""
        now = datetime.datetime.now()
        # 현재 분을 5분 단위로 올림 → 다음 5분 정각
        next_min = (now.minute // 5 + 1) * 5
        if next_min >= 60:
            next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1)
        else:
            next_time = now.replace(minute=next_min, second=0, microsecond=0)
        return (next_time - now).total_seconds()

    async def _universe_scan_scheduler(self):
        """
        5분마다 정각에 유니버스 스캔 실행. (블로킹 방지: run_in_executor로 실행)
        - 기존: 메인 루프에서 update_universe() 호출 시 스캔이 길어져 다음 :05를 놓침 → 10분/15분 간격 발생
        - 변경: 이 태스크만 다음 5분 정각까지 sleep 후 스캔 → 21:50, 21:55, 22:00 ... 고정 간격
        """
        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 = datetime.datetime.now()
                logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}")
                await loop.run_in_executor(None, self.update_universe)
                self.cleanup_banned_list()
                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)
    
    def run(self):
        """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출."""
        asyncio.run(self._run_async())

    async def _run_async(self):
        """메인 루프 (async). 5분 스캔/뉴스 감시는 별도 태스크, I/O 대기는 await."""
        logger.info("🚀 트레이딩봇 Ver2 가동 시작 (async)")
        # 이벤트 루프 내에서 Lock 생성 (asyncio.Lock은 루프 필요)
        if self.split_buy_lock is None:
            self.split_buy_lock = asyncio.Lock()
        
        # 🔥 봇 시작 시 계좌 ↔ DB 동기화 (최초 1회)
        self.sync_portfolio_from_api()
        
        # 뉴스 감시 태스크 시작 (스레드 대신 asyncio 태스크)
        if self.export_sniper and not self.news_monitor_running:
            self._news_monitor_task = asyncio.create_task(self._news_monitor_loop())
            logger.info("✅ 뉴스 감시 태스크 시작 완료")
        
        # 5분 정각 스캔 스케줄러 태스크 시작 (update_universe는 executor에서 실행 → 메인 루프 블로킹 없음)
        self._scan_task = asyncio.create_task(self._universe_scan_scheduler())
        
        # 봇 시작 시 장 상태 확인
        first_check = self.api.check_market_status()
        if self.force_market_open:
            logger.info("⚠️ FORCE_MARKET_OPEN 활성화 - 장 상태 무시(테스트 모드)")
            first_check = True

        if not first_check:
            self.send_mm("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.")
            logger.info("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.")
        
        loop_count = 0
        
        while True:
            try:
                loop_count += 1
                current_time = datetime.datetime.now()
                current_hour = current_time.hour
                current_minute = current_time.minute
                current_second = current_time.second
                
                # 0. 날짜 변경 체크
                today = current_time.strftime("%Y%m%d")
                if today != self.today_date:
                    logger.info(f"📅 날짜 변경: {self.today_date} → {today}")
                    self.today_date = today
                    self.start_day_asset = self.current_total_asset
                    self.morning_report_sent = False
                    self.closing_report_sent = False
                    self.final_report_sent = False
                    self.news_analyzed_today = False
                    self.trading_halted = False  # 🔓 거래 중단 해제 (새로운 날!)
                    self._first_sync_done = False  # 다음 날 첫 동기화 허용
                    # 🧹 금지 종목 정리 (만료된 종목 제거)
                    self.cleanup_banned_list()
                    
                    # ML 모델 재학습 체크 (월요일마다 or 7일 경과 시)
                    if self.use_ml and self.ml_predictor:
                        if current_time.weekday() == 0 or self.ml_predictor.should_retrain():
                            logger.info("🤖 [ML 재학습] 새로운 주 시작 or 7일 경과 → 모델 재학습 시작")
                            self.ml_predictor.train_model(retrain=True)
                
                # 1. 장 운영 시간 체크
                is_open = self.api.check_market_status()
                if self.force_market_open:
                    is_open = True
                
                # 장 시작/마감 이벤트 처리
                if is_open and not self.was_market_open:
                    self.refresh_account()
                    self.check_risk_status()  # 🚨 리스크 상태 체크
                    self.was_market_open = True
                    self.send_mm("🌅 **[장 시작]** 봇이 매매를 시작합니다.")
                    logger.info("🌅 [장 시작] 매매 시작!")
                
                elif not is_open and self.was_market_open:
                    self.was_market_open = False
                    self.refresh_account()
                    day_profit = self.current_total_asset - self.start_day_asset
                    self.send_mm(f"🌙 **[장 마감]**\n오늘 손익: {day_profit:,.0f}원")
                    logger.info(f"🌙 [장 마감] 오늘 손익: {day_profit:,.0f}원")
                    self.is_first_run = True  # 다음 날 스캔 스케줄러용
                    self._first_sync_done = False  # 다음 날 첫 동기화 허용
                
                # 장 휴장 시 대기
                if not is_open:
                    if loop_count % 60 == 0:  # 1분마다
                        logger.info("💤 장 운영 시간 외")
                    await asyncio.sleep(60)
                    continue
                
                # [유니버스 갱신] → _universe_scan_scheduler 태스크에서 5분 정각에 실행 (여기서 호출 안 함)
                
                # 생존 신고 (1분마다)
                if current_minute % 1 == 0 and current_second < 2:
                    active_count = len(self.db.get_active_trades())
                    targets = self.load_target_universe()
                    logger.info(f"👀 [생존] 타겟:{len(targets)} | 보유:{active_count}/{self.max_stocks} | 예수금:{self.current_cash:,.0f}원")
                    await asyncio.sleep(2)
                
                # 2. 시간대별 리포트 (리포트 전 계좌 조회 + 동기화!)
                if current_hour == 13 and current_minute == 0 and not self.morning_report_sent:
                    self.refresh_account()
                    self.sync_portfolio_from_api()  # 리포트 전 DB 동기화
                    self.send_morning_report()
                elif current_hour == 15 and current_minute == 15 and not self.closing_report_sent:
                    self.refresh_account()
                    self.sync_portfolio_from_api()
                    self.send_closing_report()
                elif current_hour == 15 and current_minute >= 35 and not self.final_report_sent:
                    self.refresh_account()
                    self.sync_portfolio_from_api()
                    self.send_final_report()
                
                # 2-1. 뉴스 AI 분석 (하루 1회)
                if self.use_news and self.news_analyzer:
                    if current_hour == self.news_hour and current_minute == 0 and not self.news_analyzed_today:
                        self.analyze_and_send_news()
                
                # 3. [제거됨] 계좌 정보 갱신
                # - 시작 시 1회만 조회
                # - 매수/매도 후 update_account_light()로 즉시 갱신
                # - 5분마다 자동 조회는 불필요 (API 낭비)
                
                # 4. TWAP 분할 매수 처리
                if self.use_twap:
                    self.process_twap_orders()
                
                # 5. [동기화 → 주문 정리 → 매도 → 매수] 계좌↔DB 동기화 (첫 1회 + 이후 2분마다)
                first_sync_done = getattr(self, "_first_sync_done", False)
                if not first_sync_done:
                    self.sync_portfolio_from_api()
                    self._first_sync_done = True
                    logger.info(f"🔄 [첫실행] 동기화 완료")
                elif current_minute % 2 == 0 and current_second < 5:
                    self.sync_portfolio_from_api()
                    # 미체결 주문 정리 (N초 이상 미체결 주문 취소)
                    self.cancel_stale_orders(max_age_seconds=10)
                    # 5분마다 주문·체결 보강 (kt00007, ka10076) — API 2회 + 2초 sleep
                    if current_minute % 5 == 0 and current_second < 8:
                        self.sync_order_execution_from_api()

                # 6. 보유 종목 매도 체크
                self.check_sell_signals()
                
                # 7. 새로운 매수 기회 탐색
                active_count = len(self.db.get_active_trades())
                
                if not self.trading_halted and active_count < self.max_stocks:
                    targets = self.load_target_universe()
                    
                    if not targets:
                        # 타겟이 0개면 매수 체크 로직 안 돌아감
                        if loop_count % 60 == 0:  # 1분마다 한 번만
                            logger.info(f"⚠️ [매수 체크 스킵] 타겟 0개 (DB 저장 실패 or 스캔 결과 없음)")
                    else:
                        logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(targets)}개 | 보유:{active_count}/{self.max_stocks}")
                        for item in targets:
                            code = item['code']
                            name = item['name']
                            
                            # 이미 보유 중이면 스킵
                            active_trades = self.db.get_active_trades()
                            if code in active_trades:
                                continue
                            
                            # 분할 매수 진행 중인 종목 스킵 (asyncio 태스크)
                            async with self.split_buy_lock:
                                if code in self.active_split_buys:
                                    task = self.active_split_buys[code]
                                    if not task.done():
                                        continue  # 아직 진행 중이면 스킵
                            
                            # 돈 없으면 스탑
                            if self.current_cash < self.risk_mgr.min_amount:
                                break
                            
                            # 매수 시그널 확인
                            signal = self.check_buy_signal_tail_catch(code, name)
                            if signal:
                                await self._execute_buy_async(signal)
                                await asyncio.sleep(1)  # 매수 후 1초 대기
                
                # 7. 대기 (1초마다 체크 - 단타 타이밍 중요!)
                await asyncio.sleep(1)
                
            except KeyboardInterrupt:
                logger.info("⏸️ 사용자 중단")
                break
            
            except Exception as e:
                import traceback
                logger.error(f"❌ 메인 루프 에러: {e}")
                logger.error(f"상세 오류:\n{traceback.format_exc()}")
                logger.info("⏳ 예외 발생 -> 5초 대기 후 재시도")
                await asyncio.sleep(5)
        
        # 종료 처리 (스캔/뉴스 태스크 취소)
        logger.info("🛑 봇 종료 중...")
        if getattr(self, "_scan_task", None) and not self._scan_task.done():
            self._scan_task.cancel()
            try:
                await self._scan_task
            except asyncio.CancelledError:
                pass
        if self._news_monitor_task and not self._news_monitor_task.done():
            self.news_monitor_running = False
            self._news_monitor_task.cancel()
            try:
                await self._news_monitor_task
            except asyncio.CancelledError:
                pass
        if self.db:
            self.db.close()
        logger.info("✅ 정상 종료 완료")


# ==========================================================
# [메인 실행]
# ==========================================================
if __name__ == "__main__":
    try:
        broker = BrokerAPI()
        bot = TradingBotV2(broker)
        bot.run()
    except Exception as e:
        logger.critical(f"💀 봇 실행 실패: {e}")
        raise e
