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", "anthropic>=0.40.0",
"openai>=1.50.0", "openai>=1.50.0",
"requests>=2.32.0", "requests>=2.32.0",
"discord.py>=2.3.0",
"vaderSentiment>=3.3.2", "vaderSentiment>=3.3.2",
"sqlmodel>=0.0.22", "sqlmodel>=0.0.22",
"streamlit>=1.36.0", "streamlit>=1.36.0",

View File

@ -62,6 +62,7 @@ class DiscordConfig(BaseModel):
) )
news_sentiment_threshold: float = 0.4 news_sentiment_threshold: float = 0.4
daily_summary_hour_utc: int = 22 daily_summary_hour_utc: int = 22
auto_cleanup_hours: int = 24
class Settings(BaseModel): class Settings(BaseModel):
@ -88,6 +89,10 @@ class Settings(BaseModel):
cryptopanic_api_key: str = "" cryptopanic_api_key: str = ""
discord_webhook_url: str = "" discord_webhook_url: str = ""
discord_webhook_decisions_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" db_path: str = "data/aitrader.db"
@ -115,6 +120,10 @@ def get_settings() -> Settings:
cryptopanic_api_key=os.getenv("CRYPTOPANIC_API_KEY", ""), cryptopanic_api_key=os.getenv("CRYPTOPANIC_API_KEY", ""),
discord_webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""), discord_webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""),
discord_webhook_decisions_url=os.getenv("DISCORD_WEBHOOK_DECISIONS_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"), db_path=os.getenv("AITRADER_DB", "data/aitrader.db"),
) )
return settings return settings

View File

@ -1,13 +1,19 @@
"""Sammelt Marktdaten (OHLCV mehrerer Timeframes + Orderbook + Ticker).""" """Sammelt Marktdaten (OHLCV mehrerer Timeframes + Orderbook + Ticker)."""
from __future__ import annotations from __future__ import annotations
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
import pandas as pd import pandas as pd
from ..logging_setup import get_logger
from .kraken import KrakenClient from .kraken import KrakenClient
log = get_logger(__name__)
_RETRY_DELAYS = (5, 15) # Sekunden zwischen Retry-Versuchen bei 503
@dataclass @dataclass
class MarketSnapshot: class MarketSnapshot:
@ -17,6 +23,22 @@ class MarketSnapshot:
orderbook: dict[str, Any] = field(default_factory=dict) 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( def collect_snapshot(
client: KrakenClient, client: KrakenClient,
symbol: str, symbol: str,
@ -24,8 +46,11 @@ def collect_snapshot(
ohlcv_limit: int, ohlcv_limit: int,
orderbook_depth: int = 10, orderbook_depth: int = 10,
) -> MarketSnapshot: ) -> 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: for tf in timeframes:
snap.ohlcv[tf] = client.fetch_ohlcv(symbol, tf, limit=ohlcv_limit) snap.ohlcv[tf] = _with_retry(client.fetch_ohlcv, symbol, tf, limit=ohlcv_limit)
snap.orderbook = client.fetch_orderbook(symbol, depth=orderbook_depth) 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 return snap

View File

@ -21,6 +21,7 @@ from .features.orderbook import summarize_orderbook
from .logging_setup import configure_logging, get_logger from .logging_setup import configure_logging, get_logger
from .news.sentiment import aggregate_sentiment, fetch_headlines from .news.sentiment import aggregate_sentiment, fetch_headlines
from .notify import discord from .notify import discord
from .notify.bot import start_bot
from .storage import db as dbm from .storage import db as dbm
from .storage.models import Decision, Trade from .storage.models import Decision, Trade
from .trader import portfolio, risk from .trader import portfolio, risk
@ -219,6 +220,7 @@ def cli() -> None:
voter_a = make_voter(settings.ai.voter_a, settings) 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 voter_b = make_voter(settings.ai.voter_b, settings) if settings.ai.mode == "ensemble" else None
start_bot(settings)
discord.notify_startup(settings) discord.notify_startup(settings)
if args.once: if args.once:

View File

@ -8,6 +8,7 @@ import requests
from ..config import Settings from ..config import Settings
from ..logging_setup import get_logger from ..logging_setup import get_logger
from . import state
log = get_logger(__name__) log = get_logger(__name__)
@ -17,12 +18,20 @@ COLOR_BLUE = 0x3498DB
COLOR_GRAY = 0x95A5A6 COLOR_GRAY = 0x95A5A6
COLOR_YELLOW = 0xF1C40F 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: def _enabled(settings: Settings) -> bool:
return bool(settings.discord.enabled and settings.discord_webhook_url) 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): if not _enabled(settings):
return return
if channel == "decisions" and settings.discord_webhook_decisions_url: 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: else:
url = settings.discord_webhook_url url = settings.discord_webhook_url
embed.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) embed.setdefault("timestamp", datetime.now(timezone.utc).isoformat())
payload: dict[str, Any] = {"embeds": [embed]}
if content:
payload["content"] = content
try: try:
r = requests.post(url, json={"embeds": [embed]}, timeout=8) r = requests.post(url, json=payload, timeout=8)
if r.status_code >= 400: if r.status_code >= 400:
log.warning("discord.post_failed", status=r.status_code, body=r.text[:200]) log.warning("discord.post_failed", status=r.status_code, body=r.text[:200])
except requests.RequestException as e: 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: 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: def notify_startup(settings: Settings) -> None:
@ -127,6 +139,9 @@ def notify_trade_closed(settings: Settings, trade) -> None:
if not _should(settings, "trade_close"): if not _should(settings, "trade_close"):
return return
pnl = trade.pnl_eur or 0.0 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( _post(
settings, settings,
{ {
@ -140,6 +155,7 @@ def notify_trade_closed(settings: Settings, trade) -> None:
{"name": "Qty", "value": f"{trade.qty:.6f}", "inline": True}, {"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: def notify_error(settings: Settings, where: str, error: str) -> None:
if not _should(settings, "error"): if not _should(settings, "error"):
return 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( _post(
settings, settings,
{ {