"""Optionale Benachrichtigungen über einen generischen Webhook (Slack/Discord-kompatibel).""" from __future__ import annotations import asyncio import logging import aiohttp from .config import NotificationConfig from .models import Trade log = logging.getLogger(__name__) class Notifier: """Verschickt kurze Statusmeldungen. Fehler werden geloggt, nie weitergereicht.""" def __init__(self, config: NotificationConfig, timeout: float = 8.0) -> None: self.config = config self._timeout = aiohttp.ClientTimeout(total=timeout) self._session: aiohttp.ClientSession | None = None self._tasks: set[asyncio.Task[None]] = set() @property def enabled(self) -> bool: return bool(self.config.webhook_url) async def start(self) -> None: if self.enabled and self._session is None: self._session = aiohttp.ClientSession(timeout=self._timeout) async def close(self) -> None: for task in list(self._tasks): task.cancel() if self._tasks: await asyncio.gather(*self._tasks, return_exceptions=True) if self._session is not None: await self._session.close() self._session = None def send_soon(self, message: str) -> None: """Nachricht im Hintergrund verschicken, ohne den Handels-Loop zu blockieren.""" if not self.enabled: return task = asyncio.create_task(self._send(message)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def _send(self, message: str) -> None: if self._session is None: await self.start() if self._session is None or not self.config.webhook_url: return # "text" bedient Slack, "content" bedient Discord – ein Payload für beide. payload = {"text": message, "content": message} try: async with self._session.post(self.config.webhook_url, json=payload) as response: if response.status >= 400: body = (await response.text())[:200] log.warning("Webhook antwortete mit HTTP %s: %s", response.status, body) except asyncio.CancelledError: raise except Exception as exc: # noqa: BLE001 - Benachrichtigungen dürfen nie den Bot stoppen log.warning("Webhook-Zustellung fehlgeschlagen: %s", exc) # ------------------------------------------------------------- Bausteine def trade_closed(self, trade: Trade, equity: float, quote: str) -> None: if not self.config.notify_on_trade: return icon = "🟢" if trade.is_win else "🔴" tag = " [Exploration]" if trade.exploratory else "" self.send_soon( f"{icon} {trade.symbol} geschlossen ({trade.exit_reason.value}){tag}\n" f"P/L: {trade.pnl_quote:+.2f} {quote} ({trade.pnl_pct * 100:+.2f}%) | " f"Einstieg {trade.entry_price:.6f} → Ausstieg {trade.exit_price:.6f} | " f"Equity: {equity:.2f} {quote}" ) def position_opened( self, symbol: str, amount: float, price: float, confidence: float, quote: str ) -> None: if not self.config.notify_on_trade: return self.send_soon( f"📈 Position eröffnet: {symbol} {amount:.8f} @ {price:.6f} " f"(≈{amount * price:.2f} {quote}, Modellkonfidenz {confidence:.2f})" ) def risk_halt(self, reason: str) -> None: if not self.config.notify_on_risk_halt: return self.send_soon(f"⛔ Handel gestoppt: {reason}") def startup(self, mode: str, exchange: str, symbols: list[str], timeframe: str) -> None: self.send_soon( f"🤖 TradeMind gestartet – Modus **{mode}**, Börse {exchange}, " f"Symbole {', '.join(symbols)} ({timeframe})" )