import asyncio import os import json import time from concurrent.futures import ProcessPoolExecutor from concurrent.futures.process import BrokenProcessPool from datetime import datetime, timedelta from modules.config import Config from modules.services.kis import KISClient from modules.services.news import AsyncNewsCollector from modules.services.ollama import OllamaManager from modules.services.telegram import TelegramMessenger from modules.analysis.macro import MacroAnalyzer from modules.utils.monitor import SystemMonitor from modules.utils.performance_db import PerformanceDB from modules.strategy.process import analyze_stock_process, calculate_position_size from modules.analysis.ensemble import get_ensemble try: from theme_manager import ThemeManager except ImportError: class ThemeManager: def get_themes(self, code): return [] def init_worker(): try: from modules.utils.process_tracker import ProcessTracker ProcessTracker.register("Trading Bot Worker") except Exception: pass class AutoTradingBot: """ [v2.0] 개선된 자동매매 봇 주요 개선사항: 1. ATR 기반 동적 손절/익절 + 트레일링 스탑 2. 변동성 기반 포지션 사이징 (1주 고정 → 동적 수량) 3. 보유종목 분석 기반 매도 (score 기반 SELL 판단) 4. 매크로 상태를 분석 워커에 전달 (동적 임계값) 5. 최고가 추적 (트레일링 스탑용) 6. 상세한 매매 로그 및 텔레그램 알림 """ def __init__(self, ipc_lock=None, command_queue=None, shutdown_event=None, eod_event=None): # 1. 서비스 초기화 self.kis = KISClient() self.news = AsyncNewsCollector() self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker) self.messenger = TelegramMessenger() self.theme_manager = ThemeManager() self.ollama_monitor = OllamaManager() # 2. 유틸리티 초기화 self.monitor = SystemMonitor(self.messenger, self.ollama_monitor) # 3. 상태 변수 self.daily_trade_history = [] self.discovered_stocks = set() self.is_macro_warning_sent = False self.watchlist_updated_today = False self.report_sent = False # [v2.0] 트레일링 스탑용 최고가 추적 # {ticker: peak_price} self.peak_prices = {} # [v2.0] 최근 매크로 상태 캐싱 self.last_macro_status = None # [v2.1] 연속 손절 안전장치 # 당일 손절 횟수가 임계치 초과 시 매수 일시 중단 self._consecutive_stop_losses_today = 0 self._buy_paused_until = None # datetime or None # [v3.1] 사이클 간 당일 매수 금액 추적 (KIS T+2 미차감 문제 보완) self._today_buy_total = 0 # 당일 누적 매수 집행 금액 (원) self._today_buy_date = None # 날짜 리셋용 # [v3.1] 앙상블 학습용 매수 당시 신호 점수 보관 {ticker: {tech, sentiment, lstm}} # 매도 시 실현 수익률과 함께 ensemble.record_trade()에 전달 self._buy_scores: dict = {} # 4. 프로세스 관리 self.shutdown_event = shutdown_event self.eod_event = eod_event # EOD 셧다운 시그널 (→ main_server 자동 종료) self._eod_shutdown_done = False # 당일 EOD 처리 완료 여부 # KRX 캘린더 (장 운영 여부 판단) from modules.utils.market_calendar import get_calendar self._calendar = get_calendar() # 5. IPC (Shared Memory) try: from modules.utils.ipc import SharedIPC self.ipc = SharedIPC(lock=ipc_lock, command_queue=command_queue) except ImportError: print("[Bot] SharedIPC module not found.") self.ipc = None # 6. Watchlist Manager try: from watchlist_manager import WatchlistManager self.watchlist_manager = WatchlistManager(self.kis, watchlist_file=Config.WATCHLIST_FILE) except ImportError: self.watchlist_manager = None # 7. 기록 로드 self.history_file = Config.HISTORY_FILE self.load_trade_history() # 7-1. 성과 DB 및 평가 플래그 self.perf_db = PerformanceDB() self.weekly_eval_sent = False self._snapshot_taken_today = False self._pending_evaluate = False # 8. AI 하드웨어 점검 from modules.analysis.deep_learning import PricePredictor PricePredictor.verify_hardware() # 9. KIS 비동기 클라이언트 try: from modules.services.kis import KISAsyncClient self.kis_async = KISAsyncClient(self.kis) except ImportError: self.kis_async = None def load_trade_history(self): if os.path.exists(self.history_file): try: with open(self.history_file, "r", encoding="utf-8") as f: self.daily_trade_history = json.load(f) except Exception: self.daily_trade_history = [] else: self.daily_trade_history = [] def save_trade_history(self): try: with open(self.history_file, "w", encoding="utf-8") as f: json.dump(self.daily_trade_history, f, ensure_ascii=False, indent=2) except Exception as e: print(f"[Bot] Failed to save history: {e}") def load_watchlist(self): try: with open(Config.WATCHLIST_FILE, "r", encoding="utf-8") as f: return json.load(f) except Exception: return {} def _take_daily_snapshot(self, macro_status, balance): """일별 자산 스냅샷을 perf_db에 저장 (09:05~09:15 호출).""" try: total_eval_snap = int(balance.get("total_eval", 0)) deposit_snap = int(balance.get("deposit", 0)) holdings_count_snap = len([ h for h in balance.get("holdings", []) if int(h.get("qty", 0)) > 0 ]) # KOSPI 현재가 (macro_status 지표에서 추출) kospi_close = None try: indicators = macro_status.get("indicators", {}) kospi_price = float(indicators.get("KOSPI", {}).get("price", 0)) if kospi_price > 0: kospi_close = kospi_price except Exception: pass self.perf_db.save_daily_snapshot( total_eval_snap, deposit_snap, holdings_count_snap, kospi_close) self._snapshot_taken_today = True except Exception as e: print(f"[Bot] Daily snapshot error: {e}") async def _end_of_day_shutdown(self): """ [EOD] 장 마감 후 전체 학습 상태 저장 + 봇 프로세스 종료 저장 항목: 1. 앙상블 가중치 & 매매 히스토리 (ensemble_history.json) 2. 트레일링 스탑 최고가 (peak_prices.json) 3. 일일 거래 기록 (daily_trade_history.json) 4. 일별 자산 스냅샷 (perf_db) 5. EOD 마커 파일 (data/.eod_date → Watchdog 재시작 차단) """ print("[Bot] ===== EOD 상태 저장 시작 =====") # 1. 앙상블 가중치 강제 저장 try: ensemble = get_ensemble() ensemble._save() print("[Bot] [EOD] 앙상블 가중치 저장 완료") except Exception as e: print(f"[Bot] [EOD] 앙상블 저장 오류: {e}") # 2. 트레일링 스탑 최고가 저장 try: self._save_peak_prices() print("[Bot] [EOD] 최고가 데이터 저장 완료") except Exception as e: print(f"[Bot] [EOD] 최고가 저장 오류: {e}") # 3. 일일 거래 기록 저장 try: self.save_trade_history() print(f"[Bot] [EOD] 거래 기록 저장 완료 ({len(self.daily_trade_history)}건)") except Exception as e: print(f"[Bot] [EOD] 거래 기록 저장 오류: {e}") # 4. 일별 자산 스냅샷 (미완료 시) if not self._snapshot_taken_today: try: balance_snap = self.kis.get_balance() macro_cached = self.last_macro_status or {"indicators": {}} self._take_daily_snapshot(macro_cached, balance_snap) print("[Bot] [EOD] 자산 스냅샷 저장 완료") except Exception as e: print(f"[Bot] [EOD] 스냅샷 저장 오류: {e}") # 5. EOD 마커 파일 기록 (Watchdog 재시작 차단) try: from pathlib import Path import datetime as _dt eod_file = Path(Config.DATA_DIR) / ".eod_date" eod_file.parent.mkdir(exist_ok=True) eod_file.write_text(str(_dt.date.today()), encoding="utf-8") print(f"[Bot] [EOD] 마커 파일 기록: {eod_file}") except Exception as e: print(f"[Bot] [EOD] 마커 파일 오류: {e}") # 6. 텔레그램 알림 try: today_trades = len(self.daily_trade_history) try: nxt = self._calendar.next_trading_open() next_str = nxt.strftime('%m/%d(%a) %H:%M') except Exception: next_str = "미정" self.messenger.send_message( f"[장 마감] EOD 상태 저장 완료\n" f"오늘 매매: {today_trades}건\n" f"다음 거래일: {next_str} KST 자동 시작" ) except Exception as e: print(f"[Bot] [EOD] 알림 오류: {e}") print("[Bot] ===== EOD 상태 저장 완료 =====") # 7. 종료 시그널 if self.eod_event: self.eod_event.set() # main_server → 서버 프로세스 자동 종료 if self.shutdown_event: self.shutdown_event.set() # 텔레그램 봇 등 자식 프로세스 종료 async def _run_weekly_evaluation(self): """주간 성과 평가 실행 후 텔레그램으로 전송.""" try: from modules.analysis.evaluator import PerformanceEvaluator evaluator = PerformanceEvaluator() loop = asyncio.get_running_loop() # Ollama 호출이 동기 블로킹이므로 executor에서 실행 report = await loop.run_in_executor(None, evaluator.generate_weekly_report) if len(report) > 4000: report = report[:4000] + "\n... (일부 생략)" self.messenger.send_message(report) self.weekly_eval_sent = True print("[Bot] Weekly evaluation report sent.") except Exception as e: print(f"[Bot] Weekly evaluation error: {e}") self.messenger.send_message(f"[Bot] 주간 평가 오류: {e}") def _load_peak_prices(self): """트레일링 스탑용 최고가 데이터 로드""" peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json") if os.path.exists(peak_file): try: with open(peak_file, "r", encoding="utf-8") as f: self.peak_prices = json.load(f) except Exception: self.peak_prices = {} def _save_peak_prices(self): """트레일링 스탑용 최고가 데이터 저장""" peak_file = os.path.join(Config.DATA_DIR, "peak_prices.json") try: with open(peak_file, "w", encoding="utf-8") as f: json.dump(self.peak_prices, f, indent=2) except Exception: pass def _update_peak_price(self, ticker, current_price): """보유 종목의 최고가 갱신""" if ticker not in self.peak_prices: self.peak_prices[ticker] = current_price elif current_price > self.peak_prices[ticker]: self.peak_prices[ticker] = current_price print(f" 📈 [Peak Updated] {ticker}: {current_price:,.0f}") def send_daily_report(self): if self.report_sent: return print("[Bot] Generating Daily Report...") balance = self.kis.get_balance() total_eval = int(balance.get("total_eval", 0)) deposit = int(balance.get("deposit", 0)) report = (f"📅 [Daily Closing Report]\n" f"💰 Total Asset: {total_eval:,}원\n" f"💵 Cash: {deposit:,}원\n" f"📜 Trades Today: {len(self.daily_trade_history)}건\n\n") # 매매 내역 if self.daily_trade_history: total_profit = 0 buy_count = 0 sell_count = 0 for trade in self.daily_trade_history: action = trade['action'] icon = "🔴" if action == "BUY" else "🔵" qty = trade.get('qty', 0) price = trade.get('price', 0) reason = trade.get('reason', '') report += f"{icon} {action} {trade['name']} {qty}주 @ {price:,.0f}원" if reason: report += f" ({reason})" report += "\n" if action == "BUY": buy_count += 1 else: sell_count += 1 total_profit += trade.get('profit', 0) report += f"\n📊 매수 {buy_count}건 / 매도 {sell_count}건" if sell_count > 0: report += f" | 실현손익: {total_profit:,.0f}원" report += "\n" # 보유종목 현황 if "holdings" in balance and balance["holdings"]: report += "\n📊 [Holdings]\n" for stock in balance["holdings"]: yld = float(stock.get('yield', 0)) profit_loss = int(stock.get('profit_loss', 0)) if yld > 0: icon = "🔴" yld_str = f"+{yld}" elif yld < 0: icon = "🔵" yld_str = f"{yld}" else: icon = "⚪" yld_str = f"{yld}" report += (f"{icon} {stock['name']}: {yld_str}% " f"({profit_loss:+,}원)\n") self.messenger.send_message(report) self.report_sent = True def restart_executor(self): print("[Bot] Restarting Process Executor...") try: self.executor.shutdown(wait=False) except Exception: pass self.executor = ProcessPoolExecutor(max_workers=1, initializer=init_worker) print("[Bot] Process Executor Restarted.") def _process_commands(self): """IPC command queue 폴링 및 처리""" if not self.ipc: return commands = self.ipc.poll_commands() for cmd in commands: command = cmd.get('command', '') print(f"[Bot] Received command: {command}") if command == 'restart': self.messenger.send_message("[Bot] Restart requested via Telegram.") self.restart_executor() elif command == 'update_watchlist': if self.watchlist_manager: try: summary = self.watchlist_manager.update_watchlist_daily() self.messenger.send_message(f"[Watchlist Updated]\n{summary}") except Exception as e: self.messenger.send_message(f"Watchlist update failed: {e}") elif command == 'evaluate': self._pending_evaluate = True async def run_cycle(self): now = datetime.now() # 0. 명령 큐 폴링 self._process_commands() # 0-1. 즉시 평가 요청 처리 (IPC 'evaluate' 명령) if self._pending_evaluate: self._pending_evaluate = False await self._run_weekly_evaluation() # 1. 거시경제 분석 macro_status = MacroAnalyzer.get_macro_status(self.kis) self.last_macro_status = macro_status is_crash = False if macro_status['status'] == 'DANGER': is_crash = True if not self.is_macro_warning_sent: self.messenger.send_message( "🚨 [MARKET CRASH ALERT]\n" "시장 급락 감지! 매수 중단, 매도 기준 상향.\n" f"Risk Score: {macro_status['risk_score']}") self.is_macro_warning_sent = True elif macro_status['status'] == 'CAUTION': if not self.is_macro_warning_sent: self.messenger.send_message( "⚠️ [MARKET CAUTION]\n" "시장 불안정. 보수적 매매 모드 전환.\n" f"Risk Score: {macro_status['risk_score']}") self.is_macro_warning_sent = True else: if self.is_macro_warning_sent: self.messenger.send_message("🌤️ [MARKET RECOVERY] 시장 안정화.") self.is_macro_warning_sent = False # 2. IPC 상태 업데이트 if self.ipc: try: balance = self.kis.get_balance() gpu_status = self.ollama_monitor.get_gpu_status() watchlist = self.load_watchlist() self.ipc.write_status({ 'balance': balance, 'gpu': gpu_status, 'watchlist': watchlist, 'discovered_stocks': list(self.discovered_stocks), 'is_macro_warning': self.is_macro_warning_sent, 'macro_indices': macro_status['indicators'], 'themes': {} }) except Exception: pass # 3. 아침 업데이트 (08:00) if now.hour == 8 and 0 <= now.minute < 5: if not self.watchlist_updated_today and self.watchlist_manager: print("[Bot] Morning Update...") try: summary = self.watchlist_manager.update_watchlist_daily() self.messenger.send_message(summary) self.watchlist_updated_today = True except Exception as e: self.messenger.send_message(f"Update Failed: {e}") # 4. 리셋 (09:00) if now.hour == 9 and now.minute < 5: self.daily_trade_history = [] self.save_trade_history() self.report_sent = False self.weekly_eval_sent = False self._snapshot_taken_today = False self.discovered_stocks.clear() self.watchlist_updated_today = False # 전일 최고가 초기화 (보유하지 않는 종목) self._load_peak_prices() # [v3.1] 당일 매수 추적 리셋 self._today_buy_total = 0 self._today_buy_date = now.date() self._buy_scores.clear() # 미매도 종목 신호 점수도 초기화 print(f"[Bot] 일일 매수 추적 리셋 (날짜: {now.date()})") # 5. 시스템 감시 (3분 간격) self.monitor.check_health() # 6. 장 운영 시간 체크 if not (9 <= now.hour < 15 or (now.hour == 15 and now.minute < 30)): if now.hour == 15 and now.minute >= 40: self.send_daily_report() # 일별 스냅샷 (16:00~16:30, 당일 최종 포트폴리오 가치 기록) if now.hour == 16 and now.minute <= 30 and not self._snapshot_taken_today: try: balance_snap = self.kis.get_balance() self._take_daily_snapshot(macro_status, balance_snap) except Exception as e: print(f"[Bot] Snapshot error: {e}") # 주간 평가 (금요일 15:35~15:45, 장 마감 직후) if (now.weekday() == 4 and now.hour == 15 and 35 <= now.minute <= 45 and not self.weekly_eval_sent): await self._run_weekly_evaluation() # [EOD 셧다운] 장 마감 후 Config.EOD_SHUTDOWN_BUFFER_MIN 분 경과 시 저장 후 종료 eod_buffer = now.hour == 15 and now.minute >= (30 + Config.EOD_SHUTDOWN_BUFFER_MIN) eod_buffer = eod_buffer or (now.hour >= 16) # 16시 이후도 포함 if eod_buffer and not self._eod_shutdown_done: self._eod_shutdown_done = True await self._end_of_day_shutdown() return # 장 외 시간에는 서킷 브레이커도 리셋 self.monitor.reset_circuit() if not self._eod_shutdown_done: print("[Bot] Market Closed. Waiting...") return # [서킷 브레이커] CPU 과부하 시 분석 사이클 일시 중단 if self.monitor.is_cpu_critical(): print("[Bot] ⛔ CPU Circuit Breaker 발동 중. 분석 사이클 스킵.") return cycle_start_time = time.time() print(f"[Bot] Cycle Start: {now.strftime('%H:%M:%S')}") # 7. 종목 분석 및 매매 target_dict = self.load_watchlist() # [v2.0] 잔고 조회 및 보유종목 맵 생성 balance = self.kis.get_balance() current_holdings = {} total_eval = int(balance.get("total_eval", 0)) if balance and "holdings" in balance: for stock in balance["holdings"]: code = stock.get("code") qty = int(stock.get("qty", 0)) if qty > 0: current_holdings[code] = stock # 최고가 업데이트 (트레일링 스탑용) current_price = float(stock.get('current_price', 0)) if current_price > 0: self._update_peak_price(code, current_price) # [v2.0] 보유종목도 분석 대상에 포함 (watchlist에 없어도) for code in current_holdings: if code not in target_dict: name = current_holdings[code].get('name', 'Unknown') target_dict[code] = name print(f"[Bot] Added holding to analysis: {name} ({code})") # 분석 실행 (병렬 처리) analysis_tasks = [] news_data = await self.news.get_market_news_async() raw_deposit = int(balance.get("deposit", 0)) # [v3.1] 사이클 간 누적 매수금액 추적 (KIS 모의투자 T+2 미차감 보완) # KIS API의 dnca_tot_amt(예수금)는 당일 매수를 즉시 차감하지 않아 # 매 사이클마다 전체 잔고처럼 보이는 문제를 방지 today = now.date() if self._today_buy_date != today: # 날짜 변경 시 리셋 (09:00 리셋 블록에서 이미 처리되지만 안전망으로 이중 체크) self._today_buy_total = 0 self._today_buy_date = today # KIS가 제공하는 금일매수금액이 있으면 그것을 우선 사용 (더 정확) kis_today_buy = int(balance.get("today_buy_amt", 0)) if kis_today_buy > 0: # KIS 값이 유효하면 로컬 추적값과 최댓값으로 사용 (둘 다 참조) effective_today_buy = max(kis_today_buy, self._today_buy_total) else: effective_today_buy = self._today_buy_total # 실제 사용 가능한 예수금 = KIS 예수금 - 당일 이미 집행한 매수금액 max_daily_buy = int(raw_deposit * Config.MAX_DAILY_BUY_RATIO) tracking_deposit = max(0, min(raw_deposit, max_daily_buy) - effective_today_buy) print(f"[Bot] 예수금: {raw_deposit:,}원 | 당일매수: {effective_today_buy:,}원 | " f"사용가능: {tracking_deposit:,}원 (한도 {max_daily_buy:,}원)") # [v3.0] 비동기 OHLCV + 투자자 동향 배치 조회 tickers_list = list(target_dict.keys()) ohlcv_batch = {} investor_batch = {} if self.kis_async and tickers_list: try: print(f"[Bot] 비동기 OHLCV 배치 조회: {len(tickers_list)}종목") ohlcv_batch = await self.kis_async.get_daily_ohlcv_batch(tickers_list) investor_batch = await self.kis_async.get_investor_trends_batch(tickers_list) except Exception as e: print(f"[Bot] 비동기 배치 조회 실패: {e} -> 동기 fallback") ohlcv_batch = {} investor_batch = {} # [v3.1] 사이클당 매수 횟수 제한 buys_this_cycle = 0 try: for ticker, name in target_dict.items(): # OHLCV 데이터 획득 (배치 결과 우선, 실패 시 동기 fallback) ohlcv_data = ohlcv_batch.get(ticker) if not ohlcv_data or not ohlcv_data.get('close'): ohlcv_data = self.kis.get_daily_ohlcv(ticker) if not ohlcv_data or not ohlcv_data.get('close'): continue # [v2.0] 보유 정보 전달 (분석 워커에서 동적 손절/익절 사용) holding_info = None if ticker in current_holdings: h = current_holdings[ticker] holding_info = { 'qty': int(h.get('qty', 0)), 'yield': float(h.get('yield', 0.0)), 'purchase_price': float(h.get('purchase_price', 0)), 'current_price': float(h.get('current_price', 0)), 'peak_price': self.peak_prices.get(ticker, float(h.get('current_price', 0))) } # investor_trend fallback investor_trend = investor_batch.get(ticker) if investor_trend is None: investor_trend = self.kis.get_investor_trend(ticker) future = self.executor.submit( analyze_stock_process, ticker, ohlcv_data, news_data, investor_trend, macro_status, holding_info, total_eval if total_eval > 0 else None) analysis_tasks.append(future) # 결과 처리 loop = asyncio.get_running_loop() for future in analysis_tasks: try: # 240초 타임아웃: LSTM 학습 + Ollama 추론 시간 고려 res = await loop.run_in_executor(None, lambda f=future: f.result(240)) ticker = res['ticker'] ticker_name = target_dict.get(ticker, 'Unknown') print(f"[Bot] [{ticker_name}] Score: {res['score']:.2f} ({res['decision']})" f" | SL:{res.get('sl_tp', {}).get('stop_loss_pct', 'N/A')}%" f" TP:{res.get('sl_tp', {}).get('take_profit_pct', 'N/A')}%") # ===== 매수 처리 ===== if res['decision'] == "BUY": if is_crash: print(f"[Bot] [Skip Buy] Market DANGER mode - {ticker_name}") continue # [v3.1] 사이클당 최대 매수 종목 수 제한 if buys_this_cycle >= Config.MAX_BUY_PER_CYCLE: print(f"[Bot] [Skip Buy] 사이클 최대 매수 횟수 초과 " f"({buys_this_cycle}/{Config.MAX_BUY_PER_CYCLE}) - {ticker_name}") continue # [v2.1] 연속 손절 후 매수 일시 중단 체크 if self._buy_paused_until and datetime.now() < self._buy_paused_until: print(f"[Bot] [Skip Buy] 연속 손절 매수 중단 중 (재개: " f"{self._buy_paused_until.strftime('%H:%M')}) - {ticker_name}") continue elif self._buy_paused_until and datetime.now() >= self._buy_paused_until: self._buy_paused_until = None self._consecutive_stop_losses_today = 0 print("[Bot] 매수 일시 중단 해제") current_price = float(res['current_price']) if current_price <= 0: continue # [v3.1] 워커에서 Kelly Criterion으로 계산한 수량 직접 사용 # (중복 계산 제거 — 워커가 total_eval 기준으로 이미 계산 완료) qty = res.get('suggested_qty', 0) if qty <= 0: print(f"[Bot] [Skip Buy] Position size = 0 ({ticker_name})") continue required_amount = current_price * qty # 예수금 확인 (tracking_deposit는 당일 누적 매수 차감 후 가용액) if tracking_deposit < required_amount: qty = int(tracking_deposit / current_price) if qty <= 0: print(f"[Bot] [Skip Buy] 예수금 부족 ({ticker_name}): " f"필요 {required_amount:,.0f} > 가용 {tracking_deposit:,.0f}") continue required_amount = current_price * qty print(f"[Bot] Buying {ticker_name} {qty}ea @ ~{current_price:,.0f}") order = self.kis.buy_stock(ticker, qty) if order.get("status"): reason = res.get('decision_reason', '') sl_tp = res.get('sl_tp', {}) msg = (f"🔴 [BUY] {ticker_name} {qty}주\n" f" Price: {current_price:,.0f}원\n" f" Score: {res['score']:.2f}\n" f" SL: {sl_tp.get('stop_loss_pct', -5):.1f}%" f" | TP: {sl_tp.get('take_profit_pct', 8):.1f}%" f" | Trail: {sl_tp.get('trailing_stop_pct', 3):.1f}%") if reason: msg += f"\n Reason: {reason}" self.messenger.send_message(msg) self.daily_trade_history.append({ "action": "BUY", "name": ticker_name, "qty": qty, "price": current_price, "score": res['score'], "reason": reason }) self.save_trade_history() # 성과 DB 기록 pred = res.get("prediction") or {} self.perf_db.save_trade_record( action="BUY", ticker=ticker, name=ticker_name, qty=qty, price=current_price, scores_dict={ "tech": res.get("tech", 0.0), "sentiment": res.get("sentiment", 0.0), "lstm_score": res.get("lstm_score", 0.0), "score": res.get("score", 0.0), "ai_confidence": res.get("ai_confidence", 0.5), "prediction_change": pred.get("change_rate", 0.0) }, reason=reason, macro_state=macro_status.get("status", "SAFE") ) tracking_deposit -= required_amount # [v3.1] 사이클 간 추적 (KIS T+2 미차감 보완) self._today_buy_total += required_amount buys_this_cycle += 1 print(f"[Bot] 당일 누적 매수: {self._today_buy_total:,}원 " f"(잔여 예수금: {tracking_deposit:,}원)") # [v3.1] 앙상블 학습용 매수 신호 점수 보관 (매도 시 record_trade에 활용) self._buy_scores[ticker] = { "tech": res.get("tech", 0.5), "sentiment": res.get("sentiment", 0.5), "lstm": res.get("lstm_score", 0.5), } # 최고가 초기 설정 self.peak_prices[ticker] = current_price self._save_peak_prices() # ===== 매도 처리 (v2.1 - 연속 손절 안전장치 포함) ===== elif res['decision'] == "SELL" and ticker in current_holdings: h = current_holdings[ticker] qty = int(h.get('qty', 0)) yld = float(h.get('yield', 0.0)) profit_loss = int(h.get('profit_loss', 0)) if qty > 0: print(f"[Bot] Selling {ticker_name} {qty}ea (Yield: {yld:.1f}%)") sell_res = self.kis.sell_stock(ticker, qty) if sell_res and sell_res.get("status"): reason = res.get('decision_reason', 'AI Signal') msg = (f"🔵 [SELL] {ticker_name} {qty}주\n" f" Yield: {yld:.1f}%\n" f" P&L: {profit_loss:+,}원\n" f" Reason: {reason}") self.messenger.send_message(msg) sell_price = float(h.get('current_price', 0)) self.daily_trade_history.append({ "action": "SELL", "name": ticker_name, "qty": qty, "price": sell_price, "yield": yld, "profit": profit_loss, "reason": reason }) self.save_trade_history() # 성과 DB 매도 결과 기록 self.perf_db.close_trade(ticker, sell_price, yld) # [v3.1] 앙상블 학습 데이터 기록 (매수 시 저장한 신호 점수 + 실현 수익률) buy_sig = self._buy_scores.pop(ticker, None) if buy_sig is not None: try: get_ensemble().record_trade( ticker=ticker, tech_score=buy_sig["tech"], sentiment_score=buy_sig["sentiment"], lstm_score=buy_sig["lstm"], decision="BUY", outcome_pct=yld ) print(f"[Bot] [Ensemble] {ticker_name} 학습 기록: " f"outcome={yld:+.1f}%") except Exception as _ee: print(f"[Bot] [Ensemble] record_trade 실패: {_ee}") # [v2.1] 손절 횟수 추적 → 연속 3회 손절 시 매수 30분 일시 중단 if yld < 0: self._consecutive_stop_losses_today += 1 if self._consecutive_stop_losses_today >= 3: self._buy_paused_until = datetime.now() + timedelta(minutes=30) warn_msg = ( f"⛔ [매수 일시 중단] 당일 손절 " f"{self._consecutive_stop_losses_today}회 → " f"30분간 매수 정지 (재개: " f"{self._buy_paused_until.strftime('%H:%M')})" ) self.messenger.send_message(warn_msg) print(f"[Bot] 연속 손절 {self._consecutive_stop_losses_today}회 → 매수 30분 중단") else: # 수익 실현 시 연속 손절 카운터 리셋 self._consecutive_stop_losses_today = 0 # 최고가 기록 삭제 if ticker in self.peak_prices: del self.peak_prices[ticker] self._save_peak_prices() except BrokenProcessPool: raise except Exception as e: print(f"[Bot] Analysis Worker Error: {e}") except BrokenProcessPool: print("[Bot] Worker Process Crashed. Restarting Executor...") self.restart_executor() except KeyboardInterrupt: raise except Exception as e: print(f"[Bot] Cycle Loop Error: {e}") # 사이클 소요시간 로깅 (120초 초과 시 경고) cycle_elapsed = time.time() - cycle_start_time if cycle_elapsed > 120: print(f"[Bot] ⚠️ 사이클 소요 {cycle_elapsed:.0f}초 (120초 초과) → LSTM 쿨다운 활성화 권장") else: print(f"[Bot] Cycle Done: {cycle_elapsed:.1f}초") def loop(self): print(f"[Bot] Module Started (PID: {os.getpid()}) [v3.1]") # [캘린더 체크] 오늘이 휴장일이면 알림 후 즉시 EOD 종료 if not self._calendar.is_trading_day(): summary = self._calendar.status_summary() print(f"[Bot] 오늘은 휴장일 ({summary}) — 봇을 시작하지 않습니다.") self.messenger.send_message( f"[Bot] 오늘은 휴장일입니다.\n{summary}" ) # EOD 마커 기록 후 종료 try: from pathlib import Path import datetime as _dt eod_file = Path(Config.DATA_DIR) / ".eod_date" eod_file.write_text(str(_dt.date.today()), encoding="utf-8") except Exception: pass if self.eod_event: self.eod_event.set() if self.shutdown_event: self.shutdown_event.set() return _llm_label = ( f"Gemini ({Config.GEMINI_MODEL})" if Config.GEMINI_API_KEY else f"Ollama ({Config.OLLAMA_MODEL})" ) self.messenger.send_message( "🚀 [Bot Started v3.1]\n" f"✅ LSTM 쿨다운: {Config.LSTM_COOLDOWN//60}분\n" f"✅ LLM 엔진: {_llm_label}\n" f"✅ CPU 서킷브레이커: {Config.CPU_CIRCUIT_BREAKER_THRESHOLD}% 기준\n" f"✅ 장 상태: {self._calendar.status_summary()}\n" "✅ 동적 손절/익절, 트레일링 스탑, 포지션 사이징") # 최고가 데이터 로드 self._load_peak_prices() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: while True: if self.shutdown_event and self.shutdown_event.is_set(): print("[Bot] Shutdown signal received.") break try: loop.run_until_complete(self.run_cycle()) except Exception as e: print(f"[Bot] Loop Error: {e}") self.messenger.send_message(f"[Bot] Loop Error: {e}") for _ in range(60): if self.shutdown_event and self.shutdown_event.is_set(): break time.sleep(1) except KeyboardInterrupt: print("[Bot] Stopped by User.") finally: print("[Bot] Shutting down executor...") self.executor.shutdown(wait=False) if self.ipc: self.ipc.cleanup() loop.close() print("[Bot] Executor shutdown complete.")