All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
SlackNotifier posts a short message to a Slack incoming webhook on:
- trade-executor submits an order (filled or pending)
- RiskManager rejects a signal (except outside_market_hours, which
spams every poll when the bot tries to trade after-hours)
Key properties:
- No-op when slack_webhook_url is empty (fail-soft default).
- HTTP errors are swallowed — a Slack outage MUST NOT crash the
consumer loop; the trade already happened on Alpaca.
- Kevin-strategy signals tagged "Meet Kevin" in the message so I can
tell which strategy fired.
Wiring:
- TradeExecutorConfig.slack_webhook_url + TRADING_SLACK_WEBHOOK_URL
env var, sourced from Vault secret/trading-bot/slack_webhook_url
via existing ExternalSecret.
- SlackNotifier passed to process_signal; both rejection + post-trade
paths call it.
Tests: 7 new (no-op when disabled, post calls webhook with correct
text, Kevin strategy tag, swallows HTTP errors, suppresses noisy
rejections).
142 lines
5.5 KiB
Python
142 lines
5.5 KiB
Python
"""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 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()
|