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).
94 lines
3.1 KiB
Python
94 lines
3.1 KiB
Python
"""Slack webhook 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.
|
|
|
|
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,
|
|
quiet_rejections: Iterable[str] | None = None,
|
|
) -> None:
|
|
self.webhook_url = webhook_url 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)
|
|
|
|
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)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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:
|
|
payload = {"text": text}
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
await client.post(self.webhook_url, json=payload)
|
|
except Exception as exc:
|
|
logger.warning("Slack post failed (swallowed): %s", exc)
|