feat: discord bot mit slash commands + auto-cleanup + ping bei gewinn

This commit is contained in:
sylyx 2026-05-24 14:18:48 +02:00
parent 4e5051b8a8
commit f50f198a71
5 changed files with 66 additions and 6 deletions

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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,
{