From f50f198a714d8fcacbb8bf89e495e4a48c13bbbe Mon Sep 17 00:00:00 2001 From: sylyx Date: Sun, 24 May 2026 14:18:48 +0200 Subject: [PATCH] feat: discord bot mit slash commands + auto-cleanup + ping bei gewinn --- pyproject.toml | 1 + src/aitrader/config.py | 9 ++++++++ src/aitrader/exchange/market_data.py | 31 +++++++++++++++++++++++++--- src/aitrader/main.py | 2 ++ src/aitrader/notify/discord.py | 29 +++++++++++++++++++++++--- 5 files changed, 66 insertions(+), 6 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f4f7a69..1b6379d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "anthropic>=0.40.0", "openai>=1.50.0", "requests>=2.32.0", + "discord.py>=2.3.0", "vaderSentiment>=3.3.2", "sqlmodel>=0.0.22", "streamlit>=1.36.0", diff --git a/src/aitrader/config.py b/src/aitrader/config.py index b7d419a..09e7092 100644 --- a/src/aitrader/config.py +++ b/src/aitrader/config.py @@ -62,6 +62,7 @@ class DiscordConfig(BaseModel): ) news_sentiment_threshold: float = 0.4 daily_summary_hour_utc: int = 22 + auto_cleanup_hours: int = 24 class Settings(BaseModel): @@ -88,6 +89,10 @@ class Settings(BaseModel): cryptopanic_api_key: str = "" discord_webhook_url: str = "" discord_webhook_decisions_url: str = "" + discord_bot_token: str = "" + discord_channel_id: str = "" + discord_decisions_channel_id: str = "" + discord_ping_user_id: str = "" db_path: str = "data/aitrader.db" @@ -115,6 +120,10 @@ def get_settings() -> Settings: cryptopanic_api_key=os.getenv("CRYPTOPANIC_API_KEY", ""), discord_webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""), discord_webhook_decisions_url=os.getenv("DISCORD_WEBHOOK_DECISIONS_URL", ""), + discord_bot_token=os.getenv("DISCORD_BOT_TOKEN", ""), + discord_channel_id=os.getenv("DISCORD_CHANNEL_ID", ""), + discord_decisions_channel_id=os.getenv("DISCORD_DECISIONS_CHANNEL_ID", ""), + discord_ping_user_id=os.getenv("DISCORD_PING_USER_ID", ""), db_path=os.getenv("AITRADER_DB", "data/aitrader.db"), ) return settings diff --git a/src/aitrader/exchange/market_data.py b/src/aitrader/exchange/market_data.py index c84e67a..670a44b 100644 --- a/src/aitrader/exchange/market_data.py +++ b/src/aitrader/exchange/market_data.py @@ -1,13 +1,19 @@ """Sammelt Marktdaten (OHLCV mehrerer Timeframes + Orderbook + Ticker).""" from __future__ import annotations +import time from dataclasses import dataclass, field from typing import Any import pandas as pd +from ..logging_setup import get_logger from .kraken import KrakenClient +log = get_logger(__name__) + +_RETRY_DELAYS = (5, 15) # Sekunden zwischen Retry-Versuchen bei 503 + @dataclass class MarketSnapshot: @@ -17,6 +23,22 @@ class MarketSnapshot: orderbook: dict[str, Any] = field(default_factory=dict) +def _with_retry(fn, *args, **kwargs): + """Führt fn aus, wiederholt bei ServiceUnavailable/NetworkError bis zu 2x.""" + import ccxt + last_exc: Exception | None = None + for attempt, delay in enumerate(("first", *_RETRY_DELAYS)): + try: + return fn(*args, **kwargs) + except (ccxt.NetworkError, ccxt.RequestTimeout) as exc: + last_exc = exc + if delay == "first": + continue + log.warning("market_data.retry", attempt=attempt, delay=delay, error=str(exc)) + time.sleep(delay) + raise last_exc # type: ignore[misc] + + def collect_snapshot( client: KrakenClient, symbol: str, @@ -24,8 +46,11 @@ def collect_snapshot( ohlcv_limit: int, orderbook_depth: int = 10, ) -> MarketSnapshot: - snap = MarketSnapshot(symbol=symbol, ticker=client.fetch_ticker(symbol)) + snap = MarketSnapshot(symbol=symbol, ticker=_with_retry(client.fetch_ticker, symbol)) for tf in timeframes: - snap.ohlcv[tf] = client.fetch_ohlcv(symbol, tf, limit=ohlcv_limit) - snap.orderbook = client.fetch_orderbook(symbol, depth=orderbook_depth) + snap.ohlcv[tf] = _with_retry(client.fetch_ohlcv, symbol, tf, limit=ohlcv_limit) + try: + snap.orderbook = _with_retry(client.fetch_orderbook, symbol, depth=orderbook_depth) + except Exception as e: + log.warning("orderbook.unavailable", symbol=symbol, error=str(e)) return snap diff --git a/src/aitrader/main.py b/src/aitrader/main.py index 94e1742..0917edf 100644 --- a/src/aitrader/main.py +++ b/src/aitrader/main.py @@ -21,6 +21,7 @@ from .features.orderbook import summarize_orderbook from .logging_setup import configure_logging, get_logger from .news.sentiment import aggregate_sentiment, fetch_headlines from .notify import discord +from .notify.bot import start_bot from .storage import db as dbm from .storage.models import Decision, Trade from .trader import portfolio, risk @@ -219,6 +220,7 @@ def cli() -> None: voter_a = make_voter(settings.ai.voter_a, settings) voter_b = make_voter(settings.ai.voter_b, settings) if settings.ai.mode == "ensemble" else None + start_bot(settings) discord.notify_startup(settings) if args.once: diff --git a/src/aitrader/notify/discord.py b/src/aitrader/notify/discord.py index bd5e970..006a248 100644 --- a/src/aitrader/notify/discord.py +++ b/src/aitrader/notify/discord.py @@ -8,6 +8,7 @@ import requests from ..config import Settings from ..logging_setup import get_logger +from . import state log = get_logger(__name__) @@ -17,12 +18,20 @@ COLOR_BLUE = 0x3498DB COLOR_GRAY = 0x95A5A6 COLOR_YELLOW = 0xF1C40F +_ERROR_COOLDOWN_SECS = 3600 # gleicher Fehler max. 1x pro Stunde melden +_error_last_sent: dict[str, float] = {} # key -> timestamp + def _enabled(settings: Settings) -> bool: return bool(settings.discord.enabled and settings.discord_webhook_url) -def _post(settings: Settings, embed: dict[str, Any], channel: str = "trades") -> None: +def _post( + settings: Settings, + embed: dict[str, Any], + channel: str = "trades", + content: str | None = None, +) -> None: if not _enabled(settings): return if channel == "decisions" and settings.discord_webhook_decisions_url: @@ -30,8 +39,11 @@ def _post(settings: Settings, embed: dict[str, Any], channel: str = "trades") -> else: url = settings.discord_webhook_url embed.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) + payload: dict[str, Any] = {"embeds": [embed]} + if content: + payload["content"] = content try: - r = requests.post(url, json={"embeds": [embed]}, timeout=8) + r = requests.post(url, json=payload, timeout=8) if r.status_code >= 400: log.warning("discord.post_failed", status=r.status_code, body=r.text[:200]) except requests.RequestException as e: @@ -39,7 +51,7 @@ def _post(settings: Settings, embed: dict[str, Any], channel: str = "trades") -> def _should(settings: Settings, event: str) -> bool: - return _enabled(settings) and event in settings.discord.notify_on + return _enabled(settings) and event in settings.discord.notify_on and state.is_enabled(event) def notify_startup(settings: Settings) -> None: @@ -127,6 +139,9 @@ def notify_trade_closed(settings: Settings, trade) -> None: if not _should(settings, "trade_close"): return pnl = trade.pnl_eur or 0.0 + content = None + if pnl > 0 and settings.discord_ping_user_id: + content = f"<@{settings.discord_ping_user_id}> 🎉 Gewinn!" _post( settings, { @@ -140,6 +155,7 @@ def notify_trade_closed(settings: Settings, trade) -> None: {"name": "Qty", "value": f"{trade.qty:.6f}", "inline": True}, ], }, + content=content, ) @@ -159,6 +175,13 @@ def notify_risk_block(settings: Settings, symbol: str, reason: str) -> None: def notify_error(settings: Settings, where: str, error: str) -> None: if not _should(settings, "error"): return + import time + key = f"{where}:{error[:120]}" + now = time.monotonic() + if now - _error_last_sent.get(key, 0) < _ERROR_COOLDOWN_SECS: + log.debug("discord.error_suppressed", where=where) + return + _error_last_sent[key] = now _post( settings, {