Files
auto-stock/backend/app/services/telegram_notifier.py
shuim de7921d270 fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건)
- day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가
- trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산
- risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수
- risk_management/recommendations: date.today() → KST 변환
- mdd_manager: Lock 밖 executed_key 참조 수정
- correlation_filter: output → output2 키 수정
- portfolio: 해외 종목 USD/KRW 환율 적용 분리
- Sharpe Ratio 거래빈도 기반 연간화
- telegram_notifier: send_message() 공개 래퍼 추가
- position_manager: 앱 재시작 시 DB 포지션 복원
- swing exit_service: remaining_pct 조건 범위 수정
- run_backtest: original_qty 기반 remaining_pct 계산
- us_market_schedule: 2027년 미국 공휴일 추가
- Login.tsx: res.ok 검증 추가
- mean_reversion_strategy: RSI 로그 하드코딩 수정

## HIGH 수정 (10건)
- db_worker: DLQ 파일 백업 메커니즘 추가
- korea_investment_api: 만료 토큰 임의 연장 제거
- market_data: chart_cache 최대 100개 + TTL 만료
- main.py: rate_limit_store 1시간 미접근 IP 정리
- recommendations: fire-and-forget try/except 추가
- swing/scheduler: DB 세션 3개 분리
- Dashboard: wsMessages 전체 순회
- Layout: /health 엔드포인트 + 30초 폴링
- ml_ensemble: POST /train require_api_key 추가
- day_trading: record_trade_result_safe() 전환

## 코드 정리
- 프론트엔드: isUsStock, formatTime 공통 유틸 추출
- 백엔드: dead code 제거, 중복 import 통합
- MCP: JSON 파싱 헬퍼 추출 (~60줄 감소)
- 전체 ~100개 파일 한국어 주석 추가
- 테스트 13건 코드 기준 수정 (468 passed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:05:41 +09:00

303 lines
9.9 KiB
Python

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