diff --git a/src/notifications/telegram_client.py b/src/notifications/telegram_client.py index 3482646..70c6dfc 100644 --- a/src/notifications/telegram_client.py +++ b/src/notifications/telegram_client.py @@ -304,6 +304,77 @@ class TelegramClient: NotificationMessage(priority=NotificationPriority.MEDIUM, message=message) ) + async def notify_playbook_generated( + self, + market: str, + stock_count: int, + scenario_count: int, + token_count: int, + ) -> None: + """ + Notify that a daily playbook was generated. + + Args: + market: Market code (e.g., "KR", "US") + stock_count: Number of stocks in the playbook + scenario_count: Total number of scenarios + token_count: Gemini token usage for the playbook + """ + message = ( + f"Playbook Generated\n" + f"Market: {market}\n" + f"Stocks: {stock_count}\n" + f"Scenarios: {scenario_count}\n" + f"Tokens: {token_count}" + ) + await self._send_notification( + NotificationMessage(priority=NotificationPriority.MEDIUM, message=message) + ) + + async def notify_scenario_matched( + self, + stock_code: str, + action: str, + condition_summary: str, + confidence: float, + ) -> None: + """ + Notify that a scenario matched for a stock. + + Args: + stock_code: Stock ticker symbol + action: Scenario action (BUY/SELL/HOLD/REDUCE_ALL) + condition_summary: Short summary of the matched condition + confidence: Scenario confidence (0-100) + """ + message = ( + f"Scenario Matched\n" + f"Symbol: {stock_code}\n" + f"Action: {action}\n" + f"Condition: {condition_summary}\n" + f"Confidence: {confidence:.0f}%" + ) + await self._send_notification( + NotificationMessage(priority=NotificationPriority.HIGH, message=message) + ) + + async def notify_playbook_failed(self, market: str, reason: str) -> None: + """ + Notify that playbook generation failed. + + Args: + market: Market code (e.g., "KR", "US") + reason: Failure reason summary + """ + message = ( + f"Playbook Failed\n" + f"Market: {market}\n" + f"Reason: {reason[:200]}" + ) + await self._send_notification( + NotificationMessage(priority=NotificationPriority.HIGH, message=message) + ) + async def notify_system_shutdown(self, reason: str) -> None: """ Notify system shutdown. diff --git a/tests/test_telegram.py b/tests/test_telegram.py index b0872fe..4aae621 100644 --- a/tests/test_telegram.py +++ b/tests/test_telegram.py @@ -160,6 +160,83 @@ class TestNotificationSending: assert "250.50" in payload["text"] assert "92%" in payload["text"] + @pytest.mark.asyncio + async def test_playbook_generated_format(self) -> None: + """Playbook generated notification has expected fields.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + await client.notify_playbook_generated( + market="KR", + stock_count=4, + scenario_count=12, + token_count=980, + ) + + payload = mock_post.call_args.kwargs["json"] + assert "Playbook Generated" in payload["text"] + assert "Market: KR" in payload["text"] + assert "Stocks: 4" in payload["text"] + assert "Scenarios: 12" in payload["text"] + assert "Tokens: 980" in payload["text"] + + @pytest.mark.asyncio + async def test_scenario_matched_format(self) -> None: + """Scenario matched notification has expected fields.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + await client.notify_scenario_matched( + stock_code="AAPL", + action="BUY", + condition_summary="RSI < 30, volume_ratio > 2.0", + confidence=88.2, + ) + + payload = mock_post.call_args.kwargs["json"] + assert "Scenario Matched" in payload["text"] + assert "AAPL" in payload["text"] + assert "Action: BUY" in payload["text"] + assert "RSI < 30" in payload["text"] + assert "88%" in payload["text"] + + @pytest.mark.asyncio + async def test_playbook_failed_format(self) -> None: + """Playbook failed notification has expected fields.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + await client.notify_playbook_failed( + market="US", + reason="Gemini timeout", + ) + + payload = mock_post.call_args.kwargs["json"] + assert "Playbook Failed" in payload["text"] + assert "Market: US" in payload["text"] + assert "Gemini timeout" in payload["text"] + @pytest.mark.asyncio async def test_circuit_breaker_priority(self) -> None: """Circuit breaker uses CRITICAL priority.""" @@ -309,6 +386,73 @@ class TestMessagePriorities: payload = mock_post.call_args.kwargs["json"] assert NotificationPriority.CRITICAL.emoji in payload["text"] + @pytest.mark.asyncio + async def test_playbook_generated_priority(self) -> None: + """Playbook generated uses MEDIUM priority emoji.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + await client.notify_playbook_generated( + market="KR", + stock_count=2, + scenario_count=4, + token_count=123, + ) + + payload = mock_post.call_args.kwargs["json"] + assert NotificationPriority.MEDIUM.emoji in payload["text"] + + @pytest.mark.asyncio + async def test_playbook_failed_priority(self) -> None: + """Playbook failed uses HIGH priority emoji.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + await client.notify_playbook_failed( + market="KR", + reason="Invalid JSON", + ) + + payload = mock_post.call_args.kwargs["json"] + assert NotificationPriority.HIGH.emoji in payload["text"] + + @pytest.mark.asyncio + async def test_scenario_matched_priority(self) -> None: + """Scenario matched uses HIGH priority emoji.""" + client = TelegramClient( + bot_token="123:abc", chat_id="456", enabled=True + ) + + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=False) + + with patch("aiohttp.ClientSession.post", return_value=mock_resp) as mock_post: + await client.notify_scenario_matched( + stock_code="AAPL", + action="BUY", + condition_summary="RSI < 30", + confidence=80.0, + ) + + payload = mock_post.call_args.kwargs["json"] + assert NotificationPriority.HIGH.emoji in payload["text"] + class TestClientCleanup: """Test client cleanup behavior."""