diff --git a/services/trade_executor/slack_notifier.py b/services/trade_executor/slack_notifier.py deleted file mode 100644 index ce82bd3..0000000 --- a/services/trade_executor/slack_notifier.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Slack notifier for trade-executor. - -Supports two transports, picked by what's configured: - 1. **Bot token + channel** (preferred) — uses chat.postMessage. Channel - can be changed via env var without redeploying the Slack app or - rotating webhook URLs. - 2. **Webhook URL** (legacy) — single-channel, pinned at webhook - creation time. - -If both are set, the bot-token path wins. If neither, the notifier -is a no-op. - -Designed to fail-soft: a Slack outage MUST NOT bubble up and crash -the consumer loop. The trade has already happened on Alpaca — Slack -is a downstream observer, not a transactional dependency. -""" - -from __future__ import annotations - -import logging -from typing import Iterable - -import httpx - -from shared.constants.kevin import KEVIN_STRATEGY_UUID -from shared.schemas.trading import OrderResult, TradeSignal - -logger = logging.getLogger(__name__) - - -# Reasons we DON'T want to spam Slack about. outside_market_hours fires -# every poll when a fresh signal lands after-hours — silencing it keeps -# Slack signal-to-noise high. -_DEFAULT_QUIET = frozenset({"outside_market_hours"}) - - -class SlackNotifier: - def __init__( - self, - webhook_url: str = "", - bot_token: str = "", - channel: str = "", - quiet_rejections: Iterable[str] | None = None, - ) -> None: - self.webhook_url = webhook_url or "" - self.bot_token = bot_token or "" - self.channel = channel or "" - self.quiet_rejections = frozenset( - quiet_rejections if quiet_rejections is not None else _DEFAULT_QUIET - ) - - @property - def enabled(self) -> bool: - # Either transport must be fully configured. - if self.bot_token and self.channel: - return True - if self.webhook_url: - return True - return False - - @property - def uses_bot_token(self) -> bool: - return bool(self.bot_token and self.channel) - - async def notify_trade(self, signal: TradeSignal, result: OrderResult) -> None: - if not self.enabled: - return - text = self._format_trade(signal, result) - await self._post(text) - - async def notify_rejection(self, signal: TradeSignal, reason: str) -> None: - if not self.enabled: - return - if reason in self.quiet_rejections: - return - text = self._format_rejection(signal, reason) - await self._post(text) - - async def notify_deferred(self, signal: TradeSignal, target_ts) -> None: - if not self.enabled: - return - tag = self._strategy_tag(signal) - when = target_ts.strftime("%a %H:%M UTC") if target_ts else "?" - text = ( - f":clock3: *{tag}*: DEFERRED {signal.ticker} until {when} " - f"(market closed; conviction {signal.strength:.2f})" - ) - await self._post(text) - - # ------------------------------------------------------------------ - # Internal - # ------------------------------------------------------------------ - - def _strategy_tag(self, signal: TradeSignal) -> str: - if signal.strategy_id == KEVIN_STRATEGY_UUID: - return "Meet Kevin" - return "trading-bot" - - def _format_trade(self, signal: TradeSignal, result: OrderResult) -> str: - tag = self._strategy_tag(signal) - price = ( - f"${result.filled_price:.2f}" - if result.filled_price is not None - else "—" - ) - return ( - f":chart_with_upwards_trend: *{tag}*: " - f"{result.side.value} {result.qty:g} {result.ticker} @ {price} " - f"(conviction {signal.strength:.2f}, status {result.status.value})" - ) - - def _format_rejection(self, signal: TradeSignal, reason: str) -> str: - tag = self._strategy_tag(signal) - return ( - f":no_entry: *{tag}*: REJECTED {signal.ticker} — {reason} " - f"(conviction {signal.strength:.2f})" - ) - - async def _post(self, text: str) -> None: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - if self.uses_bot_token: - resp = await client.post( - "https://slack.com/api/chat.postMessage", - headers={ - "Authorization": f"Bearer {self.bot_token}", - "Content-Type": "application/json; charset=utf-8", - }, - json={"channel": self.channel, "text": text}, - ) - body = resp.json() - if not body.get("ok"): - logger.warning( - "Slack chat.postMessage refused: %s (channel=%s)", - body.get("error"), - self.channel, - ) - else: - await client.post(self.webhook_url, json={"text": text}) - except Exception as exc: - logger.warning("Slack post failed (swallowed): %s", exc) diff --git a/tests/services/trade_executor/test_slack_notifier.py b/tests/services/trade_executor/test_slack_notifier.py deleted file mode 100644 index ec043c1..0000000 --- a/tests/services/trade_executor/test_slack_notifier.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for the SlackNotifier used by trade-executor.""" - -from __future__ import annotations - -from datetime import datetime, timezone -from decimal import Decimal -from unittest.mock import AsyncMock, patch -from uuid import UUID, uuid4 - -import pytest - -from services.trade_executor.slack_notifier import SlackNotifier -from shared.constants.kevin import KEVIN_STRATEGY_UUID -from shared.schemas.trading import ( - OrderResult, - OrderSide, - OrderStatus, - SignalDirection, - TradeSignal, -) - - -def _signal(strategy_id=None, target_dollars=None) -> TradeSignal: - return TradeSignal( - ticker="NVDA", - direction=SignalDirection.LONG, - strength=0.85, - strategy_sources=["meet_kevin:buy:0.85"], - timestamp=datetime.now(timezone.utc), - strategy_id=strategy_id, - target_dollars=target_dollars, - ) - - -def _filled_order(ticker="NVDA", qty=10, price=217.50) -> OrderResult: - return OrderResult( - order_id=str(uuid4()), - ticker=ticker, - side=OrderSide.BUY, - qty=qty, - filled_price=price, - status=OrderStatus.FILLED, - timestamp=datetime.now(timezone.utc), - ) - - -class TestSlackNotifierNoWebhook: - """Empty webhook_url -> notifier is a no-op (returns without raising).""" - - @pytest.mark.asyncio - async def test_notify_trade_noop(self): - notifier = SlackNotifier(webhook_url="") - # should not raise even with no mock - await notifier.notify_trade(_signal(), _filled_order()) - - @pytest.mark.asyncio - async def test_notify_rejection_noop(self): - notifier = SlackNotifier(webhook_url="") - await notifier.notify_rejection(_signal(), "outside_market_hours") - - -class TestSlackNotifierTradePost: - @pytest.mark.asyncio - async def test_trade_post_calls_webhook(self): - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_post = AsyncMock(return_value=AsyncMock(status_code=200)) - mock_client.post = mock_post - mock_client_cls.return_value.__aenter__.return_value = mock_client - await notifier.notify_trade( - _signal(strategy_id=KEVIN_STRATEGY_UUID), - _filled_order(qty=10, price=217.50), - ) - mock_post.assert_called_once() - url, kwargs = mock_post.call_args.args[0], mock_post.call_args.kwargs - assert url == "https://hooks.slack.test/abc" - payload = kwargs["json"] - assert "NVDA" in payload["text"] - assert "10" in payload["text"] - assert "217.50" in payload["text"] - - @pytest.mark.asyncio - async def test_trade_post_strategy_tag_when_kevin(self): - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=AsyncMock(status_code=200)) - mock_client_cls.return_value.__aenter__.return_value = mock_client - await notifier.notify_trade( - _signal(strategy_id=KEVIN_STRATEGY_UUID), - _filled_order(), - ) - payload = mock_client.post.call_args.kwargs["json"] - assert "Meet Kevin" in payload["text"] - - @pytest.mark.asyncio - async def test_trade_post_swallows_http_errors(self): - """A failed Slack post must NOT bubble up — the trade already - happened; we shouldn't crash the consumer loop because Slack is - having a bad day.""" - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(side_effect=Exception("network down")) - mock_client_cls.return_value.__aenter__.return_value = mock_client - # should NOT raise - await notifier.notify_trade(_signal(), _filled_order()) - - -class TestSlackNotifierBotToken: - @pytest.mark.asyncio - async def test_bot_token_calls_chat_postmessage(self): - notifier = SlackNotifier(bot_token="xoxb-test", channel="trading-bot") - assert notifier.uses_bot_token - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_resp = AsyncMock() - mock_resp.json = lambda: {"ok": True, "ts": "1.2"} - mock_client.post = AsyncMock(return_value=mock_resp) - mock_client_cls.return_value.__aenter__.return_value = mock_client - await notifier.notify_trade(_signal(), _filled_order()) - url = mock_client.post.call_args.args[0] - assert url == "https://slack.com/api/chat.postMessage" - kwargs = mock_client.post.call_args.kwargs - assert kwargs["headers"]["Authorization"] == "Bearer xoxb-test" - body = kwargs["json"] - assert body["channel"] == "trading-bot" - assert "NVDA" in body["text"] - - @pytest.mark.asyncio - async def test_bot_token_swallows_channel_not_found(self): - """When the user hasn't created #trading-bot yet, the API returns - ok=false / error=channel_not_found. We log and continue.""" - notifier = SlackNotifier(bot_token="xoxb-test", channel="nonexistent") - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_resp = AsyncMock() - mock_resp.json = lambda: {"ok": False, "error": "channel_not_found"} - mock_client.post = AsyncMock(return_value=mock_resp) - mock_client_cls.return_value.__aenter__.return_value = mock_client - # should not raise - await notifier.notify_trade(_signal(), _filled_order()) - - @pytest.mark.asyncio - async def test_bot_token_wins_when_both_set(self): - notifier = SlackNotifier( - webhook_url="https://hooks.slack.test/abc", - bot_token="xoxb-test", - channel="trading-bot", - ) - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=AsyncMock(json=lambda: {"ok": True})) - mock_client_cls.return_value.__aenter__.return_value = mock_client - await notifier.notify_trade(_signal(), _filled_order()) - assert mock_client.post.call_args.args[0] == "https://slack.com/api/chat.postMessage" - - -class TestSlackNotifierRejectionPost: - @pytest.mark.asyncio - async def test_rejection_post_calls_webhook(self): - notifier = SlackNotifier(webhook_url="https://hooks.slack.test/abc") - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock(return_value=AsyncMock(status_code=200)) - mock_client_cls.return_value.__aenter__.return_value = mock_client - await notifier.notify_rejection( - _signal(strategy_id=KEVIN_STRATEGY_UUID), - reason="kevin_daily_trade_cap", - ) - payload = mock_client.post.call_args.kwargs["json"] - assert "REJECTED" in payload["text"] - assert "kevin_daily_trade_cap" in payload["text"] - assert "NVDA" in payload["text"] - - @pytest.mark.asyncio - async def test_rejection_post_skips_noise(self): - """outside_market_hours fires every minute when the bot tries to - trade after-hours — we don't want a Slack barrage. The notifier - skips it.""" - notifier = SlackNotifier( - webhook_url="https://hooks.slack.test/abc", - quiet_rejections={"outside_market_hours"}, - ) - with patch("httpx.AsyncClient") as mock_client_cls: - mock_client = AsyncMock() - mock_client.post = AsyncMock() - mock_client_cls.return_value.__aenter__.return_value = mock_client - await notifier.notify_rejection(_signal(), "outside_market_hours") - mock_client.post.assert_not_called()