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>
This commit is contained in:
302
backend/app/services/telegram_notifier.py
Normal file
302
backend/app/services/telegram_notifier.py
Normal file
@@ -0,0 +1,302 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user