feat: add Telegram playbook notifications (issue #81)
Some checks failed
CI / test (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
- notify_playbook_generated(): market, stock/scenario count, token usage (MEDIUM) - notify_scenario_matched(): stock, action, condition, confidence (HIGH) - notify_playbook_failed(): market, reason with 200-char truncation (HIGH) - 6 new tests: 3 format + 3 priority validations Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -304,6 +304,77 @@ class TelegramClient:
|
|||||||
NotificationMessage(priority=NotificationPriority.MEDIUM, message=message)
|
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"<b>Playbook Generated</b>\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"<b>Scenario Matched</b>\n"
|
||||||
|
f"Symbol: <code>{stock_code}</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"<b>Playbook Failed</b>\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:
|
async def notify_system_shutdown(self, reason: str) -> None:
|
||||||
"""
|
"""
|
||||||
Notify system shutdown.
|
Notify system shutdown.
|
||||||
|
|||||||
@@ -160,6 +160,83 @@ class TestNotificationSending:
|
|||||||
assert "250.50" in payload["text"]
|
assert "250.50" in payload["text"]
|
||||||
assert "92%" 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
|
@pytest.mark.asyncio
|
||||||
async def test_circuit_breaker_priority(self) -> None:
|
async def test_circuit_breaker_priority(self) -> None:
|
||||||
"""Circuit breaker uses CRITICAL priority."""
|
"""Circuit breaker uses CRITICAL priority."""
|
||||||
@@ -309,6 +386,73 @@ class TestMessagePriorities:
|
|||||||
payload = mock_post.call_args.kwargs["json"]
|
payload = mock_post.call_args.kwargs["json"]
|
||||||
assert NotificationPriority.CRITICAL.emoji in payload["text"]
|
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:
|
class TestClientCleanup:
|
||||||
"""Test client cleanup behavior."""
|
"""Test client cleanup behavior."""
|
||||||
|
|||||||
Reference in New Issue
Block a user