From 680a4fcc5fb79492a1ad1a5a144544f68411ee2b Mon Sep 17 00:00:00 2001 From: sylyx Date: Thu, 7 May 2026 19:55:47 +0200 Subject: [PATCH] feat: graceful fallback to single-voter when voter_a hits rate limit - main.py: detect rate_limit_exhausted from voter_a, switch to single(voter_b) and notify Discord once per session - discord.py: add notify_voter_fallback() with yellow warning embed - config.py: add voter_fallback to default notify_on list - dashboard/app.py: show warning banner when fallback mode active (last 2h) - CLAUDE.md: refresh architecture docs, fix stale google-genai migration note Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 118 ++++++++++++++++++--------------- src/aitrader/config.py | 1 + src/aitrader/dashboard/app.py | 20 ++++++ src/aitrader/main.py | 12 +++- src/aitrader/notify/discord.py | 16 +++++ 5 files changed, 114 insertions(+), 53 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index eefde9a..ff0d194 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,71 +1,96 @@ -# aitrader — Projekt-Kontext für Claude +# CLAUDE.md -Diese Datei wird von Claude Code automatisch geladen. Sie beschreibt das Projekt so kompakt wie möglich, damit jede neue Session — lokal oder auf dem VPS — sofort produktiv ist. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Was das ist -Crypto-Trading-Bot, der **alle 15 Minuten** Marktdaten von **Kraken Futures Demo** holt, sie an **Gemini + Claude im Ensemble** schickt und nur bei Konsens (beide BUY/SELL ≥ 0.6 confidence) einen Trade ausführt. Alle Decisions/Trades landen in SQLite, ein Streamlit-Dashboard visualisiert PnL, ein Discord-Webhook benachrichtigt über Trades/Errors/Daily-Summary. +Crypto-Trading-Bot, der **alle 15 Minuten** Marktdaten von **Kraken Futures Demo** holt, sie an **zwei LLM-Voter im Ensemble** schickt und nur bei Konsens (beide BUY/SELL ≥ 0.6 confidence) einen Trade ausführt. Alle Decisions/Trades landen in SQLite, ein Streamlit-Dashboard visualisiert PnL, ein Discord-Webhook benachrichtigt über Trades/Errors/Daily-Summary. **Zweck:** Empirisch herausfinden, ob LLM-basiertes Trading profitabel wäre — ohne reales Risiko (Demo-Account). -## Architektur (kurz) +## Commands + +```bash +# Tests + Lint +.venv/bin/pytest -q # Unit-Tests +.venv/bin/pytest tests/test_indicators.py # einzelner Test +.venv/bin/ruff check src/ # Linting + +# Smoke-Tests (erfordern .env mit echten Keys) +.venv/bin/python scripts/smoke_kraken.py # Demo-API erreichbar? +.venv/bin/python scripts/smoke_ai.py # AI-Voter liefern JSON? +.venv/bin/python scripts/smoke_discord.py # Webhook erreichbar? + +# Ausführen +.venv/bin/python -m aitrader.main --once # Ein vollständiger Tick +aitrader --once # alternativ via installiertem Entrypoint + +# Dashboard (lokal) +.venv/bin/streamlit run src/aitrader/dashboard/app.py +``` + +Package-Manager: `uv`. Dependencies installieren mit `uv pip install -e ".[dev]"`. + +## Architektur ``` src/aitrader/ -├── main.py # Scheduler (APScheduler), --once Modus -├── config.py # YAML + ENV → Pydantic Settings -├── logging_setup.py # structlog +├── main.py # run_tick(): vollständiger Tick-Flow von oben nach unten +├── config.py # YAML (config.yaml) + .env → Pydantic Settings (lru_cache) ├── exchange/ -│ ├── kraken.py # ccxt Wrapper, sandbox=True für Demo -│ └── market_data.py # OHLCV + Orderbook + Ticker Snapshots +│ ├── kraken.py # ccxt-Wrapper, sandbox=True für Demo +│ └── market_data.py # OHLCV + Orderbook + Ticker → MarketSnapshot ├── features/ -│ ├── indicators.py # RSI/MACD/EMA/ATR (eigene Implementierung, kein pandas-ta) +│ ├── indicators.py # RSI/MACD/EMA/ATR — pandas-only, kein pandas-ta │ └── orderbook.py # Spread, Imbalance ├── news/sentiment.py # CryptoPanic + VADER (optional) ├── ai/ -│ ├── prompt.py # Prompt-Builder (System + User) -│ ├── schema.py # TradeDecision Pydantic + JSON-Schema -│ ├── gemini.py # google-generativeai, response_schema +│ ├── registry.py # VoterConfig → konkreter Client (make_voter) +│ ├── prompt.py # System- + User-Prompt-Builder +│ ├── schema.py # TradeDecision Pydantic-Modell + JSON-Schema +│ ├── gemini.py # google-genai SDK, response_schema │ ├── claude.py # anthropic SDK, Tool-Use für strukturierten Output -│ └── ensemble.py # Konsens-Logik +│ ├── openai_compat.py # OpenAI-kompatible Endpunkte (groq/deepseek/xai/openrouter/ollama) +│ └── ensemble.py # combine() / single() — HOLD bei Disagreement oder zu niedriger Confidence ├── trader/ -│ ├── risk.py # Position-Cap, Daily-Loss-Limit, max offene Positionen +│ ├── risk.py # evaluate(): Position-Cap, Daily-Loss-Limit, max offene Positionen │ ├── executor.py # Market-Order auf Kraken Demo + DB-Eintrag │ └── portfolio.py # SL/TP-Check, Equity-Snapshot, Trade-Close ├── notify/discord.py # Webhook-Notifier (Embeds) ├── storage/ │ ├── models.py # SQLModel-Tabellen: Decision, Trade, EquitySnapshot │ └── db.py # SQLite-Engine + create_all -└── dashboard/app.py # Streamlit +└── dashboard/app.py # Streamlit — absolute Imports wegen Script-Ausführung ``` +**Tick-Flow** (`main.py:run_tick`): Marktdaten → Features/Indikatoren → Orderbook → News/Sentiment → Prompt-Builder → Voter A + B → Ensemble → DB → Discord → Risk-Check → Execute. + ## AI-Voter -Statt fest verdrahtetem Gemini+Claude gibt es zwei generische **Voter-Slots** in `config.yaml` (`ai.voter_a`, `ai.voter_b`). Provider werden via `ai/registry.py` instanziiert. Default: `voter_a=gemini`, `voter_b=groq`. Wechsel auf andere Provider braucht nur Config-Änderung + den passenden ENV-Key. +Zwei generische **Voter-Slots** in `config.yaml` (`ai.voter_a`, `ai.voter_b`). Provider werden via `ai/registry.py` instanziiert. Default: `voter_a=gemini`, `voter_b=groq`. Wechsel braucht nur Config-Änderung + den passenden ENV-Key. -| provider | base_url | ENV-Key | Beispiel-Modell | -|---|---|---|---| -| gemini | (Google SDK) | `GEMINI_API_KEY` | `gemini-2.0-flash` | -| claude | (Anthropic SDK) | `ANTHROPIC_API_KEY` | `claude-haiku-4-5-20251001` | -| groq | api.groq.com | `GROQ_API_KEY` | `llama-3.3-70b-versatile` | -| deepseek | api.deepseek.com | `DEEPSEEK_API_KEY` | `deepseek-chat` | -| xai | api.x.ai | `XAI_API_KEY` | `grok-4-fast` | -| openrouter | openrouter.ai | `OPENROUTER_API_KEY` | `meta-llama/llama-3.3-70b-instruct:free` | -| ollama | localhost:11434 | (kein Key) | `llama3.3` | +| provider | ENV-Key | Beispiel-Modell | +|---|---|---| +| gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` | +| claude | `ANTHROPIC_API_KEY` | `claude-haiku-4-5-20251001` | +| groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` | +| deepseek | `DEEPSEEK_API_KEY` | `deepseek-chat` | +| xai | `XAI_API_KEY` | `grok-4-fast` | +| openrouter | `OPENROUTER_API_KEY` | `meta-llama/llama-3.3-70b-instruct:free` | +| ollama | (kein Key) | `llama3.3` | -Modus `ai.mode: single` → nur `voter_a` wird gefragt, kein Konsens nötig. +`ai.mode: single` → nur `voter_a`, kein Konsens nötig. ## Wichtige Regeln & Gotchas -- **Niemals** `exchange.sandbox` in `config.yaml` auf `false` ändern, ohne dass der User das explizit will. Das ist die Schutzlinie zum Live-Geld. -- **Keine API-Keys** ins Repo. Alle gehen via `.env` → `config.py:get_settings()`. -- **Indikatoren** sind selbst implementiert (pandas-only) weil pandas-ta + numpy 2 broken war. -- **Pair-Symbole**: Kraken Futures nutzt teils `PI_XBTUSD`-Format. Wenn `BTC/EUR` nicht gefunden wird, ist das die Ursache — `pairs:` in `config.yaml` anpassen. +- **Niemals** `exchange.sandbox: false` ohne explizite User-Bestätigung. Das ist die einzige Schutzlinie zum Live-Geld. +- `exchange.paper_only: true` blockt Trade-Execution zusätzlich, auch bei sandbox=true. +- **Indikatoren** sind selbst implementiert (pandas-only) weil pandas-ta + numpy 2 inkompatibel war. +- **Pair-Symbole**: Kraken Futures nutzt teils `PI_XBTUSD`-Format statt `BTC/EUR` — bei Symbol-Fehlern `pairs:` in `config.yaml` anpassen. - **Ensemble-HOLD ist gewollt**: Bei Disagreement oder zu niedriger Confidence wird absichtlich nicht getradet. -- **Decision-Notify ist standardmäßig AUS** in `config.yaml` (`discord.notify_on` enthält `decision` nicht), weil 192 Embeds/Tag spammig wären. -- **SQLite-DB unter `data/aitrader.db`** (lokal) bzw. `/opt/aitrader/data/aitrader.db` (Server) — nicht löschen, da Backtest-Replay drauf basiert. -- **Daily-Loss-Limit (5%)** pausiert Trading automatisch — Reset um 00:00 UTC weil `daily_pnl_eur` nur seit Tagesbeginn summiert. -- **`run_in_background` für lange Operationen**: Smoke-Tests und Bot sind interaktiv okay, aber wenn du den Bot im Dauerbetrieb startest mach das via `systemctl`, nicht im Vordergrund. +- **`discord.notify_on`** enthält `decision` standardmäßig nicht (192 Embeds/Tag wären spammig). Nur `trade_open`, `trade_close`, `error` etc. +- **Daily-Loss-Limit (5%)** pausiert Trading automatisch — Reset um 00:00 UTC. +- **`get_settings()` ist `lru_cache`** — Änderungen an `.env`/`config.yaml` nach Start werden nicht übernommen ohne Neustart. ## Wo läuft was @@ -73,23 +98,13 @@ Modus `ai.mode: single` → nur `voter_a` wird gefragt, kein Konsens nötig. |---|---|---| | Lokal (Dev) | `~/code/aitrader` | `.venv/bin/python -m aitrader.main --once` | | Server (Prod) | `/opt/aitrader` | `systemctl status aitrader` | -| DB | `data/aitrader.db` | SQLite — `sqlite3 data/aitrader.db ".tables"` | +| DB | `data/aitrader.db` | `sqlite3 data/aitrader.db ".tables"` | | Logs (Server) | journald | `journalctl -u aitrader -f` | | Dashboard (Server) | `http://:8501` | nur via Tailscale | -## Test + Verifikation - -```bash -.venv/bin/pytest -q # Unit-Tests -.venv/bin/python scripts/smoke_kraken.py # Demo-API erreichbar? -.venv/bin/python scripts/smoke_ai.py # Gemini+Claude liefern JSON? -.venv/bin/python scripts/smoke_discord.py # Webhook erreichbar? -.venv/bin/python -m aitrader.main --once # Ein vollständiger Tick -``` - ## Update-Workflow (lokal → Server) -1. Lokal Änderung → `git commit && git push` +1. `git commit && git push` 2. Auf Server: `cd /opt/aitrader && sudo -u aitrader git pull` 3. Bei neuen Dependencies: `sudo -u aitrader /home/aitrader/.local/bin/uv pip install -e .` 4. `sudo systemctl restart aitrader aitrader-dashboard` @@ -97,17 +112,16 @@ Modus `ai.mode: single` → nur `voter_a` wird gefragt, kein Konsens nötig. Siehe `DEPLOY.md` für vollen Setup vom Frischserver inkl. Tailscale. -## Ausstehende / sinnvolle Erweiterungen (nicht implementiert) +## Ausstehende Erweiterungen (nicht implementiert) - Backtest-Modus (historische Daten replay durch Decisions-Tabelle) - Mehr Pairs / dynamisches Universe-Selection - Position-Sizing per Kelly oder Vol-Targeting - Prompt-Tuning A/B (zwei Prompts, vergleichen welcher besser performt) -- Migration weg von `google-generativeai` (deprecated) hin zu `google-genai` -## Wenn Claude diese Datei liest +## Für Claude - Kein Refactor "weil schöner" — der Code ist bewusst kompakt gehalten. -- Bei Fragen zu Trading-Logik: `ai/ensemble.py` und `trader/risk.py` sind die zwei kritischen Dateien. -- Bei Fragen zu Daten-Pipeline: `main.py:run_tick` ist der Flow von oben nach unten. +- Bei Trading-Logik: `ai/ensemble.py` und `trader/risk.py` sind die zwei kritischen Dateien. +- Bei Daten-Pipeline: `main.py:run_tick` ist der Flow von oben nach unten. - Niemals echtes Geld ohne explizite User-Bestätigung — das Projekt ist Demo-only. diff --git a/src/aitrader/config.py b/src/aitrader/config.py index 226e2a9..1c3ef13 100644 --- a/src/aitrader/config.py +++ b/src/aitrader/config.py @@ -57,6 +57,7 @@ class DiscordConfig(BaseModel): "error", "daily_summary", "news_alert", + "voter_fallback", ] ) news_sentiment_threshold: float = 0.4 diff --git a/src/aitrader/dashboard/app.py b/src/aitrader/dashboard/app.py index 6a2518f..85a7bfb 100644 --- a/src/aitrader/dashboard/app.py +++ b/src/aitrader/dashboard/app.py @@ -1,6 +1,8 @@ """Streamlit-Dashboard: Equity, Trades, AI-Vergleich.""" from __future__ import annotations +from datetime import datetime, timedelta, timezone + import pandas as pd import plotly.express as px import streamlit as st @@ -26,6 +28,24 @@ tab_overview, tab_trades, tab_decisions, tab_ai = st.tabs( ) with tab_overview: + # Fallback-Status: Decisions der letzten 2h mit rate_limit_exhausted + cutoff = datetime.now(timezone.utc) - timedelta(hours=2) + with dbm.session(settings.db_path) as _s: + _fb_rows = _s.exec( + select(Decision) + .where(Decision.voter_a_reasoning == "rate_limit_exhausted") + .where(Decision.ts >= cutoff) + ).all() + if _fb_rows: + _providers = {r.voter_a_provider for r in _fb_rows} + _fallback = {r.voter_b_provider for r in _fb_rows} + st.warning( + f"⚠️ **Fallback-Modus aktiv** — {', '.join(_providers)} hat Rate-Limit erreicht. " + f"Bot läuft mit {', '.join(_fallback)} als Single-Voter " + f"({len(_fb_rows)} Ticks in den letzten 2h betroffen).", + icon="⚠️", + ) + st.subheader("Equity-Kurve") df_eq = load_df(select(EquitySnapshot).order_by(EquitySnapshot.ts)) if df_eq.empty: diff --git a/src/aitrader/main.py b/src/aitrader/main.py index b2d0304..9e2310c 100644 --- a/src/aitrader/main.py +++ b/src/aitrader/main.py @@ -28,6 +28,9 @@ from .trader.executor import execute_trade log = get_logger(__name__) +# Provider die in dieser Session bereits ihr Limit erreicht haben (Reset bei Neustart). +_exhausted_providers: set[str] = set() + def _current_position_summary(settings: Settings, symbol: str) -> dict | None: open_trades = portfolio.open_trades_for_symbol(settings, symbol) @@ -92,6 +95,13 @@ def run_tick( discord.notify_error(settings, f"voter_a={voter_a.provider} ({symbol})", str(e)) continue + voter_a_exhausted = a.reasoning == "rate_limit_exhausted" + if voter_a_exhausted and voter_a.provider not in _exhausted_providers: + _exhausted_providers.add(voter_a.provider) + fb = voter_b.provider if voter_b else "—" + log.warning("voter_a.rate_limit_fallback", provider=voter_a.provider, fallback=fb) + discord.notify_voter_fallback(settings, voter_a.provider, fb) + if voter_b is not None: try: b = voter_b.decide(prompt) @@ -99,7 +109,7 @@ def run_tick( log.error("voter_b.failed", provider=voter_b.provider, error=str(e)) discord.notify_error(settings, f"voter_b={voter_b.provider} ({symbol})", str(e)) continue - result = combine(a, b, settings.ai.min_confidence) + result = single(b, settings.ai.min_confidence) if voter_a_exhausted else combine(a, b, settings.ai.min_confidence) else: b = a result = single(a, settings.ai.min_confidence) diff --git a/src/aitrader/notify/discord.py b/src/aitrader/notify/discord.py index 9bf845b..e1659cd 100644 --- a/src/aitrader/notify/discord.py +++ b/src/aitrader/notify/discord.py @@ -189,6 +189,22 @@ def notify_daily_summary( ) +def notify_voter_fallback(settings: Settings, exhausted_provider: str, fallback_provider: str) -> None: + if not _should(settings, "voter_fallback"): + return + _post( + settings, + { + "title": f"⚠️ {exhausted_provider} Rate-Limit — Fallback auf {fallback_provider}", + "color": COLOR_YELLOW, + "description": ( + f"`{exhausted_provider}` hat das API-Tageslimit erreicht.\n" + f"Bot läuft jetzt im **Single-Voter-Modus** mit `{fallback_provider}`." + ), + }, + ) + + def notify_news_alert(settings: Settings, symbol: str, headlines: list[dict[str, Any]], avg_sentiment: float) -> None: """Postet wenn |avg_sentiment| über Schwellwert liegt.""" if not _should(settings, "news_alert"):