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