From d111cf1fdc92bcbd61630919406024a27f7eb543 Mon Sep 17 00:00:00 2001 From: sylyx Date: Thu, 7 May 2026 14:06:34 +0200 Subject: [PATCH] initial: aitrader bot (Gemini+Groq ensemble, Kraken Demo, Discord notifier) --- .env.example | 27 +++ .gitignore | 14 ++ CLAUDE.md | 113 ++++++++++ DEPLOY.md | 205 ++++++++++++++++++ README.md | 40 ++++ READMEV2.md | 0 config.yaml | 52 +++++ deploy/install.sh | 36 ++++ deploy/systemd/aitrader-dashboard.service | 31 +++ deploy/systemd/aitrader.service | 31 +++ pyproject.toml | 49 +++++ scripts/smoke_ai.py | 44 ++++ scripts/smoke_discord.py | 29 +++ scripts/smoke_kraken.py | 28 +++ src/aitrader/__init__.py | 0 src/aitrader/ai/__init__.py | 0 src/aitrader/ai/claude.py | 48 +++++ src/aitrader/ai/ensemble.py | 67 ++++++ src/aitrader/ai/gemini.py | 49 +++++ src/aitrader/ai/openai_compat.py | 61 ++++++ src/aitrader/ai/prompt.py | 43 ++++ src/aitrader/ai/registry.py | 51 +++++ src/aitrader/ai/schema.py | 27 +++ src/aitrader/config.py | 117 +++++++++++ src/aitrader/dashboard/__init__.py | 0 src/aitrader/dashboard/app.py | 92 ++++++++ src/aitrader/exchange/__init__.py | 0 src/aitrader/exchange/kraken.py | 69 ++++++ src/aitrader/exchange/market_data.py | 31 +++ src/aitrader/features/__init__.py | 0 src/aitrader/features/indicators.py | 67 ++++++ src/aitrader/features/orderbook.py | 26 +++ src/aitrader/logging_setup.py | 31 +++ src/aitrader/main.py | 242 ++++++++++++++++++++++ src/aitrader/news/__init__.py | 0 src/aitrader/news/sentiment.py | 67 ++++++ src/aitrader/notify/__init__.py | 0 src/aitrader/notify/discord.py | 208 +++++++++++++++++++ src/aitrader/storage/__init__.py | 0 src/aitrader/storage/db.py | 24 +++ src/aitrader/storage/models.py | 63 ++++++ src/aitrader/trader/__init__.py | 0 src/aitrader/trader/executor.py | 73 +++++++ src/aitrader/trader/portfolio.py | 94 +++++++++ src/aitrader/trader/risk.py | 71 +++++++ tests/__init__.py | 0 tests/test_ensemble.py | 47 +++++ tests/test_indicators.py | 32 +++ tests/test_risk.py | 44 ++++ 49 files changed, 2443 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 DEPLOY.md create mode 100644 README.md create mode 100644 READMEV2.md create mode 100644 config.yaml create mode 100755 deploy/install.sh create mode 100644 deploy/systemd/aitrader-dashboard.service create mode 100644 deploy/systemd/aitrader.service create mode 100644 pyproject.toml create mode 100644 scripts/smoke_ai.py create mode 100644 scripts/smoke_discord.py create mode 100644 scripts/smoke_kraken.py create mode 100644 src/aitrader/__init__.py create mode 100644 src/aitrader/ai/__init__.py create mode 100644 src/aitrader/ai/claude.py create mode 100644 src/aitrader/ai/ensemble.py create mode 100644 src/aitrader/ai/gemini.py create mode 100644 src/aitrader/ai/openai_compat.py create mode 100644 src/aitrader/ai/prompt.py create mode 100644 src/aitrader/ai/registry.py create mode 100644 src/aitrader/ai/schema.py create mode 100644 src/aitrader/config.py create mode 100644 src/aitrader/dashboard/__init__.py create mode 100644 src/aitrader/dashboard/app.py create mode 100644 src/aitrader/exchange/__init__.py create mode 100644 src/aitrader/exchange/kraken.py create mode 100644 src/aitrader/exchange/market_data.py create mode 100644 src/aitrader/features/__init__.py create mode 100644 src/aitrader/features/indicators.py create mode 100644 src/aitrader/features/orderbook.py create mode 100644 src/aitrader/logging_setup.py create mode 100644 src/aitrader/main.py create mode 100644 src/aitrader/news/__init__.py create mode 100644 src/aitrader/news/sentiment.py create mode 100644 src/aitrader/notify/__init__.py create mode 100644 src/aitrader/notify/discord.py create mode 100644 src/aitrader/storage/__init__.py create mode 100644 src/aitrader/storage/db.py create mode 100644 src/aitrader/storage/models.py create mode 100644 src/aitrader/trader/__init__.py create mode 100644 src/aitrader/trader/executor.py create mode 100644 src/aitrader/trader/portfolio.py create mode 100644 src/aitrader/trader/risk.py create mode 100644 tests/__init__.py create mode 100644 tests/test_ensemble.py create mode 100644 tests/test_indicators.py create mode 100644 tests/test_risk.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..507b298 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# Kraken Futures Demo (https://demo-futures.kraken.com) +KRAKEN_DEMO_KEY= +KRAKEN_DEMO_SECRET= + +# Google Gemini (https://aistudio.google.com/apikey) — Free-Tier +GEMINI_API_KEY= + +# Anthropic Claude (https://console.anthropic.com) — optional, nur wenn als Voter konfiguriert +ANTHROPIC_API_KEY= + +# Groq (https://console.groq.com) — Free-Tier mit Llama 3.3 70B +GROQ_API_KEY= + +# Optional weitere Provider (nur eintragen falls als Voter genutzt) +DEEPSEEK_API_KEY= +XAI_API_KEY= +OPENROUTER_API_KEY= + +# CryptoPanic (optional, https://cryptopanic.com/developers/api/) +CRYPTOPANIC_API_KEY= + +# Discord-Webhook (optional, Server-Settings → Integrationen → Webhooks) +DISCORD_WEBHOOK_URL= + +# Pfade +AITRADER_CONFIG=config.yaml +AITRADER_DB=data/aitrader.db diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8bbd733 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.venv/ +__pycache__/ +*.pyc +.env +data/*.db +data/*.db-journal +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ +.streamlit/ +.claude/ +*.swp diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..eefde9a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,113 @@ +# aitrader — Projekt-Kontext für Claude + +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. + +## 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. + +**Zweck:** Empirisch herausfinden, ob LLM-basiertes Trading profitabel wäre — ohne reales Risiko (Demo-Account). + +## Architektur (kurz) + +``` +src/aitrader/ +├── main.py # Scheduler (APScheduler), --once Modus +├── config.py # YAML + ENV → Pydantic Settings +├── logging_setup.py # structlog +├── exchange/ +│ ├── kraken.py # ccxt Wrapper, sandbox=True für Demo +│ └── market_data.py # OHLCV + Orderbook + Ticker Snapshots +├── features/ +│ ├── indicators.py # RSI/MACD/EMA/ATR (eigene Implementierung, 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 +│ ├── claude.py # anthropic SDK, Tool-Use für strukturierten Output +│ └── ensemble.py # Konsens-Logik +├── trader/ +│ ├── risk.py # 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 +``` + +## 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. + +| 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` | + +Modus `ai.mode: single` → nur `voter_a` wird gefragt, 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. +- **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. + +## Wo läuft was + +| Umgebung | Pfad | Aufruf | +|---|---|---| +| 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"` | +| 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` +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` +5. `sudo journalctl -u aitrader -f` + +Siehe `DEPLOY.md` für vollen Setup vom Frischserver inkl. Tailscale. + +## Ausstehende / sinnvolle 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 + +- 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. +- Niemals echtes Geld ohne explizite User-Bestätigung — das Projekt ist Demo-only. diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..c4029ed --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,205 @@ +# Deployment auf gemietetem Ubuntu-VPS + +End-to-end Anleitung. Annahme: frischer Ubuntu 22.04/24.04 VPS, du hast root via SSH, Domain optional. Trading läuft gegen Kraken **Demo** (kein echtes Geld). + +## 0. Was du brauchst + +- SSH-Zugriff zum VPS als `root` oder Sudo-User +- Git-Remote (GitHub/GitLab/self-hosted) für lokal↔Server-Sync +- API-Keys: Kraken Demo (https://demo-futures.kraken.com), Gemini, Anthropic, optional CryptoPanic +- Discord-Webhook-URL +- Tailscale-Account (kostenlos, https://tailscale.com) + +## 1. Lokales Repo zu Git-Remote pushen + +Auf deinem Laptop: + +```bash +cd ~/code/aitrader +git init +git add . +git commit -m "initial: aitrader bot + discord notifier" +# auf GitHub: neues *privates* Repo "aitrader" anlegen, dann: +git remote add origin git@github.com:DEIN_USER/aitrader.git +git branch -M main +git push -u origin main +``` + +> ⚠️ **Repo MUSS privat sein.** `.env` ist via `.gitignore` ausgeschlossen, aber Keys gehören niemals ins Repo. + +## 2. VPS vorbereiten + +SSH zum VPS und als root: + +```bash +# UFW: nur SSH öffentlich +ufw default deny incoming +ufw default allow outgoing +ufw allow 22/tcp +ufw enable +ufw status +``` + +## 3. Tailscale installieren + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +tailscale up +``` + +`tailscale up` zeigt einen Login-Link → in Browser öffnen → mit Google/GitHub einloggen → Server taucht in deinem Tailnet auf. Schreib dir den **Tailscale-Hostnamen** auf (z.B. `aitrader-vps`), den nutzt du gleich fürs Dashboard. + +```bash +tailscale ip -4 # zeigt 100.x.y.z — die private Tailnet-IP +tailscale status +``` + +Auch auf **deinem Laptop und Phone** Tailscale installieren und einloggen → alle drei Geräte sind im selben Mesh. + +**Test:** Vom Laptop aus `ping aitrader-vps` (Magic-DNS aktiviert in Tailscale-Admin) oder `ping 100.x.y.z`. + +## 4. Repo auf den VPS clonen + +Als root oder Sudo-User auf dem VPS: + +```bash +sudo mkdir -p /opt +cd /opt +sudo git clone https://github.com/DEIN_USER/aitrader.git aitrader +# oder mit SSH-Key: +# sudo git clone git@github.com:DEIN_USER/aitrader.git aitrader +sudo chown -R $USER:$USER /opt/aitrader +cd /opt/aitrader +``` + +Wenn du **SSH-Keys** für GitHub nutzen willst (für `git pull` ohne Passwort): +```bash +sudo -u aitrader bash -c 'ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519' +sudo cat /home/aitrader/.ssh/id_ed25519.pub +# diesen Pub-Key in GitHub → Settings → Deploy Keys (read-only) fürs Repo eintragen +``` + +(Der `aitrader`-User wird im nächsten Schritt vom Install-Script erstellt — Reihenfolge: erst install.sh, dann SSH-Key generieren.) + +## 5. Install-Script ausführen + +```bash +cd /opt/aitrader +sudo bash deploy/install.sh +``` + +Das macht: +- erstellt System-User `aitrader` +- installiert Python 3.12, uv +- legt venv unter `/opt/aitrader/.venv` an, installiert dependencies +- kopiert systemd-Units nach `/etc/systemd/system/` + +## 6. `.env` auf dem Server einrichten + +```bash +sudo cp /opt/aitrader/.env.example /opt/aitrader/.env +sudo nano /opt/aitrader/.env +# Trage ein: +# KRAKEN_DEMO_KEY=... +# KRAKEN_DEMO_SECRET=... +# GEMINI_API_KEY=... +# ANTHROPIC_API_KEY=... +# DISCORD_WEBHOOK_URL=... +# CRYPTOPANIC_API_KEY= (optional) +sudo chown aitrader:aitrader /opt/aitrader/.env +sudo chmod 600 /opt/aitrader/.env +``` + +## 7. Smoke-Tests vor dem Start + +```bash +sudo -u aitrader /opt/aitrader/.venv/bin/python /opt/aitrader/scripts/smoke_kraken.py +sudo -u aitrader /opt/aitrader/.venv/bin/python /opt/aitrader/scripts/smoke_ai.py +sudo -u aitrader /opt/aitrader/.venv/bin/python /opt/aitrader/scripts/smoke_discord.py +``` + +Jeder Test sollte ohne Fehler laufen + der Discord-Webhook sollte 2 Embeds posten. + +## 8. Bot + Dashboard als Services starten + +```bash +sudo systemctl enable --now aitrader.service +sudo systemctl enable --now aitrader-dashboard.service +sudo systemctl status aitrader aitrader-dashboard +``` + +Logs live anschauen: +```bash +sudo journalctl -u aitrader -f +sudo journalctl -u aitrader-dashboard -f +``` + +## 9. Dashboard via Tailscale erreichen + +Auf deinem Laptop (Tailscale läuft): +``` +http://aitrader-vps:8501 +``` +oder +``` +http://100.x.y.z:8501 +``` + +Port 8501 ist **nicht** öffentlich — UFW blockt ihn, Streamlit lauscht auf `0.0.0.0`, aber der Tailscale-Tunnel ist der einzige Weg rein. Wenn du noch paranoider sein willst, in `aitrader-dashboard.service` `--server.address` auf die Tailscale-IP setzen. + +## 10. Updates deployen (Workflow) + +Lokal arbeiten, Änderung pushen, Server pullt + restartet: + +```bash +# auf dem Laptop +cd ~/code/aitrader +# … Änderungen mit Claude Code … +git add . && git commit -m "fix: foo" && git push + +# auf dem VPS +cd /opt/aitrader +sudo -u aitrader git pull +# bei Code-Änderungen reicht restart; bei neuen Dependencies erst: +# sudo -u aitrader /home/aitrader/.local/bin/uv pip install -e . +sudo systemctl restart aitrader aitrader-dashboard +sudo journalctl -u aitrader -f +``` + +**Tipp:** Falls du häufig deployst, leg dir lokal eine Funktion an: +```bash +# in ~/.bashrc +deploy-aitrader() { + ssh DEIN_VPS 'cd /opt/aitrader && sudo -u aitrader git pull && sudo systemctl restart aitrader aitrader-dashboard && sudo journalctl -u aitrader -n 30' +} +``` + +## 11. Backup + +Die SQLite-DB unter `/opt/aitrader/data/aitrader.db` enthält alle Trades + Decisions. Backup z.B. via cron: + +```bash +sudo crontab -e +# Tägliches Backup nach /root/backups +0 3 * * * cp /opt/aitrader/data/aitrader.db /root/backups/aitrader-$(date +\%F).db +``` + +## Troubleshooting + +| Problem | Check | +|---|---| +| `aitrader.service: status=1` | `journalctl -u aitrader -n 100` — meist fehlende ENV-Vars | +| Kraken-Symbol nicht gefunden | Kraken Futures nutzt `PI_XBTUSD`-Symbole. `python -c "import ccxt; x=ccxt.krakenfutures(); x.set_sandbox_mode(True); x.load_markets(); print(list(x.symbols)[:30])"` und `pairs:` in `config.yaml` anpassen | +| Dashboard nicht erreichbar | `sudo ss -tlnp \| grep 8501` (lauscht?), `tailscale ping aitrader-vps` (Mesh ok?) | +| Webhook tot | Discord rate-limit (429)? `journalctl -u aitrader \| grep discord` | +| Bot pausiert | `journalctl \| grep daily_loss_limit_hit` — Risk-Limit greift | + +## Sicherheits-Checkliste + +- [ ] Repo ist **privat** auf GitHub +- [ ] `.env` hat `chmod 600` und gehört `aitrader:aitrader` +- [ ] UFW: nur Port 22 öffentlich offen +- [ ] Tailscale `up`, Server im Tailnet +- [ ] SSH: Password-Auth aus, nur Key-Auth (`PasswordAuthentication no` in `/etc/ssh/sshd_config`) +- [ ] `exchange.sandbox: true` in `config.yaml` — **niemals** versehentlich auf `false` +- [ ] Discord-Webhook regelmäßig rotieren falls geleakt diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a2d378 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# aitrader + +AI-Trader: Gemini + Claude Ensemble entscheidet alle 15 Minuten über BTC/EUR und ETH/EUR auf der Kraken Futures Demo. Lokales SQLite + Streamlit-Dashboard. + +## Setup + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" +cp .env.example .env # Keys eintragen +``` + +## Nutzung + +```bash +# Smoke-Tests +python scripts/smoke_kraken.py +python scripts/smoke_ai.py + +# Einzelner Tick (kein Scheduler) +python -m aitrader.main --once + +# Dauerbetrieb (15-Min-Cron) +python -m aitrader.main + +# Dashboard +streamlit run src/aitrader/dashboard/app.py +``` + +## Deployment auf Server + +Siehe **[DEPLOY.md](DEPLOY.md)** — Schritt-für-Schritt für Ubuntu-VPS mit systemd + Tailscale. + +## Projekt-Kontext + +Siehe **[CLAUDE.md](CLAUDE.md)** — wird automatisch von Claude Code geladen, beschreibt Architektur + Gotchas. + +## Status + +Demo/Paper-Trading. **Niemals** ohne sorgfältigen Review auf Live umstellen. diff --git a/READMEV2.md b/READMEV2.md new file mode 100644 index 0000000..e69de29 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..18acb9c --- /dev/null +++ b/config.yaml @@ -0,0 +1,52 @@ +pairs: [BTC/EUR, ETH/EUR] +interval_minutes: 15 +timeframes: [15m, 1h, 4h] +ohlcv_limit: 200 + +starting_equity_eur: 10000 # Demo-Startkapital (nur für lokale Buchführung) + +risk: + max_position_pct: 0.20 + max_open_positions: 2 + stop_loss_atr_mult: 2.0 + take_profit_atr_mult: 3.0 + daily_loss_limit_pct: 0.05 + min_order_eur: 25 + +ai: + mode: ensemble # ensemble (beide Voter müssen sich einig sein) | single (nur voter_a) + min_confidence: 0.6 + timeout_seconds: 30 + voter_a: + provider: gemini # gemini | claude | groq | deepseek | xai | openrouter | ollama + model: gemini-2.5-flash + temperature: 0.1 + voter_b: + provider: groq + model: llama-3.3-70b-versatile + temperature: 0.4 + +news: + enabled: true + source: cryptopanic + max_headlines: 10 + lookback_hours: 6 + +exchange: + id: krakenfutures + sandbox: true + paper_only: false # true = nie echte Orders, nur DB-Logging + +discord: + enabled: true + notify_on: + - startup + - trade_open + - trade_close + - risk_block + - error + - daily_summary + - news_alert + # - decision # auskommentieren um JEDE Ensemble-Decision zu posten (spammig) + news_sentiment_threshold: 0.4 + daily_summary_hour_utc: 22 # täglicher Summary-Post um 22:00 UTC diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100755 index 0000000..859745d --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Idempotentes Setup auf dem VPS. Aus dem Repo-Root mit `sudo bash deploy/install.sh` aufrufen. +set -euo pipefail + +REPO_DIR="${REPO_DIR:-/opt/aitrader}" +USER_NAME="aitrader" + +echo "[1/6] System-Pakete" +apt-get update +apt-get install -y python3 python3-venv git curl ufw + +echo "[2/6] Service-User" +id "$USER_NAME" &>/dev/null || useradd --system --create-home --shell /usr/sbin/nologin "$USER_NAME" + +echo "[3/6] Repo-Verzeichnis" +mkdir -p "$REPO_DIR/data" +chown -R "$USER_NAME":"$USER_NAME" "$REPO_DIR" + +echo "[4/6] uv installieren (für $USER_NAME)" +sudo -u "$USER_NAME" bash -c 'command -v uv >/dev/null 2>&1 || curl -LsSf https://astral.sh/uv/install.sh | sh' + +echo "[5/6] venv + dependencies" +cd "$REPO_DIR" +sudo -u "$USER_NAME" bash -c "cd '$REPO_DIR' && \$HOME/.local/bin/uv venv --python 3.12 && \$HOME/.local/bin/uv pip install -e ." + +echo "[6/6] systemd-Units installieren" +cp deploy/systemd/aitrader.service /etc/systemd/system/ +cp deploy/systemd/aitrader-dashboard.service /etc/systemd/system/ +systemctl daemon-reload + +echo +echo "Fertig. Nächste Schritte:" +echo " 1) .env in $REPO_DIR/.env eintragen (Keys!)" +echo " 2) systemctl enable --now aitrader.service" +echo " 3) systemctl enable --now aitrader-dashboard.service" +echo " 4) journalctl -u aitrader -f" diff --git a/deploy/systemd/aitrader-dashboard.service b/deploy/systemd/aitrader-dashboard.service new file mode 100644 index 0000000..eb1adc8 --- /dev/null +++ b/deploy/systemd/aitrader-dashboard.service @@ -0,0 +1,31 @@ +[Unit] +Description=aitrader Streamlit dashboard +After=network-online.target tailscaled.service +Wants=network-online.target + +[Service] +Type=simple +User=aitrader +Group=aitrader +WorkingDirectory=/opt/aitrader +EnvironmentFile=/opt/aitrader/.env +# Bindet sich an alle Interfaces; UFW blockt 8501 öffentlich → erreichbar nur über Tailscale. +# Falls du strikt nur ans tailscale0-Interface binden willst, ersetze --server.address durch die Tailscale-IP. +ExecStart=/opt/aitrader/.venv/bin/streamlit run src/aitrader/dashboard/app.py \ + --server.port 8501 \ + --server.address 0.0.0.0 \ + --server.headless true \ + --browser.gatherUsageStats false +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/aitrader/data +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/aitrader.service b/deploy/systemd/aitrader.service new file mode 100644 index 0000000..442db33 --- /dev/null +++ b/deploy/systemd/aitrader.service @@ -0,0 +1,31 @@ +[Unit] +Description=aitrader bot (Gemini+Claude → Kraken Demo) +After=network-online.target tailscaled.service +Wants=network-online.target + +[Service] +Type=simple +User=aitrader +Group=aitrader +WorkingDirectory=/opt/aitrader +EnvironmentFile=/opt/aitrader/.env +ExecStart=/opt/aitrader/.venv/bin/python -m aitrader.main +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# Hardening +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/aitrader/data +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +LockPersonality=true + +[Install] +WantedBy=multi-user.target diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..86ff324 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,49 @@ +[project] +name = "aitrader" +version = "0.1.0" +description = "AI-driven crypto trader using Gemini + Claude ensemble against Kraken Futures Demo" +requires-python = ">=3.11" +dependencies = [ + "ccxt>=4.4.0", + "pandas>=2.2.0", + "numpy>=1.26.0", + "apscheduler>=3.10.4", + "pydantic>=2.6.0", + "pydantic-settings>=2.2.0", + "pyyaml>=6.0.1", + "google-generativeai>=0.8.0", + "anthropic>=0.40.0", + "openai>=1.50.0", + "requests>=2.32.0", + "vaderSentiment>=3.3.2", + "sqlmodel>=0.0.22", + "streamlit>=1.36.0", + "plotly>=5.22.0", + "python-dotenv>=1.0.1", + "structlog>=24.1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.5.0", +] + +[project.scripts] +aitrader = "aitrader.main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/aitrader"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/scripts/smoke_ai.py b/scripts/smoke_ai.py new file mode 100644 index 0000000..736d3fe --- /dev/null +++ b/scripts/smoke_ai.py @@ -0,0 +1,44 @@ +"""Smoke-Test: beide Voter liefern valides JSON.""" +from __future__ import annotations + +from aitrader.ai.ensemble import combine +from aitrader.ai.prompt import build_user_prompt +from aitrader.ai.registry import make_voter +from aitrader.config import get_settings +from aitrader.logging_setup import configure_logging, get_logger + + +def main() -> None: + configure_logging() + log = get_logger("smoke_ai") + s = get_settings() + + fake_features = { + "15m": {"close": 65000.0, "rsi14": 58.0, "macd": 12.0, "macd_signal": 8.0, "macd_hist": 4.0, + "ema20": 64900, "ema50": 64000, "ema200": 60000, "atr14": 350.0, "pct_change_24": 1.8}, + "1h": {"close": 65000.0, "rsi14": 60.0, "macd": 50.0, "macd_signal": 30.0, "macd_hist": 20.0, + "ema20": 64500, "ema50": 63000, "ema200": 58000, "atr14": 800.0, "pct_change_24": 2.3}, + "4h": {"close": 65000.0, "rsi14": 65.0, "macd": 200.0, "macd_signal": 150.0, "macd_hist": 50.0, + "ema20": 63000, "ema50": 60000, "ema200": 55000, "atr14": 1500.0, "pct_change_24": 5.0}, + } + ob = {"best_bid": 64995.0, "best_ask": 65005.0, "spread_bps": 1.5, "depth_imbalance": 0.12} + prompt = build_user_prompt("BTC/EUR", fake_features, ob, [], 0.0, None, 10000.0) + + voter_a = make_voter(s.ai.voter_a, s) + a = voter_a.decide(prompt) + log.info("voter_a", provider=voter_a.provider, model=voter_a.model, + action=a.action, conf=a.confidence, size=a.suggested_size_pct, + reason=a.reasoning[:160]) + + voter_b = make_voter(s.ai.voter_b, s) + b = voter_b.decide(prompt) + log.info("voter_b", provider=voter_b.provider, model=voter_b.model, + action=b.action, conf=b.confidence, size=b.suggested_size_pct, + reason=b.reasoning[:160]) + + r = combine(a, b, s.ai.min_confidence) + log.info("ensemble", action=r.action, rationale=r.rationale) + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_discord.py b/scripts/smoke_discord.py new file mode 100644 index 0000000..de24798 --- /dev/null +++ b/scripts/smoke_discord.py @@ -0,0 +1,29 @@ +"""Sendet einen Test-Ping an den Discord-Webhook.""" +from __future__ import annotations + +from aitrader.config import get_settings +from aitrader.logging_setup import configure_logging, get_logger +from aitrader.notify import discord + + +def main() -> None: + configure_logging() + log = get_logger("smoke_discord") + s = get_settings() + if not s.discord_webhook_url: + log.error("DISCORD_WEBHOOK_URL nicht gesetzt — siehe .env") + return + discord.notify_startup(s) + discord._post( + s, + { + "title": "🧪 Test-Ping", + "color": discord.COLOR_BLUE, + "description": "Wenn du das in Discord siehst, funktioniert der Webhook.", + }, + ) + log.info("smoke_discord.done") + + +if __name__ == "__main__": + main() diff --git a/scripts/smoke_kraken.py b/scripts/smoke_kraken.py new file mode 100644 index 0000000..1df409e --- /dev/null +++ b/scripts/smoke_kraken.py @@ -0,0 +1,28 @@ +"""Smoke-Test: Demo-Kraken-Verbindung + OHLCV + Orderbook.""" +from __future__ import annotations + +from aitrader.config import get_settings +from aitrader.exchange.kraken import KrakenClient +from aitrader.logging_setup import configure_logging, get_logger + + +def main() -> None: + configure_logging() + log = get_logger("smoke_kraken") + s = get_settings() + c = KrakenClient(s) + for symbol in s.pairs: + log.info("ticker", symbol=symbol, t=c.fetch_ticker(symbol).get("last")) + ohlcv = c.fetch_ohlcv(symbol, "15m", limit=5) + log.info("ohlcv_tail", symbol=symbol, rows=len(ohlcv), last_close=float(ohlcv["close"].iloc[-1])) + ob = c.fetch_orderbook(symbol, depth=3) + log.info("orderbook", symbol=symbol, top_bid=ob["bids"][0], top_ask=ob["asks"][0]) + try: + bal = c.fetch_balance() + log.info("balance.ok", keys=list(bal.keys())[:5]) + except Exception as e: + log.warning("balance.failed", error=str(e)) + + +if __name__ == "__main__": + main() diff --git a/src/aitrader/__init__.py b/src/aitrader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/ai/__init__.py b/src/aitrader/ai/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/ai/claude.py b/src/aitrader/ai/claude.py new file mode 100644 index 0000000..6c8a3bc --- /dev/null +++ b/src/aitrader/ai/claude.py @@ -0,0 +1,48 @@ +"""Anthropic-Claude-Client via Tool-Use für strukturierten Output.""" +from __future__ import annotations + +import anthropic + +from ..config import Settings +from ..logging_setup import get_logger +from .prompt import SYSTEM_PROMPT +from .schema import JSON_SCHEMA, TradeDecision + +log = get_logger(__name__) + +_TOOL = { + "name": "decide_trade", + "description": "Liefert die Trading-Entscheidung als strukturierte Daten.", + "input_schema": JSON_SCHEMA, +} + + +class ClaudeClient: + provider = "claude" + + def __init__(self, settings: Settings, model: str | None = None) -> None: + if not settings.anthropic_api_key: + raise RuntimeError("ANTHROPIC_API_KEY nicht gesetzt") + self.client = anthropic.Anthropic( + api_key=settings.anthropic_api_key, + timeout=float(settings.ai.timeout_seconds), + ) + self.model = model or "claude-haiku-4-5-20251001" + + def decide(self, user_prompt: str) -> TradeDecision: + resp = self.client.messages.create( + model=self.model, + max_tokens=1024, + system=SYSTEM_PROMPT, + tools=[_TOOL], + tool_choice={"type": "tool", "name": "decide_trade"}, + messages=[{"role": "user", "content": user_prompt}], + ) + for block in resp.content: + if getattr(block, "type", None) == "tool_use" and block.name == "decide_trade": + try: + return TradeDecision.model_validate(block.input) + except Exception as e: + log.warning("claude.parse_failed", error=str(e), raw=str(block.input)[:300]) + break + return TradeDecision(action="HOLD", confidence=0.0, suggested_size_pct=0.0, reasoning="no_tool_use") diff --git a/src/aitrader/ai/ensemble.py b/src/aitrader/ai/ensemble.py new file mode 100644 index 0000000..a8637e5 --- /dev/null +++ b/src/aitrader/ai/ensemble.py @@ -0,0 +1,67 @@ +"""Ensemble: Konsens zwischen zwei generischen Votern.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .schema import Action, TradeDecision + + +@dataclass +class EnsembleResult: + action: Action + confidence: float + suggested_size_pct: float + voter_a: TradeDecision + voter_b: TradeDecision + rationale: str + + +def combine(a: TradeDecision, b: TradeDecision, min_conf: float = 0.6) -> EnsembleResult: + """Beide BUY/SELL mit confidence>=min_conf → Trade. Sonst HOLD.""" + if a.action == b.action and a.action in ("BUY", "SELL"): + if a.confidence >= min_conf and b.confidence >= min_conf: + return EnsembleResult( + action=a.action, + confidence=min(a.confidence, b.confidence), + suggested_size_pct=min(a.suggested_size_pct, b.suggested_size_pct), + voter_a=a, + voter_b=b, + rationale=f"consensus_{a.action.lower()}", + ) + return EnsembleResult( + action="HOLD", + confidence=min(a.confidence, b.confidence), + suggested_size_pct=0.0, + voter_a=a, + voter_b=b, + rationale="consensus_below_threshold", + ) + return EnsembleResult( + action="HOLD", + confidence=0.0, + suggested_size_pct=0.0, + voter_a=a, + voter_b=b, + rationale="disagreement", + ) + + +def single(a: TradeDecision, min_conf: float = 0.6) -> EnsembleResult: + """Nur ein Voter — Trade wenn confidence ≥ min_conf.""" + if a.action in ("BUY", "SELL") and a.confidence >= min_conf: + return EnsembleResult( + action=a.action, + confidence=a.confidence, + suggested_size_pct=a.suggested_size_pct, + voter_a=a, + voter_b=a, + rationale=f"single_{a.action.lower()}", + ) + return EnsembleResult( + action="HOLD", + confidence=a.confidence, + suggested_size_pct=0.0, + voter_a=a, + voter_b=a, + rationale="single_below_threshold" if a.action != "HOLD" else "single_hold", + ) diff --git a/src/aitrader/ai/gemini.py b/src/aitrader/ai/gemini.py new file mode 100644 index 0000000..f3aa871 --- /dev/null +++ b/src/aitrader/ai/gemini.py @@ -0,0 +1,49 @@ +"""Google-Gemini-Client mit JSON-Output.""" +from __future__ import annotations + +import json + +import google.generativeai as genai + +from ..config import Settings +from ..logging_setup import get_logger +from .prompt import SYSTEM_PROMPT +from .schema import JSON_SCHEMA, TradeDecision + +log = get_logger(__name__) + + +class GeminiClient: + provider = "gemini" + + def __init__( + self, settings: Settings, model: str | None = None, temperature: float = 0.2 + ) -> None: + if not settings.gemini_api_key: + raise RuntimeError("GEMINI_API_KEY nicht gesetzt") + genai.configure(api_key=settings.gemini_api_key) + self.model = model or "gemini-2.0-flash" + self.temperature = temperature + self._model = genai.GenerativeModel( + self.model, + system_instruction=SYSTEM_PROMPT, + ) + self.timeout = settings.ai.timeout_seconds + + def decide(self, user_prompt: str) -> TradeDecision: + resp = self._model.generate_content( + user_prompt, + generation_config={ + "response_mime_type": "application/json", + "response_schema": JSON_SCHEMA, + "temperature": self.temperature, + }, + request_options={"timeout": self.timeout}, + ) + text = resp.text or "{}" + try: + data = json.loads(text) + return TradeDecision.model_validate(data) + except Exception as e: + log.warning("gemini.parse_failed", error=str(e), raw=text[:300]) + return TradeDecision(action="HOLD", confidence=0.0, suggested_size_pct=0.0, reasoning=f"parse_error: {e}") diff --git a/src/aitrader/ai/openai_compat.py b/src/aitrader/ai/openai_compat.py new file mode 100644 index 0000000..4c064c5 --- /dev/null +++ b/src/aitrader/ai/openai_compat.py @@ -0,0 +1,61 @@ +"""Generischer OpenAI-kompatibler Client für Groq / DeepSeek / xAI / OpenRouter / Ollama.""" +from __future__ import annotations + +import json + +from openai import OpenAI + +from ..logging_setup import get_logger +from .prompt import SYSTEM_PROMPT +from .schema import TradeDecision + +log = get_logger(__name__) + + +class OpenAICompatClient: + """Funktioniert mit jedem Provider, der das OpenAI-Chat-API-Format spricht.""" + + def __init__( + self, + provider_name: str, + base_url: str, + api_key: str, + model: str, + temperature: float = 0.2, + timeout: int = 30, + ) -> None: + if not api_key: + raise RuntimeError(f"API-Key für {provider_name} nicht gesetzt") + self.provider = provider_name + self.model = model + self.temperature = temperature + self.client = OpenAI(api_key=api_key, base_url=base_url, timeout=timeout) + + def decide(self, user_prompt: str) -> TradeDecision: + try: + resp = self.client.chat.completions.create( + model=self.model, + temperature=self.temperature, + response_format={"type": "json_object"}, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt}, + ], + ) + except Exception as e: + log.warning(f"{self.provider}.api_error", error=str(e)) + return TradeDecision( + action="HOLD", confidence=0.0, suggested_size_pct=0.0, + reasoning=f"api_error: {e}", + ) + + text = (resp.choices[0].message.content or "{}").strip() + try: + data = json.loads(text) + return TradeDecision.model_validate(data) + except Exception as e: + log.warning(f"{self.provider}.parse_failed", error=str(e), raw=text[:300]) + return TradeDecision( + action="HOLD", confidence=0.0, suggested_size_pct=0.0, + reasoning=f"parse_error: {e}", + ) diff --git a/src/aitrader/ai/prompt.py b/src/aitrader/ai/prompt.py new file mode 100644 index 0000000..cab8578 --- /dev/null +++ b/src/aitrader/ai/prompt.py @@ -0,0 +1,43 @@ +"""Prompt-Builder: kondensiert Marktdaten + Indikatoren + News in einen kompakten Kontext.""" +from __future__ import annotations + +import json +from typing import Any + + +SYSTEM_PROMPT = ( + "Du bist ein vorsichtiger, regelbasierter Crypto-Trading-Analyst. " + "Du erhältst aktuelle Marktdaten und gibst genau eine Entscheidung in JSON aus: " + "BUY, SELL oder HOLD. " + "Sei konservativ: HOLD wenn Signale uneindeutig sind. confidence muss deine " + "tatsächliche Überzeugung widerspiegeln (≥0.6 nur wenn das Signal klar ist). " + "suggested_size_pct ist der Anteil der maximal erlaubten Positionsgröße (0-1)." +) + + +def build_user_prompt( + symbol: str, + features_by_tf: dict[str, dict[str, float | None]], + orderbook: dict[str, float], + headlines: list[dict[str, Any]], + sentiment: float, + current_position: dict[str, Any] | None, + equity_eur: float, +) -> str: + payload = { + "symbol": symbol, + "equity_eur": round(equity_eur, 2), + "current_position": current_position, + "indicators_by_timeframe": features_by_tf, + "orderbook_summary": orderbook, + "news": { + "avg_sentiment": round(sentiment, 3), + "headlines": [{"t": h["title"], "s": round(h["sentiment"], 2)} for h in headlines], + }, + } + return ( + "Treffe eine Trading-Entscheidung für das folgende Symbol auf Basis dieser Daten. " + "Antworte ausschließlich mit gültigem JSON gemäß Schema " + '({action, confidence, suggested_size_pct, reasoning}).\n\n' + f"DATEN:\n{json.dumps(payload, ensure_ascii=False, indent=2)}" + ) diff --git a/src/aitrader/ai/registry.py b/src/aitrader/ai/registry.py new file mode 100644 index 0000000..bf6007f --- /dev/null +++ b/src/aitrader/ai/registry.py @@ -0,0 +1,51 @@ +"""Provider-Registry: VoterConfig → konkreter Client.""" +from __future__ import annotations + +from typing import Protocol + +from ..config import Settings, VoterConfig +from .schema import TradeDecision + + +class Voter(Protocol): + provider: str + model: str + + def decide(self, user_prompt: str) -> TradeDecision: ... + + +# OpenAI-kompatible Endpunkte +_OPENAI_COMPAT = { + "groq": {"base_url": "https://api.groq.com/openai/v1", "env": "groq_api_key"}, + "deepseek": {"base_url": "https://api.deepseek.com", "env": "deepseek_api_key"}, + "xai": {"base_url": "https://api.x.ai/v1", "env": "xai_api_key"}, + "openrouter": {"base_url": "https://openrouter.ai/api/v1", "env": "openrouter_api_key"}, + "ollama": {"base_url": "http://localhost:11434/v1", "env": None}, # kein Key +} + + +def make_voter(cfg: VoterConfig, settings: Settings) -> Voter: + p = cfg.provider.lower() + + if p == "gemini": + from .gemini import GeminiClient + return GeminiClient(settings, model=cfg.model, temperature=cfg.temperature) + + if p == "claude" or p == "anthropic": + from .claude import ClaudeClient + return ClaudeClient(settings, model=cfg.model) + + if p in _OPENAI_COMPAT: + from .openai_compat import OpenAICompatClient + spec = _OPENAI_COMPAT[p] + api_key = getattr(settings, spec["env"]) if spec["env"] else "ollama" + return OpenAICompatClient( + provider_name=p, + base_url=spec["base_url"], + api_key=api_key, + model=cfg.model, + temperature=cfg.temperature, + timeout=settings.ai.timeout_seconds, + ) + + raise ValueError(f"Unbekannter Provider: {cfg.provider}") diff --git a/src/aitrader/ai/schema.py b/src/aitrader/ai/schema.py new file mode 100644 index 0000000..f78d5ff --- /dev/null +++ b/src/aitrader/ai/schema.py @@ -0,0 +1,27 @@ +"""Gemeinsames Antwort-Schema für AI-Entscheidungen.""" +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + +Action = Literal["BUY", "SELL", "HOLD"] + + +class TradeDecision(BaseModel): + action: Action + confidence: float = Field(ge=0.0, le=1.0) + suggested_size_pct: float = Field(ge=0.0, le=1.0, default=0.0) + reasoning: str = "" + + +JSON_SCHEMA = { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["BUY", "SELL", "HOLD"]}, + "confidence": {"type": "number"}, + "suggested_size_pct": {"type": "number"}, + "reasoning": {"type": "string"}, + }, + "required": ["action", "confidence", "suggested_size_pct", "reasoning"], +} diff --git a/src/aitrader/config.py b/src/aitrader/config.py new file mode 100644 index 0000000..226e2a9 --- /dev/null +++ b/src/aitrader/config.py @@ -0,0 +1,117 @@ +"""Konfiguration: YAML + ENV → Pydantic-Models.""" +from __future__ import annotations + +import os +from functools import lru_cache +from pathlib import Path + +import yaml +from dotenv import load_dotenv +from pydantic import BaseModel, Field + + +class RiskConfig(BaseModel): + max_position_pct: float = 0.20 + max_open_positions: int = 2 + stop_loss_atr_mult: float = 2.0 + take_profit_atr_mult: float = 3.0 + daily_loss_limit_pct: float = 0.05 + min_order_eur: float = 25.0 + + +class VoterConfig(BaseModel): + provider: str = "gemini" # gemini | claude | groq | deepseek | xai | openrouter | ollama + model: str = "gemini-2.0-flash" + temperature: float = 0.2 + + +class AIConfig(BaseModel): + mode: str = "ensemble" # ensemble | single (single → nur voter_a) + voter_a: VoterConfig = VoterConfig(provider="gemini", model="gemini-2.0-flash", temperature=0.1) + voter_b: VoterConfig = VoterConfig(provider="groq", model="llama-3.3-70b-versatile", temperature=0.4) + min_confidence: float = 0.6 + timeout_seconds: int = 30 + + +class NewsConfig(BaseModel): + enabled: bool = True + source: str = "cryptopanic" + max_headlines: int = 10 + lookback_hours: int = 6 + + +class ExchangeConfig(BaseModel): + id: str = "krakenfutures" + sandbox: bool = True + paper_only: bool = False + + +class DiscordConfig(BaseModel): + enabled: bool = True + notify_on: list[str] = Field( + default_factory=lambda: [ + "startup", + "trade_open", + "trade_close", + "risk_block", + "error", + "daily_summary", + "news_alert", + ] + ) + news_sentiment_threshold: float = 0.4 + daily_summary_hour_utc: int = 22 + + +class Settings(BaseModel): + pairs: list[str] = Field(default_factory=lambda: ["BTC/EUR", "ETH/EUR"]) + interval_minutes: int = 15 + timeframes: list[str] = Field(default_factory=lambda: ["15m", "1h", "4h"]) + ohlcv_limit: int = 200 + starting_equity_eur: float = 10000.0 + risk: RiskConfig = RiskConfig() + ai: AIConfig = AIConfig() + news: NewsConfig = NewsConfig() + exchange: ExchangeConfig = ExchangeConfig() + discord: DiscordConfig = DiscordConfig() + + # ENV-Werte (nicht in YAML) + kraken_key: str = "" + kraken_secret: str = "" + gemini_api_key: str = "" + anthropic_api_key: str = "" + groq_api_key: str = "" + deepseek_api_key: str = "" + xai_api_key: str = "" + openrouter_api_key: str = "" + cryptopanic_api_key: str = "" + discord_webhook_url: str = "" + db_path: str = "data/aitrader.db" + + +def _project_root() -> Path: + return Path(__file__).resolve().parents[2] + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + load_dotenv(_project_root() / ".env", override=False) + cfg_path = Path(os.getenv("AITRADER_CONFIG", _project_root() / "config.yaml")) + raw: dict = {} + if cfg_path.exists(): + raw = yaml.safe_load(cfg_path.read_text()) or {} + settings = Settings( + **raw, + kraken_key=os.getenv("KRAKEN_DEMO_KEY", ""), + kraken_secret=os.getenv("KRAKEN_DEMO_SECRET", ""), + gemini_api_key=os.getenv("GEMINI_API_KEY", ""), + anthropic_api_key=os.getenv("ANTHROPIC_API_KEY", ""), + groq_api_key=os.getenv("GROQ_API_KEY", ""), + deepseek_api_key=os.getenv("DEEPSEEK_API_KEY", ""), + xai_api_key=os.getenv("XAI_API_KEY", ""), + openrouter_api_key=os.getenv("OPENROUTER_API_KEY", ""), + cryptopanic_api_key=os.getenv("CRYPTOPANIC_API_KEY", ""), + discord_webhook_url=os.getenv("DISCORD_WEBHOOK_URL", ""), + db_path=os.getenv("AITRADER_DB", "data/aitrader.db"), + ) + return settings diff --git a/src/aitrader/dashboard/__init__.py b/src/aitrader/dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/dashboard/app.py b/src/aitrader/dashboard/app.py new file mode 100644 index 0000000..db1bfd9 --- /dev/null +++ b/src/aitrader/dashboard/app.py @@ -0,0 +1,92 @@ +"""Streamlit-Dashboard: Equity, Trades, AI-Vergleich.""" +from __future__ import annotations + +import pandas as pd +import plotly.express as px +import streamlit as st +from sqlmodel import select + +from ..config import get_settings +from ..storage import db as dbm +from ..storage.models import Decision, EquitySnapshot, Trade + +st.set_page_config(page_title="aitrader", layout="wide") +settings = get_settings() +engine = dbm.get_engine(settings.db_path) + + +def load_df(stmt) -> pd.DataFrame: + with dbm.session(settings.db_path) as s: + rows = s.exec(stmt).all() + return pd.DataFrame([r.model_dump() for r in rows]) + + +tab_overview, tab_trades, tab_decisions, tab_ai = st.tabs( + ["Overview", "Trades", "Decisions", "AI-Vergleich"] +) + +with tab_overview: + st.subheader("Equity-Kurve") + df_eq = load_df(select(EquitySnapshot).order_by(EquitySnapshot.ts)) + if df_eq.empty: + st.info("Noch keine Snapshots. Bot mindestens einmal ticken lassen.") + else: + fig = px.line(df_eq, x="ts", y="equity_eur", title="Equity (EUR)") + st.plotly_chart(fig, use_container_width=True) + col1, col2, col3, col4 = st.columns(4) + col1.metric("Equity", f"{df_eq['equity_eur'].iloc[-1]:,.2f} EUR") + col2.metric("Realized PnL", f"{df_eq['realized_pnl_eur'].iloc[-1]:,.2f} EUR") + col3.metric("Unrealized PnL", f"{df_eq['unrealized_pnl_eur'].iloc[-1]:,.2f} EUR") + col4.metric("Offene Positionen", int(df_eq["open_positions"].iloc[-1])) + +with tab_trades: + st.subheader("Trades") + df_t = load_df(select(Trade).order_by(Trade.entry_ts.desc())) + if df_t.empty: + st.info("Keine Trades.") + else: + st.dataframe(df_t, use_container_width=True) + closed = df_t[df_t["status"] == "closed"] + if not closed.empty: + wins = (closed["pnl_eur"] > 0).sum() + st.metric("Win-Rate", f"{wins / len(closed) * 100:.1f}%") + st.metric("Gesamt-PnL", f"{closed['pnl_eur'].sum():,.2f} EUR") + +with tab_decisions: + st.subheader("AI-Decisions (alle Ticks)") + df_d = load_df(select(Decision).order_by(Decision.ts.desc())) + if df_d.empty: + st.info("Keine Decisions geloggt.") + else: + st.dataframe( + df_d[ + [ + "ts", + "symbol", + "ensemble_action", + "ensemble_confidence", + "voter_a_provider", + "voter_a_action", + "voter_a_confidence", + "voter_b_provider", + "voter_b_action", + "voter_b_confidence", + "rationale", + ] + ], + use_container_width=True, + ) + +with tab_ai: + st.subheader("AI-Übereinstimmung") + df_d = load_df(select(Decision)) + if df_d.empty: + st.info("Keine Daten.") + else: + agree = (df_d["voter_a_action"] == df_d["voter_b_action"]).mean() * 100 + st.metric("Voter-A ↔ Voter-B Übereinstimmung", f"{agree:.1f}%") + counts = df_d.groupby(["voter_a_provider", "voter_a_action", "voter_b_provider", "voter_b_action"]).size().reset_index(name="n") + st.dataframe(counts, use_container_width=True) + st.caption( + "Hinweis: Nur Ticks bei denen beide BUY/SELL mit ausreichender Confidence stimmen führen zu Trades." + ) diff --git a/src/aitrader/exchange/__init__.py b/src/aitrader/exchange/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/exchange/kraken.py b/src/aitrader/exchange/kraken.py new file mode 100644 index 0000000..55aa032 --- /dev/null +++ b/src/aitrader/exchange/kraken.py @@ -0,0 +1,69 @@ +"""Kraken-Client (Futures Demo via ccxt).""" +from __future__ import annotations + +from typing import Any + +import ccxt +import pandas as pd + +from ..config import Settings +from ..logging_setup import get_logger + +log = get_logger(__name__) + + +class KrakenClient: + def __init__(self, settings: Settings) -> None: + self.settings = settings + exchange_cls = getattr(ccxt, settings.exchange.id) + self.x = exchange_cls( + { + "apiKey": settings.kraken_key, + "secret": settings.kraken_secret, + "enableRateLimit": True, + "timeout": 20000, + } + ) + if settings.exchange.sandbox: + self.x.set_sandbox_mode(True) + self._markets_loaded = False + + def _ensure_markets(self) -> None: + if not self._markets_loaded: + self.x.load_markets() + self._markets_loaded = True + + def fetch_ohlcv(self, symbol: str, timeframe: str, limit: int = 200) -> pd.DataFrame: + self._ensure_markets() + raw = self.x.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit) + df = pd.DataFrame(raw, columns=["ts", "open", "high", "low", "close", "volume"]) + df["ts"] = pd.to_datetime(df["ts"], unit="ms", utc=True) + return df + + def fetch_orderbook(self, symbol: str, depth: int = 10) -> dict[str, Any]: + self._ensure_markets() + ob = self.x.fetch_order_book(symbol, limit=depth) + return {"bids": ob["bids"][:depth], "asks": ob["asks"][:depth]} + + def fetch_ticker(self, symbol: str) -> dict[str, Any]: + self._ensure_markets() + return self.x.fetch_ticker(symbol) + + def fetch_balance(self) -> dict[str, Any]: + self._ensure_markets() + return self.x.fetch_balance() + + def create_market_order(self, symbol: str, side: str, amount: float) -> dict[str, Any]: + self._ensure_markets() + if self.settings.exchange.paper_only: + log.info("paper_only.skip_order", symbol=symbol, side=side, amount=amount) + return {"id": "paper", "status": "simulated", "symbol": symbol, "side": side, "amount": amount} + return self.x.create_order(symbol, type="market", side=side, amount=amount) + + def fetch_open_positions(self) -> list[dict[str, Any]]: + self._ensure_markets() + try: + return self.x.fetch_positions() + except Exception as e: # ccxt exchange may not support + log.warning("fetch_positions.unsupported", error=str(e)) + return [] diff --git a/src/aitrader/exchange/market_data.py b/src/aitrader/exchange/market_data.py new file mode 100644 index 0000000..c84e67a --- /dev/null +++ b/src/aitrader/exchange/market_data.py @@ -0,0 +1,31 @@ +"""Sammelt Marktdaten (OHLCV mehrerer Timeframes + Orderbook + Ticker).""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +import pandas as pd + +from .kraken import KrakenClient + + +@dataclass +class MarketSnapshot: + symbol: str + ticker: dict[str, Any] + ohlcv: dict[str, pd.DataFrame] = field(default_factory=dict) # timeframe -> df + orderbook: dict[str, Any] = field(default_factory=dict) + + +def collect_snapshot( + client: KrakenClient, + symbol: str, + timeframes: list[str], + ohlcv_limit: int, + orderbook_depth: int = 10, +) -> MarketSnapshot: + snap = MarketSnapshot(symbol=symbol, ticker=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) + return snap diff --git a/src/aitrader/features/__init__.py b/src/aitrader/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/features/indicators.py b/src/aitrader/features/indicators.py new file mode 100644 index 0000000..480b7bb --- /dev/null +++ b/src/aitrader/features/indicators.py @@ -0,0 +1,67 @@ +"""Technische Indikatoren ohne externe Lib (vermeidet pandas-ta-numpy2-Probleme).""" +from __future__ import annotations + +import pandas as pd + + +def _ema(s: pd.Series, length: int) -> pd.Series: + return s.ewm(span=length, adjust=False).mean() + + +def rsi(close: pd.Series, length: int = 14) -> pd.Series: + delta = close.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.ewm(alpha=1 / length, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / length, adjust=False).mean() + rs = avg_gain / avg_loss.replace(0, pd.NA) + return 100 - (100 / (1 + rs)) + + +def macd(close: pd.Series, fast: int = 12, slow: int = 26, signal: int = 9) -> pd.DataFrame: + macd_line = _ema(close, fast) - _ema(close, slow) + signal_line = _ema(macd_line, signal) + hist = macd_line - signal_line + return pd.DataFrame({"macd": macd_line, "signal": signal_line, "hist": hist}) + + +def atr(df: pd.DataFrame, length: int = 14) -> pd.Series: + high, low, close = df["high"], df["low"], df["close"] + prev_close = close.shift(1) + tr = pd.concat( + [(high - low), (high - prev_close).abs(), (low - prev_close).abs()], axis=1 + ).max(axis=1) + return tr.ewm(alpha=1 / length, adjust=False).mean() + + +def compute_features(df: pd.DataFrame) -> dict[str, float | None]: + """Letzte Werte einer Kerzenreihe als kompakter Feature-Block.""" + if len(df) < 50: + return {} + close = df["close"] + rsi14 = rsi(close, 14).iloc[-1] + macd_df = macd(close) + ema20 = _ema(close, 20).iloc[-1] + ema50 = _ema(close, 50).iloc[-1] + ema200 = _ema(close, 200).iloc[-1] if len(df) >= 200 else None + atr14 = atr(df, 14).iloc[-1] + last_close = close.iloc[-1] + + def _f(x): + try: + return None if pd.isna(x) else float(x) + except (TypeError, ValueError): + return None + + return { + "close": _f(last_close), + "rsi14": _f(rsi14), + "macd": _f(macd_df["macd"].iloc[-1]), + "macd_signal": _f(macd_df["signal"].iloc[-1]), + "macd_hist": _f(macd_df["hist"].iloc[-1]), + "ema20": _f(ema20), + "ema50": _f(ema50), + "ema200": _f(ema200), + "atr14": _f(atr14), + "pct_change_24": _f((last_close / close.iloc[-min(96, len(close))] - 1) * 100), + } diff --git a/src/aitrader/features/orderbook.py b/src/aitrader/features/orderbook.py new file mode 100644 index 0000000..f3e3e84 --- /dev/null +++ b/src/aitrader/features/orderbook.py @@ -0,0 +1,26 @@ +"""Orderbook-Features.""" +from __future__ import annotations + +from typing import Any + + +def summarize_orderbook(ob: dict[str, Any]) -> dict[str, float]: + bids = ob.get("bids", []) + asks = ob.get("asks", []) + if not bids or not asks: + return {} + best_bid, bid_vol = bids[0] + best_ask, ask_vol = asks[0] + mid = (best_bid + best_ask) / 2 + spread_bps = (best_ask - best_bid) / mid * 10_000 if mid else 0.0 + + bid_total = sum(v for _, v in bids) + ask_total = sum(v for _, v in asks) + imbalance = (bid_total - ask_total) / (bid_total + ask_total) if (bid_total + ask_total) else 0.0 + + return { + "best_bid": float(best_bid), + "best_ask": float(best_ask), + "spread_bps": float(spread_bps), + "depth_imbalance": float(imbalance), # >0 mehr Kauf, <0 mehr Verkauf + } diff --git a/src/aitrader/logging_setup.py b/src/aitrader/logging_setup.py new file mode 100644 index 0000000..5bc0f11 --- /dev/null +++ b/src/aitrader/logging_setup.py @@ -0,0 +1,31 @@ +"""Strukturiertes Logging via structlog.""" +from __future__ import annotations + +import logging +import sys + +import structlog + + +def configure_logging(level: str = "INFO") -> None: + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=getattr(logging, level.upper(), logging.INFO), + ) + structlog.configure( + processors=[ + structlog.contextvars.merge_contextvars, + structlog.processors.add_log_level, + structlog.processors.TimeStamper(fmt="iso"), + structlog.dev.ConsoleRenderer(colors=sys.stdout.isatty()), + ], + wrapper_class=structlog.make_filtering_bound_logger( + getattr(logging, level.upper(), logging.INFO) + ), + cache_logger_on_first_use=True, + ) + + +def get_logger(name: str = "aitrader") -> structlog.stdlib.BoundLogger: + return structlog.get_logger(name) diff --git a/src/aitrader/main.py b/src/aitrader/main.py new file mode 100644 index 0000000..b2d0304 --- /dev/null +++ b/src/aitrader/main.py @@ -0,0 +1,242 @@ +"""Scheduler-Entry: Tick alle N Minuten, oder --once für Einzeldurchlauf.""" +from __future__ import annotations + +import argparse +import json +import signal +import sys +from datetime import datetime, timedelta, timezone + +from apscheduler.schedulers.blocking import BlockingScheduler +from sqlmodel import select + +from .ai.ensemble import combine, single +from .ai.prompt import build_user_prompt +from .ai.registry import Voter, make_voter +from .config import Settings, get_settings +from .exchange.kraken import KrakenClient +from .exchange.market_data import collect_snapshot +from .features.indicators import compute_features +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 .storage import db as dbm +from .storage.models import Decision, Trade +from .trader import portfolio, risk +from .trader.executor import execute_trade + +log = get_logger(__name__) + + +def _current_position_summary(settings: Settings, symbol: str) -> dict | None: + open_trades = portfolio.open_trades_for_symbol(settings, symbol) + if not open_trades: + return None + t = open_trades[0] + return { + "side": t.side, + "qty": t.qty, + "entry_price": t.entry_price, + "stop_loss": t.stop_loss, + "take_profit": t.take_profit, + } + + +def run_tick( + settings: Settings, + kraken: KrakenClient, + voter_a: Voter, + voter_b: Voter | None, +) -> None: + log.info("tick.start", pairs=settings.pairs) + last_prices: dict[str, float] = {} + + for symbol in settings.pairs: + try: + snap = collect_snapshot(kraken, symbol, settings.timeframes, settings.ohlcv_limit) + except Exception as e: + log.error("market_data.failed", symbol=symbol, error=str(e)) + discord.notify_error(settings, f"market_data ({symbol})", str(e)) + continue + + last = float(snap.ticker.get("last") or snap.ohlcv[settings.timeframes[0]]["close"].iloc[-1]) + last_prices[symbol] = last + + closed_ids = portfolio.check_stop_take_profit(settings, symbol, last) + if closed_ids: + log.info("trade.sl_tp_closed", symbol=symbol, trade_ids=closed_ids) + + features_by_tf = {tf: compute_features(snap.ohlcv[tf]) for tf in settings.timeframes} + ob_summary = summarize_orderbook(snap.orderbook) + headlines = fetch_headlines(settings, symbol) + sentiment = aggregate_sentiment(headlines) + equity = risk.latest_equity_eur(settings) + position = _current_position_summary(settings, symbol) + + prompt = build_user_prompt( + symbol=symbol, + features_by_tf=features_by_tf, + orderbook=ob_summary, + headlines=headlines, + sentiment=sentiment, + current_position=position, + equity_eur=equity, + ) + discord.notify_news_alert(settings, symbol, headlines, sentiment) + + try: + a = voter_a.decide(prompt) + except Exception as e: + log.error("voter_a.failed", provider=voter_a.provider, error=str(e)) + discord.notify_error(settings, f"voter_a={voter_a.provider} ({symbol})", str(e)) + continue + + if voter_b is not None: + try: + b = voter_b.decide(prompt) + except Exception as 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)) + continue + result = combine(a, b, settings.ai.min_confidence) + else: + b = a + result = single(a, settings.ai.min_confidence) + + log.info( + "ensemble.result", + symbol=symbol, + action=result.action, + conf=result.confidence, + voter_a=f"{voter_a.provider}:{a.action}", + voter_b=f"{voter_b.provider if voter_b else '-'}:{b.action}", + rationale=result.rationale, + ) + + decision = Decision( + symbol=symbol, + ensemble_action=result.action, + ensemble_confidence=result.confidence, + ensemble_size_pct=result.suggested_size_pct, + voter_a_provider=voter_a.provider, + voter_a_model=voter_a.model, + voter_a_action=a.action, + voter_a_confidence=a.confidence, + voter_a_size_pct=a.suggested_size_pct, + voter_a_reasoning=a.reasoning, + voter_b_provider=voter_b.provider if voter_b else "", + voter_b_model=voter_b.model if voter_b else "", + voter_b_action=b.action, + voter_b_confidence=b.confidence, + voter_b_size_pct=b.suggested_size_pct, + voter_b_reasoning=b.reasoning, + rationale=result.rationale, + features_json=json.dumps( + {"indicators": features_by_tf, "orderbook": ob_summary, "sentiment": sentiment}, + default=str, + ), + ) + with dbm.session(settings.db_path) as s: + s.add(decision) + s.commit() + s.refresh(decision) + decision_id = decision.id + + discord.notify_decision( + settings, symbol, result, + label_a=f"{voter_a.provider}", + label_b=(voter_b.provider if voter_b else "-"), + ) + + if result.action in ("BUY", "SELL") and not position: + check = risk.evaluate(settings, result.action, result.suggested_size_pct, last) + if not check.allow: + log.info("risk.block", symbol=symbol, reason=check.reason) + discord.notify_risk_block(settings, symbol, check.reason) + else: + atr_value = features_by_tf.get(settings.timeframes[0], {}).get("atr14") + execute_trade( + settings, kraken, symbol, result.action, + check.qty_eur, last, atr_value, decision_id, + ) + + portfolio.snapshot_equity(settings, last_prices) + log.info("tick.done") + + +def run_daily_summary(settings: Settings) -> None: + cutoff = datetime.now(timezone.utc) - timedelta(hours=24) + with dbm.session(settings.db_path) as s: + closed = s.exec( + select(Trade).where(Trade.exit_ts.is_not(None), Trade.exit_ts >= cutoff) + ).all() + open_count = len(s.exec(select(Trade).where(Trade.status == "open")).all()) + realized = sum((t.pnl_eur or 0.0) for t in closed) + wins = sum(1 for t in closed if (t.pnl_eur or 0.0) > 0) + discord.notify_daily_summary( + settings, + equity_eur=risk.latest_equity_eur(settings), + realized_24h=realized, + trades_24h=len(closed), + wins_24h=wins, + open_positions=open_count, + ) + + +def cli() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--once", action="store_true", help="Nur ein Tick, dann beenden") + parser.add_argument("--log-level", default="INFO") + args = parser.parse_args() + + configure_logging(args.log_level) + settings = get_settings() + log.info( + "startup", + pairs=settings.pairs, + sandbox=settings.exchange.sandbox, + paper_only=settings.exchange.paper_only, + interval=settings.interval_minutes, + mode=settings.ai.mode, + voter_a=f"{settings.ai.voter_a.provider}/{settings.ai.voter_a.model}", + voter_b=f"{settings.ai.voter_b.provider}/{settings.ai.voter_b.model}", + ) + + kraken = KrakenClient(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 + + discord.notify_startup(settings) + + if args.once: + run_tick(settings, kraken, voter_a, voter_b) + return + + sched = BlockingScheduler(timezone="UTC") + sched.add_job( + run_tick, "cron", + minute=f"*/{settings.interval_minutes}", + args=[settings, kraken, voter_a, voter_b], + id="aitrader_tick", max_instances=1, coalesce=True, + ) + sched.add_job( + run_daily_summary, "cron", + hour=settings.discord.daily_summary_hour_utc, minute=0, + args=[settings], + id="aitrader_daily_summary", max_instances=1, + ) + + def _shutdown(signum, _frame): + log.info("shutdown.signal", signum=signum) + sched.shutdown(wait=False) + sys.exit(0) + + signal.signal(signal.SIGINT, _shutdown) + signal.signal(signal.SIGTERM, _shutdown) + log.info("scheduler.start") + sched.start() + + +if __name__ == "__main__": + cli() diff --git a/src/aitrader/news/__init__.py b/src/aitrader/news/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/news/sentiment.py b/src/aitrader/news/sentiment.py new file mode 100644 index 0000000..e2d81fb --- /dev/null +++ b/src/aitrader/news/sentiment.py @@ -0,0 +1,67 @@ +"""News + Sentiment via CryptoPanic (optional).""" +from __future__ import annotations + +from typing import Any + +import requests +from vaderSentiment.vaderSentiment import SentimentIntensityAnalyzer + +from ..config import Settings +from ..logging_setup import get_logger + +log = get_logger(__name__) +_vader = SentimentIntensityAnalyzer() + +_SYMBOL_TO_CURRENCY = { + "BTC/EUR": "BTC", + "ETH/EUR": "ETH", + "BTC/USD": "BTC", + "ETH/USD": "ETH", +} + + +def fetch_headlines(settings: Settings, symbol: str) -> list[dict[str, Any]]: + if not settings.news.enabled: + return [] + if settings.news.source != "cryptopanic" or not settings.cryptopanic_api_key: + log.debug("news.disabled_or_no_key") + return [] + + currency = _SYMBOL_TO_CURRENCY.get(symbol) + if not currency: + return [] + + url = "https://cryptopanic.com/api/v1/posts/" + params = { + "auth_token": settings.cryptopanic_api_key, + "currencies": currency, + "public": "true", + "kind": "news", + } + try: + r = requests.get(url, params=params, timeout=10) + r.raise_for_status() + except requests.RequestException as e: + log.warning("news.fetch_failed", error=str(e)) + return [] + + items = r.json().get("results", [])[: settings.news.max_headlines] + out = [] + for it in items: + title = it.get("title", "") + score = _vader.polarity_scores(title)["compound"] if title else 0.0 + out.append( + { + "title": title, + "url": it.get("url"), + "published_at": it.get("published_at"), + "sentiment": score, + } + ) + return out + + +def aggregate_sentiment(headlines: list[dict[str, Any]]) -> float: + if not headlines: + return 0.0 + return sum(h["sentiment"] for h in headlines) / len(headlines) diff --git a/src/aitrader/notify/__init__.py b/src/aitrader/notify/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/notify/discord.py b/src/aitrader/notify/discord.py new file mode 100644 index 0000000..9bf845b --- /dev/null +++ b/src/aitrader/notify/discord.py @@ -0,0 +1,208 @@ +"""Discord-Webhook-Notifier.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import requests + +from ..config import Settings +from ..logging_setup import get_logger + +log = get_logger(__name__) + +COLOR_GREEN = 0x2ECC71 +COLOR_RED = 0xE74C3C +COLOR_BLUE = 0x3498DB +COLOR_GRAY = 0x95A5A6 +COLOR_YELLOW = 0xF1C40F + + +def _enabled(settings: Settings) -> bool: + return bool(settings.discord.enabled and settings.discord_webhook_url) + + +def _post(settings: Settings, embed: dict[str, Any]) -> None: + if not _enabled(settings): + return + embed.setdefault("timestamp", datetime.now(timezone.utc).isoformat()) + try: + r = requests.post( + settings.discord_webhook_url, + json={"embeds": [embed]}, + 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: + log.warning("discord.post_exception", error=str(e)) + + +def _should(settings: Settings, event: str) -> bool: + return _enabled(settings) and event in settings.discord.notify_on + + +def notify_startup(settings: Settings) -> None: + if not _should(settings, "startup"): + return + _post( + settings, + { + "title": "🤖 aitrader gestartet", + "color": COLOR_BLUE, + "fields": [ + {"name": "Pairs", "value": ", ".join(settings.pairs), "inline": True}, + {"name": "Intervall", "value": f"{settings.interval_minutes} min", "inline": True}, + { + "name": "Modus", + "value": ("Sandbox" if settings.exchange.sandbox else "**LIVE**") + + (" / paper-only" if settings.exchange.paper_only else ""), + "inline": True, + }, + ], + }, + ) + + +def notify_decision(settings: Settings, symbol: str, ensemble, label_a: str, label_b: str) -> None: + """Optional: jede Ensemble-Decision posten (kann spammig sein).""" + if not _should(settings, "decision"): + return + color = ( + COLOR_GREEN if ensemble.action == "BUY" + else COLOR_RED if ensemble.action == "SELL" + else COLOR_GRAY + ) + _post( + settings, + { + "title": f"🧠 Decision {symbol}: {ensemble.action}", + "color": color, + "fields": [ + {"name": label_a, "value": f"{ensemble.voter_a.action} ({ensemble.voter_a.confidence:.2f})", "inline": True}, + {"name": label_b, "value": f"{ensemble.voter_b.action} ({ensemble.voter_b.confidence:.2f})", "inline": True}, + {"name": "Rationale", "value": ensemble.rationale, "inline": False}, + ], + }, + ) + + +def notify_trade_opened(settings: Settings, trade) -> None: + if not _should(settings, "trade_open"): + return + side_emoji = "📈" if trade.side == "buy" else "📉" + _post( + settings, + { + "title": f"{side_emoji} Trade eröffnet — {trade.symbol}", + "color": COLOR_GREEN if trade.side == "buy" else COLOR_RED, + "fields": [ + {"name": "Side", "value": trade.side.upper(), "inline": True}, + {"name": "Qty", "value": f"{trade.qty:.6f}", "inline": True}, + {"name": "Entry", "value": f"{trade.entry_price:.2f} EUR", "inline": True}, + { + "name": "Stop-Loss", + "value": f"{trade.stop_loss:.2f}" if trade.stop_loss else "—", + "inline": True, + }, + { + "name": "Take-Profit", + "value": f"{trade.take_profit:.2f}" if trade.take_profit else "—", + "inline": True, + }, + {"name": "Order-ID", "value": trade.exchange_order_id or "—", "inline": True}, + ], + }, + ) + + +def notify_trade_closed(settings: Settings, trade) -> None: + if not _should(settings, "trade_close"): + return + pnl = trade.pnl_eur or 0.0 + _post( + settings, + { + "title": ("✅ Gewinn" if pnl > 0 else "❌ Verlust") + f" — {trade.symbol}", + "color": COLOR_GREEN if pnl > 0 else COLOR_RED, + "fields": [ + {"name": "Side", "value": trade.side.upper(), "inline": True}, + {"name": "Entry", "value": f"{trade.entry_price:.2f}", "inline": True}, + {"name": "Exit", "value": f"{trade.exit_price:.2f}" if trade.exit_price else "—", "inline": True}, + {"name": "PnL", "value": f"**{pnl:+.2f} EUR**", "inline": True}, + {"name": "Qty", "value": f"{trade.qty:.6f}", "inline": True}, + ], + }, + ) + + +def notify_risk_block(settings: Settings, symbol: str, reason: str) -> None: + if not _should(settings, "risk_block"): + return + _post( + settings, + { + "title": f"⚠️ Risk-Block — {symbol}", + "color": COLOR_YELLOW, + "description": f"Grund: `{reason}`", + }, + ) + + +def notify_error(settings: Settings, where: str, error: str) -> None: + if not _should(settings, "error"): + return + _post( + settings, + { + "title": f"🛑 Fehler: {where}", + "color": COLOR_RED, + "description": f"```\n{error[:1500]}\n```", + }, + ) + + +def notify_daily_summary( + settings: Settings, + equity_eur: float, + realized_24h: float, + trades_24h: int, + wins_24h: int, + open_positions: int, +) -> None: + if not _should(settings, "daily_summary"): + return + win_rate = (wins_24h / trades_24h * 100) if trades_24h else 0.0 + _post( + settings, + { + "title": "📊 Daily Summary (letzte 24h)", + "color": COLOR_GREEN if realized_24h >= 0 else COLOR_RED, + "fields": [ + {"name": "Equity", "value": f"{equity_eur:,.2f} EUR", "inline": True}, + {"name": "PnL 24h", "value": f"**{realized_24h:+.2f} EUR**", "inline": True}, + {"name": "Trades", "value": str(trades_24h), "inline": True}, + {"name": "Win-Rate", "value": f"{win_rate:.1f}%", "inline": True}, + {"name": "Offene Positionen", "value": str(open_positions), "inline": True}, + ], + }, + ) + + +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"): + return + threshold = settings.discord.news_sentiment_threshold + if abs(avg_sentiment) < threshold or not headlines: + return + color = COLOR_GREEN if avg_sentiment > 0 else COLOR_RED + top = "\n".join(f"• ({h['sentiment']:+.2f}) {h['title'][:140]}" for h in headlines[:5]) + _post( + settings, + { + "title": f"📰 News-Alert {symbol} (sentiment {avg_sentiment:+.2f})", + "color": color, + "description": top, + }, + ) diff --git a/src/aitrader/storage/__init__.py b/src/aitrader/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/storage/db.py b/src/aitrader/storage/db.py new file mode 100644 index 0000000..422ee76 --- /dev/null +++ b/src/aitrader/storage/db.py @@ -0,0 +1,24 @@ +"""SQLite-Engine + Init.""" +from __future__ import annotations + +from pathlib import Path + +from sqlmodel import Session, SQLModel, create_engine + +from . import models # noqa: F401 registriert Tabellen + + +_engine = None + + +def get_engine(db_path: str): + global _engine + if _engine is None: + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + _engine = create_engine(f"sqlite:///{db_path}", echo=False) + SQLModel.metadata.create_all(_engine) + return _engine + + +def session(db_path: str) -> Session: + return Session(get_engine(db_path)) diff --git a/src/aitrader/storage/models.py b/src/aitrader/storage/models.py new file mode 100644 index 0000000..5562284 --- /dev/null +++ b/src/aitrader/storage/models.py @@ -0,0 +1,63 @@ +"""SQLModel-Tabellen.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Optional + +from sqlmodel import Field, SQLModel + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Decision(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + ts: datetime = Field(default_factory=_utcnow, index=True) + symbol: str = Field(index=True) + ensemble_action: str + ensemble_confidence: float + ensemble_size_pct: float + + voter_a_provider: str = "" + voter_a_model: str = "" + voter_a_action: str = "" + voter_a_confidence: float = 0.0 + voter_a_size_pct: float = 0.0 + voter_a_reasoning: str = "" + + voter_b_provider: str = "" + voter_b_model: str = "" + voter_b_action: str = "" + voter_b_confidence: float = 0.0 + voter_b_size_pct: float = 0.0 + voter_b_reasoning: str = "" + + rationale: str = "" + features_json: str = "" + + +class Trade(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + decision_id: Optional[int] = Field(default=None, foreign_key="decision.id") + symbol: str = Field(index=True) + side: str + qty: float + entry_price: float + entry_ts: datetime = Field(default_factory=_utcnow) + exit_price: Optional[float] = None + exit_ts: Optional[datetime] = None + pnl_eur: Optional[float] = None + status: str = "open" + exchange_order_id: str = "" + stop_loss: Optional[float] = None + take_profit: Optional[float] = None + + +class EquitySnapshot(SQLModel, table=True): + id: Optional[int] = Field(default=None, primary_key=True) + ts: datetime = Field(default_factory=_utcnow, index=True) + equity_eur: float + realized_pnl_eur: float = 0.0 + unrealized_pnl_eur: float = 0.0 + open_positions: int = 0 diff --git a/src/aitrader/trader/__init__.py b/src/aitrader/trader/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/aitrader/trader/executor.py b/src/aitrader/trader/executor.py new file mode 100644 index 0000000..c18b4d8 --- /dev/null +++ b/src/aitrader/trader/executor.py @@ -0,0 +1,73 @@ +"""Order-Execution → Kraken Demo + DB-Logging.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from ..config import Settings +from ..exchange.kraken import KrakenClient +from ..logging_setup import get_logger +from ..notify import discord +from ..storage import db as dbm +from ..storage.models import Trade + +log = get_logger(__name__) + + +def execute_trade( + settings: Settings, + client: KrakenClient, + symbol: str, + side: str, # BUY/SELL + qty_eur: float, + price: float, + atr_value: float | None, + decision_id: int | None, +) -> Trade | None: + if qty_eur <= 0 or price <= 0: + return None + qty = qty_eur / price + ccxt_side = "buy" if side == "BUY" else "sell" + + # Stop/TP + sl = tp = None + if atr_value: + if ccxt_side == "buy": + sl = price - settings.risk.stop_loss_atr_mult * atr_value + tp = price + settings.risk.take_profit_atr_mult * atr_value + else: + sl = price + settings.risk.stop_loss_atr_mult * atr_value + tp = price - settings.risk.take_profit_atr_mult * atr_value + + try: + order = client.create_market_order(symbol, ccxt_side, qty) + except Exception as e: + log.error("execute_trade.failed", error=str(e), symbol=symbol, qty=qty) + return None + + trade = Trade( + decision_id=decision_id, + symbol=symbol, + side=ccxt_side, + qty=qty, + entry_price=price, + entry_ts=datetime.now(timezone.utc), + status="open", + exchange_order_id=str(order.get("id", "")), + stop_loss=sl, + take_profit=tp, + ) + with dbm.session(settings.db_path) as s: + s.add(trade) + s.commit() + s.refresh(trade) + log.info( + "trade.opened", + symbol=symbol, + side=ccxt_side, + qty=qty, + entry=price, + sl=sl, + tp=tp, + ) + discord.notify_trade_opened(settings, trade) + return trade diff --git a/src/aitrader/trader/portfolio.py b/src/aitrader/trader/portfolio.py new file mode 100644 index 0000000..e8a8089 --- /dev/null +++ b/src/aitrader/trader/portfolio.py @@ -0,0 +1,94 @@ +"""Portfolio-State + Stop/TP-Handling.""" +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Iterable + +from sqlmodel import select + +from ..config import Settings +from ..notify import discord +from ..storage import db as dbm +from ..storage.models import EquitySnapshot, Trade + + +def open_trades_for_symbol(settings: Settings, symbol: str) -> list[Trade]: + with dbm.session(settings.db_path) as s: + return list( + s.exec(select(Trade).where(Trade.symbol == symbol, Trade.status == "open")).all() + ) + + +def all_open_trades(settings: Settings) -> list[Trade]: + with dbm.session(settings.db_path) as s: + return list(s.exec(select(Trade).where(Trade.status == "open")).all()) + + +def close_trade(settings: Settings, trade_id: int, exit_price: float) -> None: + with dbm.session(settings.db_path) as s: + t = s.get(Trade, trade_id) + if not t or t.status != "open": + return + t.exit_price = exit_price + t.exit_ts = datetime.now(timezone.utc) + sign = 1 if t.side == "buy" else -1 + t.pnl_eur = sign * (exit_price - t.entry_price) * t.qty + t.status = "closed" + s.add(t) + s.commit() + s.refresh(t) + discord.notify_trade_closed(settings, t) + + +def check_stop_take_profit( + settings: Settings, symbol: str, current_price: float +) -> list[int]: + """Schließt Trades, wenn SL/TP erreicht. Gibt geschlossene Trade-IDs zurück.""" + closed: list[int] = [] + for t in open_trades_for_symbol(settings, symbol): + hit = False + if t.side == "buy": + if t.stop_loss and current_price <= t.stop_loss: + hit = True + elif t.take_profit and current_price >= t.take_profit: + hit = True + else: # sell + if t.stop_loss and current_price >= t.stop_loss: + hit = True + elif t.take_profit and current_price <= t.take_profit: + hit = True + if hit: + close_trade(settings, t.id, current_price) + closed.append(t.id) + return closed + + +def snapshot_equity( + settings: Settings, prices: dict[str, float] +) -> EquitySnapshot: + """Berechnet Equity = starting_equity + realized + unrealized.""" + realized = 0.0 + unrealized = 0.0 + open_count = 0 + with dbm.session(settings.db_path) as s: + all_trades: Iterable[Trade] = s.exec(select(Trade)).all() + for t in all_trades: + if t.status == "closed": + realized += t.pnl_eur or 0.0 + else: + open_count += 1 + px = prices.get(t.symbol) + if px is not None: + sign = 1 if t.side == "buy" else -1 + unrealized += sign * (px - t.entry_price) * t.qty + + snap = EquitySnapshot( + equity_eur=settings.starting_equity_eur + realized + unrealized, + realized_pnl_eur=realized, + unrealized_pnl_eur=unrealized, + open_positions=open_count, + ) + s.add(snap) + s.commit() + s.refresh(snap) + return snap diff --git a/src/aitrader/trader/risk.py b/src/aitrader/trader/risk.py new file mode 100644 index 0000000..21a0bf6 --- /dev/null +++ b/src/aitrader/trader/risk.py @@ -0,0 +1,71 @@ +"""Risk-Management.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone + +from sqlmodel import select + +from ..config import Settings +from ..storage import db as dbm +from ..storage.models import EquitySnapshot, Trade + + +@dataclass +class RiskCheck: + allow: bool + reason: str + qty_eur: float = 0.0 + + +def _today_start_utc() -> datetime: + now = datetime.now(timezone.utc) + return now.replace(hour=0, minute=0, second=0, microsecond=0) + + +def daily_pnl_eur(settings: Settings) -> float: + with dbm.session(settings.db_path) as s: + rows = s.exec( + select(Trade).where( + Trade.exit_ts.is_not(None), Trade.exit_ts >= _today_start_utc() + ) + ).all() + return sum((t.pnl_eur or 0.0) for t in rows) + + +def open_positions_count(settings: Settings) -> int: + with dbm.session(settings.db_path) as s: + rows = s.exec(select(Trade).where(Trade.status == "open")).all() + return len(rows) + + +def latest_equity_eur(settings: Settings) -> float: + with dbm.session(settings.db_path) as s: + row = s.exec( + select(EquitySnapshot).order_by(EquitySnapshot.ts.desc()).limit(1) + ).first() + if row: + return row.equity_eur + return settings.starting_equity_eur + + +def evaluate(settings: Settings, side: str, suggested_size_pct: float, price: float) -> RiskCheck: + if side not in ("BUY", "SELL"): + return RiskCheck(allow=False, reason="non_trade_action") + + # Daily-Loss-Limit + equity = latest_equity_eur(settings) + today_pnl = daily_pnl_eur(settings) + if equity > 0 and today_pnl / equity <= -settings.risk.daily_loss_limit_pct: + return RiskCheck(allow=False, reason="daily_loss_limit_hit") + + # Max offene Positionen + if open_positions_count(settings) >= settings.risk.max_open_positions: + return RiskCheck(allow=False, reason="max_open_positions") + + # Position-Size + target_eur = equity * settings.risk.max_position_pct * max(0.0, min(1.0, suggested_size_pct)) + if target_eur < settings.risk.min_order_eur: + return RiskCheck(allow=False, reason="below_min_order", qty_eur=target_eur) + + return RiskCheck(allow=True, reason="ok", qty_eur=target_eur) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_ensemble.py b/tests/test_ensemble.py new file mode 100644 index 0000000..bfb7295 --- /dev/null +++ b/tests/test_ensemble.py @@ -0,0 +1,47 @@ +from aitrader.ai.ensemble import combine, single +from aitrader.ai.schema import TradeDecision + + +def _d(action, conf=0.8, size=0.5): + return TradeDecision(action=action, confidence=conf, suggested_size_pct=size, reasoning="") + + +def test_consensus_buy_above_threshold(): + r = combine(_d("BUY", 0.8, 0.6), _d("BUY", 0.7, 0.4), min_conf=0.6) + assert r.action == "BUY" + assert r.suggested_size_pct == 0.4 + assert r.confidence == 0.7 + assert r.rationale == "consensus_buy" + + +def test_consensus_sell(): + r = combine(_d("SELL", 0.9, 0.3), _d("SELL", 0.65, 0.5), min_conf=0.6) + assert r.action == "SELL" + + +def test_below_threshold_holds(): + r = combine(_d("BUY", 0.8, 0.5), _d("BUY", 0.4, 0.5), min_conf=0.6) + assert r.action == "HOLD" + assert r.rationale == "consensus_below_threshold" + + +def test_disagreement_holds(): + r = combine(_d("BUY", 0.9, 0.5), _d("SELL", 0.9, 0.5), min_conf=0.6) + assert r.action == "HOLD" + assert r.rationale == "disagreement" + + +def test_hold_consensus_holds(): + r = combine(_d("HOLD", 0.9, 0.0), _d("HOLD", 0.9, 0.0), min_conf=0.6) + assert r.action == "HOLD" + + +def test_single_buy(): + r = single(_d("BUY", 0.8, 0.5), min_conf=0.6) + assert r.action == "BUY" + assert r.rationale == "single_buy" + + +def test_single_below_threshold(): + r = single(_d("BUY", 0.4, 0.5), min_conf=0.6) + assert r.action == "HOLD" diff --git a/tests/test_indicators.py b/tests/test_indicators.py new file mode 100644 index 0000000..c96f26b --- /dev/null +++ b/tests/test_indicators.py @@ -0,0 +1,32 @@ +import numpy as np +import pandas as pd + +from aitrader.features.indicators import compute_features +from aitrader.features.orderbook import summarize_orderbook + + +def _fake_ohlcv(n=250, seed=42): + rng = np.random.default_rng(seed) + close = 100 + np.cumsum(rng.normal(0, 1, n)) + high = close + rng.uniform(0, 1, n) + low = close - rng.uniform(0, 1, n) + open_ = close + rng.normal(0, 0.5, n) + vol = rng.uniform(1, 10, n) + ts = pd.date_range("2025-01-01", periods=n, freq="15min", tz="UTC") + return pd.DataFrame({"ts": ts, "open": open_, "high": high, "low": low, "close": close, "volume": vol}) + + +def test_compute_features_returns_keys(): + df = _fake_ohlcv() + f = compute_features(df) + assert {"close", "rsi14", "macd", "ema20", "ema50", "ema200", "atr14"}.issubset(f.keys()) + assert 0 <= f["rsi14"] <= 100 + + +def test_orderbook_summary(): + ob = {"bids": [[100.0, 1.0], [99.5, 2.0]], "asks": [[100.5, 1.5], [101.0, 1.0]]} + s = summarize_orderbook(ob) + assert s["best_bid"] == 100.0 + assert s["best_ask"] == 100.5 + assert s["spread_bps"] > 0 + assert -1 <= s["depth_imbalance"] <= 1 diff --git a/tests/test_risk.py b/tests/test_risk.py new file mode 100644 index 0000000..cc58704 --- /dev/null +++ b/tests/test_risk.py @@ -0,0 +1,44 @@ +import os +import tempfile + +import pytest + +from aitrader.config import Settings +from aitrader.storage.models import EquitySnapshot, Trade +from aitrader.storage import db as dbm +from aitrader.trader import risk + + +@pytest.fixture +def settings(tmp_path): + s = Settings(starting_equity_eur=10000.0, db_path=str(tmp_path / "t.db")) + # init DB + dbm.get_engine(s.db_path) + return s + + +def test_blocks_non_trade(settings): + r = risk.evaluate(settings, "HOLD", 0.5, 100.0) + assert not r.allow + + +def test_allows_within_caps(settings): + r = risk.evaluate(settings, "BUY", 1.0, 100.0) + assert r.allow + assert r.qty_eur == settings.starting_equity_eur * settings.risk.max_position_pct + + +def test_blocks_below_min_order(settings): + r = risk.evaluate(settings, "BUY", 0.001, 100.0) + assert not r.allow + assert r.reason == "below_min_order" + + +def test_blocks_max_open_positions(settings): + with dbm.session(settings.db_path) as s: + for i in range(settings.risk.max_open_positions): + s.add(Trade(symbol=f"X{i}/EUR", side="buy", qty=1.0, entry_price=100.0, status="open")) + s.commit() + r = risk.evaluate(settings, "BUY", 1.0, 100.0) + assert not r.allow + assert r.reason == "max_open_positions"