summaryrefslogtreecommitdiff
path: root/shared
diff options
context:
space:
mode:
authorTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 16:07:05 +0900
committerTheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com>2026-04-01 16:07:05 +0900
commite1bf24b2a639ba21361ae408ee6c1eebe37801aa (patch)
tree83127c396a78a70a3cd6317e11b140b3de5ed141 /shared
parent558de36fdd886a117c66dee73850cc219c86b2a4 (diff)
feat(shared): add Telegram notification service
Diffstat (limited to 'shared')
-rw-r--r--shared/src/shared/config.py7
-rw-r--r--shared/src/shared/notifier.py134
-rw-r--r--shared/tests/test_notifier.py177
3 files changed, 318 insertions, 0 deletions
diff --git a/shared/src/shared/config.py b/shared/src/shared/config.py
index 1304c5e..511654f 100644
--- a/shared/src/shared/config.py
+++ b/shared/src/shared/config.py
@@ -12,5 +12,12 @@ class Settings(BaseSettings):
risk_stop_loss_pct: float = 5.0
risk_daily_loss_limit_pct: float = 10.0
dry_run: bool = True
+ telegram_bot_token: str = ""
+ telegram_chat_id: str = ""
+ telegram_enabled: bool = False
+ log_format: str = "json"
+ health_port: int = 8080
+ circuit_breaker_threshold: int = 5
+ circuit_breaker_timeout: int = 60
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
diff --git a/shared/src/shared/notifier.py b/shared/src/shared/notifier.py
new file mode 100644
index 0000000..de86f87
--- /dev/null
+++ b/shared/src/shared/notifier.py
@@ -0,0 +1,134 @@
+"""Telegram notification service for the trading platform."""
+import asyncio
+import logging
+from decimal import Decimal
+from typing import Optional, Sequence
+
+import aiohttp
+
+from shared.models import Signal, Order, Position
+
+logger = logging.getLogger(__name__)
+
+TELEGRAM_API_URL = "https://api.telegram.org/bot{token}/sendMessage"
+MAX_RETRIES = 3
+
+
+class TelegramNotifier:
+ """Sends notifications via Telegram Bot API."""
+
+ def __init__(self, bot_token: str, chat_id: str) -> None:
+ self._bot_token = bot_token
+ self._chat_id = chat_id
+ self._semaphore = asyncio.Semaphore(1)
+ self._session: Optional[aiohttp.ClientSession] = None
+
+ @property
+ def enabled(self) -> bool:
+ """Return True if a bot token has been configured."""
+ return bool(self._bot_token)
+
+ def _get_session(self) -> aiohttp.ClientSession:
+ if self._session is None or self._session.closed:
+ self._session = aiohttp.ClientSession()
+ return self._session
+
+ async def send(self, message: str, parse_mode: str = "HTML") -> None:
+ """Send a message via Telegram with retries and rate limiting.
+
+ Does nothing if the notifier is not enabled.
+ """
+ if not self.enabled:
+ return
+
+ url = TELEGRAM_API_URL.format(token=self._bot_token)
+ payload = {
+ "chat_id": self._chat_id,
+ "text": message,
+ "parse_mode": parse_mode,
+ }
+
+ session = self._session or self._get_session()
+ async with self._semaphore:
+ for attempt in range(1, MAX_RETRIES + 1):
+ try:
+ async with session.post(url, json=payload) as resp:
+ if resp.status == 200:
+ return
+ body = await resp.json()
+ logger.warning(
+ "Telegram API error (attempt %d/%d): %s",
+ attempt,
+ MAX_RETRIES,
+ body,
+ )
+ except Exception:
+ logger.exception(
+ "Telegram send failed (attempt %d/%d)", attempt, MAX_RETRIES
+ )
+ if attempt < MAX_RETRIES:
+ await asyncio.sleep(attempt)
+
+ async def send_signal(self, signal: Signal) -> None:
+ """Format and send a trading signal notification."""
+ msg = (
+ "<b>📊 Signal</b>\n"
+ f"Side: <b>{signal.side.value}</b>\n"
+ f"Strategy: {signal.strategy}\n"
+ f"Symbol: {signal.symbol}\n"
+ f"Price: {signal.price}\n"
+ f"Quantity: {signal.quantity}\n"
+ f"Reason: {signal.reason}"
+ )
+ await self.send(msg)
+
+ async def send_order(self, order: Order) -> None:
+ """Format and send an order notification."""
+ msg = (
+ "<b>📝 Order</b>\n"
+ f"Status: <b>{order.status.value}</b>\n"
+ f"Symbol: {order.symbol}\n"
+ f"Side: {order.side.value}\n"
+ f"Price: {order.price}\n"
+ f"Quantity: {order.quantity}"
+ )
+ await self.send(msg)
+
+ async def send_error(self, error: str, service: str) -> None:
+ """Format and send an error alert."""
+ msg = (
+ "<b>🚨 Error Alert</b>\n"
+ f"Service: <b>{service}</b>\n"
+ f"Error: {error}"
+ )
+ await self.send(msg)
+
+ async def send_daily_summary(
+ self,
+ positions: Sequence[Position],
+ total_value: Decimal,
+ daily_pnl: Decimal,
+ ) -> None:
+ """Format and send a daily portfolio summary."""
+ lines = [
+ "<b>📈 Daily Summary</b>",
+ f"Total Value: {total_value}",
+ f"Daily P&amp;L: {daily_pnl}",
+ "",
+ "<b>Positions:</b>",
+ ]
+ for pos in positions:
+ lines.append(
+ f" {pos.symbol}: qty={pos.quantity} "
+ f"entry={pos.avg_entry_price} "
+ f"current={pos.current_price} "
+ f"pnl={pos.unrealized_pnl}"
+ )
+ if not positions:
+ lines.append(" No open positions")
+ await self.send("\n".join(lines))
+
+ async def close(self) -> None:
+ """Close the underlying aiohttp session."""
+ if self._session is not None:
+ await self._session.close()
diff --git a/shared/tests/test_notifier.py b/shared/tests/test_notifier.py
new file mode 100644
index 0000000..09e731a
--- /dev/null
+++ b/shared/tests/test_notifier.py
@@ -0,0 +1,177 @@
+"""Tests for Telegram notification service."""
+import os
+import uuid
+from decimal import Decimal
+from datetime import datetime, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+import pytest_asyncio
+
+from shared.models import Signal, Order, OrderSide, OrderType, OrderStatus, Position
+from shared.notifier import TelegramNotifier
+
+
+class TestTelegramNotifierEnabled:
+ """Test the enabled property."""
+
+ def test_disabled_when_no_token(self):
+ notifier = TelegramNotifier(bot_token="", chat_id="123")
+ assert notifier.enabled is False
+
+ def test_enabled_with_token(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
+ assert notifier.enabled is True
+
+ def test_disabled_when_token_is_empty_string(self):
+ notifier = TelegramNotifier(bot_token="", chat_id="")
+ assert notifier.enabled is False
+
+
+class TestTelegramNotifierSend:
+ """Test send method."""
+
+ @pytest.mark.asyncio
+ async def test_send_does_nothing_when_disabled(self):
+ notifier = TelegramNotifier(bot_token="", chat_id="123")
+ # Should not raise or do anything
+ await notifier.send("test message")
+
+ @pytest.mark.asyncio
+ async def test_send_posts_to_api(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="12345")
+
+ mock_response = AsyncMock()
+ mock_response.status = 200
+ mock_response.json = AsyncMock(return_value={"ok": True})
+ mock_response.__aenter__ = AsyncMock(return_value=mock_response)
+ mock_response.__aexit__ = AsyncMock(return_value=False)
+
+ mock_session = AsyncMock()
+ mock_session.post = MagicMock(return_value=mock_response)
+
+ with patch.object(notifier, "_session", mock_session):
+ await notifier.send("Hello, world!")
+
+ mock_session.post.assert_called_once()
+ call_args = mock_session.post.call_args
+ assert "fake-token" in call_args[0][0]
+ assert call_args[1]["json"]["chat_id"] == "12345"
+ assert call_args[1]["json"]["text"] == "Hello, world!"
+ assert call_args[1]["json"]["parse_mode"] == "HTML"
+
+ @pytest.mark.asyncio
+ async def test_send_with_custom_parse_mode(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="12345")
+
+ mock_response = AsyncMock()
+ mock_response.status = 200
+ mock_response.json = AsyncMock(return_value={"ok": True})
+ mock_response.__aenter__ = AsyncMock(return_value=mock_response)
+ mock_response.__aexit__ = AsyncMock(return_value=False)
+
+ mock_session = AsyncMock()
+ mock_session.post = MagicMock(return_value=mock_response)
+
+ with patch.object(notifier, "_session", mock_session):
+ await notifier.send("test", parse_mode="Markdown")
+
+ call_args = mock_session.post.call_args
+ assert call_args[1]["json"]["parse_mode"] == "Markdown"
+
+
+class TestTelegramNotifierFormatters:
+ """Test message formatting methods."""
+
+ @pytest.mark.asyncio
+ async def test_send_signal_formats_message(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
+ signal = Signal(
+ strategy="rsi_strategy",
+ symbol="BTCUSDT",
+ side=OrderSide.BUY,
+ price=Decimal("50000.00"),
+ quantity=Decimal("0.01"),
+ reason="RSI oversold",
+ )
+
+ with patch.object(notifier, "send", new_callable=AsyncMock) as mock_send:
+ await notifier.send_signal(signal)
+ mock_send.assert_called_once()
+ msg = mock_send.call_args[0][0]
+ assert "BUY" in msg
+ assert "rsi_strategy" in msg
+ assert "BTCUSDT" in msg
+ assert "50000.00" in msg
+ assert "0.01" in msg
+ assert "RSI oversold" in msg
+
+ @pytest.mark.asyncio
+ async def test_send_order_formats_message(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
+ order = Order(
+ signal_id=str(uuid.uuid4()),
+ symbol="ETHUSDT",
+ side=OrderSide.SELL,
+ type=OrderType.LIMIT,
+ price=Decimal("3000.50"),
+ quantity=Decimal("1.5"),
+ status=OrderStatus.FILLED,
+ )
+
+ with patch.object(notifier, "send", new_callable=AsyncMock) as mock_send:
+ await notifier.send_order(order)
+ mock_send.assert_called_once()
+ msg = mock_send.call_args[0][0]
+ assert "FILLED" in msg
+ assert "ETHUSDT" in msg
+ assert "SELL" in msg
+ assert "3000.50" in msg
+ assert "1.5" in msg
+
+ @pytest.mark.asyncio
+ async def test_send_error_formats_message(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
+
+ with patch.object(notifier, "send", new_callable=AsyncMock) as mock_send:
+ await notifier.send_error("Connection failed", service="executor")
+ mock_send.assert_called_once()
+ msg = mock_send.call_args[0][0]
+ assert "Connection failed" in msg
+ assert "executor" in msg
+
+ @pytest.mark.asyncio
+ async def test_send_daily_summary_formats_message(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
+ positions = [
+ Position(
+ symbol="BTCUSDT",
+ quantity=Decimal("0.1"),
+ avg_entry_price=Decimal("50000"),
+ current_price=Decimal("51000"),
+ ),
+ ]
+
+ with patch.object(notifier, "send", new_callable=AsyncMock) as mock_send:
+ await notifier.send_daily_summary(
+ positions=positions,
+ total_value=Decimal("5100.00"),
+ daily_pnl=Decimal("100.00"),
+ )
+ mock_send.assert_called_once()
+ msg = mock_send.call_args[0][0]
+ assert "BTCUSDT" in msg
+ assert "5100.00" in msg
+ assert "100.00" in msg
+
+
+class TestTelegramNotifierClose:
+ """Test close method."""
+
+ @pytest.mark.asyncio
+ async def test_close_closes_session(self):
+ notifier = TelegramNotifier(bot_token="fake-token", chat_id="123")
+ mock_session = AsyncMock()
+ notifier._session = mock_session
+ await notifier.close()
+ mock_session.close.assert_called_once()