diff --git a/.woodpecker.yml b/.woodpecker.yml index 4bd4a27..03e4af7 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -33,8 +33,11 @@ steps: build_args: - SERVICE_MODULE=api_gateway cache_from: viktorbarzin/trading-bot-service:latest + # Semver tags: 0.1.${CI_PIPELINE_NUMBER}. Keel policy:patch is bounded + # to patch-level bumps within 0.1.x, no surprise tag rewrites to + # different major/minor. :latest kept for cache + bootstrap. tags: - - "${CI_PIPELINE_NUMBER}" + - "0.1.${CI_PIPELINE_NUMBER}" - latest - name: build-dashboard-image @@ -50,7 +53,7 @@ steps: context: . cache_from: viktorbarzin/trading-bot-dashboard:latest tags: - - "${CI_PIPELINE_NUMBER}" + - "0.1.${CI_PIPELINE_NUMBER}" - latest - name: update-deployment @@ -62,8 +65,8 @@ steps: - apk add --no-cache curl jq - | TOKEN=$$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - SERVICE_IMAGE="viktorbarzin/trading-bot-service:${CI_PIPELINE_NUMBER}" - DASHBOARD_IMAGE="viktorbarzin/trading-bot-dashboard:${CI_PIPELINE_NUMBER}" + SERVICE_IMAGE="viktorbarzin/trading-bot-service:0.1.${CI_PIPELINE_NUMBER}" + DASHBOARD_IMAGE="viktorbarzin/trading-bot-dashboard:0.1.${CI_PIPELINE_NUMBER}" RESTART_AT=$$(date -u +%Y-%m-%dT%H:%M:%SZ) API="https://kubernetes:6443/apis/apps/v1/namespaces/trading-bot/deployments" @@ -115,8 +118,8 @@ steps: - apk add --no-cache curl jq - | TOKEN=$$(cat /var/run/secrets/kubernetes.io/serviceaccount/token) - EXPECTED_SERVICE="viktorbarzin/trading-bot-service:${CI_PIPELINE_NUMBER}" - EXPECTED_DASHBOARD="viktorbarzin/trading-bot-dashboard:${CI_PIPELINE_NUMBER}" + EXPECTED_SERVICE="viktorbarzin/trading-bot-service:0.1.${CI_PIPELINE_NUMBER}" + EXPECTED_DASHBOARD="viktorbarzin/trading-bot-dashboard:0.1.${CI_PIPELINE_NUMBER}" BASE_API="https://kubernetes:6443/api/v1/namespaces/trading-bot/pods" DEPLOY_API="https://kubernetes:6443/apis/apps/v1/namespaces/trading-bot/deployments" diff --git a/services/trade_executor/config.py b/services/trade_executor/config.py index e33f3a2..6e39b27 100644 --- a/services/trade_executor/config.py +++ b/services/trade_executor/config.py @@ -22,7 +22,12 @@ class TradeExecutorConfig(BaseConfig): kevin_equity_drawdown_halt_pct: float = 0.20 # 20% drawdown → permanent pause kevin_daily_loss_circuit_pct: float = 0.05 # 5% daily loss → 24h pause - # Slack webhook for per-trade notifications (empty → notifier no-ops). + # Slack notifications — pick ONE transport: + # 1. Bot token + channel (preferred) → chat.postMessage API + # 2. Webhook URL (legacy) → single-channel webhook + # If both set, bot-token wins. If neither, notifier is a no-op. slack_webhook_url: str = "" + slack_bot_token: str = "" + slack_channel: str = "" model_config = {"env_prefix": "TRADING_"} diff --git a/services/trade_executor/main.py b/services/trade_executor/main.py index 87025ad..209c4ce 100644 --- a/services/trade_executor/main.py +++ b/services/trade_executor/main.py @@ -204,10 +204,19 @@ async def run(config: TradeExecutorConfig | None = None) -> None: # --- Risk manager --- risk_manager = RiskManager(config, broker, redis=redis) - # --- Slack notifier (no-op when slack_webhook_url is empty) --- - slack_notifier = SlackNotifier(webhook_url=config.slack_webhook_url) + # --- Slack notifier (no-op when both transports are empty) --- + slack_notifier = SlackNotifier( + webhook_url=config.slack_webhook_url, + bot_token=config.slack_bot_token, + channel=config.slack_channel, + ) if slack_notifier.enabled: - logger.info("Slack notifications enabled") + transport = "bot-token" if slack_notifier.uses_bot_token else "webhook" + logger.info( + "Slack notifications enabled (%s%s)", + transport, + f", channel=#{slack_notifier.channel}" if slack_notifier.uses_bot_token else "", + ) # --- Database (for persisting trades) --- db_session_factory = None diff --git a/services/trade_executor/slack_notifier.py b/services/trade_executor/slack_notifier.py index 1f7432d..933f639 100644 --- a/services/trade_executor/slack_notifier.py +++ b/services/trade_executor/slack_notifier.py @@ -1,7 +1,14 @@ -"""Slack webhook notifier for trade-executor. +"""Slack notifier for trade-executor. -Posts a short message on each successful order submit and on -notable risk rejections. No-op when the webhook URL is empty. +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 @@ -30,17 +37,30 @@ _DEFAULT_QUIET = frozenset({"outside_market_hours"}) class SlackNotifier: def __init__( self, - webhook_url: str, + 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: - return bool(self.webhook_url) + # 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: @@ -86,9 +106,25 @@ class SlackNotifier: ) async def _post(self, text: str) -> None: - payload = {"text": text} try: async with httpx.AsyncClient(timeout=5.0) as client: - await client.post(self.webhook_url, json=payload) + 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 index 1a1bce4..ec043c1 100644 --- a/tests/services/trade_executor/test_slack_notifier.py +++ b/tests/services/trade_executor/test_slack_notifier.py @@ -108,6 +108,55 @@ class TestSlackNotifierTradePost: 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):