"""Telegram 알림 서비스 (telegram_notifier.py) Telegram Bot API를 httpx로 직접 호출하여 매매 체결, MDD 경고, 일일 성과 리포트, 시스템 경고 등을 전송한다. 설정: - TELEGRAM_BOT_TOKEN: 텔레그램 봇 토큰 (빈 문자열이면 비활성화) - TELEGRAM_CHAT_ID: 알림 수신 채팅 ID (빈 문자열이면 비활성화) 아키텍처 원칙: - 모든 메서드 async, 전송 실패 시 로그만 남기고 매매 로직에 영향 없음 - Rate Limit: 1초에 최대 1건 (Telegram API 제한 준수) - Markdown V2 포맷 사용 """ import asyncio import logging import time from typing import Optional import httpx from app.core.config import settings logger = logging.getLogger(__name__) # Telegram API base URL _TG_API_BASE = "https://api.telegram.org" # Rate limit: 최소 전송 간격 (초) _MIN_SEND_INTERVAL = 1.0 # httpx 클라이언트 싱글톤 (lazy init) _http_client: Optional[httpx.AsyncClient] = None def _is_enabled() -> bool: """텔레그램 알림이 활성화되어 있는지 확인.""" return bool( getattr(settings, "TELEGRAM_BOT_TOKEN", "") and getattr(settings, "TELEGRAM_CHAT_ID", "") ) def _escape_markdown_v2(text: str) -> str: """Telegram MarkdownV2 특수문자 이스케이프. MarkdownV2에서는 다음 문자를 백슬래시로 이스케이프해야 한다: _ * [ ] ( ) ~ ` > # + - = | { } . ! """ special_chars = r"_*[]()~`>#+-=|{}.!" result = [] for ch in text: if ch in special_chars: result.append("\\") result.append(ch) return "".join(result) class TelegramNotifier: """Telegram 알림 전송기 (싱글톤으로 사용).""" def __init__(self): self._last_send_time: float = 0.0 self._lock = asyncio.Lock() self._client: Optional[httpx.AsyncClient] = None def _get_client(self) -> httpx.AsyncClient: """httpx 클라이언트 lazy init.""" if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient(timeout=10.0) return self._client async def close(self) -> None: """httpx 클라이언트 종료 (앱 셧다운 시 호출).""" if self._client and not self._client.is_closed: await self._client.aclose() self._client = None async def _send_message(self, text: str, parse_mode: str = "MarkdownV2") -> bool: """Telegram sendMessage API 호출. Rate limit을 준수하며, 실패 시 False 반환 (예외를 raise하지 않음). """ if not _is_enabled(): return False async with self._lock: # Rate limit: 최소 1초 간격 now = time.monotonic() elapsed = now - self._last_send_time if elapsed < _MIN_SEND_INTERVAL: await asyncio.sleep(_MIN_SEND_INTERVAL - elapsed) try: token = settings.TELEGRAM_BOT_TOKEN chat_id = settings.TELEGRAM_CHAT_ID url = f"{_TG_API_BASE}/bot{token}/sendMessage" client = self._get_client() resp = await client.post( url, json={ "chat_id": chat_id, "text": text, "parse_mode": parse_mode, }, ) self._last_send_time = time.monotonic() if resp.status_code == 200: return True else: logger.warning( "[Telegram] sendMessage 실패: status=%d body=%s", resp.status_code, resp.text[:200], ) return False except Exception as e: logger.warning("[Telegram] 전송 오류: %s: %s", type(e).__name__, e) self._last_send_time = time.monotonic() return False async def send_message(self, text: str, parse_mode: str = "MarkdownV2") -> bool: """Public wrapper for _send_message(). 외부 모듈(us_market_sentiment 등)에서 범용 텍스트 메시지를 전송할 때 사용한다. 내부적으로 _send_message()를 호출한다. """ return await self._send_message(text, parse_mode=parse_mode) # ── 알림 유형별 메서드 ────────────────────────────────────────────────────── async def notify_trade_executed( self, order_type: str, stock_code: str, stock_name: str, quantity: int, price: float, profit_loss: Optional[float] = None, profit_loss_rate: Optional[float] = None, ) -> None: """매매 체결 알림 (매수/매도 성공 시 즉시). Args: order_type: "BUY" 또는 "SELL" stock_code: 종목 코드 stock_name: 종목명 quantity: 체결 수량 price: 체결 가격 profit_loss: 손익 금액 (매도 시) profit_loss_rate: 손익률 % (매도 시) """ if not _is_enabled(): return esc = _escape_markdown_v2 if order_type == "BUY": emoji = "\U0001F7E2" # green circle type_label = esc("매수") else: emoji = "\U0001F534" # red circle type_label = esc("매도") total_amount = price * quantity lines = [ f"{emoji} *{type_label} 체결*", f"", f"\U0001F4C8 {esc(stock_name)} \\({esc(stock_code)}\\)", f"\U0001F4B0 {esc(f'{price:,.0f}')}원 x {esc(str(quantity))}주", f"\U0001F4B5 {esc(f'총 {total_amount:,.0f}')}원", ] if order_type == "SELL" and profit_loss is not None: pl_emoji = "\U0001F4C8" if profit_loss >= 0 else "\U0001F4C9" pl_sign = "+" if profit_loss >= 0 else "" lines.append(f"") lines.append( f"{pl_emoji} {esc('손익')}: {esc(f'{pl_sign}{profit_loss:,.0f}')}원" ) if profit_loss_rate is not None: lines.append( f"\U0001F4CA {esc('수익률')}: {esc(f'{pl_sign}{profit_loss_rate:.2f}%')}" ) text = "\n".join(lines) await self._send_message(text) async def notify_mdd_warning( self, action: str, drawdown: float, reduce_pct: float = 0.0, ) -> None: """MDD 경고 알림 (-10%, -20% 돌파 시 즉시). Args: action: "REDUCE_AND_PAUSE" 또는 "FORCE_REDUCE" drawdown: 현재 MDD % (음수) reduce_pct: 축소 비율 (0.30 또는 0.50) """ if not _is_enabled(): return esc = _escape_markdown_v2 if action == "FORCE_REDUCE": emoji = "\U0001F6A8" # police car light level = esc("CRITICAL") desc = esc(f"포지션 {reduce_pct*100:.0f}% 강제 축소") else: emoji = "\U000026A0" # warning level = esc("WARNING") desc = esc(f"신규 진입 중단 + {reduce_pct*100:.0f}% 축소") lines = [ f"{emoji} *MDD {level}*", f"", f"\U0001F4C9 MDD: {esc(f'{drawdown:.1f}%')}", f"\U0001F6E1 {esc('조치')}: {desc}", ] text = "\n".join(lines) await self._send_message(text) async def notify_daily_report( self, date_str: str, total_pnl: float, trade_count: int, win_count: int, loss_count: int, portfolio_value: float, cash_balance: float, ) -> None: """일일 성과 리포트 (장 마감 후). Args: date_str: 날짜 문자열 (예: "2026-03-24") total_pnl: 당일 총 손익 trade_count: 당일 체결 건수 win_count: 이익 매매 수 loss_count: 손실 매매 수 portfolio_value: 포트폴리오 총 평가액 cash_balance: 현금 잔고 """ if not _is_enabled(): return esc = _escape_markdown_v2 pnl_emoji = "\U0001F4C8" if total_pnl >= 0 else "\U0001F4C9" pnl_sign = "+" if total_pnl >= 0 else "" win_rate = (win_count / trade_count * 100) if trade_count > 0 else 0.0 lines = [ f"\U0001F4CA *{esc('일일 성과 리포트')}*", f"\U0001F4C5 {esc(date_str)}", f"", f"{pnl_emoji} {esc('당일 PnL')}: {esc(f'{pnl_sign}{total_pnl:,.0f}')}원", f"\U0001F3AF {esc('체결')}: {esc(str(trade_count))}건 " f"\\({esc(f'W:{win_count}')} / {esc(f'L:{loss_count}')}\\)", f"\U0001F4AF {esc('승률')}: {esc(f'{win_rate:.1f}%')}", f"", f"\U0001F4B0 {esc('포트폴리오')}: {esc(f'{portfolio_value:,.0f}')}원", f"\U0001F4B5 {esc('현금')}: {esc(f'{cash_balance:,.0f}')}원", ] text = "\n".join(lines) await self._send_message(text) async def notify_system_warning( self, category: str, message: str, ) -> None: """시스템 경고 알림 (db_worker 크래시, 토큰 만료, Redis 연결 실패 등). Args: category: 경고 카테고리 (예: "DB_WORKER", "KIS_TOKEN", "REDIS") message: 경고 메시지 """ if not _is_enabled(): return esc = _escape_markdown_v2 lines = [ f"\U0001F6A8 *{esc('시스템 경고')}*", f"", f"\U0001F3F7 {esc('유형')}: {esc(category)}", f"\U0001F4DD {esc(message)}", ] text = "\n".join(lines) await self._send_message(text) # 싱글톤 인스턴스 telegram_notifier = TelegramNotifier()