153 lines
4.5 KiB
Python
153 lines
4.5 KiB
Python
|
|
"""Telegram notification bot for trade alerts and daily summaries."""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import asyncio
|
|||
|
|
from typing import Optional
|
|||
|
|
|
|||
|
|
import structlog
|
|||
|
|
|
|||
|
|
from src.config import NotificationConfig
|
|||
|
|
|
|||
|
|
log = structlog.get_logger()
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TelegramNotifier:
|
|||
|
|
"""Sends trading notifications via Telegram Bot API.
|
|||
|
|
|
|||
|
|
Uses aiohttp directly (no python-telegram-bot dependency for async)
|
|||
|
|
for lightweight non-blocking notification delivery.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
BASE_URL = "https://api.telegram.org/bot{token}/sendMessage"
|
|||
|
|
|
|||
|
|
def __init__(self, config: NotificationConfig) -> None:
|
|||
|
|
self.config = config
|
|||
|
|
self._enabled = config.telegram_enabled and bool(config.telegram_token) and bool(config.telegram_chat_id)
|
|||
|
|
self._session = None
|
|||
|
|
|
|||
|
|
if not self._enabled:
|
|||
|
|
log.info("telegram_disabled", reason="missing token or chat_id")
|
|||
|
|
|
|||
|
|
async def _get_session(self):
|
|||
|
|
if self._session is None:
|
|||
|
|
import aiohttp
|
|||
|
|
self._session = aiohttp.ClientSession()
|
|||
|
|
return self._session
|
|||
|
|
|
|||
|
|
async def close(self) -> None:
|
|||
|
|
if self._session:
|
|||
|
|
await self._session.close()
|
|||
|
|
self._session = None
|
|||
|
|
|
|||
|
|
async def send(self, message: str, parse_mode: str = "HTML") -> bool:
|
|||
|
|
"""Send a message to the configured Telegram chat."""
|
|||
|
|
if not self._enabled:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
url = self.BASE_URL.format(token=self.config.telegram_token)
|
|||
|
|
payload = {
|
|||
|
|
"chat_id": self.config.telegram_chat_id,
|
|||
|
|
"text": message,
|
|||
|
|
"parse_mode": parse_mode,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
session = await self._get_session()
|
|||
|
|
async with session.post(url, json=payload, timeout=10) as resp:
|
|||
|
|
if resp.status == 200:
|
|||
|
|
return True
|
|||
|
|
else:
|
|||
|
|
body = await resp.text()
|
|||
|
|
log.warning("telegram_send_failed", status=resp.status, body=body[:200])
|
|||
|
|
return False
|
|||
|
|
except Exception:
|
|||
|
|
log.exception("telegram_send_error")
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# Convenience methods
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
async def notify_trade(
|
|||
|
|
self,
|
|||
|
|
asset: str,
|
|||
|
|
direction: str,
|
|||
|
|
timeframe: str,
|
|||
|
|
price: float,
|
|||
|
|
size: int,
|
|||
|
|
edge: float,
|
|||
|
|
) -> None:
|
|||
|
|
"""Send a trade notification."""
|
|||
|
|
if not self.config.notify_on_trade:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
msg = (
|
|||
|
|
f"🔔 <b>Trade Signal</b>\n"
|
|||
|
|
f"Asset: <b>{asset}</b> | {direction}\n"
|
|||
|
|
f"Timeframe: {timeframe}\n"
|
|||
|
|
f"Price: {price:.2f} | Size: {size}\n"
|
|||
|
|
f"Edge: {edge:.2%}"
|
|||
|
|
)
|
|||
|
|
await self.send(msg)
|
|||
|
|
|
|||
|
|
async def notify_fill(
|
|||
|
|
self,
|
|||
|
|
asset: str,
|
|||
|
|
direction: str,
|
|||
|
|
fill_price: float,
|
|||
|
|
fill_size: int,
|
|||
|
|
trade_id: str,
|
|||
|
|
) -> None:
|
|||
|
|
"""Send a fill notification."""
|
|||
|
|
if not self.config.notify_on_trade:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
msg = (
|
|||
|
|
f"✅ <b>Order Filled</b>\n"
|
|||
|
|
f"Asset: {asset} | {direction}\n"
|
|||
|
|
f"Fill: {fill_price:.2f} × {fill_size}\n"
|
|||
|
|
f"Trade ID: <code>{trade_id}</code>"
|
|||
|
|
)
|
|||
|
|
await self.send(msg)
|
|||
|
|
|
|||
|
|
async def notify_daily_summary(
|
|||
|
|
self,
|
|||
|
|
date: str,
|
|||
|
|
total_trades: int,
|
|||
|
|
wins: int,
|
|||
|
|
losses: int,
|
|||
|
|
pnl: float,
|
|||
|
|
fees: float,
|
|||
|
|
volume: float,
|
|||
|
|
) -> None:
|
|||
|
|
"""Send daily summary."""
|
|||
|
|
if not self.config.notify_on_daily_summary:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
win_rate = wins / total_trades * 100 if total_trades > 0 else 0
|
|||
|
|
emoji = "📈" if pnl >= 0 else "📉"
|
|||
|
|
|
|||
|
|
msg = (
|
|||
|
|
f"{emoji} <b>Daily Summary — {date}</b>\n"
|
|||
|
|
f"Trades: {total_trades} (W:{wins} / L:{losses})\n"
|
|||
|
|
f"Win Rate: {win_rate:.1f}%\n"
|
|||
|
|
f"PnL: <b>${pnl:+.2f}</b>\n"
|
|||
|
|
f"Fees: ${fees:.2f}\n"
|
|||
|
|
f"Volume: ${volume:,.0f}"
|
|||
|
|
)
|
|||
|
|
await self.send(msg)
|
|||
|
|
|
|||
|
|
async def notify_error(self, error_msg: str) -> None:
|
|||
|
|
"""Send an error alert."""
|
|||
|
|
if not self.config.notify_on_error:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
msg = f"🚨 <b>Error Alert</b>\n<code>{error_msg[:500]}</code>"
|
|||
|
|
await self.send(msg)
|
|||
|
|
|
|||
|
|
async def notify_halt(self, reason: str) -> None:
|
|||
|
|
"""Send a trading halt alert."""
|
|||
|
|
msg = f"🛑 <b>TRADING HALTED</b>\nReason: {reason}"
|
|||
|
|
await self.send(msg)
|