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 <noreply@anthropic.com>
This commit is contained in:
sylyx 2026-05-07 19:55:47 +02:00
parent 2ec58d01f7
commit 680a4fcc5f
5 changed files with 114 additions and 53 deletions

118
CLAUDE.md
View File

@ -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 ## 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). **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/ src/aitrader/
├── main.py # Scheduler (APScheduler), --once Modus ├── main.py # run_tick(): vollständiger Tick-Flow von oben nach unten
├── config.py # YAML + ENV → Pydantic Settings ├── config.py # YAML (config.yaml) + .env → Pydantic Settings (lru_cache)
├── logging_setup.py # structlog
├── exchange/ ├── exchange/
│ ├── kraken.py # ccxt Wrapper, sandbox=True für Demo │ ├── kraken.py # ccxt-Wrapper, sandbox=True für Demo
│ └── market_data.py # OHLCV + Orderbook + Ticker Snapshots │ └── market_data.py # OHLCV + Orderbook + Ticker → MarketSnapshot
├── features/ ├── 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 │ └── orderbook.py # Spread, Imbalance
├── news/sentiment.py # CryptoPanic + VADER (optional) ├── news/sentiment.py # CryptoPanic + VADER (optional)
├── ai/ ├── ai/
│ ├── prompt.py # Prompt-Builder (System + User) │ ├── registry.py # VoterConfig → konkreter Client (make_voter)
│ ├── schema.py # TradeDecision Pydantic + JSON-Schema │ ├── prompt.py # System- + User-Prompt-Builder
│ ├── gemini.py # google-generativeai, response_schema │ ├── schema.py # TradeDecision Pydantic-Modell + JSON-Schema
│ ├── gemini.py # google-genai SDK, response_schema
│ ├── claude.py # anthropic SDK, Tool-Use für strukturierten Output │ ├── 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/ ├── 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 │ ├── executor.py # Market-Order auf Kraken Demo + DB-Eintrag
│ └── portfolio.py # SL/TP-Check, Equity-Snapshot, Trade-Close │ └── portfolio.py # SL/TP-Check, Equity-Snapshot, Trade-Close
├── notify/discord.py # Webhook-Notifier (Embeds) ├── notify/discord.py # Webhook-Notifier (Embeds)
├── storage/ ├── storage/
│ ├── models.py # SQLModel-Tabellen: Decision, Trade, EquitySnapshot │ ├── models.py # SQLModel-Tabellen: Decision, Trade, EquitySnapshot
│ └── db.py # SQLite-Engine + create_all │ └── 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 ## 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 | | provider | ENV-Key | Beispiel-Modell |
|---|---|---|---| |---|---|---|
| gemini | (Google SDK) | `GEMINI_API_KEY` | `gemini-2.0-flash` | | gemini | `GEMINI_API_KEY` | `gemini-2.0-flash` |
| claude | (Anthropic SDK) | `ANTHROPIC_API_KEY` | `claude-haiku-4-5-20251001` | | claude | `ANTHROPIC_API_KEY` | `claude-haiku-4-5-20251001` |
| groq | api.groq.com | `GROQ_API_KEY` | `llama-3.3-70b-versatile` | | groq | `GROQ_API_KEY` | `llama-3.3-70b-versatile` |
| deepseek | api.deepseek.com | `DEEPSEEK_API_KEY` | `deepseek-chat` | | deepseek | `DEEPSEEK_API_KEY` | `deepseek-chat` |
| xai | api.x.ai | `XAI_API_KEY` | `grok-4-fast` | | xai | `XAI_API_KEY` | `grok-4-fast` |
| openrouter | openrouter.ai | `OPENROUTER_API_KEY` | `meta-llama/llama-3.3-70b-instruct:free` | | openrouter | `OPENROUTER_API_KEY` | `meta-llama/llama-3.3-70b-instruct:free` |
| ollama | localhost:11434 | (kein Key) | `llama3.3` | | 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 ## 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. - **Niemals** `exchange.sandbox: false` ohne explizite User-Bestätigung. Das ist die einzige Schutzlinie zum Live-Geld.
- **Keine API-Keys** ins Repo. Alle gehen via `.env``config.py:get_settings()`. - `exchange.paper_only: true` blockt Trade-Execution zusätzlich, auch bei sandbox=true.
- **Indikatoren** sind selbst implementiert (pandas-only) weil pandas-ta + numpy 2 broken war. - **Indikatoren** sind selbst implementiert (pandas-only) weil pandas-ta + numpy 2 inkompatibel 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. - **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. - **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. - **`discord.notify_on`** enthält `decision` standardmäßig nicht (192 Embeds/Tag wären spammig). Nur `trade_open`, `trade_close`, `error` etc.
- **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.
- **Daily-Loss-Limit (5%)** pausiert Trading automatisch — Reset um 00:00 UTC weil `daily_pnl_eur` nur seit Tagesbeginn summiert. - **`get_settings()` ist `lru_cache`** — Änderungen an `.env`/`config.yaml` nach Start werden nicht übernommen ohne Neustart.
- **`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.
## Wo läuft was ## 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` | | Lokal (Dev) | `~/code/aitrader` | `.venv/bin/python -m aitrader.main --once` |
| Server (Prod) | `/opt/aitrader` | `systemctl status aitrader` | | 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` | | Logs (Server) | journald | `journalctl -u aitrader -f` |
| Dashboard (Server) | `http://<tailscale-host>:8501` | nur via Tailscale | | Dashboard (Server) | `http://<tailscale-host>: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) ## 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` 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 .` 3. Bei neuen Dependencies: `sudo -u aitrader /home/aitrader/.local/bin/uv pip install -e .`
4. `sudo systemctl restart aitrader aitrader-dashboard` 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. 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) - Backtest-Modus (historische Daten replay durch Decisions-Tabelle)
- Mehr Pairs / dynamisches Universe-Selection - Mehr Pairs / dynamisches Universe-Selection
- Position-Sizing per Kelly oder Vol-Targeting - Position-Sizing per Kelly oder Vol-Targeting
- Prompt-Tuning A/B (zwei Prompts, vergleichen welcher besser performt) - 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. - 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 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 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. - Niemals echtes Geld ohne explizite User-Bestätigung — das Projekt ist Demo-only.

View File

@ -57,6 +57,7 @@ class DiscordConfig(BaseModel):
"error", "error",
"daily_summary", "daily_summary",
"news_alert", "news_alert",
"voter_fallback",
] ]
) )
news_sentiment_threshold: float = 0.4 news_sentiment_threshold: float = 0.4

View File

@ -1,6 +1,8 @@
"""Streamlit-Dashboard: Equity, Trades, AI-Vergleich.""" """Streamlit-Dashboard: Equity, Trades, AI-Vergleich."""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pandas as pd import pandas as pd
import plotly.express as px import plotly.express as px
import streamlit as st import streamlit as st
@ -26,6 +28,24 @@ tab_overview, tab_trades, tab_decisions, tab_ai = st.tabs(
) )
with tab_overview: 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") st.subheader("Equity-Kurve")
df_eq = load_df(select(EquitySnapshot).order_by(EquitySnapshot.ts)) df_eq = load_df(select(EquitySnapshot).order_by(EquitySnapshot.ts))
if df_eq.empty: if df_eq.empty:

View File

@ -28,6 +28,9 @@ from .trader.executor import execute_trade
log = get_logger(__name__) 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: def _current_position_summary(settings: Settings, symbol: str) -> dict | None:
open_trades = portfolio.open_trades_for_symbol(settings, symbol) 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)) discord.notify_error(settings, f"voter_a={voter_a.provider} ({symbol})", str(e))
continue 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: if voter_b is not None:
try: try:
b = voter_b.decide(prompt) b = voter_b.decide(prompt)
@ -99,7 +109,7 @@ def run_tick(
log.error("voter_b.failed", provider=voter_b.provider, error=str(e)) 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)) discord.notify_error(settings, f"voter_b={voter_b.provider} ({symbol})", str(e))
continue 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: else:
b = a b = a
result = single(a, settings.ai.min_confidence) result = single(a, settings.ai.min_confidence)

View File

@ -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: def notify_news_alert(settings: Settings, symbol: str, headlines: list[dict[str, Any]], avg_sentiment: float) -> None:
"""Postet wenn |avg_sentiment| über Schwellwert liegt.""" """Postet wenn |avg_sentiment| über Schwellwert liegt."""
if not _should(settings, "news_alert"): if not _should(settings, "news_alert"):