feat: discord bot mit slash commands + auto-cleanup + ping bei gewinn
This commit is contained in:
parent
4e5051b8a8
commit
f50f198a71
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user