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."""