trading/shared/slack_notifier.py
Viktor Barzin 6fec9963fb
Some checks failed
ci/woodpecker/push/woodpecker Pipeline was canceled
feat(notify): Slack message for reconcile-booked closes (realized P&L)
Entries, deferrals and rejections posted to #trading-bot, but exits booked by the reconcile loop (bracket stop/take-profit legs that fill at Alpaca) were silent — the two Jun 9 stop-outs produced no message. Viktor asked for a Slack message on each position execution.

- move SlackNotifier to shared/ (now used by trade-executor AND api-gateway)

- add notify_close (ticker/qty/price/realized P&L/reason, win-loss emoji)

- reconcile loop notifies on each booked close, fail-soft; api-gateway config gains slack fields (channel defaults to trading-bot since the env carries only the bot token; chat:write.public covers posting)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:44:35 +00:00

164 lines
5.7 KiB
Python

"""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
from uuid import UUID
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)
async def notify_close(
self,
*,
ticker: str,
qty: float,
price: float,
pnl: float,
strategy_id: UUID | None,
reason: str,
) -> None:
"""Position close booked outside the executor (e.g. a bracket
stop-loss / take-profit leg that filled at the broker)."""
if not self.enabled:
return
tag = "Meet Kevin" if strategy_id == KEVIN_STRATEGY_UUID else "trading-bot"
emoji = ":moneybag:" if pnl >= 0 else ":small_red_triangle_down:"
text = (
f"{emoji} *{tag}*: CLOSED {qty:g} {ticker} @ ${price:.2f}"
f"P&L {'+' if pnl >= 0 else '-'}${abs(pnl):.2f} ({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:
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)