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

# ==========================================================
# [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_", "")
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("JungleBot")

# 외부 라이브러리 로그 레벨 조정 (너무 시끄러운 로그 방지)
logging.getLogger("urllib3").setLevel(logging.WARNING)
logging.getLogger("requests").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)

# 키움 API 모듈 임포트
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


# ==========================================================
# [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):
		"""설정 파일에서 채널 ID 정보를 읽어옵니다."""
		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):
		"""지정된 채널(alias)로 메시지를 전송합니다."""
		channel_id = self.channels.get(channel_alias)
		if not channel_id:
			# 채널 ID가 없으면 로그만 찍고 넘어감 (봇 중단 방지)
			logger.warning(f"❌ '{channel_alias}' 채널 ID 없음. mm_config.json 확인 필요.")
			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] 데이터 클래스 (주식 정보 객체)
# ==========================================================
class StockData:
	def __init__(self, code, name, price, open_p, high, low, close, volume):
		self.code = code
		self.name = name
		self.current_price = price
		self.open = open_p
		self.high = high
		self.low = low
		self.close = close
		self.volume = volume


# ==========================================================
# [Part 2] 브로커 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)
			self.acc_no = os.environ.get("KIWOOM_ACCOUNT_NO", "YOUR_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 에러 핸들링 강화)
		- 429 에러(Too Many Requests) 발생 시 대기 후 재시도
		- 일반 에러 발생 시 로그 기록
		"""
		full_name = func.__name__
		api_id = full_name.split('_')[-1]
		max_retries = 3

		for i in range(max_retries):
			try:
				# 기본 안전 대기 (API 과부하 방지)
				time.sleep(1)

				result = func(*args, **kwargs)

				# 키움 API 특성상 200 OK라도 에러 메시지가 있을 수 있음
				if isinstance(result, dict) and result.get('return_code') != '0' and '초과' in result.get('msg1', ''):
					raise Exception("429 Rate Limit Detected")

				return result

			except Exception as e:
				# 429 또는 과부하 에러 시 더 오래 대기
				if "429" in str(e) or "과부하" in str(e):
					logger.warning(f"⚠️ [{api_id}] API 과부하 감지 -> 5초 대기 후 재시도 ({i + 1}/{max_retries})")
					time.sleep(5)
				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
			# D+2 예수금이 마이너스면 미수 발생 상황이므로 0으로 처리
			return 0 if d2_deposit < 0 else current_deposit
		except Exception as e:
			logger.error(f"예수금 조회 실패: {e}")
			return 0

	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", trde_tp="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:
			# 수급 조회 실패는 치명적이지 않으므로 0 리턴
			return 0, 0

	def _is_target_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

	def check_market_status(self):
		"""
		장 운영 시간 체크 (08:30 ~ 16:00)
		- 주말 및 장운영 시간 외에는 False 반환
		"""
		now = datetime.datetime.now()

		# 1. 시간 체크 (08:30 ~ 16:00)
		if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)):
			if now.minute == 0 and now.second < 2:
				logger.info(f"💤 [장운영] 정규장 시간이 아닙니다. ({now.time().strftime('%H:%M:%S')})")
			return False

		# 2. 주말 체크
		if now.weekday() >= 5:
			if now.minute == 0 and now.second < 2:
				logger.info("⏸️ [장운영] 주말 휴장입니다.")
			return False

		# 공휴일 체크 로직은 제외 (Fail-Open 방식: 장이 안 열리면 주문 실패로 처리됨)
		return True

	def get_multi_stock_data(self, code_list):
		"""여러 종목의 현재가 정보를 한 번에 조회"""
		if not code_list: return {}
		code_str = "|".join([str(c).strip() for c in code_list])

		try:
			res = self._safe_request(self.stock_info.watchlist_stock_information_request_ka10095, stock_code=code_str)
			result_map = {}
			if res and 'atn_stk_infr' in res:
				for item in res['atn_stk_infr']:
					code = item.get('stk_cd', '')
					if not code: continue
					if len(code) > 6: code = code[-6:]
					try:
						c = abs(float(item['cur_prc']))
						if c > 0:
							result_map[code] = {
								'code': code, 'name': item['stk_nm'],
								'price': c, 'open': abs(float(item['open_pric'])),
								'high': abs(float(item['high_pric'])), 'low': abs(float(item['low_pric']))
							}
					except:
						continue
			return result_map
		except Exception as e:
			logger.error(f"멀티 시세 조회 실패: {e}")
			return {}

	def get_ohlcv_limit(self, code, timeframe='1m'):
		"""분봉 차트 데이터 조회 (틱 범위 지정 가능)"""
		tic_scope = "1"
		if timeframe == '3m':
			tic_scope = "3"
		elif timeframe == '5m':
			tic_scope = "5"
		elif timeframe == '10m':
			tic_scope = "10"

		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'})
			# 데이터를 시간순(과거->현재)으로 정렬
			return df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs().iloc[::-1].reset_index(drop=True)
		except Exception as e:
			logger.error(f"차트 조회 실패({code}): {e}")
			return pd.DataFrame()

	def scan_ant_shaking_candidates(self, max_price_limit=None):
		"""
		[조건검색 대체 로직] 개미털기(눌림목) 후보 종목 스캔
		- 거래대금 및 회전율 상위 종목 중, 고점 대비 일정 비율 하락 후 반등 시도하는 종목 추출
		"""
		logger.info(f"🐜 [개미털기] 스캔 시작")
		raw_codes_set = set()
		scan_strategies = [("3", "거래대금"), ("2", "회전율")]

		for sort_tp, desc in scan_strategies:
			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: continue

				for stock in res['tdy_trde_qty_upper']:
					code = stock['stk_cd'].split('_')[0]
					try:
						price = abs(float(stock['cur_prc']))
					except:
						continue

					# 전일 종가 조회 (API 함수 확인 필요)
					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: continue  # 10만원 이상 제외
					if self._is_target_stock(stock['stk_nm'], 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]
				multi_data = self.get_multi_stock_data(chunk)
				time.sleep(0.5)

				for code, data in multi_data.items():
					if max_price_limit and data['price'] > max_price_limit: continue

					op, hi, lo, cl = data['open'], data['high'], data['low'], data['price']
					if op == 0: continue

					drop_rate = (op - lo) / op
					total_range = hi - lo

					# 낙폭이 어느 정도 있고(3% 이상), 아래꼬리를 달고 올라온 종목
					if total_range > 0 and drop_rate > 0.03:
						recovery_pos = (cl - lo) / total_range
						if recovery_pos > 0.5:
							score = drop_rate * 100
							final_list.append({'code': code, 'name': data['name'], 'price': cl, 'score': score})

			final_list.sort(key=lambda x: x['score'], reverse=True)
			return final_list
		except Exception as e:
			logger.error(f"분석 중 치명적 에러: {e}")
			return []

	def get_account_info(self):
		"""
		전체 계좌 잔고 및 평가금액 조회
		- 예수금, 총 자산, 보유 종목 리스트 반환
		"""
		try:
			# 1. 예수금 상세
			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
			deposit = 0 if d2_deposit < 0 else current_deposit
			logger.info(f"예수금: {deposit:,.0f}원")

			# 2. 잔고 상세
			res_bal = self._safe_request(self.account.account_evaluation_balance_detail_request_kt00018, query_type="1",
			                             domestic_exchange_type="KRX")
			total_asset = float(res_bal.get('tot_aset_amt', 0)) if res_bal else 0

			balances = {}
			if res_bal and 'acnt_evlt_remn_indv_tot' in res_bal:
				for item in res_bal['acnt_evlt_remn_indv_tot']:
					code = item['stk_cd'].strip()[1:] if item['stk_cd'].startswith('A') else item['stk_cd'].strip()
					balances[code] = {
						'buy_price': abs(float(item['pur_pric'])),
						'qty': int(item['rmnd_qty']),
						'name': item['stk_nm'].strip(),
						'current_price': abs(float(item['cur_prc'])),
						'profit_rate': float(item.get('erng_rt', 0))
					}

			# API에서 총자산이 0으로 올 경우, 직접 계산
			if total_asset == 0:
				stock_val = sum([b['current_price'] * b['qty'] for b in balances.values()])
				total_asset = current_deposit + stock_val

			return total_asset, deposit, balances
		except Exception as e:
			logger.error(f"계좌 조회 에러: {e}")
			return 0, 0, {}

	def get_current_data(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];
				p = abs(float(item.get('cur_prc', 0)))
				return StockData(code, item.get('stk_nm', 'Unknown'), p, abs(float(item.get('open_pric', p))),
				                 abs(float(item.get('high_pric', p))), abs(float(item.get('low_pric', p))), p,
				                 int(item.get('trde_qty', 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


# ==========================================================
# [Part 3] 정글 서바이버 봇 (개선된 메인 로직)
# ==========================================================
class JungleSurvivorBot:
	def __init__(self, broker_api, budget=None):
		self.api = broker_api
		self.budget = budget

		# Mattermost 초기화
		self.mm = MattermostBot()
		self.mm_channel = "stock"  # 기본 채널 alias

		# 파일 경로 설정
		self.portfolio_file = os.path.join(current_dir, 'portfolio.json')
		self.history_real_file = os.path.join(current_dir, 'trade_history_real.json')
		self.target_file = os.path.join(current_dir, 'target_universe.json')
		self.banned_file = os.path.join(current_dir, 'banned_codes.json')

		# 자금 관리 설정
		self.max_stocks = 5
		self.slot_money = 0
		self.daily_stop_loss_pct = -0.05  # 일일 손실 한도 -5%
		self.stop_loss_pct = -0.035  # 종목별 손절 -3.5%
		self.enable_consecutive_loss_cut = False  # 연속 손절 제한 (일단 끔)

		# 매수/매도 로직 파라미터
		self.rsi_overheat_threshold = float(os.environ.get("RSI_OVERHEAT_THRESHOLD", "73"))  # RSI 과열 기준
		self.shoulder_cut_pct = float(os.environ.get("SHOULDER_CUT_PCT", "0.03"))  # 어깨 매도 기준 (고점 대비 하락률)
		self.high_price_chase_threshold = float(
			os.environ.get("HIGH_PRICE_CHASE_THRESHOLD", "0.96"))  # 일일 최고가 추격 매수 방지 기준
		self.min_recovery_ratio = float(os.environ.get("MIN_RECOVERY_RATIO", "0.5"))  # 꼬리 잡기 최소 회복 비율
		self.max_recovery_ratio = float(os.environ.get("MAX_RECOVERY_RATIO", "0.8"))  # 꼬리 잡기 최대 회복 비율
		self.candle_open_price_buffer = float(os.environ.get("CANDLE_OPEN_PRICE_BUFFER", "0.995"))  # 시가 대비 현재가 버퍼
		self.volume_avg_multiplier = float(os.environ.get("VOLUME_AVG_MULTIPLIER", "1.0"))  # 거래량 평균 대비 배율
		self.intraday_investor_net_buy_threshold = int(os.environ.get("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000"))  # 장중 투자자 순매수 기준

		self.stop_atr_multiplier_tail = float(os.environ.get("STOP_ATR_MULTIPLIER_TAIL", "3.5"))  # TAIL_CATCH_3M 전략용 (2일 보유 전제)
		self.target_atr_multiplier_tail = float(os.environ.get("TARGET_ATR_MULTIPLIER_TAIL", "8.0"))  # TAIL_CATCH_3M 전략용 (2일 보유 전제)
		self.stop_atr_multiplier_normal = float(os.environ.get("STOP_ATR_MULTIPLIER_NORMAL", "2.5"))  # 일반 전략용
		self.target_atr_multiplier_normal = float(os.environ.get("TARGET_ATR_MULTIPLIER_NORMAL", "5.0"))  # 일반 전략용

		# 상태 변수 초기화
		self.current_cash = 0
		self.start_of_day_asset = 0
		self.today_date = datetime.datetime.now().strftime("%Y%m%d")
		self.consecutive_losses = 0
		self.trading_halted = False
		self.was_market_open = False
		self.is_first_run = True

		# 초기 데이터 로드 및 정리
		self.refresh_account_status(is_init=True)
		self.cleanup_banned_list()

		msg = f"🤖 **[정글봇 가동-Full Version]**\n- 시작 자산: {self.start_of_day_asset:,.0f}원\n- **[NEW] 3% 하락시 어깨 매도 적용**\n- **[NEW] RSI 과열 필터 적용**"
		logger.info(msg)
		self.send_mm(msg)

	def send_mm(self, msg):
		"""Mattermost 알림 전송 래퍼 함수"""
		try:
			self.mm.send(self.mm_channel, msg)
		except Exception as e:
			logger.error(f"❌ MM 전송 중 에러: {e}")

	# ======================================================
	# 파일 입출력 및 관리 유틸리티
	# ======================================================
	def load_json_file(self, path, is_list=False):
		try:
			if os.path.exists(path):
				if os.path.getsize(path) == 0:
					logger.warning(f"⚠️ 빈 파일 감지됨(초기화): {os.path.basename(path)}")
					return [] if is_list else {}
				with open(path, 'r', encoding='utf-8') as f:
					return json.load(f)
			return [] if is_list else {}
		except json.JSONDecodeError:
			logger.warning(f"⚠️ JSON 형식이 깨짐(초기화): {os.path.basename(path)}")
			return [] if is_list else {}
		except Exception as e:
			logger.error(f"❌ load_json_file 에러: {e}")
			return [] if is_list else {}

	def save_json_file(self, path, data):
		try:
			with open(path, 'w', encoding='utf-8') as f:
				json.dump(data, f, ensure_ascii=False, indent=4)
		except Exception as e:
			logger.error(f" 파일저장에러 {e}")

	def get_banned_codes(self):
		"""금일 매매 금지(손절 등) 종목 리스트 반환"""
		data = self.load_json_file(self.banned_file)
		active = []
		now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
		for c, e in data.items():
			if e > now: active.append(c)
		return active

	def add_ban(self, code, hours=24):
		"""종목을 벤(Ban) 리스트에 추가"""
		data = self.load_json_file(self.banned_file)
		data[code] = (datetime.datetime.now() + datetime.timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S')
		self.save_json_file(self.banned_file, data)

	def cleanup_banned_list(self):
		"""만료된 벤 리스트 정리"""
		data = self.load_json_file(self.banned_file)
		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): self.save_json_file(self.banned_file, new_data)

	# ======================================================
	# 계좌 및 자산 관리
	# ======================================================
	def refresh_account_status(self, is_init=False):
		"""계좌 상태를 API로부터 갱신하고 슬롯 머니를 재계산"""
		try:
			curr_date = datetime.datetime.now().strftime("%Y%m%d")
			if curr_date != self.today_date:
				self.today_date = curr_date
				self.consecutive_losses = 0
				self.trading_halted = False
				self.start_of_day_asset = 0
				logger.info("📅 [날짜변경] 데이터 리셋")

			total, deposit, balances = self.api.get_account_info()

			if total == 0:
				logger.warning("⚠️ 자산 조회 실패(0원) -> 기존 자산 유지")
				total = self.current_total_asset if hasattr(self, 'current_total_asset') else 1000000

			if self.start_of_day_asset == 0 or is_init:
				self.start_of_day_asset = total

			self.current_total_asset = total
			self.current_cash = min(self.budget, deposit) if self.budget else deposit

			# 현재 보유 주식의 대략적인 평가금액 (예수금과의 갭 확인용)
			try:
				stock_val = sum(
					[b.get('current_price', 0) * b.get('qty', 0) for b in balances.values()]
				) if balances else 0
			except Exception:
				stock_val = 0

			# 예수금의 90%만 사용 (안전 버퍼)
			investable = self.current_cash * 0.9
			min_bet = 100000

			if investable < min_bet:
				self.max_stocks = 1
				self.slot_money = int(investable)
			else:
				if (investable / 30) < min_bet:
					self.max_stocks = max(int(investable / min_bet), 1)
				else:
					self.max_stocks = 30
				self.slot_money = int(investable / self.max_stocks)

			if is_init:
				logger.info(
					f"💰 [자금 설정] "
					f"추정자산: {self.current_total_asset:,.0f}원 "
					f"(예수금: {self.current_cash:,.0f}원 + 주식평가: {stock_val:,.0f}원) | "
					f"1종목당: {self.slot_money:,.0f}원"
				)

			self.portfolio = self.sync_portfolio_internal(balances)
			self.check_risk_status(total)

		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
		except Exception as e:
			logger.error(f"경량 갱신 실패: {e}")

	def get_daily_profit_rate(self, current_asset):
		if self.start_of_day_asset == 0: return 0.0
		return (current_asset - self.start_of_day_asset) / self.start_of_day_asset

	def check_risk_status(self, current_asset):
		"""일일 손실 한도 체크"""
		p = self.get_daily_profit_rate(current_asset)

		if p <= self.daily_stop_loss_pct and not self.trading_halted:
			self.trading_halted = True
			msg = f"🛑 **[STOP LOSS 발동]**\n일 손실률 {p * 100:.2f}% 도달 -> 금일 매수 중단"
			logger.critical(msg)
			self.send_mm(msg)

		if self.enable_consecutive_loss_cut and self.consecutive_losses >= 4 and not self.trading_halted:
			self.trading_halted = True
			msg = f"🛑 **[연속 손절 과다]**\n4회 연속 손절 -> 금일 매수 중단"
			logger.critical(msg)
			self.send_mm(msg)

	def sync_portfolio_internal(self, real_balances):
		"""API 잔고와 로컬 포트폴리오 파일 동기화"""
		local = self.load_json_file(self.portfolio_file)
		final = {}

		# 1. 실제 잔고 기준 업데이트
		for c, r in real_balances.items():
			final[c] = r
			if c in local:
				final[c].update({
					'strategy': local[c].get('strategy', 'MANUAL'),
					'max_price': max(r['current_price'], local[c].get('max_price', 0)),
					'target_price': local[c].get('target_price', 0),
					'stop_price': local[c].get('stop_price', 0),
					'atr_at_entry': local[c].get('atr_at_entry', 0),
					'buy_date': local[c].get('buy_date', '')
				})
			else:
				final[c].update({'strategy': 'MANUAL', 'max_price': r['current_price']})

			# 데이터 복구 (봇 재시작 시 누락된 전략 정보 복원)
			if final[c].get('atr_at_entry') == 0 or final[c].get('strategy') == 'MANUAL':
				try:
					df_temp = self.api.get_ohlcv_limit(c, timeframe='1m')
					if not df_temp.empty:
						new_atr = self.calculate_atr(df_temp)
						base_p = r.get('buy_price', r['current_price'])
						final[c].update({
							'strategy': 'RECOVERED',
							'atr_at_entry': new_atr,
							'stop_price': base_p - (new_atr * 3.0),
							'target_price': base_p + (new_atr * 5.0),
							'buy_date': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
						})
						logger.info(f"♻️ [데이터 복구] {r['name']} 전략 정보 재계산 완료")
				except Exception as e:
					logger.error(f"❌ {c} 데이터 복구 실패: {e}")

		# 2. 동기화 보호 (매수 직후 API 반영 지연 시 로컬 데이터 우선)
		now = datetime.datetime.now()
		for code, info in local.items():
			if code not in final:
				try:
					buy_time = datetime.datetime.strptime(info.get('buy_date', ''), '%Y-%m-%d %H:%M:%S')
					if (now - buy_time).total_seconds() < 120:
						final[code] = info
						logger.info(f"🛡️ [동기화 보호] {info['name']}: 잔고 미반영 유지")
				except:
					pass

		self.save_json_file(self.portfolio_file, final)
		return final

	def update_universe(self):
		"""매수 후보군 업데이트"""
		logger.info(f"🔄 [리스트 갱신] 배정액 {self.slot_money:,.0f}원")
		candidates = self.api.scan_ant_shaking_candidates(max_price_limit=self.slot_money)
		if not candidates: return

		candidates.sort(key=lambda x: (x['score'], x['price']), reverse=True)
		current_data = []
		for item in candidates:
			current_data.append(
				{"code": item['code'], "name": item['name'], "score": item['score'], "price": item['price']})

		self.save_json_file(self.target_file, current_data)

		top_picks = [f"{x['name']}({x['score']:.1f})" for x in current_data[:5]]
		logger.info(f"   🔝 Top 5: {', '.join(top_picks)}")

	# ======================================================
	# 기술적 지표 계산
	# ======================================================
	def calculate_atr(self, df, period=14):
		"""ATR(Average True Range) 계산"""
		df['tr'] = np.maximum(df['high'] - df['low'],
		                      np.maximum(np.abs(df['high'] - df['close'].shift()),
		                                 np.abs(df['low'] - df['close'].shift())))
		return df['tr'].rolling(window=period).mean().iloc[-1]

	def calculate_rsi(self, series, period=14):
		"""RSI(Relative Strength Index) 계산"""
		delta = series.diff()
		gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
		loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
		return 100 - (100 / (1 + gain / loss))

	# =========================================================
	# ⚡ [핵심 수정 1] 매수 시그널 - RSI 필터 및 고점 추격 방지
	# =========================================================
	def check_buy_signal(self, code):
		"""
		매수 시그널 확인 함수 (3분봉 꼬리잡기 전략)
		- RSI 과열 체크 + 고가 추격 방지 + 꼬리 필터 강화
		"""
		# ⭐ name 변수 먼저 초기화 (에러 방지)
		name = code

		# 1. 밴(Ban) 리스트 확인
		if code in self.get_banned_codes():
			return None

		try:
			# 2. 데이터 조회
			df = self.api.get_ohlcv_limit(code, timeframe='3m')
			curr = self.api.get_current_data(code)

			# name 업데이트
			if curr:
				name = curr.name

			# 데이터 유효성 검사
			if curr is None or 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]
			avg_vol = df['volume'].rolling(window=20).mean().iloc[-1]
			current_price = curr.current_price
			current_vol = df['volume'].iloc[-1]

			# ---------------------------------------------------------
			# [필터 1] RSI 과열 체크
			rsi = self.calculate_rsi(df['close']).iloc[-1]
			if rsi >= self.rsi_overheat_threshold:
				return None

			# [필터 2] 일일 최고가 부근 추격 매수 방지
			if current_price >= curr.high * self.high_price_chase_threshold:
				return None

			# [필터 3] 20일 이동평균선(MA20) 아래인지?
			if current_price < ma20:
				logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price}) < MA20({ma20:.2f})")
				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_low = min(df['low'].iloc[-1], current_price)
			total_tail = candle_open - candle_low

			if total_tail <= 0:
				return None

			# 회복 비율 계산
			recovery_ratio = (current_price - candle_low) / total_tail

			# --- 로그로 상세 수치 확인 ---
			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 * self.volume_avg_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%)
			if current_price < candle_open * self.candle_open_price_buffer:
				logger.info(f"{log_msg} -> ❌ 시가 회복 부족")
				return None

			# [조건 3] 거래량 폭발 여부 (환경변수 사용: 기본 평균 × 1.0)
			if current_vol <= avg_vol * self.volume_avg_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)
			if foreigner < self.intraday_investor_net_buy_threshold and institution < self.intraday_investor_net_buy_threshold:
				logger.info(f"⛔ 수급 이탈 감지({name} {code}): 외인{foreigner} / 기관{institution} -> 매수 포기")
				return None

			# 최종 매수 시그널
			logger.info(f"🚀 [매수 신호] {name} {code}: 3분봉 꼬리 공략! (회복률: {recovery_ratio:.2f})")
			return 'TAIL_CATCH_3M'

		except Exception as e:
			logger.error(f"⚠️ 매수 시그널 분석 중 에러({name} {code}): {e}", exc_info=True)
			return None

	def execute_buy(self, code, strategy):
		"""매수 실행 및 포트폴리오 등록"""
		if self.trading_halted: return
		if self.current_cash < self.slot_money: return

		curr = self.api.get_current_data(code)
		if not curr or curr.current_price > self.slot_money: return

		# ATR 계산 및 손절/목표가 설정
		df = self.api.get_ohlcv_limit(code, timeframe='1m')
		atr = curr.current_price * 0.01
		if len(df) >= 20: atr = self.calculate_atr(df)

		# stop = curr.current_price - (atr * 2.0)
		# target = curr.current_price + (atr * 3.0)
		if strategy == 'TAIL_CATCH_3M':
			# 2일 보유 전제: 손절 넓게, 목표가 크게
			stop = curr.current_price - (atr * self.stop_atr_multiplier_tail)
			target = curr.current_price + (atr * self.target_atr_multiplier_tail)
		else:
			stop = curr.current_price - (atr * self.stop_atr_multiplier_normal)
			target = curr.current_price + (atr * self.target_atr_multiplier_tail)

		# 분할 매수 실행
		total_qty = int(self.slot_money / curr.current_price)
		if total_qty > 0:
			split_count = 1
			if self.slot_money >= 100000:
				split_count = random.randint(10, 20)

			base_qty = total_qty // split_count
			remainder = total_qty % split_count
			bought_qty = 0

			logger.info(f"🔫 [진입 시작] {curr.name} 총 {total_qty}주 | {split_count}회 분할")

			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

				if self.api.buy_market_order(code, qty_to_buy):
					bought_qty += qty_to_buy
					if i < split_count - 1:
						time.sleep(random.uniform(0.8, 1))
				else:
					logger.error(f"❌ 매수 주문 실패: {curr.name}")

			# 매수 성공 시 처리
			if bought_qty > 0:
				self.portfolio = self.load_json_file(self.portfolio_file)
				now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')

				self.portfolio[code] = {
					'buy_price': curr.current_price,
					'qty': bought_qty,
					'strategy': strategy,
					'buy_date': now_str,
					'name': curr.name,
					'stop_price': stop,
					'target_price': target,
					'atr_at_entry': atr,
					'max_price': curr.current_price  # 초기값은 매수단가
				}

				self.save_json_file(self.portfolio_file, self.portfolio)
				self.add_history('BUY', code, curr.name, curr.current_price, bought_qty, strategy)

				# 알림
				try:
					self.update_account_light(profit_val=0)
					total_p = self.get_daily_profit_rate(self.current_total_asset) * 100
					msg = f"🔫 **[매수 완료] {curr.name}**\n수량: {bought_qty}주\n전략: {strategy}\n자산변동: {total_p:+.2f}%"
					self.send_mm(msg)
				except Exception as e:
					logger.error(f"알림 전송 실패: {e}")

				time.sleep(0.5)

	# =========================================================
	# ⚡ [핵심 수정 2] 매도 로직 - 어깨 매도 및 거래량 체크 추가
	# =========================================================
	def check_sell(self, code):
		"""매도 조건 체크 (트레일링 스탑, 손절, 어깨 매도)"""
		if code not in self.portfolio: return False, None, 0, 0

		info = self.portfolio[code]
		curr = self.api.get_current_data(code)
		if not curr: return False, None, 0, 0

		buy_price = info.get('buy_price', 0)
		current_price = curr.current_price

		# 고점(Max Price) 갱신
		if current_price > info.get('max_price', 0):
			info['max_price'] = current_price
			self.save_json_file(self.portfolio_file, self.portfolio)

		max_price = info.get('max_price', buy_price)
		profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0
		profit_val = (current_price - buy_price) * info['qty']
		atr = info.get('atr_at_entry', buy_price * 0.01)
		reason = None

		# [필수 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:
			# 추가 체크: 거래량이 터지면서 떨어지면 더 위험하지만, 어깨 매도는 무조건 자르는게 원칙
			return True, f"어깨매도(고점대비-{drop_from_high * 100:.1f}%)", profit_pct, profit_val

		# 기존 로직 분기
		if info.get('strategy') == 'TAIL_CATCH_3M':
			# 스캘핑 로직
			if (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2):
				reason = "스캘핑_본절사수"
			elif current_price < (max_price - atr * 1.0) and profit_pct > 0:
				reason = "스캘핑_익절보존"

			# 시간 컷 (10분 내 승부 안나면 매도)
			buy_time_str = info.get('buy_date', '')
			hours_passed = 0
			if buy_time_str:
				buy_time = datetime.datetime.strptime(buy_time_str, '%Y-%m-%d %H:%M:%S')
				hours_passed = (datetime.datetime.now() - buy_time).total_seconds() / 3600

			# [전략별 분기]
			if info.get('strategy') == 'TAIL_CATCH_3M':
				# 2일(48시간) 이내는 보수적 보유
				if hours_passed < 48:
					# 큰 수익만 익절
					if profit_pct > 0.05:
						reason = "💰 2일내 5%+ 익절"
					elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97:
						reason = "📈 2일내 고점7% 찍고 3% 하락"
				else:
					# 2일 경과 후 적극 익절
					if profit_pct > 0.02:
						reason = "⏰ 2일 경과 2%+ 익절"
					elif profit_pct > 0 and current_price < max_price * 0.97:
						reason = "⏰ 2일 경과 익절보호"

		else:
			# 일반/MANUAL/RECOVERED 전략
			max_profit_pct = (max_price - buy_price) / buy_price if buy_price > 0 else 0
			if max_profit_pct >= 0.015 and profit_pct <= 0.005:
				reason = "본절보호"
			elif profit_pct > 0 and max_profit_pct >= 0.03 and current_price < max_price * 0.99:
				reason = "트레일링스탑"

		# [공통] 최후의 보루 (목표가 달성 및 손절)
		if not reason:
			if current_price >= info.get('target_price', 9999999):
				reason = "목표달성"
			elif profit_pct <= self.stop_loss_pct:
				reason = f"칼손절({profit_pct * 100:.1f}%)"
				self.add_ban(code)
				self.consecutive_losses += 1
			elif current_price <= info.get('stop_price', 0):
				reason = "전략손절"

		if reason:
			if profit_pct > 0: self.consecutive_losses = 0
			return True, reason, profit_pct, profit_val

		return False, None, 0, 0

	def add_history(self, type, code, name, price, qty, reason="", strategy=""):
		"""매매 이력 저장"""
		try:
			rec = {
				'type': type,
				'time': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
				'code': code,
				'name': name,
				'price': price,
				'qty': qty,
				'reason': reason,
				'strategy': strategy
			}
			h = self.load_json_file(self.history_real_file, is_list=True)
			h.append(rec)
			with open(self.history_real_file, 'w', encoding='utf-8') as f:
				json.dump(h, f, ensure_ascii=False, indent=4)
		except:
			pass

	def run(self):
		"""메인 실행 루프"""
		logger.info(f"🚀 감시 시작 (손절: {self.stop_loss_pct * 100}%)")

		# 봇 시작 시 장 상태 확인 및 알림
		first_check = self.api.check_market_status()
		if not first_check:
			self.send_mm("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.")

		while True:
			# 장 상태 체크
			is_open = self.api.check_market_status()

			# 장 시작/마감 이벤트 처리
			if is_open and not self.was_market_open:
				self.refresh_account_status()
				self.was_market_open = True
				self.send_mm("🌅 **[장 시작]** 봇이 매매를 시작합니다.")

			elif not is_open and self.was_market_open:
				self.was_market_open = False
				self.refresh_account_status()
				day_profit = self.current_total_asset - self.start_of_day_asset
				self.send_mm(f"🌙 **[장 마감]**\n오늘 손익: {day_profit:,.0f}원")
				self.is_first_run = True  # 다음 날을 위해 리셋

			# 장 휴장 시 대기
			if not is_open:
				time.sleep(60)
				continue

			try:
				now = datetime.datetime.now()

				# [유니버스 갱신] 5분 주기
				if self.is_first_run or (now.minute % 5 == 0 and now.second < 5):
					self.update_universe()
					self.cleanup_banned_list()
					self.is_first_run = False
					logger.info("⏳ [주기] 유니버스 갱신 완료, 5초 대기 후 다음 루프")
					time.sleep(5)

				# [생존 신고] 1분 주기 (로그만)
				if now.minute % 1 == 0 and now.second < 2:
					targets = self.load_json_file(self.target_file, is_list=True)
					logger.info(f"👀 [생존] 타겟:{len(targets)} | 보유:{len(self.portfolio)}/{self.max_stocks} | 2초 대기")
					time.sleep(2)

				# 1. 매도 로직 (우선 순위)
				for code in list(self.portfolio.keys()):
					sell, reason, profit_pct, profit_val = self.check_sell(code)
					if sell:
						info = self.portfolio[code]
						if self.api.sell_market_order(code, info['qty']):
							del self.portfolio[code]
							self.save_json_file(self.portfolio_file, self.portfolio)

							strategy_tag = info.get('strategy', '')
							self.add_history('SELL', code, info['name'], 0, info['qty'], reason, strategy_tag)

							# 알림 전송
							try:
								self.update_account_light(profit_val)
								total_p = self.get_daily_profit_rate(self.current_total_asset) * 100
								icon = "💰" if profit_pct > 0 else "💧"
								r_tag = "[RECOVERED] " if strategy_tag == "RECOVERED" else ""

								msg = f"{icon} **[매도] {r_tag}{info['name']}**\n수익: {profit_pct * 100:.2f}% ({profit_val:,.0f}원)\n사유: {reason}\n누적: {total_p:+.2f}%"
								self.send_mm(msg)
							except Exception as e:
								logger.error(f"매도 알림 실패: {e}")

							time.sleep(1)

				# 2. 매수 로직
				if not self.trading_halted and len(self.portfolio) < self.max_stocks:
					targets = self.load_json_file(self.target_file, is_list=True)
					if targets:
						for item in targets:
							if item['code'] in self.portfolio: continue

							# 돈 없으면 스탑
							if self.current_cash < self.slot_money: break

							# 매수 시그널 확인
							sig = self.check_buy_signal(item['code'])
							if sig:
								self.execute_buy(item['code'], sig)
								time.sleep(1)

				time.sleep(1)

			except KeyboardInterrupt:
				logger.info("🛑 사용자에 의해 종료됨")
				break
			except Exception as e:
				logger.error(f"메인 루프 에러: {e}")
				self.send_mm(f"⚠️ **[봇 에러 발생]**\n내용: {e}")
				logger.info("⏳ [복구] 에러 후 5초 대기 후 재개")
				time.sleep(5)


if __name__ == "__main__":
	bot = JungleSurvivorBot(BrokerAPI())
	bot.run()