initial: aitrader bot (Gemini+Groq ensemble, Kraken Demo, Discord notifier)

This commit is contained in:
sylyx 2026-05-07 14:06:34 +02:00
commit d111cf1fdc
49 changed files with 2443 additions and 0 deletions

27
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View File

52
config.yaml Normal file
View 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
View 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"

View 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

View 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
View 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
View 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
View 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
View 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
View File

View File

48
src/aitrader/ai/claude.py Normal file
View 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")

View 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
View 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}")

View 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
View 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)}"
)

View 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
View 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
View 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

View File

View 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."
)

View File

View 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 []

View 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

View File

View 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),
}

View 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
}

View 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
View 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()

View File

View 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)

View File

View 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,
},
)

View File

View 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))

View 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

View File

View 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

View 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

View 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
View File

47
tests/test_ensemble.py Normal file
View 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
View 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
View 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"