""" Kiwoom Trading Bot Ver2 - DB 기반 고급 트레이딩 시스템 - SQLite DB 기반 안전한 데이터 관리 - 변동성 기반 자금 관리 (Risk Manager) - TWAP 스마트 분할 매수 (Smart Executor) - 하프 켈리 공식 적용 - 기존 TAIL_CATCH_3M 전략 유지 및 강화 """ import time import json import datetime import pandas as pd import numpy as np import os import logging import requests from dotenv import load_dotenv # 새로운 모듈 임포트 from database import TradeDB from risk_manager import RiskManager from smart_executor import SmartOrderExecutor # ========================================================== # [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) # 키움 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 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) 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 에러 핸들링)""" full_name = func.__name__ api_id = full_name.split('_')[-1] max_retries = 3 for i in range(max_retries): try: time.sleep(1) # 기본 안전 대기 result = func(*args, **kwargs) 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: if "429" in str(e) or "과부하" in str(e): logger.warning(f"⚠️ [{api_id}] API 과부하 -> 5초 대기 ({i + 1}/{max_retries})") logger.info("5초 대기 시작...") time.sleep(5) logger.info("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 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: res = self._safe_request(self.account.account_evaluation_balance_detail_request_kt00018, qry_tp="2", prd_tp="1") if not res: return {'total_asset': 0, 'deposit': 0, 'stock_value': 0, 'profit_rate': 0} total_asset = float(res.get('estm_ast_amt', 0)) deposit = float(res.get('ord_alow_amt', 0)) stock_value = float(res.get('stck_pbls_amt', 0)) # 평가손익률 계산 deposit_clean = float(res.get('d2_entra', deposit)) profit_rate = 0.0 if (deposit_clean + stock_value) > 0: profit_rate = ((total_asset - (deposit_clean + stock_value)) / (deposit_clean + stock_value)) * 100 return { 'total_asset': total_asset, 'deposit': deposit, 'stock_value': stock_value, 'profit_rate': profit_rate } except Exception as e: logger.error(f"계좌 정보 조회 실패: {e}") return {'total_asset': 0, 'deposit': 0, 'stock_value': 0, 'profit_rate': 0} 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 # ========================================================== # [Part 2] 메인 트레이딩 봇 Ver2 # ========================================================== class TradingBotV2: def __init__(self, broker_api): self.api = broker_api # Mattermost 초기화 self.mm = MattermostBot() self.mm_channel = "stock" # 파일 경로 self.bot_state_file = os.path.join(current_dir, 'bot_state.json') # 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=float(os.environ.get("RISK_PCT_PER_TRADE", "0.02")), max_position_pct=float(os.environ.get("MAX_POSITION_PCT", "0.20")), min_position_amount=int(os.environ.get("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=int(os.environ.get("TWAP_MIN_SPLIT", "500000")), max_split_amount=int(os.environ.get("TWAP_MAX_SPLIT", "2000000")), min_delay_seconds=int(os.environ.get("TWAP_MIN_DELAY", "30")), max_delay_seconds=int(os.environ.get("TWAP_MAX_DELAY", "180")) ) else: self.executor = None # 거래 설정 self.max_stocks = int(os.environ.get("MAX_STOCKS", "5")) self.stop_loss_pct = float(os.environ.get("STOP_LOSS_PCT", "-0.035")) self.daily_stop_loss_pct = float(os.environ.get("DAILY_STOP_LOSS_PCT", "-0.05")) # 전략 파라미터 self.rsi_threshold = float(os.environ.get("RSI_OVERHEAT_THRESHOLD", "73")) self.shoulder_cut_pct = float(os.environ.get("SHOULDER_CUT_PCT", "0.03")) 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.current_cash = 0 self.current_total_asset = 0 self.start_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 # 리포트 플래그 self.morning_report_sent = False # 오전 장 뜸할 때 (13:00) self.closing_report_sent = False # 장마감 전 (15:15) self.final_report_sent = False # 장마감 후 (15:35) # 봇 상태 로드 (이전 실행 정보) self._load_bot_state() # 초기 계좌 정보 로드 self.refresh_account() # JSON 마이그레이션 (최초 1회) self._migrate_from_json_if_needed() # 시작 메시지 self._send_startup_message(kelly_enabled, use_twap) # 봇 상태 저장 self._save_bot_state() def _load_bot_state(self): """이전 실행 시 봇 상태 로드""" try: bot_state_file = getattr(self, 'bot_state_file', None) or os.path.join(current_dir, 'bot_state.json') if os.path.exists(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: self.start_day_asset = 0 # 계좌 조회 후 설정 logger.info(f"📅 날짜 변경 감지: {prev_day} → {self.today_date}") else: self.start_day_asset = self.prev_session_asset logger.info(f"📂 봇 상태 로드: 이전 자산 {self.prev_session_asset:,.0f}원") else: logger.info("📂 봇 상태 파일 없음 (최초 실행)") self.prev_session_asset = 0 self.start_day_asset = 0 except Exception as e: logger.error(f"❌ 봇 상태 로드 실패: {e}") self.prev_session_asset = 0 self.start_day_asset = 0 def _save_bot_state(self): """현재 봇 상태 저장""" try: state = { 'start_equity': self.current_total_asset, 'start_day': self.today_date, 'last_update': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') } with open(self.bot_state_file, 'w', encoding='utf-8') as f: json.dump(state, f, indent=4, ensure_ascii=False) except Exception as e: logger.error(f"❌ 봇 상태 저장 실패: {e}") def _send_startup_message(self, kelly_enabled, use_twap): """시작 메시지 전송 (이전 실행 대비 손익률 포함)""" # 이전 실행 대비 손익률 계산 if self.prev_session_asset > 0: session_pnl = self.current_total_asset - self.prev_session_asset session_pnl_pct = (session_pnl / self.prev_session_asset) * 100 session_info = f"\n- 이전 실행 대비: {session_pnl:+,.0f}원 ({session_pnl_pct:+.2f}%)" else: session_info = "\n- 이전 실행 대비: 데이터 없음 (최초 실행)" # 오늘 장 시작 대비 손익률 if self.start_day_asset > 0 and self.start_day_asset != self.current_total_asset: day_pnl = self.current_total_asset - self.start_day_asset day_pnl_pct = (day_pnl / self.start_day_asset) * 100 day_info = f"\n- 오늘 장 시작 대비: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)" else: day_info = "" msg = ( f"🤖 **[트레이딩봇 Ver2 가동]**\n" f"- 현재 자산: {self.current_total_asset:,.0f}원" f"{session_info}" f"{day_info}\n" f"- DB 기반 안전 관리\n" f"- 변동성 기반 자금 관리\n" f"- TWAP: {'ON' if use_twap else 'OFF'}\n" f"- 켈리 공식: {'ON' if kelly_enabled else 'OFF'}" ) 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 refresh_account(self): """계좌 정보 갱신""" try: info = self.api.get_account_info() self.current_cash = info['deposit'] self.current_total_asset = info['total_asset'] # 첫 실행 시 시작 자산 설정 if self.start_asset == 0: self.start_asset = info['total_asset'] # 오늘 장 시작 자산 설정 (날짜 바뀜 or 최초 실행) if self.start_day_asset == 0: self.start_day_asset = info['total_asset'] logger.info(f"📅 오늘 장 시작 자산 설정: {self.start_day_asset:,.0f}원") # 손익률 계산 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 logger.info( f"💰 예수금: {self.current_cash:,.0f}원 | " f"총자산: {self.current_total_asset:,.0f}원 " f"(예수금 {self.current_cash:,.0f} + 주식 {info['stock_value']:,.0f}) | " f"오늘: {day_pnl:+,.0f}원({day_pnl_pct:+.2f}%)" ) 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분봉 꼬리 잡기 - 기존 로직 유지 + 변동성 기반 비중 계산 추가 """ try: df = self.api.get_ohlcv(code, timeframe='3m', limit=50) if df is None or len(df) < 20: return None current_price = df['close'].iloc[-1] candle = df.iloc[-1] # 1. RSI 과열 필터 rsi = self.calculate_rsi(df) if rsi > self.rsi_threshold: return None # 2. 일일 고점 추격 방지 daily_high = df['high'].max() if current_price > daily_high * 0.96: return None # 3. 아래꼬리 확인 candle_low = candle['low'] candle_high = candle['high'] candle_open = candle['open'] candle_close = candle['close'] body_top = max(candle_open, candle_close) body_bottom = min(candle_open, candle_close) tail_length = body_bottom - candle_low body_length = body_top - body_bottom if tail_length <= 0 or body_length <= 0: return None # 꼬리 길이 비율 확인 tail_ratio = tail_length / body_length tail_pct = tail_length / candle_low if tail_ratio < 1.5 or tail_pct < 0.003: return None # 4. 회복 속도 확인 total_range = candle_high - candle_low if total_range <= 0: return None recovery_ratio = (current_price - candle_low) / total_range if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio): return None # 5. 변동성 계산 및 매수 금액 산출 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 ) 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 = 3.5 atr_multiplier_target = 8.0 stop_price = current_price - (atr * atr_multiplier_stop) target_price = current_price + (atr * atr_multiplier_target) logger.info( f"✅ [{name}] TAIL_CATCH 시그널 발생 | " f"가격:{current_price:,.0f} | RSI:{rsi:.1f} | " f"꼬리비율:{tail_ratio:.2f} | 회복:{recovery_ratio:.2%} | " f"매수:{safe_amount:,.0f}원({qty}주)" ) 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 } except Exception as e: logger.error(f"❌ [{name}] 매수 시그널 체크 실패: {e}") return None def execute_buy(self, signal): """매수 실행 (TWAP 분할 매수 또는 즉시 매수)""" try: code = signal['code'] name = signal['name'] qty = signal['qty'] amount = signal['amount'] # DB에 이미 있는지 확인 active_trades = self.db.get_active_trades() if code in active_trades: logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") return False # 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 # 일반 매수 (즉시 실행) success = self.api.buy_market_order(code, qty) if success: # DB에 저장 trade_data = { 'code': code, 'name': name, 'avg_buy_price': signal['price'], 'stop_price': signal['stop_price'], 'target_price': signal['target_price'], 'atr_entry': signal['atr'], 'target_qty': qty, 'current_qty': qty, 'total_invested': signal['price'] * qty, 'status': 'HOLDING', 'strategy': signal['strategy'] } self.db.upsert_trade(trade_data) msg = f"💰 **[매수 체결]** {name}\n가격: {signal['price']:,.0f}원 × {qty}주" 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): """보유 종목 매도 시그널 체크""" 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'] # 현재가 조회 current_price = self.api.get_current_price(code) if not current_price: continue # DB 현재가 업데이트 self.db.update_current_price(code, current_price) # 손익률 계산 profit_rate = ((current_price - buy_price) / buy_price) * 100 # 매도 사유 판단 sell_reason = None # 1. 손절 if current_price <= stop_price: sell_reason = f"손절({profit_rate:.1f}%)" # 2. 목표가 달성 elif current_price >= target_price: sell_reason = f"목표달성({profit_rate:.1f}%)" # 3. 어깨 매도 (고점 대비 하락) max_price = trade.get('max_price', buy_price) if current_price > max_price: self.db.update_max_price(code, current_price) else: drop_from_high = (max_price - current_price) / max_price if drop_from_high > self.shoulder_cut_pct: sell_reason = f"어깨매도({profit_rate:.1f}%)" # 매도 실행 if sell_reason: if self.api.sell_market_order(code, qty): self.db.close_trade(code, current_price, sell_reason) msg = f"📤 **[매도 체결]** {name}\n사유: {sell_reason}\n수익: {profit_rate:+.2f}%" logger.info(msg) self.send_mm(msg) except Exception as e: logger.error(f"❌ [{code}] 매도 체크 실패: {e}") def send_morning_report(self): """오전 장 뜸할 때 리포트 (13:00)""" try: # 계좌 정보 갱신 info = self.api.get_account_info() # 손익 계산 day_pnl = info['total_asset'] - self.start_day_asset day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 # 보유 종목 정보 active_trades = self.db.get_active_trades() holdings_info = "" if active_trades: holdings_info = "\n\n**보유 종목:**" for code, trade in active_trades.items(): current_price = self.api.get_current_price(code) if current_price: profit = ((current_price - trade['avg_buy_price']) / trade['avg_buy_price']) * 100 holdings_info += f"\n- {trade['name']}: {profit:+.2f}%" # 오늘 거래 통계 today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') cursor = self.db.conn.execute( "SELECT COUNT(*) as cnt, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?", (today_start,) ) row = cursor.fetchone() today_trades = row['cnt'] if row else 0 today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 msg = ( f"🌞 **[오전 장 리포트 13:00]**\n" f"- 시작 자산: {self.start_day_asset:,.0f}원\n" f"- 현재 자산: {info['total_asset']:,.0f}원\n" f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" f"- 오늘 거래: {today_trades}건 ({today_trade_pnl:+,.0f}원)" f"{holdings_info}" ) logger.info(msg) self.send_mm(msg) self.morning_report_sent = True except Exception as e: logger.error(f"❌ 오전 리포트 전송 실패: {e}") def send_closing_report(self): """장마감 전 리포트 (15:15)""" try: info = self.api.get_account_info() day_pnl = info['total_asset'] - self.start_day_asset day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 # 오늘 거래 통계 today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') cursor = self.db.conn.execute( "SELECT COUNT(*) as cnt, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?", (today_start,) ) row = cursor.fetchone() today_trades = row['cnt'] if row else 0 today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 msg = ( f"🔔 **[장마감 전 리포트 15:15]**\n" f"- 현재 자산: {info['total_asset']:,.0f}원\n" f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" f"- 오늘 거래: {today_trades}건 ({today_trade_pnl:+,.0f}원)\n" f"- 보유 종목: {len(self.db.get_active_trades())}개" ) logger.info(msg) self.send_mm(msg) self.closing_report_sent = True except Exception as e: logger.error(f"❌ 장마감 전 리포트 전송 실패: {e}") def send_final_report(self): """장마감 후 최종 리포트 (15:35)""" try: info = self.api.get_account_info() # 오늘 손익 day_pnl = info['total_asset'] - self.start_day_asset day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 # 누적 손익 (총 입금액 대비) # 총 입금액은 환경 변수나 별도 파일로 관리 필요 total_deposit = float(os.environ.get("TOTAL_DEPOSIT", str(self.start_day_asset))) total_pnl = info['total_asset'] - total_deposit total_pnl_pct = (total_pnl / total_deposit * 100) if total_deposit > 0 else 0 # 오늘 거래 통계 today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') cursor = self.db.conn.execute( """SELECT COUNT(*) as cnt, SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?""", (today_start,) ) row = cursor.fetchone() today_trades = row['cnt'] if row else 0 today_wins = row['wins'] if row else 0 today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 today_win_rate = (today_wins / today_trades * 100) if today_trades > 0 else 0 # 전체 통계 stats = self.db.get_trade_stats() msg = ( f"🌙 **[장마감 최종 리포트 15:35]**\n\n" f"**📊 오늘 실적**\n" f"- 시작: {self.start_day_asset:,.0f}원 → 종료: {info['total_asset']:,.0f}원\n" f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" f"- 오늘 거래: {today_trades}건 (익절 {today_wins}건, 승률 {today_win_rate:.1f}%)\n" f"- 거래 손익: {today_trade_pnl:+,.0f}원\n\n" f"**💰 누적 실적**\n" f"- 총 입금액: {total_deposit:,.0f}원\n" f"- 현재 자산: {info['total_asset']:,.0f}원\n" f"- 누적 손익: {total_pnl:+,.0f}원 ({total_pnl_pct:+.2f}%)\n" f"- 전체 거래: {stats['total_trades']}건 (승률 {stats['win_rate']:.1f}%)\n" f"- 전체 손익: {stats['total_pnl']:+,.0f}원" ) logger.info(msg) self.send_mm(msg) self.final_report_sent = True # 다음날을 위해 상태 저장 self._save_bot_state() except Exception as e: logger.error(f"❌ 최종 리포트 전송 실패: {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 run(self): """메인 루프""" logger.info("🚀 트레이딩봇 Ver2 가동 시작") loop_count = 0 while True: try: loop_count += 1 now = datetime.datetime.now() current_time = now.time() # 1. 장 운영 시간 체크 if not self.api.check_market_status(): # 장 시간 외: 플래그 초기화 if now.weekday() < 5: # 주중 # 날짜가 바뀌었는지 확인 today_str = now.strftime("%Y%m%d") if today_str != self.today_date: logger.info(f"📅 날짜 변경: {self.today_date} → {today_str}") self.today_date = today_str self.start_day_asset = 0 self.morning_report_sent = False self.closing_report_sent = False self.final_report_sent = False if loop_count % 60 == 0: # 1분마다 logger.info("💤 장 운영 시간 외") time.sleep(60) continue # 2. 장 중 리포트 타이밍 체크 # 오전 장 뜸할 때: 13:00 if not self.morning_report_sent and datetime.time(13, 0) <= current_time < datetime.time(13, 5): self.send_morning_report() # 장마감 전: 15:15 if not self.closing_report_sent and datetime.time(15, 15) <= current_time < datetime.time(15, 20): self.send_closing_report() # 장마감 후: 15:35 (모든 체결 완료 후) if not self.final_report_sent and datetime.time(15, 35) <= current_time < datetime.time(15, 40): self.send_final_report() # 3. 계좌 정보 갱신 (5분마다) if loop_count % 30 == 0: self.refresh_account() # 4. TWAP 분할 매수 처리 if self.use_twap: self.process_twap_orders() # 5. 보유 종목 매도 체크 self.check_sell_signals() # 6. 새로운 매수 기회 탐색 (보유 종목이 max보다 적을 때) active_count = len(self.db.get_active_trades()) if active_count < self.max_stocks and self.current_cash >= self.risk_mgr.min_amount: # 실제로는 scan_ant_shaking_candidates 같은 로직 필요 # 여기서는 예시로 생략 pass # 7. 대기 time.sleep(10) except KeyboardInterrupt: logger.info("⏸️ 사용자 중단") break except Exception as e: logger.error(f"❌ 메인 루프 에러: {e}") logger.info("⏳ 예외 발생 -> 5초 대기 후 재시도") time.sleep(5) # 종료 처리 logger.info("🛑 봇 종료 중...") self._save_bot_state() # 최종 상태 저장 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