"""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)