commit d111cf1fdc92bcbd61630919406024a27f7eb543 Author: sylyx Date: Thu May 7 14:06:34 2026 +0200 initial: aitrader bot (Gemini+Groq ensemble, Kraken Demo, Discord notifier) 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"