## 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>
303 lines
9.9 KiB
Python
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()
|