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",
|
"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",
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user