initial: aitrader bot (Gemini+Groq ensemble, Kraken Demo, Discord notifier)
This commit is contained in:
commit
d111cf1fdc
27
.env.example
Normal file
27
.env.example
Normal file
@ -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
|
||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-journal
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.streamlit/
|
||||
.claude/
|
||||
*.swp
|
||||
113
CLAUDE.md
Normal file
113
CLAUDE.md
Normal file
@ -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://<tailscale-host>:8501` | nur via Tailscale |
|
||||
|
||||
## Test + Verifikation
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest -q # Unit-Tests
|
||||
.venv/bin/python scripts/smoke_kraken.py # Demo-API erreichbar?
|
||||
.venv/bin/python scripts/smoke_ai.py # Gemini+Claude liefern JSON?
|
||||
.venv/bin/python scripts/smoke_discord.py # Webhook erreichbar?
|
||||
.venv/bin/python -m aitrader.main --once # Ein vollständiger Tick
|
||||
```
|
||||
|
||||
## Update-Workflow (lokal → Server)
|
||||
|
||||
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.
|
||||
205
DEPLOY.md
Normal file
205
DEPLOY.md
Normal file
@ -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
|
||||
40
README.md
Normal file
40
README.md
Normal file
@ -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.
|
||||
0
READMEV2.md
Normal file
0
READMEV2.md
Normal file
52
config.yaml
Normal file
52
config.yaml
Normal file
@ -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
|
||||
36
deploy/install.sh
Executable file
36
deploy/install.sh
Executable file
@ -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"
|
||||
31
deploy/systemd/aitrader-dashboard.service
Normal file
31
deploy/systemd/aitrader-dashboard.service
Normal file
@ -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
|
||||
31
deploy/systemd/aitrader.service
Normal file
31
deploy/systemd/aitrader.service
Normal file
@ -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
|
||||
49
pyproject.toml
Normal file
49
pyproject.toml
Normal file
@ -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"
|
||||
44
scripts/smoke_ai.py
Normal file
44
scripts/smoke_ai.py
Normal file
@ -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()
|
||||
29
scripts/smoke_discord.py
Normal file
29
scripts/smoke_discord.py
Normal file
@ -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()
|
||||
28
scripts/smoke_kraken.py
Normal file
28
scripts/smoke_kraken.py
Normal file
@ -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()
|
||||
0
src/aitrader/__init__.py
Normal file
0
src/aitrader/__init__.py
Normal file
0
src/aitrader/ai/__init__.py
Normal file
0
src/aitrader/ai/__init__.py
Normal file
48
src/aitrader/ai/claude.py
Normal file
48
src/aitrader/ai/claude.py
Normal file
@ -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")
|
||||
67
src/aitrader/ai/ensemble.py
Normal file
67
src/aitrader/ai/ensemble.py
Normal file
@ -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",
|
||||
)
|
||||
49
src/aitrader/ai/gemini.py
Normal file
49
src/aitrader/ai/gemini.py
Normal file
@ -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}")
|
||||
61
src/aitrader/ai/openai_compat.py
Normal file
61
src/aitrader/ai/openai_compat.py
Normal file
@ -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}",
|
||||
)
|
||||
43
src/aitrader/ai/prompt.py
Normal file
43
src/aitrader/ai/prompt.py
Normal file
@ -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)}"
|
||||
)
|
||||
51
src/aitrader/ai/registry.py
Normal file
51
src/aitrader/ai/registry.py
Normal file
@ -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}")
|
||||
27
src/aitrader/ai/schema.py
Normal file
27
src/aitrader/ai/schema.py
Normal file
@ -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"],
|
||||
}
|
||||
117
src/aitrader/config.py
Normal file
117
src/aitrader/config.py
Normal file
@ -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
|
||||
0
src/aitrader/dashboard/__init__.py
Normal file
0
src/aitrader/dashboard/__init__.py
Normal file
92
src/aitrader/dashboard/app.py
Normal file
92
src/aitrader/dashboard/app.py
Normal file
@ -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."
|
||||
)
|
||||
0
src/aitrader/exchange/__init__.py
Normal file
0
src/aitrader/exchange/__init__.py
Normal file
69
src/aitrader/exchange/kraken.py
Normal file
69
src/aitrader/exchange/kraken.py
Normal file
@ -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 []
|
||||
31
src/aitrader/exchange/market_data.py
Normal file
31
src/aitrader/exchange/market_data.py
Normal file
@ -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
|
||||
0
src/aitrader/features/__init__.py
Normal file
0
src/aitrader/features/__init__.py
Normal file
67
src/aitrader/features/indicators.py
Normal file
67
src/aitrader/features/indicators.py
Normal file
@ -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),
|
||||
}
|
||||
26
src/aitrader/features/orderbook.py
Normal file
26
src/aitrader/features/orderbook.py
Normal file
@ -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
|
||||
}
|
||||
31
src/aitrader/logging_setup.py
Normal file
31
src/aitrader/logging_setup.py
Normal file
@ -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)
|
||||
242
src/aitrader/main.py
Normal file
242
src/aitrader/main.py
Normal file
@ -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()
|
||||
0
src/aitrader/news/__init__.py
Normal file
0
src/aitrader/news/__init__.py
Normal file
67
src/aitrader/news/sentiment.py
Normal file
67
src/aitrader/news/sentiment.py
Normal file
@ -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)
|
||||
0
src/aitrader/notify/__init__.py
Normal file
0
src/aitrader/notify/__init__.py
Normal file
208
src/aitrader/notify/discord.py
Normal file
208
src/aitrader/notify/discord.py
Normal file
@ -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,
|
||||
},
|
||||
)
|
||||
0
src/aitrader/storage/__init__.py
Normal file
0
src/aitrader/storage/__init__.py
Normal file
24
src/aitrader/storage/db.py
Normal file
24
src/aitrader/storage/db.py
Normal file
@ -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))
|
||||
63
src/aitrader/storage/models.py
Normal file
63
src/aitrader/storage/models.py
Normal file
@ -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
|
||||
0
src/aitrader/trader/__init__.py
Normal file
0
src/aitrader/trader/__init__.py
Normal file
73
src/aitrader/trader/executor.py
Normal file
73
src/aitrader/trader/executor.py
Normal file
@ -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
|
||||
94
src/aitrader/trader/portfolio.py
Normal file
94
src/aitrader/trader/portfolio.py
Normal file
@ -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
|
||||
71
src/aitrader/trader/risk.py
Normal file
71
src/aitrader/trader/risk.py
Normal file
@ -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)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
47
tests/test_ensemble.py
Normal file
47
tests/test_ensemble.py
Normal file
@ -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"
|
||||
32
tests/test_indicators.py
Normal file
32
tests/test_indicators.py
Normal file
@ -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
|
||||
44
tests/test_risk.py
Normal file
44
tests/test_risk.py
Normal file
@ -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"
|
||||
Loading…
Reference in New Issue
Block a user