Files
ai-trade/modules/services/telegram_bot/server.py

395 lines
17 KiB
Python

"""
텔레그램 봇 최적화 버전
- Polling 최적화 (CPU 사용률 감소)
- 별도 프로세스로 분리
- 봇 재시작 명령어
- 원격 명령어 실행
"""
import os
import asyncio
import logging
import subprocess
import sys
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes, TypeHandler
from dotenv import load_dotenv
# 로깅 설정
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
logging.getLogger("httpx").setLevel(logging.WARNING)
class TelegramBotServer:
def __init__(self, bot_token):
# [최적화] 연결 풀 설정 추가
self.application = Application.builder()\
.token(bot_token)\
.concurrent_updates(True)\
.build()
self.bot_instance = None
self.is_shutting_down = False
self.should_restart = False
def set_bot_instance(self, bot):
"""AutoTradingBot 인스턴스를 주입받음"""
self.bot_instance = bot
def refresh_bot_instance(self):
"""IPC에서 최신 봇 인스턴스 데이터 읽기"""
# [수정] 모듈 경로 변경
from modules.utils.ipc import BotIPC
ipc = BotIPC()
self.bot_instance = ipc.get_bot_instance_data()
return self.bot_instance is not None
async def start_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/start 명령어 핸들러"""
await update.message.reply_text(
"🤖 **AI Trading Bot Command Center**\n"
"명령어 목록:\n"
"/status - 현재 봇 및 시장 상태 조회\n"
"/portfolio - 현재 보유 종목 및 평가액\n"
"/watchlist - 현재 감시 중인 종목 리스트\n"
"/update_watchlist - Watchlist 즉시 업데이트\n"
"/macro - 거시경제 지표 및 시장 위험도\n"
"/system - PC 리소스(CPU/GPU) 상태\n"
"/ai - AI 모델 학습 상태 조회\n\n"
"**[관리 명령어]**\n"
"/restart - 봇 재시작\n"
"/exec <command> - 원격 명령어 실행\n"
"/stop - 봇 종료",
parse_mode="Markdown"
)
async def status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/status: 종합 상태 브리핑"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
return
from datetime import datetime
now = datetime.now()
is_market_open = (9 <= now.hour < 15) or (now.hour == 15 and now.minute < 30)
status_msg = "✅ **System Status: ONLINE**\n"
status_msg += f"🕒 **Market:** {'OPEN 🟢' if is_market_open else 'CLOSED 🔴'}\n"
macro_warn = self.bot_instance.is_macro_warning_sent
status_msg += f"🌍 **Macro Filter:** {'DANGER 🚨 (Trading Halted)' if macro_warn else 'SAFE 🟢'}\n"
await update.message.reply_text(status_msg, parse_mode="Markdown")
async def portfolio_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/portfolio: 잔고 조회"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 봇 인스턴스가 연결되지 않았습니다.")
return
await update.message.reply_text("⏳ 잔고를 조회 중입니다...")
try:
balance = self.bot_instance.kis.get_balance()
if "error" in balance:
await update.message.reply_text(f"❌ 잔고 조회 실패: {balance['error']}")
return
msg = f"💰 **Total Asset:** `{int(balance['total_eval']):,} KRW`\n" \
f"💵 **Deposit:** `{int(balance['deposit']):,} KRW`\n\n"
if balance['holdings']:
msg += "**[Holdings]**\n"
for stock in balance['holdings']:
icon = "🔴" if stock['yield'] > 0 else "🔵"
msg += f"{icon} **{stock['name']}** `{stock['yield']}%`\n" \
f" (수량: {stock['qty']} / 평가손익: {stock['profit_loss']:,})\n"
else:
msg += "보유 중인 종목이 없습니다."
await update.message.reply_text(msg, parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ Error: {str(e)}")
async def watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/watchlist: 감시 대상 종목"""
if not self.refresh_bot_instance():
return
target_dict = self.bot_instance.load_watchlist()
discovered = list(self.bot_instance.discovered_stocks)
msg = f"👀 **Watchlist: {len(target_dict)} items**\n"
for code, name in target_dict.items():
themes = self.bot_instance.theme_manager.get_themes(code)
theme_str = f" ({', '.join(themes)})" if themes else ""
msg += f"- {name}{theme_str}\n"
if discovered:
msg += f"\n✨ **Discovered Today ({len(discovered)}):**\n"
for code in discovered:
msg += f"- {code}\n"
await update.message.reply_text(msg)
async def update_watchlist_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/update_watchlist: Watchlist 즉시 업데이트"""
await update.message.reply_text("🔄 Watchlist를 업데이트하고 있습니다... (30초 소요)")
try:
# [수정] IPC 모드에서도 직접 수행하기 위해 새로운 인스턴스 생성
from modules.services.kis import KISClient
from watchlist_manager import WatchlistManager
# 독립적인 KIS 클라이언트 생성
from modules.config import Config
temp_kis = KISClient()
mgr = WatchlistManager(temp_kis, watchlist_file=Config.WATCHLIST_FILE)
# 업데이트 수행 (파일 쓰기)
summary = mgr.update_watchlist_daily()
await update.message.reply_text(summary, parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ 업데이트 실패: {e}")
async def macro_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/macro: 거시경제 지표 조회 (IPC 데이터 사용)"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇 연결 대기 중...")
return
await update.message.reply_text("⏳ 거시경제 데이터를 불러옵니다...")
try:
# [수정] IPC 데이터를 직접 사용하여 출력 (FakeKIS의 _macro_indices 활용)
# FakeKIS는 bot_instance.kis에 할당되어 있음
indices = getattr(self.bot_instance.kis, '_macro_indices', {})
if not indices:
await update.message.reply_text("⚠️ 데이터가 아직 수집되지 않았습니다. 잠시 후 다시 시도하세요.")
return
# 리스크 점수 계산 (간이)
status = "SAFE"
msi = indices.get('MSI', 0)
if msi >= 50: status = "DANGER"
elif msi >= 30: status = "CAUTION"
color = "🟢" if status == "SAFE" else "🔴" if status == "DANGER" else "🟡"
msg = f"{color} **Market Risk: {status}**\n\n"
if 'MSI' in indices:
msg += f"🌡️ **Stress Index:** `{indices['MSI']}`\n"
for k, v in indices.items():
if k != "MSI":
icon = "🔺" if v.get('change', 0) > 0 else "🔻"
msg += f"{icon} **{k}**: {v.get('price', 0)} ({v.get('change', 0)}%)\n"
await update.message.reply_text(msg, parse_mode="Markdown")
except Exception as e:
await update.message.reply_text(f"❌ Error: {e}")
async def system_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/system: 시스템 리소스 상태"""
if not self.refresh_bot_instance():
await update.message.reply_text("⚠️ 메인 봇이 실행 중이 아닙니다.")
return
import psutil
cpu = psutil.cpu_percent(interval=1)
ram = psutil.virtual_memory().percent
# CPU 점유율 상위 3개 프로세스 수집
top_processes = []
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
try:
proc_info = proc.info
if proc_info['name'] == 'System Idle Process':
continue
top_processes.append(proc_info)
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
pass
top_processes.sort(key=lambda x: x.get('cpu_percent', 0), reverse=True)
top_3 = top_processes[:3]
gpu_status = self.bot_instance.ollama_monitor.get_gpu_status()
gpu_msg = f"N/A"
if gpu_status and gpu_status.get('name') != 'N/A':
gpu_name = gpu_status.get('name', 'GPU')
gpu_msg = f"{gpu_name}\n Temp: {gpu_status.get('temp', 0)}°C / VRAM: {gpu_status.get('vram_used', 0)}GB / {gpu_status.get('vram_total', 0)}GB"
msg = "🖥️ **PC System Status**\n" \
f"🧠 **CPU:** `{cpu}%`\n" \
f"💾 **RAM:** `{ram}%`\n" \
f"🎮 **GPU:** {gpu_msg}\n\n"
if top_3:
msg += "⚙️ **Top CPU Processes:**\n"
for i, proc in enumerate(top_3, 1):
proc_name = proc.get('name', 'Unknown')
proc_cpu = proc.get('cpu_percent', 0)
msg += f" {i}. `{proc_name}` - {proc_cpu:.1f}%\n"
await update.message.reply_text(msg, parse_mode="Markdown")
async def ai_status_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/ai: AI 모델 학습 상태 조회"""
gpu = self.bot_instance.ollama_monitor.get_gpu_status()
msg = "🧠 **AI Model Status**\n"
msg += f"• **LLM Engine:** Ollama (Llama 3.1)\n"
gpu_name = gpu.get('name', 'NVIDIA RTX 5070 Ti')
msg += f"• **Device:** {gpu_name}\n"
if gpu:
msg += f"• **GPU Load:** `{gpu.get('load', 0)}%`\n"
msg += f"• **VRAM Usage:** `{gpu.get('vram_used', 0)}GB` / {gpu.get('vram_total', 0)}GB"
await update.message.reply_text(msg, parse_mode="Markdown")
async def restart_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/restart: 텔레그램 봇 모듈만 재시작"""
await update.message.reply_text("🔄 **텔레그램 인터페이스를 재시작합니다...**")
# 재시작 플래그 설정 (runner.py에서 감지하여 재시작)
self.should_restart = True
self.application.stop_running()
async def stop_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/stop: 봇 종료"""
await update.message.reply_text("🛑 **텔레그램 봇을 종료합니다.**")
# 종료 플래그 설정 (runner.py에서 루프 탈출)
self.should_restart = False
self.application.stop_running()
async def exec_command(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
"""/exec: 원격 명령어 실행"""
if not context.args:
await update.message.reply_text("❌ 사용법: /exec <command>")
return
command = " ".join(context.args)
await update.message.reply_text(f"⚙️ 실행 중: `{command}`", parse_mode="Markdown")
try:
# 보안: 위험한 명령어 차단
dangerous_keywords = ['rm', 'del', 'format', 'shutdown', 'reboot']
if any(keyword in command.lower() for keyword in dangerous_keywords):
await update.message.reply_text("⛔ 위험한 명령어는 실행할 수 없습니다.")
return
# 명령어 실행 (타임아웃 30초)
result = subprocess.run(
command,
shell=True,
capture_output=True,
text=True,
timeout=30,
cwd=os.getcwd()
)
output = result.stdout if result.stdout else result.stderr
if not output:
output = "명령어 실행 완료 (출력 없음)"
# 출력이 너무 길면 잘라내기
if len(output) > 3000:
output = output[:3000] + "\n... (출력이 너무 깁니다)"
await update.message.reply_text(f"```\n{output}\n```", parse_mode="Markdown")
except subprocess.TimeoutExpired:
await update.message.reply_text("⏱️ 명령어 실행 시간 초과 (30초)")
except Exception as e:
await update.message.reply_text(f"❌ 실행 오류: {str(e)}")
def run(self):
"""봇 실행 (비동기 polling)"""
async def error_handler(update: object, context: ContextTypes.DEFAULT_TYPE) -> None:
"""에러 핸들러"""
import traceback
# Conflict 에러는 무시 (다른 봇 인스턴스 실행 중)
if "Conflict" in str(context.error):
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다. 이 인스턴스를 종료합니다.")
if self.application.running:
await self.application.stop()
return
tb_list = traceback.format_exception(None, context.error, context.error.__traceback__)
tb_string = ''.join(tb_list)
print(f"❌ [Telegram Error] {tb_string}")
if isinstance(update, Update) and update.effective_message:
try:
await update.effective_message.reply_text(f"⚠️ 오류 발생: {context.error}")
except:
pass
async def ultimate_handler(update: Update, context: ContextTypes.DEFAULT_TYPE):
if not update.message or not update.message.text:
return
text = update.message.text.strip()
print(f"📨 [Telegram] Command Received: {text}")
try:
if text.startswith("/start"):
await self.start_command(update, context)
elif text.startswith("/status"):
await self.status_command(update, context)
elif text.startswith("/portfolio"):
await self.portfolio_command(update, context)
elif text.startswith("/watchlist"):
await self.watchlist_command(update, context)
elif text.startswith("/update_watchlist"):
await self.update_watchlist_command(update, context)
elif text.startswith("/macro"):
await self.macro_command(update, context)
elif text.startswith("/system"):
await self.system_command(update, context)
elif text.startswith("/ai"):
await self.ai_status_command(update, context)
elif text.startswith("/restart"):
await self.restart_command(update, context)
elif text.startswith("/stop"):
await self.stop_command(update, context)
elif text.startswith("/exec"):
await self.exec_command(update, context)
except Exception as e:
print(f"❌ Handle Error: {e}")
await update.message.reply_text(f"⚠️ Error: {e}")
# 에러 핸들러 등록
self.application.add_error_handler(error_handler)
self.application.add_handler(TypeHandler(Update, ultimate_handler))
# [최적화] Polling 설정 개선
print("🤖 [Telegram] Command Server Started (Optimized Polling Mode).")
try:
self.application.run_polling(
allowed_updates=Update.ALL_TYPES,
stop_signals=None,
poll_interval=1.0, # 1초마다 폴링 (기본값 0.0)
timeout=10, # 타임아웃 10초
drop_pending_updates=True # 대기 중인 업데이트 무시
)
except Exception as e:
if "Conflict" in str(e):
print(f"⚠️ [Telegram] 다른 봇 인스턴스가 실행 중입니다.")
print(f"⚠️ [Telegram] 기존 봇을 종료하고 다시 시도하세요.")
else:
print(f"❌ [Telegram] 봇 실행 오류: {e}")
import traceback
traceback.print_exc()