Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus

Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich
aus den Ergebnissen weiter antrainiert.

Aufbau
- Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden
  nur Datenquelle und Broker.
- Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar.
  Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert.
- Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen.
- Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt
  aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der
  abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der
  Kurshistorie vorgelernt.
- Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie
  Tagesverlust- und Drawdown-Notbremsen.
- SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks.

Deployment
- Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet.
- Modell und Datenbank liegen im Volume /data und überleben Neustarts.

128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten
Container inklusive Healthcheck und Zustandswiederherstellung.
This commit is contained in:
Tobias Zimmermann
2026-08-22 08:53:04 +02:00
commit 65ed73977e
40 changed files with 7033 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
.git
.github
.gitignore
.venv
venv
__pycache__
**/__pycache__
*.pyc
*.pyo
*.egg-info
.pytest_cache
.ruff_cache
.mypy_cache
tests
data
out
backtests
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
*.npz
config/config.yaml
config/trademind.env
podman-compose.yml
deploy
+35
View File
@@ -0,0 +1,35 @@
# Geheimnisse und lokale Konfiguration niemals committen
config/config.yaml
config/trademind.env
*.env
!config/trademind.env.example
# Laufzeitdaten
data/
out/
backtests/
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm
*.npz
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
build/
dist/
.venv/
venv/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
# Editor / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
+69
View File
@@ -0,0 +1,69 @@
# syntax=docker/dockerfile:1
# ─────────────────────────────────────────────────────────────────────────────
# TradeMind Container-Image
#
# podman build --format docker -t trademind:latest -f Containerfile .
#
# --format docker ist nötig, damit die HEALTHCHECK-Anweisung ins Image übernommen wird;
# das OCI-Format kennt sie nicht. podman-compose und die Quadlet-Einheit bringen den
# Healthcheck ohnehin selbst mit, dort ist das Format egal.
# ─────────────────────────────────────────────────────────────────────────────
# ---------- Stufe 1: Abhängigkeiten in ein virtuelles Environment installieren
FROM docker.io/library/python:3.12-slim AS builder
ENV PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /build
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Erst die Metadaten kopieren so bleibt der Dependency-Layer über Codeänderungen hinweg gültig.
COPY pyproject.toml README.md ./
COPY src/trademind/__init__.py ./src/trademind/__init__.py
RUN pip install --upgrade pip setuptools wheel && pip install .
# Jetzt den vollständigen Quellcode und das Paket selbst installieren.
COPY src/ ./src/
RUN pip install --no-deps .
# ---------- Stufe 2: schlankes Laufzeit-Image
FROM docker.io/library/python:3.12-slim AS runtime
LABEL org.opencontainers.image.title="TradeMind" \
org.opencontainers.image.description="Selbstlernender Krypto-Trading-Bot (Paper, Backtest, Live)" \
org.opencontainers.image.source="https://github.com/tlt-turbo/trademind" \
org.opencontainers.image.licenses="MIT"
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
TRADEMIND_CONFIG=/config/config.yaml \
TZ=UTC
# Nicht-Root-Benutzer; UID/GID 10001 kollidiert selten mit Host-Benutzern.
RUN groupadd --gid 10001 trademind \
&& useradd --uid 10001 --gid 10001 --create-home --home-dir /home/trademind trademind \
&& mkdir -p /data/models /config \
&& chown -R 10001:10001 /data /config
COPY --from=builder /opt/venv /opt/venv
COPY --chown=10001:10001 config/config.example.yaml /config/config.example.yaml
USER 10001:10001
WORKDIR /home/trademind
# /data hält SQLite-Datenbank und trainiertes Modell als Volume einhängen, sonst
# gehen Lernfortschritt und Historie beim Neustart verloren.
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=4).status==200 else 1)"
ENTRYPOINT ["trademind"]
CMD ["run", "--config", "/config/config.yaml"]
+386
View File
@@ -0,0 +1,386 @@
# TradeMind
Ein per Podman deploybarer Krypto-Trading-Bot mit drei Betriebsarten:
| Modus | Kurse | Orders | Lernen | Zweck |
|------------|--------------|------------------|--------|------------------------------------------|
| `paper` | live | simuliert | ja | Dauerbetrieb ohne Risiko — **Standard** |
| `backtest` | historisch | simuliert | ja | Strategie und Parameter bewerten |
| `live` | live | echt | optional | Echtgeldhandel |
Die Börsenanbindung läuft über [ccxt](https://github.com/ccxt/ccxt) — Binance, Kraken, Coinbase,
Bybit, OKX, KuCoin, Bitget, Gate.io, MEXC und rund 100 weitere sind allein über den
Konfigurationsschlüssel `exchange.id` erreichbar (`trademind exchanges` listet alle auf).
> **Hinweis zum Risiko.** Der Bot ist ein Werkzeug, keine Ertragsgarantie und keine
> Anlageberatung. Die mitgelieferte Strategie ist eine funktionsfähige Grundlage, kein
> erprobtes Handelssystem — im Backtest unten liegt sie deutlich hinter Buy & Hold.
> Vor jedem Live-Einsatz gehören eigene Backtests, ein langer Paper-Lauf und eine bewusste
> Entscheidung über den Kapitaleinsatz. Der Live-Modus ist doppelt abgesichert und muss
> ausdrücklich freigeschaltet werden.
---
## Schnellstart
```bash
git clone <repo> && cd Trademind_Claude
cp config/config.example.yaml config/config.yaml
cp config/trademind.env.example config/trademind.env
podman build --format docker -t trademind:latest -f Containerfile .
podman-compose up -d
```
`--format docker` sorgt dafür, dass der eingebaute HEALTHCHECK ins Image übernommen wird —
das OCI-Format kennt die Anweisung nicht. Compose und Quadlet bringen ihren eigenen
Healthcheck mit, dort spielt das Format keine Rolle.
Dashboard: <http://127.0.0.1:8080/> · Logs: `podman-compose logs -f`
Ohne Zugangsdaten läuft der Bot im Paper-Modus auf echten Live-Kursen und beginnt sofort zu
lernen. Für den Start werden nur öffentliche Marktdaten gelesen — kein API-Schlüssel nötig.
### Ohne Container
```bash
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -e ".[dev]"
trademind backtest --config config/config.yaml --bars 6000 --fresh-model
trademind run --config config/config.yaml
```
---
## Deployment mit Podman
### Variante 1 — podman-compose
```bash
podman-compose up -d # starten
podman-compose logs -f # verfolgen
podman-compose down # stoppen
```
Der Port ist bewusst auf `127.0.0.1:8080` gebunden; das Dashboard ist damit nicht aus dem
Netz erreichbar. Für Zugriff von außen einen Reverse Proxy mit Authentifizierung davorsetzen.
> **Windows und macOS.** Dort läuft Podman in einer VM (`podman machine`). Auf Loopback
> veröffentlichte Ports bleiben in der VM und erreichen den Host nicht. Zum Testen entweder
> `podman machine ssh curl -s localhost:8080/health` verwenden oder in
> `podman-compose.yml` auf `"8080:8080"` umstellen. Auf Linux — dem Ziel für den
> Dauerbetrieb — funktioniert die Loopback-Bindung unmittelbar.
### Variante 2 — einzelner Container
```bash
podman volume create trademind-data
podman run -d --name trademind \
--restart=unless-stopped \
--env-file config/trademind.env \
-v ./config:/config:ro,Z \
-v trademind-data:/data:Z \
-p 127.0.0.1:8080:8080 \
--security-opt no-new-privileges \
trademind:latest
```
### Variante 3 — systemd über Quadlet (empfohlen für Dauerbetrieb)
```bash
mkdir -p ~/.config/containers/systemd ~/.config/trademind
cp deploy/trademind.container deploy/trademind-data.volume ~/.config/containers/systemd/
cp config/config.yaml config/trademind.env ~/.config/trademind/
loginctl enable-linger $USER
systemctl --user daemon-reload
systemctl --user start trademind
journalctl --user -u trademind -f
```
Der Container läuft als UID 10001 ohne zusätzliche Rechte. `podman stop` sendet SIGTERM; die
Engine beendet den laufenden Durchlauf, sichert Modell und Zustand und fährt sauber herunter.
### Persistenz
Alles Wichtige liegt im Volume `/data`:
| Pfad | Inhalt |
|-----------------------------|-----------------------------------------------------------|
| `/data/trademind.sqlite3` | Trades, Equity-Kurve, Laufzeitzustand |
| `/data/models/adaptive.npz` | Modellgewichte, Normalisierung, Erfahrungsspeicher |
Ohne dieses Volume geht bei jedem Neustart der gesamte Lernfortschritt verloren.
---
## Konfiguration
Alles läuft über `config/config.yaml`. Geheimnisse kommen über Umgebungsvariablen dazu:
```yaml
exchange:
id: ${TRADEMIND_EXCHANGE:-binance}
api_key: ${TRADEMIND_API_KEY}
api_secret: ${TRADEMIND_API_SECRET}
password: ${TRADEMIND_API_PASSWORD} # OKX, KuCoin, Coinbase Advanced, Bitget
sandbox: true # Testnet der Börse
```
Drei Ebenen, in dieser Reihenfolge:
1. Werte in der YAML-Datei
2. `${VAR}` / `${VAR:-fallback}` — aus der Umgebung eingesetzt
3. `TRADEMIND__ABSCHNITT__SCHLUESSEL` — überschreibt einzelne Werte punktuell,
z. B. `TRADEMIND__RISK__MAX_OPEN_POSITIONS=5`
Die vollständig kommentierte Vorlage steht in
[config/config.example.yaml](config/config.example.yaml).
### Börse wechseln
```bash
trademind exchanges --search kraken
```
Dann `exchange.id` setzen. Handelspaare in ccxt-Schreibweise (`BTC/USDT`, `ETH/EUR`);
alle Symbole müssen dieselbe Quote-Währung haben, sonst bricht der Start mit einer
verständlichen Meldung ab.
### Live-Modus freischalten
Zwei Bedingungen müssen erfüllt sein, sonst startet der Bot nicht:
```yaml
mode: live
live_confirmation: I_UNDERSTAND_THE_RISK
exchange:
api_key: ...
api_secret: ...
```
API-Schlüssel bitte ausschließlich mit **Handelsrecht** anlegen — Auszahlungen niemals
erlauben — und wenn die Börse es anbietet auf die Server-IP beschränken. Zuerst mit
`sandbox: true` gegen das Testnet fahren.
---
## Wie der Bot lernt
Die Strategie besteht aus zwei Schichten:
**1. Regelwerk** (`strategy.rules`) — erzeugt Kandidaten für Einstiege: EMA-Kreuzung nach
oben mit RSI- und Trendfilter, dazu Rücksetzer-Einstiege bei überverkauftem RSI im
Aufwärtstrend. Ausstiege über EMA-Kreuzung nach unten oder nachlassendes Momentum, dazu
immer ATR-basierter Stop und Kursziel.
**2. Lernmodell** (`strategy.learner`) — eine online trainierte logistische Regression über
18 skalenfreie Merkmale (EMA-Abstände, RSI und dessen Steigung, MACD-Histogramm, ATR in
Prozent, Volatilitätsverhältnis, Momentum, Donchian-Lage, Volumen-z-Score, Kerzenform,
Tageszeit). Sie schätzt für jeden Kandidaten die Gewinnwahrscheinlichkeit und lässt nur
Signale oberhalb von `entry_threshold` durch.
Trainiert wird aus drei Quellen:
| Quelle | Gewicht | Wofür |
|-----------------------|---------|--------------------------------------------------------------|
| Reale Trade-Ergebnisse| 3,0 | Das eigentliche Ziel: hat sich der Trade gelohnt? |
| Signal-Shadow-Labels | 1,0 | **Jeder** Kandidat wird nachträglich bewertet — auch abgelehnte |
| Hintergrund-Stichproben | 0,5 | Regelmäßige Marktzustände, damit genug Daten zusammenkommen |
Gelabelt wird nach der Triple-Barrier-Methode: Ein Signal gilt als Treffer, wenn der Kurs
innerhalb von `label_horizon_bars` das Ziel (`label_target_bps`) erreicht, ohne vorher um
denselben Betrag zu fallen. Läuft das Fenster ohne Berührung ab, entscheidet der Schlusskurs.
Zwei Details sind dabei wichtig:
- **Off-Policy-Lernen** — auch abgelehnte Signale werden gelabelt. Der Bot lernt also aus
Trades, die er *nicht* gemacht hat, und kann eine zu strenge Schwelle selbst korrigieren.
- **Exploration** — `exploration_rate` (Standard 5 %) handelt gelegentlich bewusst gegen das
Modell. Ohne das würde es seine eigenen Vorurteile nie widerlegen.
**Kaltstart.** Ein frisches Modell würde bei 5-Minuten-Kerzen Tage brauchen, um die
Aufwärmphase zu durchlaufen. Deshalb lernt der Bot beim ersten Start automatisch aus
`bootstrap_bars` historischen Kerzen vor — in der Praxis rund 3 Sekunden statt einer Woche:
```
Modell ist untrainiert lerne aus bis zu 3000 historischen Kerzen vor …
Vorlernen abgeschlossen: 662 neue Beobachtungen (gesamt 662), Modell einsatzbereit
```
Solange das Modell nicht warm ist (`warmup_samples`), entscheidet allein das Regelwerk.
Die Qualität lässt sich im Status unter `strategy.learner.online_accuracy` verfolgen — das ist
eine *prequentielle* Messung: erst vorhersagen, dann lernen, also keine Selbstbewertung auf
bereits gesehenen Daten.
Im Live-Modus lässt sich das Weiterlernen mit `freeze_in_live: true` einfrieren, wenn ein
im Paper-Betrieb gereiftes Modell unverändert bleiben soll.
---
## Risikomanagement
Vor jeder Order greifen mehrere unabhängige Grenzen:
- `max_position_pct` — Anteil der Equity je Position
- `max_total_exposure_pct` — Summe aller Positionen
- `max_open_positions` — parallele Positionen
- ATR-basierter Stop-Loss und Take-Profit, optional nachziehender Stop
- `cooldown_bars_after_exit` — Pause pro Symbol nach einem Ausstieg
- `min_notional` sowie die Mindestgrößen und Rundungsschritte der Börse
Dazu zwei Notbremsen:
- **`max_daily_loss_pct`** — Handel pausiert bis zum nächsten UTC-Tag.
- **`max_drawdown_pct`** — dauerhafter Stopp bis zum Neustart; offene Positionen werden
geschlossen.
Ausstiege werden nie durch die Liquiditätsgrenze gedrosselt: Das Risikomanagement muss
jederzeit vollständig aus einer Position herauskommen.
---
## Simulation
Der Paper-Broker bildet die Kosten nach, an denen Strategien in der Praxis scheitern:
```yaml
paper:
fee_rate: 0.001 # 0,1 % je Seite
slippage_bps: 5 # Ausführung 5 bps schlechter als der Referenzkurs
max_volume_participation: 0.1 # höchstens 10 % des Kerzenvolumens
```
Zusätzlich werden Mengen auf die Präzision der Börse abgerundet, Käufe auf das verfügbare
Guthaben begrenzt und Verkäufe auf den tatsächlichen Bestand. Ein Round-Trip ohne
Kursbewegung kostet damit realistisch ~0,3 %.
---
## Kommandos
```bash
trademind run --config config/config.yaml # Dauerbetrieb (paper oder live)
trademind backtest --config config/config.yaml # historischer Durchlauf
trademind validate --config config/config.yaml # Konfiguration + Börsenverbindung prüfen
trademind report --config config/config.yaml # Ergebnisse aus der Datenbank
trademind exchanges --search kraken # verfügbare Börsen
```
Im Container davor `podman exec -it trademind` setzen, z. B.:
```bash
podman exec -it trademind trademind report --config /config/config.yaml
```
### Backtest
```bash
trademind backtest -c config/config.yaml --bars 20000 --fresh-model --save-model --out-dir out/
trademind backtest -c config/config.yaml --start 2025-01-01T00:00:00Z --end 2025-06-30T23:59:59Z
trademind backtest -c config/config.yaml --csv-dir data/csv --json
```
| Option | Wirkung |
|------------------|--------------------------------------------------------------------|
| `--fresh-model` | untrainiertes Modell — sonst wird auf dem gespeicherten aufgesetzt |
| `--save-model` | Ergebnis nach `learner.model_path` schreiben (Vortraining) |
| `--out-dir` | `trades.csv`, `equity.csv` und `report.json` ablegen |
| `--csv-dir` | eigene OHLCV-Daten statt Börsenabruf |
| `--seed` | reproduzierbare Läufe (Exploration ist zufällig) |
CSV-Format: `timestamp,open,high,low,close,volume`, eine Datei je Symbol
(`BTC_USDT.csv`). Der Zeitstempel darf in Sekunden, Millisekunden oder ISO-8601 stehen.
Der Backtest ist ein Walk-Forward-Lauf: Das Modell trainiert währenddessen ganz normal
weiter, es gibt also keine getrennte Trainings- und Testphase.
Ein Beispiellauf über 20 Tage BTC/USDT und ETH/USDT (5m, frisches Modell):
```
Endkapital 9818.92 USDT Trades 29 (4/25)
Gesamtrendite -1.81 % Trefferquote 13.8 %
Max. Drawdown 2.23 % Profit-Faktor 0.17
Buy & Hold BTC +23.81 % Gebühren 114.84 USDT
```
Genau dafür ist der Backtest da: Die Standardparameter schlagen in einem starken
Aufwärtstrend kein Buy & Hold, und der Bot sagt das offen. Kürzere Zeiträume, andere
Timeframes und Parameter gehören ausprobiert, bevor auch nur ein Paper-Euro fließt.
---
## Monitoring
| Endpunkt | Inhalt |
|--------------|-------------------------------------------------------------|
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand |
| `/health` | Liveness — nutzt der Container-Healthcheck |
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
| `/status` | vollständiger Zustand als JSON |
| `/positions` | offene Positionen |
| `/trades` | letzte Trades (`?limit=100`) |
| `/metrics` | Prometheus-Textformat |
```bash
curl -s localhost:8080/status | jq '.portfolio, .strategy.learner'
curl -s localhost:8080/metrics | grep trademind_portfolio
```
Optionale Benachrichtigungen über `notifications.webhook_url` — ein Payload bedient sowohl
Slack als auch Discord.
---
## Aufbau
```
src/trademind/
├── cli.py Kommandozeile
├── app.py Zusammenbau aus der Konfiguration
├── config.py Konfigurationsmodell (pydantic) + ${ENV}-Auflösung
├── engine.py Handelsschleife und Bar-Verarbeitung
├── backtest.py Walk-Forward-Durchlauf und Auswertung
├── strategy.py Regelwerk und lernende Strategie
├── learner.py Online-Logistikregression, Replay-Buffer, Persistenz
├── features.py Merkmalsvektoren (vektorisiert)
├── indicators.py EMA, RSI, ATR, MACD, Bollinger, Donchian, ROC
├── risk.py Positionsgröße, Stops, Notbremsen
├── portfolio.py Positionen, Equity, Kennzahlen
├── broker.py Paper- und Live-Ausführung
├── exchange.py ccxt-Client und Börsen-Metadaten
├── data.py Marktdaten: live, CSV, Replay
├── storage.py SQLite-Persistenz
├── server.py HTTP-Status, Metriken, Dashboard
└── notify.py Webhook-Benachrichtigungen
```
Die Bar-Verarbeitung in `engine.process_bar` ist für alle drei Modi identisch — ausgetauscht
werden nur Datenquelle und Broker. Was im Backtest passiert, passiert live genauso.
### Entwicklung
```bash
pip install -e ".[dev]"
pytest -q # 128 Tests
ruff check .
```
---
## Bekannte Grenzen
- **Nur Long, nur Spot.** Keine Short-Positionen, keine Hebel, keine Futures.
- **Bar-getaktet.** Entscheidungen fallen bei Kerzenschluss, nicht tick-genau. Bei `1m` ist
die Latenz zwischen Signal und Ausführung spürbar.
- **Kein Order-Abgleich im Live-Modus.** Der Bot führt seine Positionsbuchhaltung intern;
manuelle Trades auf demselben Konto bringen sie durcheinander. Ein eigenes (Sub-)Konto
verwenden.
- **Lineares Modell.** Bewusst so gewählt: nachvollziehbar (`/status` zeigt alle Gewichte),
robust bei wenig Daten, kein Overfitting-Zoo. Es findet keine nichtlinearen Muster.
- **Marktrisiko bleibt.** Backtests sagen wenig über die Zukunft, und ein lernender Filter
macht aus einer schwachen Strategie keine starke.
---
## Lizenz
MIT
+135
View File
@@ -0,0 +1,135 @@
# ─────────────────────────────────────────────────────────────────────────────
# TradeMind Beispielkonfiguration
#
# Kopieren nach config/config.yaml und anpassen:
# cp config/config.example.yaml config/config.yaml
#
# ${VAR} wird durch die Umgebungsvariable VAR ersetzt (leer = nicht gesetzt)
# ${VAR:-wert} nutzt "wert" als Rückfallwert
# TRADEMIND__A__B überschreibt zusätzlich den Schlüssel a.b (z. B. TRADEMIND__RISK__MAX_OPEN_POSITIONS=5)
# ─────────────────────────────────────────────────────────────────────────────
# paper = simulierte Ausführung auf echten Live-Kursen; das Modell trainiert mit ← Standard
# backtest = historische Daten im Schnelldurchlauf
# live = echte Orders (erfordert live_confirmation + API-Schlüssel)
mode: ${TRADEMIND_MODE:-paper}
log_level: ${TRADEMIND_LOG_LEVEL:-INFO}
# Muss für mode: live wörtlich auf I_UNDERSTAND_THE_RISK stehen. Sicherheitsnetz gegen
# versehentlichen Echtgeldhandel.
live_confirmation: ${TRADEMIND_LIVE_CONFIRMATION}
# ─────────────────────────────── Börsenanbindung ────────────────────────────
# id: jede von ccxt unterstützte Börse. Liste anzeigen mit: trademind exchanges
# binance · binanceus · kraken · krakenfutures · coinbase · bybit · okx
# kucoin · bitget · gate · mexc · bitstamp · bitfinex · huobi · cryptocom · …
exchange:
id: ${TRADEMIND_EXCHANGE:-binance}
api_key: ${TRADEMIND_API_KEY}
api_secret: ${TRADEMIND_API_SECRET}
# Passphrase nötig bei OKX, KuCoin, Coinbase Advanced, Bitget
password: ${TRADEMIND_API_PASSWORD}
# true = Testnet/Sandbox der Börse (soweit unterstützt). Für die ersten Live-Tests dringend empfohlen.
sandbox: ${TRADEMIND_SANDBOX:-true}
enable_rate_limit: true
timeout_ms: 20000
options:
defaultType: spot # spot | swap | future je nach Börse
# ──────────────────────────────── Marktauswahl ──────────────────────────────
market:
# Alle Symbole müssen dieselbe Quote-Währung haben (hier USDT).
symbols:
- BTC/USDT
- ETH/USDT
timeframe: 5m # 1m 3m 5m 15m 30m 1h 2h 4h 6h 8h 12h 1d 3d 1w
history_bars: 500 # Kerzen pro Abfrage (Indikatoren brauchen ≥ 140)
poll_interval_seconds: 20 # wie oft nach einer neuen Kerze gesehen wird
# ───────────────────── Simulation (Modus paper und backtest) ────────────────
paper:
starting_balance: 10000
quote_currency: USDT
fee_rate: 0.001 # 0,1 % Taker-Gebühr pro Seite
slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil
max_volume_participation: 0.1 # max. 10 % des Kerzenvolumens pro Order
# ──────────────────────────────── Risikoregeln ──────────────────────────────
risk:
max_position_pct: 0.20 # je Position, gemessen an der Equity
max_total_exposure_pct: 0.60 # Summe aller Positionen
max_open_positions: 3
stop_loss_atr_mult: 2.0 # Stop = Einstieg 2 × ATR (0 = kein Stop)
take_profit_atr_mult: 3.0 # Ziel = Einstieg + 3 × ATR (0 = kein Ziel)
trailing_stop_atr_mult: 0.0 # > 0 aktiviert den nachziehenden Stop
max_holding_bars: 0 # 0 = unbegrenzt
max_daily_loss_pct: 0.05 # Notbremse bis zum nächsten UTC-Tag
max_drawdown_pct: 0.25 # Notbremse bis zum Neustart (schließt Positionen)
min_notional: 10 # kleinste sinnvolle Ordergröße in Quote-Währung
cooldown_bars_after_exit: 3 # Pause pro Symbol nach einem Ausstieg
# ───────────────────────────────── Strategie ───────────────────────────────
strategy:
# adaptive = Regelwerk erzeugt Signale, das Lernmodell filtert sie
# rules = nur das Regelwerk, kein Lernen
name: adaptive
rules:
fast_ema: 12
slow_ema: 26
rsi_period: 14
rsi_oversold: 35
rsi_overbought: 70
atr_period: 14
trend_filter_period: 100 # 0 = Trendfilter aus
min_holding_bars: 3 # Signalausstiege erst danach; Stop/Ziel gelten immer
learner:
enabled: true
model_path: /data/models/adaptive.npz
entry_threshold: 0.55 # ab welcher Gewinnwahrscheinlichkeit gehandelt wird
exploration_rate: 0.05 # Anteil bewusst gegen das Modell gehandelter Signale
learning_rate: 0.02
l2: 0.0001
replay_size: 5000
batch_size: 64
train_every_n_samples: 5
warmup_samples: 200 # bis dahin entscheidet allein das Regelwerk
label_horizon_bars: 12 # Bewertungsfenster für ein Signal
label_target_bps: 30 # 30 bps = 0,3 % Kursziel gilt als Treffer
trade_sample_weight: 3.0 # echte Trades zählen dreifach gegenüber Shadow-Labels
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands verkürzen die
# Aufwärmphase von Wochen auf Tage (0 = aus).
background_sample_every_n_bars: 10
background_sample_weight: 0.5
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, damit der Bot
# nach Sekunden einsatzbereit ist statt nach Tagen (0 = aus).
bootstrap_bars: 3000
freeze_in_live: false # true = im Live-Modus nicht weiterlernen
save_every_n_updates: 50
# ───────────────────────────────── Persistenz ───────────────────────────────
storage:
database_path: /data/trademind.sqlite3
# ─────────────────────────── Status-Server / Monitoring ─────────────────────
server:
enabled: true
host: 0.0.0.0
port: 8080
enable_metrics: true # /metrics im Prometheus-Textformat
# ──────────────────────────────── Benachrichtigungen ────────────────────────
notifications:
# Slack- oder Discord-Webhook (leer lassen = aus)
webhook_url: ${TRADEMIND_WEBHOOK_URL}
notify_on_trade: true
notify_on_risk_halt: true
# ───────────────────────────── Backtest-Voreinstellungen ────────────────────
backtest:
bars: 5000
start: # z. B. 2024-01-01T00:00:00Z überschreibt bars
end:
csv_dir: # OHLCV aus CSV statt von der Börse (Spalten: timestamp,open,high,low,close,volume)
+35
View File
@@ -0,0 +1,35 @@
# Geheimnisse und Laufzeitschalter für TradeMind.
# Kopieren nach config/trademind.env und NIEMALS committen (.gitignore deckt das ab).
#
# podman run --env-file config/trademind.env ...
# podman-compose --env-file config/trademind.env up
# paper | backtest | live
TRADEMIND_MODE=paper
# Börse (ccxt-ID): binance, kraken, coinbase, bybit, okx, kucoin, bitget, gate, mexc, ...
TRADEMIND_EXCHANGE=binance
# true = Testnet/Sandbox der Börse verwenden
TRADEMIND_SANDBOX=true
# API-Zugangsdaten. Im Paper-Modus nicht nötig dort werden nur öffentliche Kurse gelesen.
# Für Live: Schlüssel ausschließlich mit Handelsrecht anlegen, Auszahlungen NICHT erlauben,
# und wenn möglich die IP des Servers hinterlegen.
TRADEMIND_API_KEY=
TRADEMIND_API_SECRET=
# Passphrase nur bei OKX, KuCoin, Coinbase Advanced, Bitget
TRADEMIND_API_PASSWORD=
# Muss für den Live-Modus wörtlich auf I_UNDERSTAND_THE_RISK stehen.
TRADEMIND_LIVE_CONFIRMATION=
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
TRADEMIND_WEBHOOK_URL=
TRADEMIND_LOG_LEVEL=INFO
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
# TRADEMIND__RISK__MAX_OPEN_POSITIONS=5
# TRADEMIND__MARKET__SYMBOLS=BTC/USDT,ETH/USDT,SOL/USDT
# TRADEMIND__STRATEGY__LEARNER__ENTRY_THRESHOLD=0.6
+8
View File
@@ -0,0 +1,8 @@
# Persistentes Volume für SQLite-Datenbank und trainiertes Modell.
# Zusammen mit trademind.container nach ~/.config/containers/systemd/ kopieren.
[Unit]
Description=TradeMind Datenvolume (Datenbank und Modellgewichte)
[Volume]
VolumeName=trademind-data
+59
View File
@@ -0,0 +1,59 @@
# Podman-Quadlet-Einheit der sauberste Weg, TradeMind dauerhaft laufen zu lassen.
#
# Installation (rootless, empfohlen):
# mkdir -p ~/.config/containers/systemd
# cp deploy/trademind.container ~/.config/containers/systemd/
# mkdir -p ~/.config/trademind && cp config/config.yaml config/trademind.env ~/.config/trademind/
# systemctl --user daemon-reload
# systemctl --user start trademind
# systemctl --user status trademind
# journalctl --user -u trademind -f
#
# Damit der Dienst auch ohne aktive Anmeldung läuft:
# loginctl enable-linger $USER
#
# Für den systemweiten Betrieb stattdessen nach /etc/containers/systemd/ kopieren
# und "systemctl" ohne --user verwenden.
[Unit]
Description=TradeMind selbstlernender Krypto-Trading-Bot
Documentation=https://github.com/tlt-turbo/trademind
After=network-online.target
Wants=network-online.target
[Container]
Image=localhost/trademind:latest
ContainerName=trademind
AutoUpdate=local
# Konfiguration schreibgeschützt, Daten persistent
Volume=%h/.config/trademind:/config:ro,Z
Volume=trademind-data.volume:/data:Z
EnvironmentFile=%h/.config/trademind/trademind.env
Environment=TRADEMIND_CONFIG=/config/config.yaml
Exec=run --config /config/config.yaml
# Dashboard/Metriken nur auf dem Loopback-Interface
PublishPort=127.0.0.1:8080:8080
# Härtung
NoNewPrivileges=true
DropCapability=ALL
UserNS=keep-id
HealthCmd=python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=4).status==200 else 1)"
HealthInterval=30s
HealthTimeout=5s
HealthRetries=3
HealthStartPeriod=45s
[Service]
Restart=always
RestartSec=15
# SIGTERM zuerst die Engine beendet den laufenden Durchlauf und speichert das Modell.
TimeoutStopSec=90
[Install]
WantedBy=default.target
+50
View File
@@ -0,0 +1,50 @@
# Start: podman-compose up -d Logs: podman-compose logs -f
# Stop: podman-compose down Build: podman-compose build
#
# Die Datei funktioniert auch mit docker-compose; ":Z" ist eine SELinux-Kennzeichnung
# und wird auf Systemen ohne SELinux ignoriert.
services:
trademind:
build:
context: .
dockerfile: Containerfile
image: localhost/trademind:latest
container_name: trademind
restart: unless-stopped
# Geheimnisse und Schalter Vorlage: config/trademind.env.example
env_file:
- ./config/trademind.env
command: ["run", "--config", "/config/config.yaml"]
ports:
- "127.0.0.1:8080:8080" # Dashboard nur lokal erreichbar
volumes:
- ./config:/config:ro,Z # Konfiguration schreibgeschützt hineinreichen
- trademind-data:/data:Z # SQLite-Datenbank und trainiertes Modell
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=4).status==200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 45s
# Absicherung: keine zusätzlichen Rechte, Dateisystem nur dort schreibbar, wo nötig.
security_opt:
- no-new-privileges:true
read_only: false
tmpfs:
- /tmp:size=64m
logging:
driver: json-file
options:
max-size: "10m"
max-file: "5"
volumes:
trademind-data:
+50
View File
@@ -0,0 +1,50 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "trademind"
version = "0.1.0"
description = "Selbstlernender Krypto-Trading-Bot mit Paper-Trading, Backtest und Live-Modus"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"ccxt>=4.3.0",
"numpy>=1.26",
"PyYAML>=6.0",
"pydantic>=2.6",
"aiohttp>=3.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"ruff>=0.4",
]
[project.scripts]
trademind = "trademind.cli:main"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
filterwarnings = ["ignore::DeprecationWarning"]
[tool.ruff]
line-length = 110
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
# UP042: str+Enum ist bewusst gewählt es serialisiert ohne Sonderbehandlung nach JSON.
ignore = ["B008", "UP042"]
[tool.ruff.lint.per-file-ignores]
# server.py enthält das eingebettete Dashboard (HTML/CSS/JS) als String.
"src/trademind/server.py" = ["E501"]
+5
View File
@@ -0,0 +1,5 @@
"""TradeMind selbstlernender Krypto-Trading-Bot mit Paper-, Backtest- und Live-Modus."""
__version__ = "0.1.0"
__all__ = ["__version__"]
+6
View File
@@ -0,0 +1,6 @@
"""Erlaubt ``python -m trademind ...``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+206
View File
@@ -0,0 +1,206 @@
"""Zusammenbau aller Komponenten aus der Konfiguration."""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
from typing import Any
from .broker import Broker, LiveBroker, PaperBroker
from .config import Config, Mode
from .data import CcxtDataFeed, DataFeed
from .engine import TradingEngine
from .exchange import build_exchange, load_market_info
from .features import N_FEATURES
from .notify import Notifier
from .portfolio import Portfolio
from .risk import RiskManager
from .server import StatusServer
from .storage import NullStorage, Storage
from .strategy import Strategy, build_strategy
log = logging.getLogger(__name__)
def setup_logging(level: str = "INFO") -> None:
root = logging.getLogger()
if root.handlers:
root.setLevel(level)
return
# Umlaute und Symbole sollen auch auf Konsolen mit anderer Codepage lesbar bleiben.
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError): # pragma: no cover - je nach Konsole
pass
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)-7s %(name)-22s %(message)s", "%Y-%m-%d %H:%M:%S")
)
root.addHandler(handler)
root.setLevel(level)
# ccxt und aiohttp sind im INFO-Level sehr gesprächig.
logging.getLogger("ccxt").setLevel(logging.WARNING)
logging.getLogger("aiohttp").setLevel(logging.WARNING)
@dataclass
class Runtime:
"""Alle Laufzeitobjekte eines Laufs, inklusive geordnetem Herunterfahren."""
config: Config
engine: TradingEngine
broker: Broker
feed: DataFeed
strategy: Strategy
portfolio: Portfolio
risk: RiskManager
storage: Storage | NullStorage
notifier: Notifier
server: StatusServer | None
exchange: Any | None
async def start_services(self) -> None:
await self.notifier.start()
if self.server is not None:
await self.server.start()
async def close(self) -> None:
if self.server is not None:
await self.server.close()
await self.notifier.close()
if self.exchange is not None:
await self.exchange.close()
else:
await self.broker.close()
await self.feed.close()
self.storage.close()
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
quotes.discard(None)
if len(quotes) > 1:
raise ValueError(
"Alle Symbole müssen dieselbe Quote-Währung haben (gefunden: "
+ ", ".join(sorted(str(q) for q in quotes))
+ "). Bitte market.symbols anpassen."
)
return str(next(iter(quotes))) if quotes else fallback
async def build_runtime(
config: Config,
*,
with_server: bool = True,
with_storage: bool = True,
load_model: bool = True,
seed: int | None = None,
) -> Runtime:
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
storage: Storage | NullStorage = (
Storage(config.storage.database_path) if with_storage else NullStorage()
)
read_only = config.mode is not Mode.LIVE
exchange = build_exchange(config.exchange, read_only=read_only)
market_info = await load_market_info(exchange, config.market.symbols)
feed = CcxtDataFeed(exchange)
broker: Broker
if config.mode is Mode.LIVE:
quote = _quote_currency(market_info, config.market.symbols, "USDT")
broker = LiveBroker(exchange, quote_currency=quote, market_info=market_info)
starting_equity = 0.0 # wird beim Start aus dem echten Guthaben gesetzt
else:
quote = _quote_currency(market_info, config.market.symbols, config.paper.quote_currency)
if quote != config.paper.quote_currency:
log.info(
"Quote-Währung der Symbole ist %s paper.quote_currency (%s) wird überschrieben",
quote, config.paper.quote_currency,
)
paper_config = config.paper.model_copy(update={"quote_currency": quote})
broker = PaperBroker(paper_config, market_info=market_info)
starting_equity = paper_config.starting_balance
strategy = build_strategy(config.strategy, N_FEATURES, seed=seed, load_model=load_model)
learner = getattr(strategy, "learner", None)
if learner is not None and config.mode is Mode.LIVE and config.strategy.learner.freeze_in_live:
learner.frozen = True
log.info("Live-Modus: Online-Lernen eingefroren (freeze_in_live=true)")
portfolio = Portfolio(starting_equity=starting_equity, quote_currency=broker.quote_currency)
risk = RiskManager(config.risk)
notifier = Notifier(config.notifications)
engine = TradingEngine(
config=config,
broker=broker,
feed=feed,
strategy=strategy,
portfolio=portfolio,
risk=risk,
storage=storage,
notifier=notifier,
)
server = StatusServer(config.server, engine.status) if (with_server and config.server.enabled) else None
storage.start_run(
mode=config.mode.value,
exchange=config.exchange.id,
symbols=config.market.symbols,
timeframe=config.market.timeframe,
strategy=config.strategy.name,
)
return Runtime(
config=config,
engine=engine,
broker=broker,
feed=feed,
strategy=strategy,
portfolio=portfolio,
risk=risk,
storage=storage,
notifier=notifier,
server=server,
exchange=exchange,
)
def describe_config(config: Config) -> str:
"""Kompakte Übersicht der wirksamen Konfiguration (ohne Geheimnisse)."""
ex = config.exchange
lines = [
f"Modus {config.mode.value}"
+ (" ⚠ ECHTES GELD" if config.mode is Mode.LIVE else ""),
f"Börse {ex.id}" + (" (Sandbox/Testnet)" if ex.sandbox else " (Produktiv)"),
f"Zugangsdaten {'gesetzt' if ex.has_credentials() else 'nicht gesetzt'}",
f"Symbole {', '.join(config.market.symbols)}",
f"Timeframe {config.market.timeframe} "
f"(Abfrage alle {config.market.poll_interval_seconds:g}s)",
f"Strategie {config.strategy.name}"
+ (f" (Lernen aktiv, Schwelle {config.strategy.learner.entry_threshold})"
if config.strategy.name == "adaptive" and config.strategy.learner.enabled
else " (kein Lernen)"),
f"Risiko max. {config.risk.max_open_positions} Positionen, "
f"{config.risk.max_position_pct:.0%} je Position, "
f"Stop {config.risk.stop_loss_atr_mult}×ATR, Ziel {config.risk.take_profit_atr_mult}×ATR",
f"Notbremsen Tagesverlust {config.risk.max_daily_loss_pct:.0%}, "
f"Drawdown {config.risk.max_drawdown_pct:.0%}",
f"Datenbank {config.storage.database_path}",
f"Modelldatei {config.strategy.learner.model_path}",
]
if config.mode is not Mode.LIVE:
lines.insert(
3,
f"Startkapital {config.paper.starting_balance:g} {config.paper.quote_currency} "
f"(Gebühr {config.paper.fee_rate:.3%}, Slippage {config.paper.slippage_bps:g} bps)",
)
if config.server.enabled:
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
return "\n".join(" " + line for line in lines)
+251
View File
@@ -0,0 +1,251 @@
"""Backtest: historische Kerzen mit derselben Bar-Logik wie im Live-Betrieb durchspielen.
Die Indikatoren werden einmal vorberechnet; der Lauf ist damit linear in der Anzahl Kerzen.
Das Lernmodell trainiert währenddessen ganz normal weiter (Walk-Forward), es gibt also
keinen getrennten Trainings- und Testlauf der Bot lernt, während er handelt.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from .data import format_ts
from .engine import Bar, TradingEngine
from .features import FeatureMatrix, build_feature_matrix
from .models import Candles, ExitReason
log = logging.getLogger(__name__)
@dataclass
class BacktestReport:
symbols: list[str]
timeframe: str
bars: int
start: str
end: str
duration_seconds: float
portfolio: dict[str, Any]
strategy: dict[str, Any]
buy_and_hold_pct: dict[str, float] = field(default_factory=dict)
exit_reasons: dict[str, int] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"symbols": self.symbols,
"timeframe": self.timeframe,
"bars": self.bars,
"start": self.start,
"end": self.end,
"duration_seconds": round(self.duration_seconds, 2),
"portfolio": self.portfolio,
"strategy": self.strategy,
"buy_and_hold_pct": self.buy_and_hold_pct,
"exit_reasons": self.exit_reasons,
}
def render(self, quote: str = "USDT") -> str:
p = self.portfolio
lines = [
"",
"" * 66,
" BACKTEST-ERGEBNIS",
"" * 66,
f" Symbole {', '.join(self.symbols)} ({self.timeframe})",
f" Zeitraum {self.start} bis {self.end}",
f" Kerzen {self.bars:,}".replace(",", "."),
f" Laufzeit {self.duration_seconds:.1f} s",
"" * 66,
f" Endkapital {p['equity']:.2f} {quote}",
f" Gesamtrendite {p['total_return_pct']:+.2f} %",
f" Max. Drawdown {p['max_drawdown_pct']:.2f} %",
f" Sharpe (annual.) {p['sharpe']:.2f}",
"" * 66,
f" Trades {p['trades']} ({p['wins']} Gewinne / {p['losses']} Verluste)",
f" Trefferquote {p['win_rate'] * 100:.1f} %",
f" Profit-Faktor {_fmt(p['profit_factor'])}",
f" Erwartungswert {p['expectancy']:+.4f} {quote} pro Trade",
f" Gebühren gesamt {p['fees']:.2f} {quote}",
f" Bester / schlecht. {p['best_trade']:+.2f} / {p['worst_trade']:+.2f} {quote}",
]
if self.exit_reasons:
reasons = ", ".join(f"{k}: {v}" for k, v in sorted(self.exit_reasons.items()))
lines.append(f" Ausstiegsgründe {reasons}")
if self.buy_and_hold_pct:
lines.append("" * 66)
for symbol, pct in self.buy_and_hold_pct.items():
lines.append(f" Buy & Hold {symbol:<12} {pct:+.2f} %")
learner = self.strategy.get("learner") if isinstance(self.strategy, dict) else None
if isinstance(learner, dict) and learner.get("samples_seen"):
lines += [
"" * 66,
" LERNMODELL",
f" Beobachtungen {learner['samples_seen']} "
f"({learner.get('trade_samples', 0)} aus echten Trades)",
f" Trainingsschritte {learner.get('updates', 0)}",
f" Online-Accuracy {learner.get('online_accuracy', 0) * 100:.1f} %",
f" Online-LogLoss {learner.get('online_logloss', 0):.4f}",
f" Gewinneranteil {learner.get('positive_rate', 0) * 100:.1f} %",
f" Signale akzeptiert {self.strategy.get('candidates_accepted', 0)} von "
f"{self.strategy.get('candidates_seen', 0)} "
f"({self.strategy.get('acceptance_rate', 0) * 100:.1f} %)",
]
lines.append("" * 66)
return "\n".join(lines)
def _fmt(value: Any) -> str:
if value is None:
return ""
return f"{float(value):.2f}"
class BacktestRunner:
"""Spielt vorgeladene Serien Kerze für Kerze durch die Engine."""
def __init__(self, engine: TradingEngine, series: dict[str, Candles], progress_every: int = 500) -> None:
self.engine = engine
self.series = series
self.progress_every = progress_every
self._matrices: dict[str, FeatureMatrix] = {}
def _prepare(self) -> int:
rules = self.engine.config.strategy.rules
first_valid = 0
usable: dict[str, Candles] = {}
for symbol, candles in self.series.items():
matrix = build_feature_matrix(candles, rules)
if matrix is None:
log.warning(
"%s: nur %d Kerzen zu wenig für die Indikatoren, Symbol wird übersprungen",
symbol, len(candles),
)
continue
self._matrices[symbol] = matrix
usable[symbol] = candles
first_valid = max(first_valid, matrix.first_valid)
self.series = usable
if not self.series:
raise ValueError("Keine Serie hat genug Kerzen für einen Backtest")
return first_valid
async def run(self) -> BacktestReport:
start_time = time.perf_counter()
first_valid = self._prepare()
length = min(len(c) for c in self.series.values())
symbols = list(self.series)
log.info(
"Backtest über %d Kerzen (%d verwertbar) auf %s",
length, length - first_valid, ", ".join(symbols),
)
self.engine.running = True
for index in range(first_valid, length):
self.engine._cash = await self.engine.broker.cash()
for symbol in symbols:
matrix = self._matrices[symbol]
snapshot = matrix.snapshot(index)
if snapshot is None:
continue
candles = self.series[symbol]
bar = Bar(
timestamp=int(candles.timestamp[index]),
open=float(candles.open[index]),
high=float(candles.high[index]),
low=float(candles.low[index]),
close=float(candles.close[index]),
volume=float(candles.volume[index]),
)
self.engine.bar_counter[symbol] = index
self.engine.last_bar_ts[symbol] = bar.timestamp
await self.engine.process_bar(symbol, snapshot, bar)
self.engine._record_equity()
if self.progress_every and (index - first_valid) % self.progress_every == 0:
done = index - first_valid
total = length - first_valid
log.info(
"%d/%d Kerzen (%.0f %%) Equity %.2f, Trades %d",
done, total, 100.0 * done / max(total, 1),
self.engine.portfolio.equity(self.engine._cash),
self.engine.portfolio.stats.trades,
)
# Am Ende offene Positionen glattstellen, damit das Ergebnis vollständig ist.
for symbol in list(self.engine.portfolio.positions):
last_close = float(self.series[symbol].close[length - 1])
await self.engine._close_position(symbol, last_close, ExitReason.SHUTDOWN, None)
self.engine.running = False
cash = await self.engine.broker.cash()
duration = time.perf_counter() - start_time
exit_reasons: dict[str, int] = {}
for trade in self.engine.portfolio.trades:
exit_reasons[trade.exit_reason.value] = exit_reasons.get(trade.exit_reason.value, 0) + 1
buy_hold = {}
for symbol, candles in self.series.items():
first_price = float(candles.close[first_valid])
last_price = float(candles.close[length - 1])
if first_price > 0:
buy_hold[symbol] = (last_price - first_price) / first_price * 100.0
first_ts = int(next(iter(self.series.values())).timestamp[first_valid])
last_ts = int(next(iter(self.series.values())).timestamp[length - 1])
return BacktestReport(
symbols=symbols,
timeframe=self.engine.config.market.timeframe,
bars=length - first_valid,
start=format_ts(first_ts),
end=format_ts(last_ts),
duration_seconds=duration,
portfolio=self.engine.portfolio.summary(cash),
strategy=self.engine.strategy.snapshot(),
buy_and_hold_pct={k: round(v, 2) for k, v in buy_hold.items()},
exit_reasons=exit_reasons,
)
def equity_curve_csv(engine: TradingEngine) -> str:
"""Equity-Kurve als CSV-Text (für eigene Auswertungen)."""
rows = ["timestamp,equity"]
rows += [f"{ts},{eq:.8f}" for ts, eq in engine.portfolio.equity_curve]
return "\n".join(rows) + "\n"
def trades_csv(engine: TradingEngine) -> str:
trades = engine.portfolio.trades
header = (
"symbol,entry_timestamp,exit_timestamp,amount,entry_price,exit_price,"
"fees_quote,pnl_quote,pnl_pct,exit_reason,bars_held,entry_confidence,exploratory"
)
rows = [header]
for t in trades:
rows.append(
f"{t.symbol},{t.entry_timestamp},{t.exit_timestamp},{t.amount:.10f},{t.entry_price:.10f},"
f"{t.exit_price:.10f},{t.fees_quote:.10f},{t.pnl_quote:.10f},{t.pnl_pct:.10f},"
f"{t.exit_reason.value},{t.bars_held},{t.entry_confidence:.6f},{int(t.exploratory)}"
)
return "\n".join(rows) + "\n"
def summarize_returns(engine: TradingEngine) -> dict[str, float]:
"""Ein paar Verteilungskennzahlen der Trade-Renditen."""
pnls = np.array([t.pnl_pct for t in engine.portfolio.trades], dtype=np.float64)
if pnls.size == 0:
return {}
return {
"mean_pct": float(np.mean(pnls) * 100),
"median_pct": float(np.median(pnls) * 100),
"std_pct": float(np.std(pnls, ddof=1) * 100) if pnls.size > 1 else 0.0,
"p05_pct": float(np.percentile(pnls, 5) * 100),
"p95_pct": float(np.percentile(pnls, 95) * 100),
}
+345
View File
@@ -0,0 +1,345 @@
"""Order-Ausführung: simuliert (Paper/Backtest) oder echt über ccxt (Live)."""
from __future__ import annotations
import asyncio
import logging
import math
import time
import uuid
from abc import ABC, abstractmethod
from typing import Any
from .config import PaperConfig
from .models import Fill, Side
log = logging.getLogger(__name__)
class InsufficientFunds(RuntimeError):
"""Nicht genug Guthaben für die gewünschte Order."""
class OrderRejected(RuntimeError):
"""Die Börse (oder die Simulation) hat die Order abgelehnt."""
def _now_ms() -> int:
return int(time.time() * 1000)
class Broker(ABC):
"""Gemeinsame Schnittstelle für simulierte und echte Ausführung."""
quote_currency: str = "USDT"
is_simulated: bool = True
def __init__(self, market_info: dict[str, dict[str, Any]] | None = None) -> None:
self.market_info: dict[str, dict[str, Any]] = market_info or {}
async def start(self) -> None:
return None
async def close(self) -> None:
return None
@abstractmethod
async def cash(self) -> float:
"""Verfügbares Guthaben in der Quote-Währung."""
@abstractmethod
async def holdings(self, symbol: str) -> float:
"""Bestand in der Basiswährung des Symbols."""
@abstractmethod
async def execute(
self,
symbol: str,
side: Side,
amount: float,
ref_price: float,
*,
bar_volume: float | None = None,
timestamp: int | None = None,
) -> Fill:
"""Führt eine Market-Order aus und liefert die tatsächliche Ausführung."""
# ------------------------------------------------------------ Marktregeln
def min_amount(self, symbol: str) -> float:
return float(self.market_info.get(symbol, {}).get("min_amount") or 0.0)
def min_cost(self, symbol: str) -> float:
return float(self.market_info.get(symbol, {}).get("min_cost") or 0.0)
def round_amount(self, symbol: str, amount: float) -> float:
"""Auf die Mengen-Präzision der Börse abrunden (nie aufrunden)."""
precision = self.market_info.get(symbol, {}).get("amount_precision")
if precision is None:
return float(amount)
if isinstance(precision, int):
if precision <= 0:
return float(math.floor(amount))
factor = 10**precision
return math.floor(amount * factor) / factor
step = float(precision) # manche Börsen liefern die Schrittweite selbst
if step <= 0:
return float(amount)
return math.floor(amount / step) * step
class PaperBroker(Broker):
"""Simulierte Ausführung mit Gebühren, Slippage und begrenzter Marktliquidität."""
is_simulated = True
def __init__(self, config: PaperConfig, market_info: dict[str, dict[str, Any]] | None = None) -> None:
super().__init__(market_info)
self.config = config
self.quote_currency = config.quote_currency
self.starting_balance = config.starting_balance
self._cash = config.starting_balance
self._holdings: dict[str, float] = {}
self.total_fees = 0.0
self.order_count = 0
self.rejected_count = 0
async def cash(self) -> float:
return self._cash
def cash_sync(self) -> float:
return self._cash
async def holdings(self, symbol: str) -> float:
return self._holdings.get(symbol, 0.0)
def holdings_sync(self, symbol: str) -> float:
return self._holdings.get(symbol, 0.0)
def all_holdings(self) -> dict[str, float]:
return {s: a for s, a in self._holdings.items() if a > 0}
async def execute(
self,
symbol: str,
side: Side,
amount: float,
ref_price: float,
*,
bar_volume: float | None = None,
timestamp: int | None = None,
) -> Fill:
if amount <= 0:
raise OrderRejected(f"{symbol}: Ordermenge muss positiv sein (war {amount})")
if ref_price <= 0:
raise OrderRejected(f"{symbol}: ungültiger Referenzpreis {ref_price}")
requested = amount
# Liquiditätsgrenze: nie mehr als ein Bruchteil des Bar-Volumens ausführen.
if bar_volume and bar_volume > 0:
cap = bar_volume * self.config.max_volume_participation
if amount > cap:
log.debug("%s: Order von %.8f auf %.8f begrenzt (Bar-Volumen)", symbol, amount, cap)
amount = cap
amount = self.round_amount(symbol, amount)
if amount <= 0:
self.rejected_count += 1
raise OrderRejected(f"{symbol}: Menge nach Rundung auf Börsenpräzision = 0")
slip = self.config.slippage_bps / 10_000.0
fee_rate = self.config.fee_rate
ts = timestamp or _now_ms()
if side is Side.BUY:
price = ref_price * (1.0 + slip)
cost = amount * price
fee = cost * fee_rate
if cost + fee > self._cash + 1e-9:
# So weit herunterskalieren, dass es exakt passt.
affordable = self._cash / (price * (1.0 + fee_rate))
amount = self.round_amount(symbol, affordable)
if amount <= 0:
self.rejected_count += 1
raise InsufficientFunds(
f"{symbol}: Guthaben {self._cash:.2f} {self.quote_currency} reicht nicht"
)
cost = amount * price
fee = cost * fee_rate
self._cash -= cost + fee
self._holdings[symbol] = self._holdings.get(symbol, 0.0) + amount
else:
held = self._holdings.get(symbol, 0.0)
if amount > held + 1e-12:
amount = self.round_amount(symbol, held)
if amount <= 0:
self.rejected_count += 1
raise OrderRejected(f"{symbol}: kein Bestand zum Verkaufen")
price = ref_price * (1.0 - slip)
proceeds = amount * price
fee = proceeds * fee_rate
self._cash += proceeds - fee
remaining = held - amount
if remaining <= 1e-12:
self._holdings.pop(symbol, None)
else:
self._holdings[symbol] = remaining
self.total_fees += fee
self.order_count += 1
return Fill(
symbol=symbol,
side=side,
amount=amount,
price=price,
fee_quote=fee,
timestamp=ts,
order_id=f"paper-{uuid.uuid4().hex[:10]}",
requested_amount=requested,
)
def state(self) -> dict[str, Any]:
return {
"cash": round(self._cash, 8),
"holdings": dict(self._holdings),
"total_fees": round(self.total_fees, 8),
"orders": self.order_count,
"rejected": self.rejected_count,
}
def restore(self, cash: float, holdings: dict[str, float], total_fees: float = 0.0) -> None:
self._cash = float(cash)
self._holdings = {k: float(v) for k, v in holdings.items() if v > 0}
self.total_fees = float(total_fees)
class LiveBroker(Broker):
"""Echte Market-Orders über ccxt."""
is_simulated = False
def __init__(
self,
exchange,
quote_currency: str = "USDT",
market_info: dict[str, dict[str, Any]] | None = None,
fill_poll_attempts: int = 5,
fill_poll_delay: float = 1.0,
) -> None:
super().__init__(market_info)
self._exchange = exchange
self.quote_currency = quote_currency
self._fill_poll_attempts = fill_poll_attempts
self._fill_poll_delay = fill_poll_delay
self.order_count = 0
self.total_fees = 0.0
async def start(self) -> None:
balance = await self._exchange.fetch_balance()
free = (balance.get("free") or {}).get(self.quote_currency, 0.0)
log.info("Live-Broker verbunden: %.2f %s verfügbar", float(free or 0.0), self.quote_currency)
async def close(self) -> None:
await self._exchange.close()
async def cash(self) -> float:
balance = await self._exchange.fetch_balance()
return float((balance.get("free") or {}).get(self.quote_currency, 0.0) or 0.0)
async def holdings(self, symbol: str) -> float:
base = self.market_info.get(symbol, {}).get("base") or symbol.split("/")[0]
balance = await self._exchange.fetch_balance()
return float((balance.get("free") or {}).get(base, 0.0) or 0.0)
async def execute(
self,
symbol: str,
side: Side,
amount: float,
ref_price: float,
*,
bar_volume: float | None = None,
timestamp: int | None = None,
) -> Fill:
requested = amount
amount = self.round_amount(symbol, amount)
if amount <= 0:
raise OrderRejected(f"{symbol}: Menge nach Rundung = 0")
try:
order = await self._exchange.create_order(symbol, "market", side.value, amount)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - ccxt-Fehlerhierarchie ist breit
raise OrderRejected(f"{symbol}: Order abgelehnt ({type(exc).__name__}: {exc})") from exc
order = await self._await_fill(symbol, order)
filled = float(order.get("filled") or order.get("amount") or amount)
avg = order.get("average") or order.get("price") or ref_price
price = float(avg) if avg else ref_price
fee = self._extract_fee(order, filled, price)
self.order_count += 1
self.total_fees += fee
log.info(
"Live-Order ausgeführt: %s %s %.8f @ %.6f (Gebühr %.6f %s)",
side.value.upper(), symbol, filled, price, fee, self.quote_currency,
)
return Fill(
symbol=symbol,
side=side,
amount=filled,
price=price,
fee_quote=fee,
timestamp=int(order.get("timestamp") or timestamp or _now_ms()),
order_id=str(order.get("id") or ""),
requested_amount=requested,
)
async def _await_fill(self, symbol: str, order: dict[str, Any]) -> dict[str, Any]:
"""Market-Orders sind meist sofort gefüllt manche Börsen melden das verzögert."""
order_id = order.get("id")
if not order_id or order.get("status") == "closed":
return order
for _ in range(self._fill_poll_attempts):
if order.get("status") in ("closed", "canceled", "rejected"):
break
await asyncio.sleep(self._fill_poll_delay)
try:
order = await self._exchange.fetch_order(order_id, symbol)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.debug("fetch_order für %s nicht möglich: %s", order_id, exc)
break
if order.get("status") not in ("closed", None):
log.warning("Order %s hat Status '%s' Buchhaltung nutzt die gemeldete Füllmenge",
order_id, order.get("status"))
return order
def _extract_fee(self, order: dict[str, Any], filled: float, price: float) -> float:
fee_info = order.get("fee") or {}
cost = fee_info.get("cost")
currency = fee_info.get("currency")
if cost is not None and (currency is None or currency == self.quote_currency):
return float(cost)
if cost is not None and currency and price > 0:
# Gebühr in Basiswährung → in Quote umrechnen.
base = self.market_info.get(order.get("symbol", ""), {}).get("base")
if currency == base:
return float(cost) * price
fees = order.get("fees") or []
total = 0.0
for entry in fees:
entry_cost = entry.get("cost")
if entry_cost is None:
continue
if entry.get("currency") == self.quote_currency:
total += float(entry_cost)
else:
total += float(entry_cost) * price
if total:
return total
# Fallback: Taker-Gebühr aus den Marktdaten schätzen.
taker = self.market_info.get(order.get("symbol", ""), {}).get("taker") or 0.001
return filled * price * float(taker)
+368
View File
@@ -0,0 +1,368 @@
"""Kommandozeile: ``trademind run|backtest|validate|report|exchanges``."""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from . import __version__
from .app import build_runtime, describe_config, setup_logging
from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv
from .config import Config, Mode, load_config
from .data import align_series, load_csv, parse_iso8601
from .engine import install_signal_handlers
from .exchange import available_exchanges
from .models import Candles
from .storage import Storage
log = logging.getLogger("trademind.cli")
DEFAULT_CONFIG = os.environ.get("TRADEMIND_CONFIG", "/config/config.yaml")
# --------------------------------------------------------------------- Parser
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="trademind",
description="Selbstlernender Krypto-Trading-Bot (Paper, Backtest, Live).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Beispiele:\n"
" trademind run --config config/config.yaml\n"
" trademind backtest --config config/config.yaml --bars 20000 --fresh-model --save-model\n"
" trademind validate --config config/config.yaml\n"
" trademind exchanges --search kraken\n"
),
)
parser.add_argument("--version", action="version", version=f"trademind {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
def add_common(p: argparse.ArgumentParser) -> None:
p.add_argument(
"-c", "--config", default=DEFAULT_CONFIG,
help=f"Konfigurationsdatei (Standard: {DEFAULT_CONFIG})",
)
p.add_argument(
"--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log-Level überschreiben",
)
run_p = sub.add_parser("run", help="Bot dauerhaft laufen lassen (paper oder live)")
add_common(run_p)
run_p.add_argument("--mode", choices=[m.value for m in Mode], help="Modus überschreiben")
run_p.add_argument(
"--liquidate-on-exit", action="store_true", help="Beim Beenden alle Positionen schließen"
)
run_p.add_argument("--no-server", action="store_true", help="Status-Server nicht starten")
bt_p = sub.add_parser("backtest", help="Strategie auf historischen Daten durchspielen")
add_common(bt_p)
bt_p.add_argument("--bars", type=int, help="Anzahl Kerzen (Standard aus backtest.bars)")
bt_p.add_argument("--start", help="Startzeit ISO-8601, z. B. 2024-01-01T00:00:00Z")
bt_p.add_argument("--end", help="Endzeit ISO-8601")
bt_p.add_argument("--csv-dir", help="OHLCV aus CSV-Dateien statt von der Börse laden")
bt_p.add_argument("--fresh-model", action="store_true", help="Mit untrainiertem Modell starten")
bt_p.add_argument("--save-model", action="store_true", help="Trainiertes Modell nach dem Lauf speichern")
bt_p.add_argument("--out-dir", help="Trades und Equity-Kurve als CSV hier ablegen")
bt_p.add_argument("--json", action="store_true", help="Ergebnis als JSON ausgeben")
bt_p.add_argument("--seed", type=int, default=42, help="Zufallszahlen-Seed für reproduzierbare Läufe")
val_p = sub.add_parser("validate", help="Konfiguration prüfen und Börsenverbindung testen")
add_common(val_p)
val_p.add_argument(
"--offline", action="store_true", help="Nur die Datei prüfen, keine Verbindung aufbauen"
)
rep_p = sub.add_parser("report", help="Ergebnisse aus der Datenbank zusammenfassen")
add_common(rep_p)
rep_p.add_argument("--limit", type=int, default=20, help="Anzahl der zuletzt gezeigten Trades")
rep_p.add_argument("--json", action="store_true", help="Ausgabe als JSON")
ex_p = sub.add_parser("exchanges", help="Von ccxt unterstützte Börsen auflisten")
ex_p.add_argument("--search", help="Nach Namensbestandteil filtern")
return parser
# ---------------------------------------------------------------- Hilfsroutinen
def _load(args: argparse.Namespace) -> Config:
try:
config = load_config(args.config)
except FileNotFoundError as exc:
print(f"Fehler: {exc}", file=sys.stderr)
print(
"Tipp: Beispielkonfiguration kopieren cp config/config.example.yaml config/config.yaml",
file=sys.stderr,
)
raise SystemExit(2) from None
except Exception as exc: # noqa: BLE001 - Validierungsfehler leserlich ausgeben
print(f"Konfiguration ungültig ({args.config}):\n{exc}", file=sys.stderr)
raise SystemExit(2) from None
if getattr(args, "log_level", None):
config = config.model_copy(update={"log_level": args.log_level})
return config
def _symbol_to_filename(symbol: str) -> str:
return symbol.replace("/", "_").replace(":", "_")
async def _load_series(config: Config, args: argparse.Namespace, runtime) -> dict[str, Candles]:
"""Historische Kerzen laden aus CSV oder von der Börse."""
timeframe = config.market.timeframe
bars = args.bars or config.backtest.bars
csv_dir = args.csv_dir or config.backtest.csv_dir
series: dict[str, Candles] = {}
if csv_dir:
directory = Path(csv_dir)
for symbol in config.market.symbols:
candidates = [
directory / f"{_symbol_to_filename(symbol)}.csv",
directory / f"{_symbol_to_filename(symbol)}_{timeframe}.csv",
directory / f"{symbol.split('/')[0]}.csv",
]
path = next((p for p in candidates if p.is_file()), None)
if path is None:
raise SystemExit(
f"Keine CSV für {symbol} in {directory} gefunden "
f"(erwartet z. B. {_symbol_to_filename(symbol)}.csv)"
)
series[symbol] = load_csv(path, symbol, timeframe)
else:
since = parse_iso8601(args.start or config.backtest.start)
until = parse_iso8601(args.end or config.backtest.end)
for symbol in config.market.symbols:
series[symbol] = await runtime.feed.fetch_history(symbol, timeframe, bars, since, until)
empty = [s for s, c in series.items() if len(c) == 0]
if empty:
raise SystemExit(f"Keine Daten für: {', '.join(empty)}")
return align_series(series)
# ------------------------------------------------------------------- Kommandos
async def cmd_run(args: argparse.Namespace) -> int:
config = _load(args)
if args.mode and args.mode != config.mode.value:
# Über model_validate, damit die Live-Schutzprüfungen erneut greifen.
try:
config = Config.model_validate({**config.model_dump(), "mode": args.mode})
except Exception as exc: # noqa: BLE001
print(f"Modus '{args.mode}' nicht möglich:\n{exc}", file=sys.stderr)
return 2
setup_logging(config.log_level)
print("\nTradeMind startet:\n" + describe_config(config) + "\n")
if config.mode is Mode.LIVE:
log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.")
runtime = await build_runtime(config, with_server=not args.no_server)
try:
await runtime.start_services()
await runtime.engine.prepare()
await runtime.engine.bootstrap_learner()
runtime.notifier.startup(
config.mode.value, config.exchange.id, config.market.symbols, config.market.timeframe
)
install_signal_handlers(runtime.engine)
await runtime.engine.run()
except asyncio.CancelledError:
log.info("Abbruch empfangen")
finally:
await runtime.engine.shutdown(liquidate=args.liquidate_on_exit)
await runtime.close()
summary = runtime.portfolio.summary(runtime.engine._cash)
print(
f"\nBeendet. Equity {summary['equity']:.2f} {runtime.broker.quote_currency}, "
f"{summary['trades']} Trades, Rendite {summary['total_return_pct']:+.2f} %"
)
return 0
async def cmd_backtest(args: argparse.Namespace) -> int:
config = _load(args)
if config.mode is Mode.LIVE:
config = config.model_copy(update={"mode": Mode.BACKTEST})
setup_logging(config.log_level)
runtime = await build_runtime(
config,
with_server=False,
with_storage=False,
load_model=not args.fresh_model,
seed=args.seed,
)
learner = getattr(runtime.strategy, "learner", None)
if learner is not None:
learner.autosave = False
try:
series = await _load_series(config, args, runtime)
await runtime.engine.prepare()
report = await BacktestRunner(runtime.engine, series).run()
if args.json:
payload = report.as_dict()
payload["return_distribution"] = summarize_returns(runtime.engine)
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
else:
print(report.render(runtime.broker.quote_currency))
distribution = summarize_returns(runtime.engine)
if distribution:
print(
f" Trade-Renditen Median {distribution['median_pct']:+.2f} %, "
f"5%-Quantil {distribution['p05_pct']:+.2f} %, "
f"95%-Quantil {distribution['p95_pct']:+.2f} %\n"
)
if args.out_dir:
out = Path(args.out_dir)
out.mkdir(parents=True, exist_ok=True)
(out / "trades.csv").write_text(trades_csv(runtime.engine), encoding="utf-8")
(out / "equity.csv").write_text(equity_curve_csv(runtime.engine), encoding="utf-8")
(out / "report.json").write_text(
json.dumps(report.as_dict(), indent=2, ensure_ascii=False, default=str), encoding="utf-8"
)
print(f" Ergebnisdateien in {out.resolve()}")
if args.save_model and learner is not None:
path = learner.save()
print(f" Modell gespeichert: {path}")
finally:
await runtime.close()
return 0
async def cmd_validate(args: argparse.Namespace) -> int:
config = _load(args)
setup_logging(config.log_level)
print("\nKonfiguration gültig:\n" + describe_config(config) + "\n")
if args.offline:
return 0
runtime = await build_runtime(config, with_server=False, with_storage=False, load_model=False)
try:
candles = await runtime.feed.fetch(
config.market.symbols[0], config.market.timeframe, min(config.market.history_bars, 100)
)
print(
f" Verbindung zu {config.exchange.id} steht: {len(candles)} Kerzen für "
f"{config.market.symbols[0]}, letzter Kurs {candles.last_price():.6f}"
)
if config.mode is Mode.LIVE:
cash = await runtime.broker.cash()
print(f" Live-Guthaben: {cash:.2f} {runtime.broker.quote_currency}")
print()
finally:
await runtime.close()
return 0
def cmd_report(args: argparse.Namespace) -> int:
config = _load(args)
setup_logging(config.log_level)
path = Path(config.storage.database_path)
if not path.is_file():
print(f"Keine Datenbank unter {path} noch kein Lauf aufgezeichnet.", file=sys.stderr)
return 1
storage = Storage(path)
try:
total = storage.trade_count()
per_symbol = storage.performance_by_symbol()
recent = storage.recent_trades(args.limit)
if args.json:
print(json.dumps(
{"trades": total, "per_symbol": per_symbol, "recent": recent},
indent=2, ensure_ascii=False, default=str,
))
return 0
print(f"\n Datenbank: {path}")
print(f" Trades gesamt: {total}\n")
if per_symbol:
print(f" {'Symbol':<14}{'Trades':>8}{'Gewinne':>9}{'Netto-P/L':>14}{'Ø %':>9}")
print(" " + "" * 54)
for row in per_symbol:
print(
f" {row['symbol']:<14}{row['trades']:>8}{row['wins']:>9}"
f"{row['net_pnl']:>14.2f}{(row['avg_pnl_pct'] or 0) * 100:>9.2f}"
)
if recent:
print(f"\n Letzte {len(recent)} Trades")
print(f" {'Symbol':<12}{'Grund':<15}{'P/L':>12}{'%':>9}{'Konfidenz':>11}")
print(" " + "" * 59)
for row in recent:
print(
f" {row['symbol']:<12}{row['exit_reason']:<15}{row['pnl_quote']:>12.2f}"
f"{row['pnl_pct'] * 100:>9.2f}{row['entry_confidence']:>11.2f}"
)
print()
finally:
storage.close()
return 0
def cmd_exchanges(args: argparse.Namespace) -> int:
names = available_exchanges()
if args.search:
needle = args.search.lower()
names = [n for n in names if needle in n]
if not names:
print("Keine passende Börse gefunden.")
return 1
print(f"\n {len(names)} Börsen über ccxt ansprechbar (exchange.id in der Konfiguration):\n")
for i in range(0, len(names), 5):
print(" " + "".join(f"{n:<20}" for n in names[i : i + 5]))
print()
return 0
# ------------------------------------------------------------------ Einstieg
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "exchanges":
return cmd_exchanges(args)
if args.command == "report":
return cmd_report(args)
if args.command == "run":
return asyncio.run(cmd_run(args))
if args.command == "backtest":
return asyncio.run(cmd_backtest(args))
if args.command == "validate":
return asyncio.run(cmd_validate(args))
except KeyboardInterrupt:
print("\nAbgebrochen.")
return 130
except SystemExit:
raise
except Exception as exc: # noqa: BLE001 - oberste Fehlerbarriere der CLI
logging.getLogger("trademind").exception("Unbehandelter Fehler")
print(f"\nFehler: {exc}", file=sys.stderr)
return 1
parser.error(f"Unbekanntes Kommando: {args.command}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
+285
View File
@@ -0,0 +1,285 @@
"""Konfiguration: YAML laden, ``${ENV}``-Platzhalter auflösen, per pydantic validieren."""
from __future__ import annotations
import os
import re
from enum import Enum
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
LIVE_CONFIRMATION_PHRASE = "I_UNDERSTAND_THE_RISK"
class Mode(str, Enum):
PAPER = "paper"
LIVE = "live"
BACKTEST = "backtest"
class _Base(BaseModel):
model_config = ConfigDict(extra="forbid")
class ExchangeConfig(_Base):
"""Anbindung an eine Börse. ``id`` ist eine beliebige ccxt-Exchange-ID."""
id: str = "binance"
api_key: str | None = None
api_secret: str | None = None
password: str | None = None # OKX, KuCoin, Coinbase Advanced ...
uid: str | None = None
sandbox: bool = True
enable_rate_limit: bool = True
timeout_ms: int = Field(default=20_000, ge=1_000)
options: dict[str, Any] = Field(default_factory=lambda: {"defaultType": "spot"})
@field_validator("id")
@classmethod
def _lower(cls, v: str) -> str:
return v.strip().lower()
def has_credentials(self) -> bool:
return bool(self.api_key and self.api_secret)
class MarketConfig(_Base):
symbols: list[str] = Field(default_factory=lambda: ["BTC/USDT"])
timeframe: str = "5m"
history_bars: int = Field(default=500, ge=60, le=5_000)
poll_interval_seconds: float = Field(default=20.0, gt=0)
@field_validator("symbols")
@classmethod
def _non_empty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("market.symbols darf nicht leer sein")
return [s.strip().upper() for s in v]
class PaperConfig(_Base):
"""Parameter des simulierten Brokers."""
starting_balance: float = Field(default=10_000.0, gt=0)
quote_currency: str = "USDT"
fee_rate: float = Field(default=0.001, ge=0, le=0.05)
slippage_bps: float = Field(default=5.0, ge=0, le=500)
# Teilausführungen bei zu großem Ordervolumen relativ zum Bar-Volumen
max_volume_participation: float = Field(default=0.1, gt=0, le=1.0)
class RiskConfig(_Base):
max_position_pct: float = Field(default=0.2, gt=0, le=1.0)
max_total_exposure_pct: float = Field(default=0.6, gt=0, le=1.0)
max_open_positions: int = Field(default=3, ge=1)
stop_loss_atr_mult: float = Field(default=2.0, ge=0)
take_profit_atr_mult: float = Field(default=3.0, ge=0)
trailing_stop_atr_mult: float = Field(default=0.0, ge=0)
max_holding_bars: int = Field(default=0, ge=0) # 0 = unbegrenzt
max_daily_loss_pct: float = Field(default=0.05, ge=0, le=1.0)
max_drawdown_pct: float = Field(default=0.25, ge=0, le=1.0)
min_notional: float = Field(default=10.0, ge=0)
cooldown_bars_after_exit: int = Field(default=3, ge=0)
class RuleConfig(_Base):
fast_ema: int = Field(default=12, ge=2)
slow_ema: int = Field(default=26, ge=3)
rsi_period: int = Field(default=14, ge=2)
rsi_oversold: float = Field(default=35.0, ge=1, le=99)
rsi_overbought: float = Field(default=70.0, ge=1, le=99)
atr_period: int = Field(default=14, ge=2)
trend_filter_period: int = Field(default=100, ge=0) # 0 = aus
# Mindesthaltedauer für signalbasierte Ausstiege. Verhindert, dass ein frischer
# Einstieg sofort wieder ausgestoppt wird. Stop-Loss und Take-Profit gelten immer.
min_holding_bars: int = Field(default=3, ge=0)
@model_validator(mode="after")
def _ema_order(self) -> RuleConfig:
if self.fast_ema >= self.slow_ema:
raise ValueError("strategy.rules.fast_ema muss kleiner als slow_ema sein")
return self
class LearnerConfig(_Base):
"""Online-Lernen: Bewertung von Einstiegssignalen anhand realisierter Ergebnisse."""
enabled: bool = True
model_path: str = "/data/models/adaptive.npz"
entry_threshold: float = Field(default=0.55, ge=0.0, le=1.0)
exploration_rate: float = Field(default=0.05, ge=0.0, le=1.0)
learning_rate: float = Field(default=0.02, gt=0)
l2: float = Field(default=1e-4, ge=0)
replay_size: int = Field(default=5_000, ge=100)
batch_size: int = Field(default=64, ge=1)
train_every_n_samples: int = Field(default=5, ge=1)
warmup_samples: int = Field(default=200, ge=1)
label_horizon_bars: int = Field(default=12, ge=1)
label_target_bps: float = Field(default=30.0, ge=0)
trade_sample_weight: float = Field(default=3.0, gt=0)
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands beschleunigen
# die Aufwärmphase erheblich (0 = aus).
background_sample_every_n_bars: int = Field(default=10, ge=0)
background_sample_weight: float = Field(default=0.5, gt=0)
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, statt
# tagelang auf genügend Live-Beobachtungen zu warten (0 = aus).
bootstrap_bars: int = Field(default=3_000, ge=0)
freeze_in_live: bool = False
save_every_n_updates: int = Field(default=50, ge=1)
class StrategyConfig(_Base):
name: str = "adaptive" # adaptive | rules
rules: RuleConfig = Field(default_factory=RuleConfig)
learner: LearnerConfig = Field(default_factory=LearnerConfig)
@field_validator("name")
@classmethod
def _known(cls, v: str) -> str:
v = v.strip().lower()
if v not in {"adaptive", "rules"}:
raise ValueError("strategy.name muss 'adaptive' oder 'rules' sein")
return v
class StorageConfig(_Base):
database_path: str = "/data/trademind.sqlite3"
class ServerConfig(_Base):
enabled: bool = True
host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
enable_metrics: bool = True
class NotificationConfig(_Base):
webhook_url: str | None = None
notify_on_trade: bool = True
notify_on_risk_halt: bool = True
class BacktestConfig(_Base):
start: str | None = None # ISO-8601, z.B. 2024-01-01T00:00:00Z
end: str | None = None
bars: int = Field(default=5_000, ge=100)
csv_dir: str | None = None # optional: OHLCV aus CSV statt von der Börse
class Config(_Base):
mode: Mode = Mode.PAPER
log_level: str = "INFO"
live_confirmation: str | None = None
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
market: MarketConfig = Field(default_factory=MarketConfig)
paper: PaperConfig = Field(default_factory=PaperConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
server: ServerConfig = Field(default_factory=ServerConfig)
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
backtest: BacktestConfig = Field(default_factory=BacktestConfig)
@field_validator("log_level")
@classmethod
def _level(cls, v: str) -> str:
v = v.strip().upper()
if v not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError(f"Unbekannter log_level: {v}")
return v
@model_validator(mode="after")
def _live_guard(self) -> Config:
if self.mode is Mode.LIVE:
if self.live_confirmation != LIVE_CONFIRMATION_PHRASE:
raise ValueError(
"Live-Modus erfordert 'live_confirmation: "
f"{LIVE_CONFIRMATION_PHRASE}' in der Konfiguration."
)
if not self.exchange.has_credentials():
raise ValueError("Live-Modus erfordert exchange.api_key und exchange.api_secret.")
return self
@property
def is_simulated(self) -> bool:
return self.mode in (Mode.PAPER, Mode.BACKTEST)
def _substitute_env(node: Any) -> Any:
"""Ersetzt ``${VAR}`` / ``${VAR:-default}`` rekursiv durch Umgebungsvariablen."""
if isinstance(node, dict):
return {k: _substitute_env(v) for k, v in node.items()}
if isinstance(node, list):
return [_substitute_env(v) for v in node]
if not isinstance(node, str):
return node
def repl(match: re.Match[str]) -> str:
name, default = match.group(1), match.group(2)
return os.environ.get(name, default if default is not None else "")
result = _ENV_PATTERN.sub(repl, node)
# Ein Platzhalter, der zu einem leeren String auflöst, gilt als "nicht gesetzt".
if result == "" and _ENV_PATTERN.search(node):
return None
return result
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
def _coerce(raw: str) -> Any:
low = raw.strip().lower()
if low in _TRUE:
return True
if low in _FALSE:
return False
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
if "," in raw:
return [part.strip() for part in raw.split(",") if part.strip()]
return raw
def _apply_env_overrides(data: dict[str, Any], prefix: str = "TRADEMIND__") -> dict[str, Any]:
"""``TRADEMIND__RISK__MAX_OPEN_POSITIONS=5`` überschreibt ``risk.max_open_positions``."""
for key, value in os.environ.items():
if not key.startswith(prefix) or not value:
continue
path = [part.lower() for part in key[len(prefix) :].split("__") if part]
if not path:
continue
cursor: dict[str, Any] = data
for part in path[:-1]:
nxt = cursor.get(part)
if not isinstance(nxt, dict):
nxt = {}
cursor[part] = nxt
cursor = nxt
cursor[path[-1]] = _coerce(value)
return data
def load_config(path: str | Path) -> Config:
"""Lädt und validiert die Konfigurationsdatei."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {p}")
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError(f"{p}: erwartet wurde ein YAML-Mapping auf oberster Ebene")
data = _substitute_env(raw)
data = _apply_env_overrides(data)
return Config.model_validate(data)
+251
View File
@@ -0,0 +1,251 @@
"""Marktdaten-Quellen: Live-Abruf über ccxt, CSV-Import und Replay für Backtests."""
from __future__ import annotations
import asyncio
import csv
import logging
from abc import ABC, abstractmethod
from datetime import UTC, datetime
from pathlib import Path
import numpy as np
from .exchange import ExchangeError, timeframe_to_ms
from .models import Candles
log = logging.getLogger(__name__)
MAX_FETCH_LIMIT = 1_000
def parse_iso8601(value: str | None) -> int | None:
"""ISO-8601 → Millisekunden seit Epoch (UTC)."""
if not value:
return None
text = value.strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return int(dt.timestamp() * 1000)
def format_ts(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
class DataFeed(ABC):
"""Liefert OHLCV-Kerzen für ein Symbol."""
@abstractmethod
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
"""Die letzten ``limit`` abgeschlossenen Kerzen."""
async def close(self) -> None:
return None
class CcxtDataFeed(DataFeed):
"""Öffentliche Marktdaten über ccxt (keine Zugangsdaten nötig)."""
def __init__(self, exchange, max_retries: int = 3, retry_delay: float = 2.0) -> None:
self._exchange = exchange
self._max_retries = max_retries
self._retry_delay = retry_delay
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
rows = await self._fetch_with_retry(symbol, timeframe, min(limit, MAX_FETCH_LIMIT))
# Die letzte Kerze der Börse ist meist noch offen sie wird verworfen,
# damit Indikatoren nicht auf unvollständigen Daten rechnen.
if len(rows) > 1 and _is_incomplete(rows[-1][0], timeframe):
rows = rows[:-1]
return Candles.from_rows(symbol, timeframe, rows)
async def _fetch_with_retry(self, symbol: str, timeframe: str, limit: int) -> list[list[float]]:
last_error: Exception | None = None
for attempt in range(1, self._max_retries + 1):
try:
return await self._exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - ccxt-Fehlerhierarchie ist breit
last_error = exc
if attempt == self._max_retries:
break
delay = self._retry_delay * attempt
log.warning(
"OHLCV-Abruf für %s fehlgeschlagen (Versuch %d/%d): %s erneuter Versuch in %.1fs",
symbol, attempt, self._max_retries, exc, delay,
)
await asyncio.sleep(delay)
raise ExchangeError(f"OHLCV-Abruf für {symbol} endgültig fehlgeschlagen: {last_error}")
async def fetch_history(
self, symbol: str, timeframe: str, bars: int, since_ms: int | None = None, until_ms: int | None = None
) -> Candles:
"""Längere Historie seitenweise laden (für Backtests)."""
step = timeframe_to_ms(timeframe)
if since_ms is None:
end = until_ms if until_ms is not None else int(datetime.now(tz=UTC).timestamp() * 1000)
since_ms = end - bars * step
collected: list[list[float]] = []
cursor = since_ms
while len(collected) < bars:
batch = await self._fetch_with_retry_since(symbol, timeframe, cursor)
if not batch:
break
if collected and batch[0][0] <= collected[-1][0]:
batch = [row for row in batch if row[0] > collected[-1][0]]
if not batch:
break
collected.extend(batch)
cursor = int(batch[-1][0]) + step
if until_ms is not None and cursor >= until_ms:
break
await asyncio.sleep(self._exchange.rateLimit / 1000 if self._exchange.rateLimit else 0.2)
if until_ms is not None:
collected = [row for row in collected if row[0] <= until_ms]
collected = collected[:bars] if since_ms is not None and until_ms is None else collected
log.info("%s: %d historische Kerzen geladen", symbol, len(collected))
return Candles.from_rows(symbol, timeframe, collected)
async def _fetch_with_retry_since(self, symbol: str, timeframe: str, since: int) -> list[list[float]]:
last_error: Exception | None = None
for attempt in range(1, self._max_retries + 1):
try:
return await self._exchange.fetch_ohlcv(
symbol, timeframe=timeframe, since=since, limit=MAX_FETCH_LIMIT
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt == self._max_retries:
break
await asyncio.sleep(self._retry_delay * attempt)
raise ExchangeError(f"Historien-Abruf für {symbol} fehlgeschlagen: {last_error}")
async def close(self) -> None:
await self._exchange.close()
class ReplayDataFeed(DataFeed):
"""Spielt eine vorgeladene Serie Kerze für Kerze ab (Backtest)."""
def __init__(self, series: dict[str, Candles], warmup: int) -> None:
self._series = series
self._cursor = warmup
self.warmup = warmup
@property
def cursor(self) -> int:
return self._cursor
@property
def length(self) -> int:
return min(len(c) for c in self._series.values()) if self._series else 0
@property
def exhausted(self) -> bool:
return self._cursor >= self.length
def advance(self) -> bool:
self._cursor += 1
return not self.exhausted
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
full = self._series[symbol]
stop = min(self._cursor + 1, len(full))
start = max(0, stop - limit)
return full.slice(start, stop)
def current_bar(self, symbol: str) -> tuple[float, float, float, float, float]:
"""(open, high, low, close, volume) der aktuellen Kerze."""
c = self._series[symbol]
i = min(self._cursor, len(c) - 1)
return float(c.open[i]), float(c.high[i]), float(c.low[i]), float(c.close[i]), float(c.volume[i])
def timestamp(self, symbol: str) -> int:
c = self._series[symbol]
return int(c.timestamp[min(self._cursor, len(c) - 1)])
def load_csv(path: str | Path, symbol: str, timeframe: str) -> Candles:
"""Lädt OHLCV aus CSV.
Erwartete Spalten (Header, Reihenfolge egal): ``timestamp,open,high,low,close,volume``.
``timestamp`` als Millisekunden, Sekunden oder ISO-8601.
"""
p = Path(path)
rows: list[list[float]] = []
with p.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
if reader.fieldnames is None:
raise ValueError(f"{p}: CSV ohne Kopfzeile")
cols = {name.strip().lower(): name for name in reader.fieldnames}
required = ("timestamp", "open", "high", "low", "close", "volume")
missing = [c for c in required if c not in cols]
if missing:
raise ValueError(f"{p}: fehlende Spalten {missing}")
for record in reader:
raw_ts = record[cols["timestamp"]].strip()
rows.append(
[
_parse_timestamp(raw_ts),
float(record[cols["open"]]),
float(record[cols["high"]]),
float(record[cols["low"]]),
float(record[cols["close"]]),
float(record[cols["volume"]] or 0.0),
]
)
rows.sort(key=lambda r: r[0])
log.info("%s: %d Kerzen aus %s geladen", symbol, len(rows), p.name)
return Candles.from_rows(symbol, timeframe, rows)
def _parse_timestamp(raw: str) -> float:
try:
value = float(raw)
except ValueError:
ms = parse_iso8601(raw)
if ms is None:
raise ValueError(f"Zeitstempel nicht interpretierbar: {raw!r}") from None
return float(ms)
# Sekunden vs. Millisekunden unterscheiden (Schwelle ~2001 in ms).
return value * 1000.0 if value < 1e11 else value
def _is_incomplete(candle_open_ms: int, timeframe: str) -> bool:
step = timeframe_to_ms(timeframe)
now_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
return candle_open_ms + step > now_ms
def align_series(series: dict[str, Candles]) -> dict[str, Candles]:
"""Kürzt mehrere Serien auf gemeinsame Zeitstempel, damit der Backtest synchron läuft."""
if len(series) <= 1:
return series
common: set[int] | None = None
for candles in series.values():
stamps = set(int(t) for t in candles.timestamp)
common = stamps if common is None else (common & stamps)
if not common:
raise ValueError("Die geladenen Serien haben keine gemeinsamen Zeitstempel")
keep = np.array(sorted(common), dtype=np.int64)
aligned: dict[str, Candles] = {}
for symbol, candles in series.items():
mask = np.isin(candles.timestamp, keep)
aligned[symbol] = Candles(
symbol=symbol,
timeframe=candles.timeframe,
timestamp=candles.timestamp[mask],
open=candles.open[mask],
high=candles.high[mask],
low=candles.low[mask],
close=candles.close[mask],
volume=candles.volume[mask],
)
return aligned
+519
View File
@@ -0,0 +1,519 @@
"""Handels-Engine: verbindet Marktdaten, Strategie, Risiko und Ausführung.
Die Bar-Verarbeitung (:meth:`TradingEngine.process_bar`) ist identisch für Paper-, Live- und
Backtest-Betrieb; nur die Datenquelle und die Ausführung werden ausgetauscht.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import time
from dataclasses import dataclass
from typing import Any
import numpy as np
from .broker import Broker, InsufficientFunds, OrderRejected, PaperBroker
from .config import Config
from .data import DataFeed
from .features import FEATURE_NAMES, FeatureSnapshot, build_feature_matrix, required_bars
from .models import Action, Candles, ExitReason, Position, Side, Signal
from .notify import Notifier
from .portfolio import Portfolio
from .risk import RiskManager
from .storage import NullStorage, Storage
from .strategy import AdaptiveStrategy, Strategy
log = logging.getLogger(__name__)
STATE_KEY = "engine_state"
@dataclass(slots=True)
class Bar:
timestamp: int
open: float
high: float
low: float
close: float
volume: float
@classmethod
def from_candles(cls, candles: Candles, index: int = -1) -> Bar:
i = index if index >= 0 else len(candles) + index
return cls(
timestamp=int(candles.timestamp[i]),
open=float(candles.open[i]),
high=float(candles.high[i]),
low=float(candles.low[i]),
close=float(candles.close[i]),
volume=float(candles.volume[i]),
)
class TradingEngine:
"""Orchestriert einen Handelslauf."""
def __init__(
self,
config: Config,
broker: Broker,
feed: DataFeed,
strategy: Strategy,
portfolio: Portfolio,
risk: RiskManager,
storage: Storage | NullStorage,
notifier: Notifier | None = None,
) -> None:
self.config = config
self.broker = broker
self.feed = feed
self.strategy = strategy
self.portfolio = portfolio
self.risk = risk
self.storage = storage
self.notifier = notifier
self.running = False
self.startup_error: str | None = None
self.iterations = 0
self.errors = 0
self.started_at = time.time()
self.last_bar_ts: dict[str, int] = {}
self.bar_counter: dict[str, int] = {sym: 0 for sym in config.market.symbols}
self._cash = 0.0
self._stop_event: asyncio.Event | None = None
self._persist_every = 10
self._since_persist = 0
# ------------------------------------------------------------ Lebenszyklus
async def prepare(self) -> None:
await self.broker.start()
self._cash = await self.broker.cash()
self.portfolio.starting_equity = self.portfolio.starting_equity or self._cash
self._restore_state()
log.info(
"Engine bereit Modus %s, %d Symbol(e), Startguthaben %.2f %s",
self.config.mode.value,
len(self.config.market.symbols),
self._cash,
self.broker.quote_currency,
)
async def shutdown(self, liquidate: bool = False) -> None:
if liquidate and self.portfolio.positions:
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
for symbol in list(self.portfolio.positions):
price = self.portfolio.mark_prices.get(symbol)
if price:
await self._close_position(symbol, price, ExitReason.SHUTDOWN, None)
self._persist_state()
learner = getattr(self.strategy, "learner", None)
if learner is not None:
try:
learner.save()
except OSError as exc: # pragma: no cover
log.error("Modell konnte beim Herunterfahren nicht gespeichert werden: %s", exc)
self.running = False
async def bootstrap_learner(self) -> None:
"""Ein noch untrainiertes Modell aus der Kurshistorie vorlernen.
Ohne diesen Schritt bräuchte ein frisch gestarteter Bot bei 5-Minuten-Kerzen
mehrere Tage, bis das Modell genug Beobachtungen für die Aufwärmphase gesammelt hat.
"""
learner = getattr(self.strategy, "learner", None)
warmup = getattr(self.strategy, "warmup_from_history", None)
bars = self.config.strategy.learner.bootstrap_bars
if learner is None or warmup is None or not bars or learner.ready:
return
log.info("Modell ist untrainiert lerne aus bis zu %d historischen Kerzen vor …", bars)
before = learner.stats.samples_seen
for symbol in self.config.market.symbols:
try:
candles = await self._fetch_history(symbol, bars)
except Exception as exc: # noqa: BLE001 - Vorlernen darf den Start nie verhindern
log.warning("%s: Historie für das Vorlernen nicht abrufbar (%s)", symbol, exc)
continue
matrix = build_feature_matrix(candles, self.config.strategy.rules)
if matrix is None:
log.warning("%s: zu wenig Historie zum Vorlernen (%d Kerzen)", symbol, len(candles))
continue
last_index = warmup(symbol, matrix, candles)
# Zähler und Zeitstempel fortschreiben, damit der Live-Loop nahtlos anschließt
# und die zuletzt genutzte Kerze nicht doppelt verarbeitet wird.
self.bar_counter[symbol] = last_index
self.last_bar_ts[symbol] = int(candles.timestamp[last_index])
gained = learner.stats.samples_seen - before
log.info(
"Vorlernen abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
gained, learner.stats.samples_seen, "einsatzbereit" if learner.ready else "noch im Aufwärmen",
)
if gained:
try:
learner.save()
except OSError as exc: # pragma: no cover
log.error("Vorgelerntes Modell konnte nicht gespeichert werden: %s", exc)
async def _fetch_history(self, symbol: str, bars: int) -> Candles:
"""Längere Historie holen, wenn der Feed das kann sonst das normale Fenster."""
fetch_history = getattr(self.feed, "fetch_history", None)
if fetch_history is not None:
return await fetch_history(symbol, self.config.market.timeframe, bars)
return await self.feed.fetch(symbol, self.config.market.timeframe, bars)
def request_stop(self) -> None:
log.info("Stopp angefordert beende nach dem aktuellen Durchlauf")
if self._stop_event is not None:
self._stop_event.set()
self.running = False
# ------------------------------------------------------------- Hauptloop
async def run(self) -> None:
"""Endlosschleife für Paper- und Live-Betrieb."""
self._stop_event = asyncio.Event()
self.running = True
interval = self.config.market.poll_interval_seconds
history = max(self.config.market.history_bars, required_bars(self.config.strategy.rules) + 10)
while self.running:
cycle_start = time.monotonic()
try:
await self._tick(history)
self.iterations += 1
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - der Loop darf nie sterben
self.errors += 1
log.exception("Fehler im Handelsdurchlauf: %s", exc)
if self.errors > 50 and self.iterations == 0:
self.startup_error = str(exc)
log.error("Zu viele Fehler ohne erfolgreichen Durchlauf Abbruch")
break
elapsed = time.monotonic() - cycle_start
wait = max(0.5, interval - elapsed)
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=wait)
break # Stopp-Event wurde gesetzt
except TimeoutError:
continue
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
async def _tick(self, history: int) -> None:
self._cash = await self.broker.cash()
for symbol in self.config.market.symbols:
candles = await self.feed.fetch(symbol, self.config.market.timeframe, history)
if len(candles) == 0:
log.warning("%s: keine Kerzen erhalten", symbol)
continue
bar = Bar.from_candles(candles)
self.portfolio.update_mark(symbol, bar.close)
if self.last_bar_ts.get(symbol) == bar.timestamp:
continue # noch dieselbe Kerze nichts Neues zu entscheiden
self.last_bar_ts[symbol] = bar.timestamp
matrix = build_feature_matrix(candles, self.config.strategy.rules)
snapshot = matrix.snapshot(-1) if matrix is not None else None
if snapshot is None:
log.info(
"%s: Historie noch zu kurz (%d/%d Kerzen) warte",
symbol, len(candles), required_bars(self.config.strategy.rules),
)
continue
self.bar_counter[symbol] = self.bar_counter.get(symbol, 0) + 1
await self.process_bar(symbol, snapshot, bar)
self._record_equity()
self._maybe_persist()
# -------------------------------------------------------- Bar-Verarbeitung
async def process_bar(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
"""Verarbeitet genau eine abgeschlossene Kerze für ein Symbol."""
bar_index = self.bar_counter.get(symbol, 0)
self.portfolio.on_new_bar(symbol, bar.high, bar.low, bar.close)
self.strategy.on_bar(symbol, snapshot, bar_index, bar.high, bar.low, bar.close)
halt_reason = self.risk.evaluate_halt(self.portfolio, self._cash, bar.timestamp)
if halt_reason and self.notifier is not None:
self.notifier.risk_halt(halt_reason)
position = self.portfolio.positions.get(symbol)
if position is not None:
if await self._manage_open_position(symbol, position, snapshot, bar):
return
elif not self.risk.trading_halted:
await self._maybe_enter(symbol, snapshot, bar)
async def _manage_open_position(
self, symbol: str, position: Position, snapshot: FeatureSnapshot, bar: Bar
) -> bool:
"""Stop/Ziel/Signal prüfen. Gibt ``True`` zurück, wenn die Position geschlossen wurde."""
self.risk.update_trailing(position, snapshot.atr)
if self.risk.force_liquidation():
await self._close_position(symbol, bar.close, ExitReason.RISK_HALT, bar)
return True
reason, exit_price = self.risk.check_exit(position, bar.high, bar.low, bar.close)
if reason is not None:
await self._close_position(symbol, exit_price, reason, bar)
return True
signal = self.strategy.evaluate(symbol, snapshot, position)
if signal.action is Action.EXIT_LONG:
log.info("%s: Ausstiegssignal (%s)", symbol, signal.reason)
await self._close_position(symbol, bar.close, ExitReason.SIGNAL, bar)
return True
return False
async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
signal = self.strategy.evaluate(symbol, snapshot, None)
if signal.action is not Action.ENTER_LONG:
if signal.confidence and log.isEnabledFor(logging.DEBUG):
log.debug("%s: kein Einstieg %s", symbol, signal.reason)
return
decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close)
if not decision:
log.debug("%s: Einstieg durch Risikoprüfung verhindert %s", symbol, decision.reason)
return
amount, why_not = self.risk.position_size(
self.portfolio,
self._cash,
bar.close,
min_amount=self.broker.min_amount(symbol),
min_cost=self.broker.min_cost(symbol),
)
if amount <= 0:
log.info("%s: Einstieg übersprungen %s", symbol, why_not)
return
try:
fill = await self.broker.execute(
symbol, Side.BUY, amount, bar.close, bar_volume=bar.volume, timestamp=bar.timestamp
)
except (InsufficientFunds, OrderRejected) as exc:
log.warning("%s: Kauf nicht ausgeführt %s", symbol, exc)
return
self._cash = await self.broker.cash()
stop, target = self.risk.stop_levels(fill.price, snapshot.atr)
self.portfolio.open_position(
fill,
stop_loss=stop,
take_profit=target,
features=signal.features,
confidence=signal.confidence,
exploratory=signal.exploratory,
)
log.info(
"%s: EINSTIEG %.8f @ %.6f (%.2f %s) | %s | SL %s TP %s",
symbol, fill.amount, fill.price, fill.notional, self.broker.quote_currency,
signal.reason,
f"{stop:.6f}" if stop else "",
f"{target:.6f}" if target else "",
)
if self.notifier is not None:
self.notifier.position_opened(
symbol, fill.amount, fill.price, signal.confidence, self.broker.quote_currency
)
async def _close_position(
self, symbol: str, price: float, reason: ExitReason, bar: Bar | None
) -> None:
position = self.portfolio.positions.get(symbol)
if position is None:
return
try:
# Ausstiege werden bewusst nicht durch die Volumengrenze gedrosselt
# Risikomanagement muss jederzeit vollständig aussteigen können.
fill = await self.broker.execute(
symbol,
Side.SELL,
position.amount,
price,
bar_volume=None,
timestamp=bar.timestamp if bar else None,
)
except (InsufficientFunds, OrderRejected) as exc:
log.error("%s: Ausstieg fehlgeschlagen (%s) Position bleibt offen!", symbol, exc)
return
trade = self.portfolio.close_position(fill, reason, mode=self.config.mode.value)
self._cash = await self.broker.cash()
self.portfolio.start_cooldown(symbol, self.config.risk.cooldown_bars_after_exit)
self.strategy.on_trade_closed(position, trade.pnl_quote)
self.storage.record_trade(trade)
log.info(
"%s: AUSSTIEG %.8f @ %.6f (%s) | P/L %+.2f %s (%+.2f%%) | Equity %.2f",
symbol, fill.amount, fill.price, reason.value, trade.pnl_quote,
self.broker.quote_currency, trade.pnl_pct * 100.0,
self.portfolio.equity(self._cash),
)
if self.notifier is not None:
self.notifier.trade_closed(
trade, self.portfolio.equity(self._cash), self.broker.quote_currency
)
# -------------------------------------------------------------- Zustand
def _record_equity(self) -> None:
timestamp = max(self.last_bar_ts.values()) if self.last_bar_ts else int(time.time() * 1000)
equity = self.portfolio.record_equity(timestamp, self._cash)
self.storage.record_equity(timestamp, equity, self._cash, self.portfolio.exposure())
def _maybe_persist(self) -> None:
self._since_persist += 1
if self._since_persist >= self._persist_every:
self._since_persist = 0
self._persist_state()
def _persist_state(self) -> None:
state: dict[str, Any] = {
"mode": self.config.mode.value,
"cash": self._cash,
"peak_equity": self.portfolio.peak_equity,
"max_drawdown": self.portfolio.max_drawdown,
"starting_equity": self.portfolio.starting_equity,
"bar_counter": self.bar_counter,
"cooldowns": self.portfolio.cooldowns,
"positions": [_position_to_dict(p) for p in self.portfolio.positions.values()],
}
if isinstance(self.broker, PaperBroker):
state["paper"] = self.broker.state()
self.storage.save_state(STATE_KEY, state)
def _restore_state(self) -> None:
state = self.storage.load_state(STATE_KEY)
if not state:
return
if state.get("mode") != self.config.mode.value:
log.info(
"Gespeicherter Zustand stammt aus Modus '%s' wird für '%s' ignoriert",
state.get("mode"), self.config.mode.value,
)
return
if isinstance(self.broker, PaperBroker) and "paper" in state:
paper = state["paper"]
self.broker.restore(
paper.get("cash", 0.0), paper.get("holdings", {}), paper.get("total_fees", 0.0)
)
self._cash = self.broker.cash_sync()
self.portfolio.starting_equity = float(state.get("starting_equity") or self.portfolio.starting_equity)
self.portfolio.peak_equity = float(state.get("peak_equity") or self.portfolio.starting_equity)
self.portfolio.max_drawdown = float(state.get("max_drawdown") or 0.0)
self.bar_counter.update({k: int(v) for k, v in (state.get("bar_counter") or {}).items()})
self.portfolio.cooldowns = {k: int(v) for k, v in (state.get("cooldowns") or {}).items()}
restored = 0
for raw in state.get("positions", []):
position = _position_from_dict(raw)
if position.symbol not in self.config.market.symbols:
log.warning(
"Wiederhergestellte Position %s ist nicht mehr konfiguriert bitte manuell prüfen",
position.symbol,
)
self.portfolio.positions[position.symbol] = position
self.portfolio.update_mark(position.symbol, position.entry_price)
restored += 1
if restored:
log.info("%d offene Position(en) aus dem gespeicherten Zustand übernommen", restored)
# ------------------------------------------------------------- Statusdaten
def status(self) -> dict[str, Any]:
learner = getattr(self.strategy, "learner", None)
return {
"running": self.running,
"mode": self.config.mode.value,
"exchange": self.config.exchange.id,
"sandbox": self.config.exchange.sandbox,
"symbols": self.config.market.symbols,
"timeframe": self.config.market.timeframe,
"quote_currency": self.broker.quote_currency,
"uptime_seconds": round(time.time() - self.started_at, 1),
"iterations": self.iterations,
"errors": self.errors,
"startup_error": self.startup_error,
"bars_processed": dict(self.bar_counter),
"portfolio": self.portfolio.summary(self._cash),
"positions": self.portfolio.open_positions_view(),
"recent_trades": self.portfolio.recent_trades(25),
"strategy": self.strategy.snapshot(),
"risk": self.risk.snapshot(),
"feature_weights": (
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
),
}
def _position_to_dict(position: Position) -> dict[str, Any]:
return {
"id": position.id,
"symbol": position.symbol,
"amount": position.amount,
"entry_price": position.entry_price,
"entry_timestamp": position.entry_timestamp,
"stop_loss": position.stop_loss,
"take_profit": position.take_profit,
"trailing_stop": position.trailing_stop,
"highest_price": position.highest_price,
"bars_held": position.bars_held,
"entry_fee_quote": position.entry_fee_quote,
"entry_confidence": position.entry_confidence,
"exploratory": position.exploratory,
"entry_features": (
None if position.entry_features is None else [float(v) for v in position.entry_features]
),
}
def _position_from_dict(raw: dict[str, Any]) -> Position:
features = raw.get("entry_features")
return Position(
symbol=raw["symbol"],
amount=float(raw["amount"]),
entry_price=float(raw["entry_price"]),
entry_timestamp=int(raw["entry_timestamp"]),
stop_loss=raw.get("stop_loss"),
take_profit=raw.get("take_profit"),
trailing_stop=raw.get("trailing_stop"),
highest_price=float(raw.get("highest_price") or raw["entry_price"]),
bars_held=int(raw.get("bars_held") or 0),
entry_fee_quote=float(raw.get("entry_fee_quote") or 0.0),
entry_features=None if features is None else np.asarray(features, dtype=np.float64),
entry_confidence=float(raw.get("entry_confidence") or 0.0),
exploratory=bool(raw.get("exploratory")),
id=raw.get("id") or "",
)
def install_signal_handlers(engine: TradingEngine) -> None:
"""SIGTERM/SIGINT abfangen, damit ``podman stop`` sauber herunterfährt."""
loop = asyncio.get_running_loop()
for sig_name in ("SIGTERM", "SIGINT"):
sig = getattr(signal, sig_name, None)
if sig is None:
continue
try:
loop.add_signal_handler(sig, engine.request_stop)
except NotImplementedError: # Windows kennt add_signal_handler für SIGTERM nicht
signal.signal(sig, lambda *_: engine.request_stop())
__all__ = ["Bar", "TradingEngine", "install_signal_handlers", "AdaptiveStrategy", "Signal"]
+123
View File
@@ -0,0 +1,123 @@
"""Aufbau des ccxt-Clients und Hilfsfunktionen rund um Börsen-Metadaten.
Über ccxt sind u. a. Binance, Kraken, Coinbase, Bybit, OKX, KuCoin, Bitget, Gate.io,
Bitstamp und MEXC ansprechbar die Auswahl erfolgt allein über ``exchange.id``.
"""
from __future__ import annotations
import logging
from typing import Any
import ccxt.async_support as ccxt
from .config import ExchangeConfig
log = logging.getLogger(__name__)
# Börsen, die einen zusätzlichen Passphrase/Password benötigen.
PASSWORD_EXCHANGES = {"okx", "kucoin", "kucoinfutures", "coinbase", "coinbaseadvanced", "bitget"}
TIMEFRAME_MS = {
"1m": 60_000,
"3m": 180_000,
"5m": 300_000,
"15m": 900_000,
"30m": 1_800_000,
"1h": 3_600_000,
"2h": 7_200_000,
"4h": 14_400_000,
"6h": 21_600_000,
"8h": 28_800_000,
"12h": 43_200_000,
"1d": 86_400_000,
"3d": 259_200_000,
"1w": 604_800_000,
}
class ExchangeError(RuntimeError):
"""Fehler beim Aufbau oder Betrieb der Börsenanbindung."""
def timeframe_to_ms(timeframe: str) -> int:
try:
return TIMEFRAME_MS[timeframe]
except KeyError:
raise ExchangeError(
f"Unbekannter timeframe '{timeframe}'. Erlaubt: {', '.join(TIMEFRAME_MS)}"
) from None
def available_exchanges() -> list[str]:
return sorted(ccxt.exchanges)
def build_exchange(config: ExchangeConfig, *, read_only: bool = False) -> ccxt.Exchange:
"""Erzeugt einen asynchronen ccxt-Client.
``read_only=True`` lässt die Zugangsdaten weg ausreichend für öffentliche
Marktdaten und damit der Standard in Paper- und Backtest-Modus.
"""
if config.id not in ccxt.exchanges:
raise ExchangeError(
f"Börse '{config.id}' ist in ccxt unbekannt. Verfügbar u. a.: "
+ ", ".join(list(ccxt.exchanges)[:15])
+ " ..."
)
params: dict[str, Any] = {
"enableRateLimit": config.enable_rate_limit,
"timeout": config.timeout_ms,
"options": dict(config.options),
}
if not read_only:
if not config.has_credentials():
raise ExchangeError("API-Key und Secret werden für authentifizierte Aufrufe benötigt.")
params["apiKey"] = config.api_key
params["secret"] = config.api_secret
if config.password:
params["password"] = config.password
elif config.id in PASSWORD_EXCHANGES:
log.warning("Börse '%s' verlangt üblicherweise ein Passphrase (exchange.password).", config.id)
if config.uid:
params["uid"] = config.uid
exchange = getattr(ccxt, config.id)(params)
if config.sandbox:
try:
exchange.set_sandbox_mode(True)
log.info("Sandbox/Testnet für '%s' aktiviert", config.id)
except Exception as exc: # noqa: BLE001 - ccxt wirft heterogene Typen
log.warning("Sandbox-Modus für '%s' nicht verfügbar: %s", config.id, exc)
return exchange
async def load_market_info(exchange: ccxt.Exchange, symbols: list[str]) -> dict[str, dict[str, Any]]:
"""Lädt Handelsregeln (Präzision, Mindestgrößen) für die konfigurierten Symbole."""
markets = await exchange.load_markets()
info: dict[str, dict[str, Any]] = {}
missing = []
for symbol in symbols:
market = markets.get(symbol)
if market is None:
missing.append(symbol)
continue
limits = market.get("limits") or {}
info[symbol] = {
"amount_precision": (market.get("precision") or {}).get("amount"),
"price_precision": (market.get("precision") or {}).get("price"),
"min_amount": ((limits.get("amount") or {}).get("min")) or 0.0,
"min_cost": ((limits.get("cost") or {}).get("min")) or 0.0,
"maker": market.get("maker"),
"taker": market.get("taker"),
"base": market.get("base"),
"quote": market.get("quote"),
}
if missing:
raise ExchangeError(
f"Symbole an '{exchange.id}' nicht handelbar: {', '.join(missing)}. "
"Bitte market.symbols prüfen (Schreibweise z. B. 'BTC/USDT')."
)
return info
+225
View File
@@ -0,0 +1,225 @@
"""Feature-Engineering: aus einer Kerzenserie normierte Merkmalsvektoren bauen.
Alle Features sind bewusst skalenfrei (Verhältnisse, Prozentwerte, z-Scores), damit ein
Modell über verschiedene Symbole und Preisniveaus hinweg lernen kann.
Die Indikatoren werden einmal über die gesamte Serie berechnet (``build_feature_matrix``);
Backtests laufen dadurch linear statt quadratisch.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .config import RuleConfig
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
from .models import Candles
FEATURE_NAMES: tuple[str, ...] = (
"ema_spread", # (EMA_fast - EMA_slow) / Preis [%]
"ema_fast_dist", # (Preis - EMA_fast) / Preis [%]
"trend_dist", # (Preis - EMA_trend) / Preis [%]
"rsi_norm", # (RSI - 50) / 50
"rsi_slope", # RSI-Änderung über 3 Bars / 50
"macd_hist", # MACD-Histogramm / Preis [%]
"macd_hist_slope",
"atr_pct", # ATR / Preis [%]
"vol_ratio", # kurzfristige vs. langfristige Kursvolatilität
"roc_3",
"roc_12",
"donchian_pos", # Lage in der 20-Bar-Range, zentriert auf 0
"volume_z", # z-Score des Volumens
"body_ratio", # Kerzenkörper / Range
"upper_wick",
"lower_wick",
"time_sin", # zyklische Tageszeit
"time_cos",
)
N_FEATURES = len(FEATURE_NAMES)
MIN_BARS = 140
_CLIP_LIMIT = 8.0
@dataclass(slots=True)
class FeatureSnapshot:
"""Merkmalsvektor plus Roh-Kennzahlen, die Risiko und Regelwerk zusätzlich brauchen."""
values: np.ndarray
names: tuple[str, ...]
index: int
price: float
atr: float
rsi: float
rsi_prev: float
ema_fast: float
ema_slow: float
ema_fast_prev: float
ema_slow_prev: float
trend_ema: float
timestamp: int
def as_dict(self) -> dict[str, float]:
return {name: float(v) for name, v in zip(self.names, self.values, strict=True)}
def required_bars(rules: RuleConfig) -> int:
"""Minimale Anzahl Kerzen, damit alle Indikatoren belastbare Werte liefern."""
return max(MIN_BARS, rules.trend_filter_period + 10, rules.slow_ema * 3, rules.rsi_period * 4)
def _clean(arr: np.ndarray, fill: float | np.ndarray = 0.0) -> np.ndarray:
out = np.asarray(arr, dtype=np.float64).copy()
bad = ~np.isfinite(out)
if np.any(bad):
out[bad] = fill[bad] if isinstance(fill, np.ndarray) else fill
return out
def _pct(numerator: np.ndarray, price: np.ndarray) -> np.ndarray:
with np.errstate(divide="ignore", invalid="ignore"):
return np.where(price > 0, numerator / price * 100.0, 0.0)
@dataclass(slots=True)
class FeatureMatrix:
"""Alle Merkmalsvektoren einer Serie plus die Roh-Indikatoren."""
values: np.ndarray # (n, N_FEATURES)
timestamp: np.ndarray
price: np.ndarray
atr: np.ndarray
rsi: np.ndarray
ema_fast: np.ndarray
ema_slow: np.ndarray
trend_ema: np.ndarray
first_valid: int # ab hier sind die Zeilen belastbar
def __len__(self) -> int:
return int(self.values.shape[0])
def is_valid(self, index: int) -> bool:
idx = index if index >= 0 else len(self) + index
return self.first_valid <= idx < len(self)
def snapshot(self, index: int = -1) -> FeatureSnapshot | None:
n = len(self)
idx = index if index >= 0 else n + index
if not self.is_valid(idx):
return None
prev = max(idx - 1, 0)
return FeatureSnapshot(
values=self.values[idx].copy(),
names=FEATURE_NAMES,
index=idx,
price=float(self.price[idx]),
atr=float(self.atr[idx]),
rsi=float(self.rsi[idx]),
rsi_prev=float(self.rsi[prev]),
ema_fast=float(self.ema_fast[idx]),
ema_slow=float(self.ema_slow[idx]),
ema_fast_prev=float(self.ema_fast[prev]),
ema_slow_prev=float(self.ema_slow[prev]),
trend_ema=float(self.trend_ema[idx]),
timestamp=int(self.timestamp[idx]),
)
def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix | None:
"""Berechnet Indikatoren und Merkmalsvektoren für die gesamte Serie.
Gibt ``None`` zurück, wenn die Historie kürzer als ``required_bars`` ist.
"""
n = len(candles)
need = required_bars(rules)
if n < need:
return None
close = np.asarray(candles.close, dtype=np.float64)
high = np.asarray(candles.high, dtype=np.float64)
low = np.asarray(candles.low, dtype=np.float64)
open_ = np.asarray(candles.open, dtype=np.float64)
volume = np.asarray(candles.volume, dtype=np.float64)
price = np.where(close > 0, close, np.nan)
ema_fast = ema(close, rules.fast_ema)
ema_slow = ema(close, rules.slow_ema)
trend_period = rules.trend_filter_period or rules.slow_ema * 4
trend_ema = ema(close, trend_period)
rsi_arr = _clean(rsi(close, rules.rsi_period), 50.0)
atr_raw = atr(high, low, close, rules.atr_period)
atr_arr = _clean(atr_raw, close * 0.005)
atr_arr = np.where(atr_arr > 0, atr_arr, np.maximum(close * 0.005, 1e-9))
_, _, macd_hist = macd(close, rules.fast_ema, rules.slow_ema, 9)
macd_hist = _clean(macd_hist)
roc3 = _clean(roc(close, 3))
roc12 = _clean(roc(close, 12))
dpos = _clean(donchian_position(high, low, close, 20), 0.5)
vol_short = _clean(rolling_std(close, 10))
vol_long = _clean(rolling_std(close, 50))
with np.errstate(divide="ignore", invalid="ignore"):
vol_ratio = np.where(vol_long > 1e-12, vol_short / vol_long, 1.0)
volume_mean = _clean(sma(volume, 50), float(np.mean(volume)) if volume.size else 0.0)
volume_std = _clean(rolling_std(volume, 50))
with np.errstate(divide="ignore", invalid="ignore"):
volume_z = np.where(volume_std > 1e-12, (volume - volume_mean) / volume_std, 0.0)
bar_range = np.maximum(high - low, 1e-12)
body = np.abs(close - open_) / bar_range
upper_wick = (high - np.maximum(open_, close)) / bar_range
lower_wick = (np.minimum(open_, close) - low) / bar_range
seconds_of_day = (np.asarray(candles.timestamp, dtype=np.int64) // 1000) % 86_400
angle = 2.0 * np.pi * seconds_of_day.astype(np.float64) / 86_400.0
rsi_prev = np.concatenate([np.full(min(3, n), rsi_arr[0]), rsi_arr[:-3]])[:n] if n > 3 else rsi_arr
hist_prev = np.concatenate([macd_hist[:1], macd_hist[:-1]])
columns = [
_pct(ema_fast - ema_slow, price),
_pct(close - ema_fast, price),
_pct(close - trend_ema, price),
(rsi_arr - 50.0) / 50.0,
(rsi_arr - rsi_prev) / 50.0,
_pct(macd_hist, price),
_pct(macd_hist - hist_prev, price),
_pct(atr_arr, price),
vol_ratio - 1.0,
roc3 * 100.0,
roc12 * 100.0,
dpos - 0.5,
volume_z,
body,
upper_wick,
lower_wick,
np.sin(angle),
np.cos(angle),
]
values = np.column_stack([_clean(col) for col in columns])
np.clip(values, -_CLIP_LIMIT, _CLIP_LIMIT, out=values)
return FeatureMatrix(
values=values,
timestamp=np.asarray(candles.timestamp, dtype=np.int64),
price=_clean(close),
atr=atr_arr,
rsi=rsi_arr,
ema_fast=_clean(ema_fast, close),
ema_slow=_clean(ema_slow, close),
trend_ema=_clean(trend_ema, close),
first_valid=need - 1,
)
def compute_features(candles: Candles, rules: RuleConfig, index: int = -1) -> FeatureSnapshot | None:
"""Bequemlichkeits-Wrapper für einen einzelnen Zeitpunkt (Live-Loop, Tests)."""
matrix = build_feature_matrix(candles, rules)
if matrix is None:
return None
return matrix.snapshot(index)
+156
View File
@@ -0,0 +1,156 @@
"""Technische Indikatoren auf reinen numpy-Arrays (keine pandas-Abhängigkeit)."""
from __future__ import annotations
import numpy as np
__all__ = [
"ema",
"sma",
"rsi",
"true_range",
"atr",
"macd",
"bollinger",
"rolling_std",
"roc",
"donchian_position",
]
def _as_float(values: np.ndarray) -> np.ndarray:
arr = np.asarray(values, dtype=np.float64)
if arr.ndim != 1:
raise ValueError("Es wird ein eindimensionales Array erwartet")
return arr
def ema(values: np.ndarray, period: int) -> np.ndarray:
"""Exponentiell gewichteter Durchschnitt, Seed = erster Wert."""
arr = _as_float(values)
if period < 1:
raise ValueError("period muss >= 1 sein")
if arr.size == 0:
return arr
alpha = 2.0 / (period + 1.0)
out = np.empty_like(arr)
out[0] = arr[0]
for i in range(1, arr.size):
out[i] = alpha * arr[i] + (1.0 - alpha) * out[i - 1]
return out
def sma(values: np.ndarray, period: int) -> np.ndarray:
"""Gleitender Durchschnitt; die ersten ``period-1`` Werte sind NaN."""
arr = _as_float(values)
if period < 1:
raise ValueError("period muss >= 1 sein")
out = np.full(arr.size, np.nan)
if arr.size < period:
return out
cumsum = np.cumsum(np.insert(arr, 0, 0.0))
out[period - 1 :] = (cumsum[period:] - cumsum[:-period]) / period
return out
def rolling_std(values: np.ndarray, period: int) -> np.ndarray:
arr = _as_float(values)
out = np.full(arr.size, np.nan)
if arr.size < period or period < 2:
return out
for i in range(period - 1, arr.size):
out[i] = float(np.std(arr[i - period + 1 : i + 1], ddof=0))
return out
def rsi(values: np.ndarray, period: int = 14) -> np.ndarray:
"""Relative Strength Index nach Wilder. Werte vor dem Seed sind NaN."""
arr = _as_float(values)
out = np.full(arr.size, np.nan)
if arr.size <= period:
return out
delta = np.diff(arr)
gains = np.clip(delta, 0.0, None)
losses = np.clip(-delta, 0.0, None)
avg_gain = float(np.mean(gains[:period]))
avg_loss = float(np.mean(losses[:period]))
out[period] = _rsi_value(avg_gain, avg_loss)
for i in range(period + 1, arr.size):
avg_gain = (avg_gain * (period - 1) + gains[i - 1]) / period
avg_loss = (avg_loss * (period - 1) + losses[i - 1]) / period
out[i] = _rsi_value(avg_gain, avg_loss)
return out
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
if avg_loss <= 1e-12:
return 100.0 if avg_gain > 0 else 50.0
rs = avg_gain / avg_loss
return 100.0 - (100.0 / (1.0 + rs))
def true_range(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> np.ndarray:
h, low_a, c = _as_float(high), _as_float(low), _as_float(close)
tr = np.empty_like(c)
tr[0] = h[0] - low_a[0]
prev_close = c[:-1]
tr[1:] = np.maximum.reduce(
[h[1:] - low_a[1:], np.abs(h[1:] - prev_close), np.abs(low_a[1:] - prev_close)]
)
return tr
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
"""Average True Range (Wilder-Glättung)."""
tr = true_range(high, low, close)
out = np.full(tr.size, np.nan)
if tr.size < period:
return out
seed = float(np.mean(tr[:period]))
out[period - 1] = seed
for i in range(period, tr.size):
seed = (seed * (period - 1) + tr[i]) / period
out[i] = seed
return out
def macd(
values: np.ndarray, fast: int = 12, slow: int = 26, signal: int = 9
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Gibt (macd_line, signal_line, histogram) zurück."""
macd_line = ema(values, fast) - ema(values, slow)
signal_line = ema(macd_line, signal)
return macd_line, signal_line, macd_line - signal_line
def bollinger(
values: np.ndarray, period: int = 20, num_std: float = 2.0
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Gibt (unteres Band, Mittelband, oberes Band) zurück."""
mid = sma(values, period)
sd = rolling_std(values, period)
return mid - num_std * sd, mid, mid + num_std * sd
def roc(values: np.ndarray, period: int = 10) -> np.ndarray:
"""Rate of Change als Anteil (0.01 == +1 %)."""
arr = _as_float(values)
out = np.full(arr.size, np.nan)
if arr.size <= period:
return out
base = arr[:-period]
with np.errstate(divide="ignore", invalid="ignore"):
out[period:] = np.where(base != 0, (arr[period:] - base) / base, 0.0)
return out
def donchian_position(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 20) -> np.ndarray:
"""Position des Schlusskurses in der Donchian-Range: 0 = Tief, 1 = Hoch."""
h, low_a, c = _as_float(high), _as_float(low), _as_float(close)
out = np.full(c.size, np.nan)
for i in range(period - 1, c.size):
window_high = float(np.max(h[i - period + 1 : i + 1]))
window_low = float(np.min(low_a[i - period + 1 : i + 1]))
span = window_high - window_low
out[i] = 0.5 if span <= 1e-12 else (c[i] - window_low) / span
return out
+563
View File
@@ -0,0 +1,563 @@
"""Online-Lernkomponente.
Der Bot bewertet jedes Einstiegssignal mit einer Gewinnwahrscheinlichkeit. Das Modell ist
eine logistische Regression, die inkrementell (Adam + Replay-Buffer) aus zwei Quellen lernt:
1. **Shadow-Labels** für jedes Kandidatensignal wird nach ``label_horizon_bars`` geprüft,
ob der Kurs das Ziel (``label_target_bps``) erreicht hätte. Liefert schnell viele Daten.
2. **Reale Trade-Ergebnisse** abgeschlossene Round-Trips, mit höherem Gewicht.
Der Zustand (Gewichte + Normalisierung + Statistik) wird als ``.npz`` persistiert und
überlebt damit Container-Neustarts.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from .config import LearnerConfig
log = logging.getLogger(__name__)
MODEL_FORMAT_VERSION = 2
def sigmoid(z: np.ndarray | float) -> np.ndarray:
z = np.clip(np.asarray(z, dtype=np.float64), -35.0, 35.0)
return 1.0 / (1.0 + np.exp(-z))
class RunningScaler:
"""Welford-Normalisierung: laufender Mittelwert und Varianz je Feature."""
def __init__(self, n_features: int) -> None:
self.n_features = n_features
self.count = 0.0
self.mean = np.zeros(n_features, dtype=np.float64)
self.m2 = np.zeros(n_features, dtype=np.float64)
def update(self, x: np.ndarray) -> None:
x = np.asarray(x, dtype=np.float64).reshape(-1)
self.count += 1.0
delta = x - self.mean
self.mean += delta / self.count
self.m2 += delta * (x - self.mean)
@property
def std(self) -> np.ndarray:
if self.count < 2:
return np.ones(self.n_features, dtype=np.float64)
var = self.m2 / (self.count - 1.0)
return np.sqrt(np.maximum(var, 1e-8))
def transform(self, x: np.ndarray) -> np.ndarray:
arr = np.asarray(x, dtype=np.float64)
scaled = (arr - self.mean) / self.std
return np.clip(scaled, -6.0, 6.0)
def state(self) -> dict[str, np.ndarray]:
return {
"scaler_count": np.array([self.count]),
"scaler_mean": self.mean,
"scaler_m2": self.m2,
}
def load_state(self, data: dict[str, np.ndarray]) -> None:
self.count = float(data["scaler_count"][0])
self.mean = np.asarray(data["scaler_mean"], dtype=np.float64)
self.m2 = np.asarray(data["scaler_m2"], dtype=np.float64)
class OnlineLogisticRegression:
"""Logistische Regression mit Adam-Optimierer und L2-Regularisierung."""
def __init__(self, n_features: int, learning_rate: float = 0.02, l2: float = 1e-4) -> None:
self.n_features = n_features
self.lr = learning_rate
self.l2 = l2
self.w = np.zeros(n_features, dtype=np.float64)
self.b = 0.0
self._mw = np.zeros(n_features, dtype=np.float64)
self._vw = np.zeros(n_features, dtype=np.float64)
self._mb = 0.0
self._vb = 0.0
self._t = 0
self._beta1, self._beta2, self._eps = 0.9, 0.999, 1e-8
def decision(self, x: np.ndarray) -> np.ndarray:
return np.asarray(x, dtype=np.float64) @ self.w + self.b
def predict_proba(self, x: np.ndarray) -> np.ndarray:
return sigmoid(self.decision(x))
def partial_fit(self, x: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None) -> float:
"""Ein Adam-Schritt auf einem Mini-Batch. Gibt den gewichteten Log-Loss zurück."""
x = np.atleast_2d(np.asarray(x, dtype=np.float64))
y = np.asarray(y, dtype=np.float64).reshape(-1)
if x.shape[0] != y.shape[0]:
raise ValueError("x und y haben unterschiedliche Batch-Größen")
w_s = np.ones_like(y) if sample_weight is None else np.asarray(sample_weight, dtype=np.float64)
w_sum = float(np.sum(w_s))
if w_sum <= 0:
return 0.0
p = self.predict_proba(x)
error = (p - y) * w_s
grad_w = (x.T @ error) / w_sum + self.l2 * self.w
grad_b = float(np.sum(error)) / w_sum
self._t += 1
self._mw = self._beta1 * self._mw + (1 - self._beta1) * grad_w
self._vw = self._beta2 * self._vw + (1 - self._beta2) * grad_w**2
self._mb = self._beta1 * self._mb + (1 - self._beta1) * grad_b
self._vb = self._beta2 * self._vb + (1 - self._beta2) * grad_b**2
bias1 = 1 - self._beta1**self._t
bias2 = 1 - self._beta2**self._t
self.w -= self.lr * (self._mw / bias1) / (np.sqrt(self._vw / bias2) + self._eps)
self.b -= self.lr * (self._mb / bias1) / (np.sqrt(self._vb / bias2) + self._eps)
eps = 1e-12
loss = -np.sum(w_s * (y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps))) / w_sum
return float(loss)
def state(self) -> dict[str, np.ndarray]:
return {
"w": self.w,
"b": np.array([self.b]),
"mw": self._mw,
"vw": self._vw,
"mb": np.array([self._mb]),
"vb": np.array([self._vb]),
"t": np.array([self._t]),
}
def load_state(self, data: dict[str, np.ndarray]) -> None:
self.w = np.asarray(data["w"], dtype=np.float64)
self.b = float(data["b"][0])
self._mw = np.asarray(data["mw"], dtype=np.float64)
self._vw = np.asarray(data["vw"], dtype=np.float64)
self._mb = float(data["mb"][0])
self._vb = float(data["vb"][0])
self._t = int(data["t"][0])
class ReplayBuffer:
"""Ringpuffer über vergangene Beobachtungen für Mini-Batch-Wiederholung."""
def __init__(self, capacity: int, n_features: int, rng: np.random.Generator) -> None:
self.capacity = capacity
self.x = np.zeros((capacity, n_features), dtype=np.float64)
self.y = np.zeros(capacity, dtype=np.float64)
self.w = np.zeros(capacity, dtype=np.float64)
self._pos = 0
self._size = 0
self._rng = rng
def __len__(self) -> int:
return self._size
def add(self, x: np.ndarray, y: float, weight: float = 1.0) -> None:
self.x[self._pos] = np.asarray(x, dtype=np.float64).reshape(-1)
self.y[self._pos] = float(y)
self.w[self._pos] = float(weight)
self._pos = (self._pos + 1) % self.capacity
self._size = min(self._size + 1, self.capacity)
def sample(self, batch_size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
n = min(batch_size, self._size)
idx = self._rng.choice(self._size, size=n, replace=False)
return self.x[idx], self.y[idx], self.w[idx]
def positive_rate(self) -> float:
if self._size == 0:
return 0.0
return float(np.mean(self.y[: self._size]))
def state(self) -> dict[str, np.ndarray]:
return {
"buf_x": self.x[: self._size],
"buf_y": self.y[: self._size],
"buf_w": self.w[: self._size],
}
def load_state(self, data: dict[str, np.ndarray]) -> None:
xs = np.asarray(data["buf_x"], dtype=np.float64)
ys = np.asarray(data["buf_y"], dtype=np.float64)
ws = np.asarray(data["buf_w"], dtype=np.float64)
keep = min(len(ys), self.capacity)
if keep:
self.x[:keep] = xs[-keep:]
self.y[:keep] = ys[-keep:]
self.w[:keep] = ws[-keep:]
self._size = keep
self._pos = keep % self.capacity
@dataclass
class LearnerStats:
samples_seen: int = 0
trade_samples: int = 0
shadow_samples: int = 0
updates: int = 0
last_loss: float = 0.0
# Prequential (online) Bewertung: erst vorhersagen, dann lernen
prequential_correct: float = 0.0
prequential_total: float = 0.0
prequential_logloss_sum: float = 0.0
positive_rate: float = 0.0
@property
def accuracy(self) -> float:
return self.prequential_correct / self.prequential_total if self.prequential_total else 0.0
@property
def logloss(self) -> float:
return self.prequential_logloss_sum / self.prequential_total if self.prequential_total else 0.0
def as_dict(self) -> dict[str, float]:
return {
"samples_seen": self.samples_seen,
"trade_samples": self.trade_samples,
"shadow_samples": self.shadow_samples,
"updates": self.updates,
"last_loss": round(self.last_loss, 5),
"online_accuracy": round(self.accuracy, 4),
"online_logloss": round(self.logloss, 5),
"positive_rate": round(self.positive_rate, 4),
}
@dataclass(slots=True)
class PendingLabel:
"""Ein Kandidatensignal, dessen Ausgang erst in der Zukunft feststeht."""
symbol: str
features: np.ndarray
entry_price: float
created_bar: int
horizon_bars: int
target_bps: float
weight: float = 1.0
def matured(self, current_bar: int) -> bool:
return current_bar - self.created_bar >= self.horizon_bars
class AdaptiveLearner:
"""Kapselt Modell, Normalisierung, Replay-Buffer und Persistenz."""
def __init__(self, config: LearnerConfig, n_features: int, seed: int | None = None) -> None:
self.config = config
self.n_features = n_features
self._rng = np.random.default_rng(seed)
self.scaler = RunningScaler(n_features)
self.model = OnlineLogisticRegression(n_features, config.learning_rate, config.l2)
self.buffer = ReplayBuffer(config.replay_size, n_features, self._rng)
self.stats = LearnerStats()
self._pending: list[PendingLabel] = []
self._since_train = 0
self._since_save = 0
self._lock = threading.Lock()
self.frozen = False
self.autosave = True
# ------------------------------------------------------------------ Scoring
@property
def ready(self) -> bool:
"""Erst ab genügend Beobachtungen darf das Modell Signale filtern."""
return self.stats.samples_seen >= self.config.warmup_samples
def score(self, features: np.ndarray) -> float:
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1)."""
x = self.scaler.transform(np.asarray(features, dtype=np.float64).reshape(1, -1))
return float(self.model.predict_proba(x)[0])
def explore(self) -> bool:
"""Epsilon-greedy: gelegentlich ein abgelehntes Signal trotzdem handeln."""
rate = self.config.exploration_rate
return rate > 0.0 and bool(self._rng.random() < rate)
# ------------------------------------------------------------------ Lernen
def observe(
self, features: np.ndarray, label: float, weight: float = 1.0, *, is_trade: bool = False
) -> None:
"""Eine gelabelte Beobachtung aufnehmen und ggf. einen Trainingsschritt machen."""
with self._lock:
x_raw = np.asarray(features, dtype=np.float64).reshape(-1)
if x_raw.size != self.n_features:
log.warning(
"Feature-Länge %d != erwartet %d Beobachtung verworfen",
x_raw.size, self.n_features,
)
return
y = 1.0 if label > 0.5 else 0.0
# Prequential-Bewertung vor dem Lernen (nur wenn das Modell schon warm ist).
if self.ready:
p = float(self.model.predict_proba(self.scaler.transform(x_raw.reshape(1, -1)))[0])
self.stats.prequential_total += 1.0
self.stats.prequential_correct += 1.0 if (p >= 0.5) == (y >= 0.5) else 0.0
self.stats.prequential_logloss_sum += -(
y * np.log(p + 1e-12) + (1 - y) * np.log(1 - p + 1e-12)
)
self.scaler.update(x_raw)
self.buffer.add(self.scaler.transform(x_raw), y, weight)
self.stats.samples_seen += 1
if is_trade:
self.stats.trade_samples += 1
else:
self.stats.shadow_samples += 1
self.stats.positive_rate = self.buffer.positive_rate()
self._since_train += 1
if self.frozen or self._since_train < self.config.train_every_n_samples:
return
self._since_train = 0
self._train_step()
def _train_step(self) -> None:
if len(self.buffer) < min(self.config.batch_size, 16):
return
x, y, w = self.buffer.sample(self.config.batch_size)
w = self._balance_weights(y, w)
self.stats.last_loss = self.model.partial_fit(x, y, w)
self.stats.updates += 1
self._since_save += 1
def _balance_weights(self, y: np.ndarray, w: np.ndarray) -> np.ndarray:
"""Klassenungleichgewicht ausgleichen, damit seltene Gewinner nicht untergehen."""
pos = float(np.sum(y))
neg = float(y.size - pos)
if pos == 0 or neg == 0:
return w
scale = np.where(y > 0.5, neg / pos, 1.0)
return w * np.clip(scale, 0.2, 5.0)
# -------------------------------------------------- Verzögerte Shadow-Labels
def register_candidate(
self, symbol: str, features: np.ndarray, price: float, bar_index: int, weight: float = 1.0
) -> None:
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
self._pending.append(
PendingLabel(
symbol=symbol,
features=np.asarray(features, dtype=np.float64).reshape(-1).copy(),
entry_price=float(price),
created_bar=bar_index,
horizon_bars=self.config.label_horizon_bars,
target_bps=self.config.label_target_bps,
weight=weight,
)
)
def resolve_pending(self, symbol: str, bar_index: int, high: float, low: float, close: float) -> int:
"""Fällige Shadow-Labels auswerten. Gibt die Anzahl neuer Beobachtungen zurück.
Label = 1, wenn der Kurs innerhalb des Horizonts das Ziel erreicht hat, ohne vorher
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
"""
if not self._pending:
return 0
resolved = 0
still_open: list[PendingLabel] = []
for item in self._pending:
if item.symbol != symbol:
still_open.append(item)
continue
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
stop = item.entry_price * (1.0 - item.target_bps / 10_000.0)
if high >= target:
self.observe(item.features, 1.0, item.weight)
resolved += 1
continue
if low <= stop:
self.observe(item.features, 0.0, item.weight)
resolved += 1
continue
if item.matured(bar_index):
label = 1.0 if close > item.entry_price else 0.0
self.observe(item.features, label, item.weight)
resolved += 1
continue
still_open.append(item)
self._pending = still_open
return resolved
def drop_pending(self, symbol: str | None = None) -> None:
self._pending = [p for p in self._pending if symbol is not None and p.symbol != symbol]
@property
def pending_count(self) -> int:
return len(self._pending)
def learn_from_trade(self, features: np.ndarray | None, pnl_quote: float) -> None:
"""Realisiertes Trade-Ergebnis mit erhöhtem Gewicht einspeisen."""
if features is None:
return
self.observe(features, 1.0 if pnl_quote > 0 else 0.0, self.config.trade_sample_weight, is_trade=True)
# ------------------------------------------------------------- Persistenz
def save(self, path: str | Path | None = None) -> Path:
target = Path(path or self.config.model_path)
target.parent.mkdir(parents=True, exist_ok=True)
payload: dict[str, np.ndarray] = {
"version": np.array([MODEL_FORMAT_VERSION]),
"n_features": np.array([self.n_features]),
"stats": np.array(
[
self.stats.samples_seen,
self.stats.trade_samples,
self.stats.shadow_samples,
self.stats.updates,
self.stats.last_loss,
self.stats.prequential_correct,
self.stats.prequential_total,
self.stats.prequential_logloss_sum,
],
dtype=np.float64,
),
}
payload.update(self.model.state())
payload.update(self.scaler.state())
payload.update(self.buffer.state())
# Atomar schreiben: erst in eine Temp-Datei, dann umbenennen. np.savez_compressed
# bekommt bewusst ein offenes Handle bei einem Pfad würde es ".npz" anhängen.
tmp = target.with_name(target.name + ".tmp")
with tmp.open("wb") as handle:
np.savez_compressed(handle, **payload)
tmp.replace(target)
self._since_save = 0
log.info("Modell gespeichert: %s (%d Beobachtungen)", target, self.stats.samples_seen)
return target
def maybe_save(self) -> None:
if self.autosave and self._since_save >= self.config.save_every_n_updates:
try:
self.save()
except OSError as exc: # pragma: no cover - Dateisystemfehler
log.error("Modell konnte nicht gespeichert werden: %s", exc)
def load(self, path: str | Path | None = None) -> bool:
"""Modellzustand laden. Gibt ``False`` zurück, wenn nichts (Passendes) vorhanden ist."""
target = Path(path or self.config.model_path)
if not target.is_file():
log.info("Kein gespeichertes Modell unter %s starte mit frischen Gewichten", target)
return False
try:
with np.load(target, allow_pickle=False) as data:
stored = {k: data[k] for k in data.files}
except (OSError, ValueError) as exc:
log.error("Modell %s nicht lesbar (%s) starte mit frischen Gewichten", target, exc)
return False
if int(stored.get("n_features", np.array([-1]))[0]) != self.n_features:
log.warning("Modell %s passt nicht zur Feature-Anzahl wird ignoriert", target)
return False
if int(stored.get("version", np.array([0]))[0]) != MODEL_FORMAT_VERSION:
log.warning("Modell %s hat ein altes Format wird ignoriert", target)
return False
try:
self.model.load_state(stored)
self.scaler.load_state(stored)
self.buffer.load_state(stored)
s = stored["stats"]
self.stats = LearnerStats(
samples_seen=int(s[0]),
trade_samples=int(s[1]),
shadow_samples=int(s[2]),
updates=int(s[3]),
last_loss=float(s[4]),
prequential_correct=float(s[5]),
prequential_total=float(s[6]),
prequential_logloss_sum=float(s[7]),
positive_rate=self.buffer.positive_rate(),
)
except KeyError as exc:
log.error("Modelldatei unvollständig (%s) starte mit frischen Gewichten", exc)
return False
log.info(
"Modell geladen: %s (%d Beobachtungen, Online-Accuracy %.1f%%)",
target,
self.stats.samples_seen,
self.stats.accuracy * 100.0,
)
return True
# ------------------------------------------------------------- Diagnostik
def feature_importance(self, names: tuple[str, ...]) -> dict[str, float]:
"""Gewichte des linearen Modells bei normierten Features direkt vergleichbar."""
return {n: round(float(w), 4) for n, w in zip(names, self.model.w, strict=False)}
def snapshot(self) -> dict[str, object]:
return {
"ready": self.ready,
"frozen": self.frozen,
"pending_labels": self.pending_count,
"buffer_size": len(self.buffer),
**self.stats.as_dict(),
}
@dataclass
class NullLearner:
"""Platzhalter, wenn das Lernen deaktiviert ist akzeptiert jedes Signal."""
n_features: int = 0
frozen: bool = True
stats: LearnerStats = field(default_factory=LearnerStats)
ready = True
def score(self, features: np.ndarray) -> float:
return 1.0
def explore(self) -> bool:
return False
def observe(self, *args: object, **kwargs: object) -> None:
return None
def register_candidate(self, *args: object, **kwargs: object) -> None:
return None
def resolve_pending(self, *args: object, **kwargs: object) -> int:
return 0
def drop_pending(self, *args: object, **kwargs: object) -> None:
return None
def learn_from_trade(self, *args: object, **kwargs: object) -> None:
return None
def save(self, path: str | Path | None = None) -> None:
return None
def maybe_save(self) -> None:
return None
def load(self, path: str | Path | None = None) -> bool:
return False
def feature_importance(self, names: tuple[str, ...]) -> dict[str, float]:
return {}
def snapshot(self) -> dict[str, object]:
return {"enabled": False}
@property
def pending_count(self) -> int:
return 0
+188
View File
@@ -0,0 +1,188 @@
"""Datenmodelle: Kerzen, Signale, Orders, Positionen, Trades."""
from __future__ import annotations
import time
import uuid
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any
import numpy as np
class Side(str, Enum):
BUY = "buy"
SELL = "sell"
class Action(str, Enum):
HOLD = "hold"
ENTER_LONG = "enter_long"
EXIT_LONG = "exit_long"
class ExitReason(str, Enum):
STOP_LOSS = "stop_loss"
TAKE_PROFIT = "take_profit"
TRAILING_STOP = "trailing_stop"
SIGNAL = "signal"
MAX_HOLDING = "max_holding"
RISK_HALT = "risk_halt"
SHUTDOWN = "shutdown"
@dataclass(slots=True)
class Candles:
"""OHLCV-Zeitreihe in Spaltenform. ``timestamp`` in Millisekunden (UTC)."""
symbol: str
timeframe: str
timestamp: np.ndarray
open: np.ndarray
high: np.ndarray
low: np.ndarray
close: np.ndarray
volume: np.ndarray
def __len__(self) -> int:
return int(self.close.size)
@classmethod
def from_rows(cls, symbol: str, timeframe: str, rows: list[list[float]]) -> Candles:
"""Erzeugt eine Serie aus ccxt-OHLCV-Zeilen ``[ts, o, h, l, c, v]``."""
if not rows:
empty = np.empty(0, dtype=np.float64)
return cls(symbol, timeframe, np.empty(0, dtype=np.int64), empty, empty, empty, empty, empty)
arr = np.asarray(rows, dtype=np.float64)
return cls(
symbol=symbol,
timeframe=timeframe,
timestamp=arr[:, 0].astype(np.int64),
open=arr[:, 1].copy(),
high=arr[:, 2].copy(),
low=arr[:, 3].copy(),
close=arr[:, 4].copy(),
volume=arr[:, 5].copy(),
)
def slice(self, start: int, stop: int) -> Candles:
return Candles(
symbol=self.symbol,
timeframe=self.timeframe,
timestamp=self.timestamp[start:stop],
open=self.open[start:stop],
high=self.high[start:stop],
low=self.low[start:stop],
close=self.close[start:stop],
volume=self.volume[start:stop],
)
def last_price(self) -> float:
return float(self.close[-1])
def last_timestamp(self) -> int:
return int(self.timestamp[-1])
@dataclass(slots=True)
class Signal:
action: Action
confidence: float = 0.0
reason: str = ""
exploratory: bool = False
features: np.ndarray | None = None
feature_names: tuple[str, ...] = ()
@classmethod
def hold(cls, reason: str = "") -> Signal:
return cls(action=Action.HOLD, reason=reason)
@dataclass(slots=True)
class Fill:
"""Ergebnis einer ausgeführten Order."""
symbol: str
side: Side
amount: float # Basiswährung, tatsächlich ausgeführt
price: float # Durchschnittlicher Ausführungspreis inkl. Slippage
fee_quote: float # Gebühr in Quote-Währung
timestamp: int # Millisekunden
order_id: str = ""
requested_amount: float = 0.0
@property
def notional(self) -> float:
return self.amount * self.price
@dataclass(slots=True)
class Position:
symbol: str
amount: float
entry_price: float
entry_timestamp: int
stop_loss: float | None = None
take_profit: float | None = None
trailing_stop: float | None = None
highest_price: float = 0.0
bars_held: int = 0
entry_fee_quote: float = 0.0
entry_features: np.ndarray | None = None
entry_confidence: float = 0.0
exploratory: bool = False
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
def unrealized_pnl(self, price: float) -> float:
return (price - self.entry_price) * self.amount
def unrealized_pct(self, price: float) -> float:
if self.entry_price <= 0:
return 0.0
return (price - self.entry_price) / self.entry_price
def notional(self, price: float) -> float:
return self.amount * price
@dataclass(slots=True)
class Trade:
"""Ein abgeschlossener Round-Trip."""
symbol: str
amount: float
entry_price: float
exit_price: float
entry_timestamp: int
exit_timestamp: int
fees_quote: float
pnl_quote: float
pnl_pct: float
exit_reason: ExitReason
bars_held: int
entry_confidence: float = 0.0
exploratory: bool = False
mode: str = "paper"
position_id: str = ""
@property
def is_win(self) -> bool:
return self.pnl_quote > 0
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["exit_reason"] = self.exit_reason.value
return data
@dataclass(slots=True)
class EquityPoint:
timestamp: int
equity: float
cash: float
exposure: float
@classmethod
def now(cls, equity: float, cash: float, exposure: float) -> EquityPoint:
return cls(timestamp=int(time.time() * 1000), equity=equity, cash=cash, exposure=exposure)
+100
View File
@@ -0,0 +1,100 @@
"""Optionale Benachrichtigungen über einen generischen Webhook (Slack/Discord-kompatibel)."""
from __future__ import annotations
import asyncio
import logging
import aiohttp
from .config import NotificationConfig
from .models import Trade
log = logging.getLogger(__name__)
class Notifier:
"""Verschickt kurze Statusmeldungen. Fehler werden geloggt, nie weitergereicht."""
def __init__(self, config: NotificationConfig, timeout: float = 8.0) -> None:
self.config = config
self._timeout = aiohttp.ClientTimeout(total=timeout)
self._session: aiohttp.ClientSession | None = None
self._tasks: set[asyncio.Task[None]] = set()
@property
def enabled(self) -> bool:
return bool(self.config.webhook_url)
async def start(self) -> None:
if self.enabled and self._session is None:
self._session = aiohttp.ClientSession(timeout=self._timeout)
async def close(self) -> None:
for task in list(self._tasks):
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
if self._session is not None:
await self._session.close()
self._session = None
def send_soon(self, message: str) -> None:
"""Nachricht im Hintergrund verschicken, ohne den Handels-Loop zu blockieren."""
if not self.enabled:
return
task = asyncio.create_task(self._send(message))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _send(self, message: str) -> None:
if self._session is None:
await self.start()
if self._session is None or not self.config.webhook_url:
return
# "text" bedient Slack, "content" bedient Discord ein Payload für beide.
payload = {"text": message, "content": message}
try:
async with self._session.post(self.config.webhook_url, json=payload) as response:
if response.status >= 400:
body = (await response.text())[:200]
log.warning("Webhook antwortete mit HTTP %s: %s", response.status, body)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - Benachrichtigungen dürfen nie den Bot stoppen
log.warning("Webhook-Zustellung fehlgeschlagen: %s", exc)
# ------------------------------------------------------------- Bausteine
def trade_closed(self, trade: Trade, equity: float, quote: str) -> None:
if not self.config.notify_on_trade:
return
icon = "🟢" if trade.is_win else "🔴"
tag = " [Exploration]" if trade.exploratory else ""
self.send_soon(
f"{icon} {trade.symbol} geschlossen ({trade.exit_reason.value}){tag}\n"
f"P/L: {trade.pnl_quote:+.2f} {quote} ({trade.pnl_pct * 100:+.2f}%) | "
f"Einstieg {trade.entry_price:.6f} → Ausstieg {trade.exit_price:.6f} | "
f"Equity: {equity:.2f} {quote}"
)
def position_opened(
self, symbol: str, amount: float, price: float, confidence: float, quote: str
) -> None:
if not self.config.notify_on_trade:
return
self.send_soon(
f"📈 Position eröffnet: {symbol} {amount:.8f} @ {price:.6f} "
f"(≈{amount * price:.2f} {quote}, Modellkonfidenz {confidence:.2f})"
)
def risk_halt(self, reason: str) -> None:
if not self.config.notify_on_risk_halt:
return
self.send_soon(f"⛔ Handel gestoppt: {reason}")
def startup(self, mode: str, exchange: str, symbols: list[str], timeframe: str) -> None:
self.send_soon(
f"🤖 TradeMind gestartet Modus **{mode}**, Börse {exchange}, "
f"Symbole {', '.join(symbols)} ({timeframe})"
)
+300
View File
@@ -0,0 +1,300 @@
"""Portfolio-Buchhaltung: offene Positionen, Equity, realisierte Ergebnisse, Kennzahlen."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import UTC, datetime
import numpy as np
from .models import ExitReason, Fill, Position, Side, Trade
log = logging.getLogger(__name__)
@dataclass
class PerformanceStats:
trades: int = 0
wins: int = 0
losses: int = 0
gross_profit: float = 0.0
gross_loss: float = 0.0
fees: float = 0.0
best_trade: float = 0.0
worst_trade: float = 0.0
@property
def win_rate(self) -> float:
return self.wins / self.trades if self.trades else 0.0
@property
def net_pnl(self) -> float:
return self.gross_profit - self.gross_loss
@property
def profit_factor(self) -> float:
if self.gross_loss <= 0:
return float("inf") if self.gross_profit > 0 else 0.0
return self.gross_profit / self.gross_loss
@property
def expectancy(self) -> float:
return self.net_pnl / self.trades if self.trades else 0.0
def register(self, trade: Trade) -> None:
self.trades += 1
self.fees += trade.fees_quote
if trade.pnl_quote > 0:
self.wins += 1
self.gross_profit += trade.pnl_quote
else:
self.losses += 1
self.gross_loss += abs(trade.pnl_quote)
self.best_trade = max(self.best_trade, trade.pnl_quote)
self.worst_trade = min(self.worst_trade, trade.pnl_quote)
def as_dict(self) -> dict[str, float]:
pf = self.profit_factor
return {
"trades": self.trades,
"wins": self.wins,
"losses": self.losses,
"win_rate": round(self.win_rate, 4),
"net_pnl": round(self.net_pnl, 4),
"gross_profit": round(self.gross_profit, 4),
"gross_loss": round(self.gross_loss, 4),
"profit_factor": round(pf, 4) if math.isfinite(pf) else None,
"expectancy": round(self.expectancy, 4),
"fees": round(self.fees, 4),
"best_trade": round(self.best_trade, 4),
"worst_trade": round(self.worst_trade, 4),
}
def _utc_day(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%Y-%m-%d")
class Portfolio:
"""Hält offene Positionen, berechnet Equity und protokolliert Trades."""
def __init__(self, starting_equity: float, quote_currency: str = "USDT") -> None:
self.quote_currency = quote_currency
self.starting_equity = starting_equity
self.positions: dict[str, Position] = {}
self.trades: list[Trade] = []
self.stats = PerformanceStats()
self.mark_prices: dict[str, float] = {}
self.peak_equity = starting_equity
self.max_drawdown = 0.0
self.equity_curve: list[tuple[int, float]] = []
self._day_key: str | None = None
self._day_start_equity = starting_equity
self.realized_today = 0.0
self.cooldowns: dict[str, int] = {}
# -------------------------------------------------------------- Bewertung
def update_mark(self, symbol: str, price: float) -> None:
if price > 0:
self.mark_prices[symbol] = float(price)
def exposure(self) -> float:
return sum(
pos.notional(self.mark_prices.get(sym, pos.entry_price)) for sym, pos in self.positions.items()
)
def equity(self, cash: float) -> float:
return cash + self.exposure()
def unrealized_pnl(self) -> float:
return sum(
pos.unrealized_pnl(self.mark_prices.get(sym, pos.entry_price))
for sym, pos in self.positions.items()
)
def record_equity(self, timestamp: int, cash: float) -> float:
equity = self.equity(cash)
self.equity_curve.append((timestamp, equity))
if equity > self.peak_equity:
self.peak_equity = equity
if self.peak_equity > 0:
drawdown = (self.peak_equity - equity) / self.peak_equity
self.max_drawdown = max(self.max_drawdown, drawdown)
self._roll_day(timestamp, equity)
return equity
def _roll_day(self, timestamp: int, equity: float) -> None:
day = _utc_day(timestamp)
if self._day_key is None:
self._day_key = day
self._day_start_equity = equity
elif day != self._day_key:
log.info(
"Neuer Handelstag %s Tagesergebnis %s: %+.2f %s",
day, self._day_key, equity - self._day_start_equity, self.quote_currency,
)
self._day_key = day
self._day_start_equity = equity
self.realized_today = 0.0
def current_drawdown(self, cash: float) -> float:
if self.peak_equity <= 0:
return 0.0
return max(0.0, (self.peak_equity - self.equity(cash)) / self.peak_equity)
def daily_pnl_pct(self, cash: float) -> float:
if self._day_start_equity <= 0:
return 0.0
return (self.equity(cash) - self._day_start_equity) / self._day_start_equity
# -------------------------------------------------------------- Positionen
def has_position(self, symbol: str) -> bool:
return symbol in self.positions
def open_position(
self,
fill: Fill,
*,
stop_loss: float | None,
take_profit: float | None,
features: np.ndarray | None,
confidence: float,
exploratory: bool,
) -> Position:
position = Position(
symbol=fill.symbol,
amount=fill.amount,
entry_price=fill.price,
entry_timestamp=fill.timestamp,
stop_loss=stop_loss,
take_profit=take_profit,
highest_price=fill.price,
entry_fee_quote=fill.fee_quote,
entry_features=None if features is None else np.asarray(features, dtype=np.float64).copy(),
entry_confidence=confidence,
exploratory=exploratory,
)
self.positions[fill.symbol] = position
self.update_mark(fill.symbol, fill.price)
return position
def close_position(self, fill: Fill, reason: ExitReason, mode: str = "paper") -> Trade:
position = self.positions.pop(fill.symbol)
fees = position.entry_fee_quote + fill.fee_quote
gross = (fill.price - position.entry_price) * fill.amount
pnl = gross - fees
cost_basis = position.entry_price * fill.amount
trade = Trade(
symbol=fill.symbol,
amount=fill.amount,
entry_price=position.entry_price,
exit_price=fill.price,
entry_timestamp=position.entry_timestamp,
exit_timestamp=fill.timestamp,
fees_quote=fees,
pnl_quote=pnl,
pnl_pct=pnl / cost_basis if cost_basis > 0 else 0.0,
exit_reason=reason,
bars_held=position.bars_held,
entry_confidence=position.entry_confidence,
exploratory=position.exploratory,
mode=mode,
position_id=position.id,
)
self.trades.append(trade)
self.stats.register(trade)
self.realized_today += pnl
self.update_mark(fill.symbol, fill.price)
return trade
def partial_reduce(self, symbol: str, amount: float) -> None:
"""Bestand nach einer Teilausführung korrigieren."""
position = self.positions.get(symbol)
if position is None:
return
position.amount = max(0.0, position.amount - amount)
def on_new_bar(self, symbol: str, high: float, low: float, close: float) -> None:
"""Haltedauer, Höchststand und Trailing-Stop fortschreiben."""
position = self.positions.get(symbol)
if position is None:
if symbol in self.cooldowns:
self.cooldowns[symbol] -= 1
if self.cooldowns[symbol] <= 0:
self.cooldowns.pop(symbol, None)
return
position.bars_held += 1
position.highest_price = max(position.highest_price, high)
self.update_mark(symbol, close)
def start_cooldown(self, symbol: str, bars: int) -> None:
if bars > 0:
self.cooldowns[symbol] = bars
def in_cooldown(self, symbol: str) -> bool:
return self.cooldowns.get(symbol, 0) > 0
# ------------------------------------------------------------- Kennzahlen
def sharpe_ratio(self, periods_per_year: float = 105_120.0) -> float:
"""Annualisierte Sharpe Ratio aus der Equity-Kurve (risikofreier Zins = 0)."""
if len(self.equity_curve) < 3:
return 0.0
equity = np.array([e for _, e in self.equity_curve], dtype=np.float64)
equity = equity[equity > 0]
if equity.size < 3:
return 0.0
returns = np.diff(np.log(equity))
sd = float(np.std(returns, ddof=1))
if sd <= 1e-12:
return 0.0
return float(np.mean(returns) / sd * math.sqrt(periods_per_year))
def summary(self, cash: float) -> dict[str, object]:
equity = self.equity(cash)
total_return = (equity - self.starting_equity) / self.starting_equity if self.starting_equity else 0.0
return {
"equity": round(equity, 4),
"cash": round(cash, 4),
"exposure": round(self.exposure(), 4),
"unrealized_pnl": round(self.unrealized_pnl(), 4),
"total_return_pct": round(total_return * 100.0, 4),
"peak_equity": round(self.peak_equity, 4),
"max_drawdown_pct": round(self.max_drawdown * 100.0, 4),
"current_drawdown_pct": round(self.current_drawdown(cash) * 100.0, 4),
"sharpe": round(self.sharpe_ratio(), 4),
"open_positions": len(self.positions),
**self.stats.as_dict(),
}
def open_positions_view(self) -> list[dict[str, object]]:
out = []
for symbol, pos in self.positions.items():
price = self.mark_prices.get(symbol, pos.entry_price)
out.append(
{
"symbol": symbol,
"amount": pos.amount,
"entry_price": pos.entry_price,
"mark_price": price,
"unrealized_pnl": round(pos.unrealized_pnl(price), 4),
"unrealized_pct": round(pos.unrealized_pct(price) * 100.0, 4),
"stop_loss": pos.stop_loss,
"take_profit": pos.take_profit,
"bars_held": pos.bars_held,
"confidence": round(pos.entry_confidence, 4),
"exploratory": pos.exploratory,
}
)
return out
def recent_trades(self, limit: int = 20) -> list[dict[str, object]]:
return [t.to_dict() for t in self.trades[-limit:]]
def side_for_exit() -> Side:
return Side.SELL
+184
View File
@@ -0,0 +1,184 @@
"""Risikomanagement: Positionsgröße, Stop-Level, Exit-Prüfung und Notbremsen."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from .config import RiskConfig
from .models import ExitReason, Position
from .portfolio import Portfolio
log = logging.getLogger(__name__)
@dataclass(slots=True)
class RiskDecision:
allowed: bool
reason: str = ""
def __bool__(self) -> bool:
return self.allowed
ALLOWED = RiskDecision(True)
@dataclass(slots=True)
class HaltState:
"""Aktuelle Sperren. ``day`` sperrt bis zum nächsten UTC-Tag, ``permanent`` bis Neustart."""
day: str | None = None
permanent: bool = False
reason: str = ""
@property
def active(self) -> bool:
return self.permanent or self.day is not None
class RiskManager:
def __init__(self, config: RiskConfig) -> None:
self.config = config
self.halt = HaltState()
# ----------------------------------------------------------- Notbremsen
def evaluate_halt(self, portfolio: Portfolio, cash: float, timestamp: int) -> str | None:
"""Prüft Tagesverlust und Gesamt-Drawdown. Gibt den Grund zurück, wenn neu gesperrt wird."""
today = datetime.fromtimestamp(timestamp / 1000, tz=UTC).strftime("%Y-%m-%d")
if self.halt.day is not None and self.halt.day != today:
log.info("Tagesverlust-Sperre aufgehoben (neuer Handelstag %s)", today)
self.halt.day = None
self.halt.reason = ""
if self.halt.permanent:
return None
drawdown = portfolio.current_drawdown(cash)
if self.config.max_drawdown_pct > 0 and drawdown >= self.config.max_drawdown_pct:
self.halt.permanent = True
self.halt.reason = (
f"Maximaler Drawdown erreicht: {drawdown * 100:.2f}% "
f">= {self.config.max_drawdown_pct * 100:.2f}%"
)
log.error("NOTBREMSE: %s", self.halt.reason)
return self.halt.reason
if self.halt.day is None and self.config.max_daily_loss_pct > 0:
daily = portfolio.daily_pnl_pct(cash)
if daily <= -self.config.max_daily_loss_pct:
self.halt.day = today
self.halt.reason = (
f"Tagesverlustgrenze erreicht: {daily * 100:.2f}% "
f"<= -{self.config.max_daily_loss_pct * 100:.2f}%"
)
log.warning("Handel für %s gestoppt: %s", today, self.halt.reason)
return self.halt.reason
return None
@property
def trading_halted(self) -> bool:
return self.halt.active
def force_liquidation(self) -> bool:
"""Bei permanentem Halt (Drawdown) werden offene Positionen geschlossen."""
return self.halt.permanent
# ------------------------------------------------------------- Einstieg
def can_open(self, symbol: str, portfolio: Portfolio, cash: float, price: float) -> RiskDecision:
if self.halt.active:
return RiskDecision(False, f"Handel gesperrt: {self.halt.reason}")
if portfolio.has_position(symbol):
return RiskDecision(False, "Position bereits offen")
if portfolio.in_cooldown(symbol):
return RiskDecision(False, f"Cooldown aktiv ({portfolio.cooldowns.get(symbol)} Bars)")
if len(portfolio.positions) >= self.config.max_open_positions:
return RiskDecision(False, f"Maximal {self.config.max_open_positions} Positionen offen")
equity = portfolio.equity(cash)
if equity <= 0:
return RiskDecision(False, "Kein Kapital vorhanden")
exposure_after = portfolio.exposure() + self.target_notional(portfolio, cash)
if exposure_after > equity * self.config.max_total_exposure_pct + 1e-9:
return RiskDecision(False, "Gesamt-Exposure-Grenze erreicht")
if cash < self.config.min_notional:
return RiskDecision(False, f"Guthaben unter Mindestordervolumen ({self.config.min_notional})")
if price <= 0:
return RiskDecision(False, "Ungültiger Preis")
return ALLOWED
def target_notional(self, portfolio: Portfolio, cash: float) -> float:
"""Gewünschtes Ordervolumen in Quote-Währung."""
equity = portfolio.equity(cash)
by_position = equity * self.config.max_position_pct
exposure_left = max(0.0, equity * self.config.max_total_exposure_pct - portfolio.exposure())
return max(0.0, min(by_position, exposure_left, cash))
def position_size(
self, portfolio: Portfolio, cash: float, price: float, min_amount: float = 0.0, min_cost: float = 0.0
) -> tuple[float, str]:
"""Ordermenge in Basiswährung. Gibt ``(0.0, Grund)`` zurück, wenn zu klein."""
notional = self.target_notional(portfolio, cash)
floor = max(self.config.min_notional, min_cost)
if notional < floor:
return 0.0, f"Ordervolumen {notional:.2f} unter Minimum {floor:.2f}"
amount = notional / price
if min_amount and amount < min_amount:
return 0.0, f"Menge {amount:.8f} unter Börsen-Minimum {min_amount:.8f}"
return amount, ""
def stop_levels(self, entry_price: float, atr: float) -> tuple[float | None, float | None]:
stop = (
entry_price - self.config.stop_loss_atr_mult * atr
if self.config.stop_loss_atr_mult > 0
else None
)
target = (
entry_price + self.config.take_profit_atr_mult * atr
if self.config.take_profit_atr_mult > 0
else None
)
if stop is not None and stop <= 0:
stop = entry_price * 0.5
return stop, target
# --------------------------------------------------------------- Ausstieg
def update_trailing(self, position: Position, atr: float) -> None:
if self.config.trailing_stop_atr_mult <= 0:
return
candidate = position.highest_price - self.config.trailing_stop_atr_mult * atr
if position.trailing_stop is None or candidate > position.trailing_stop:
position.trailing_stop = candidate
def check_exit(
self, position: Position, high: float, low: float, close: float
) -> tuple[ExitReason | None, float]:
"""Prüft die Stop-/Ziel-Level gegen die Kerze.
Gibt (Grund, Ausführungspreis) zurück. Bei gleichzeitigem Treffer von Stop und Ziel
wird konservativ der Stop angenommen.
"""
if position.stop_loss is not None and low <= position.stop_loss:
return ExitReason.STOP_LOSS, min(position.stop_loss, high)
if position.trailing_stop is not None and low <= position.trailing_stop:
return ExitReason.TRAILING_STOP, min(position.trailing_stop, high)
if position.take_profit is not None and high >= position.take_profit:
return ExitReason.TAKE_PROFIT, max(position.take_profit, low)
if self.config.max_holding_bars and position.bars_held >= self.config.max_holding_bars:
return ExitReason.MAX_HOLDING, close
return None, close
def snapshot(self) -> dict[str, object]:
return {
"halted": self.halt.active,
"halt_permanent": self.halt.permanent,
"halt_day": self.halt.day,
"halt_reason": self.halt.reason,
"max_open_positions": self.config.max_open_positions,
"max_position_pct": self.config.max_position_pct,
"max_total_exposure_pct": self.config.max_total_exposure_pct,
}
+180
View File
@@ -0,0 +1,180 @@
"""HTTP-Schnittstelle: Health-Checks, Status-JSON, Prometheus-Metriken und Mini-Dashboard."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from aiohttp import web
from .config import ServerConfig
log = logging.getLogger(__name__)
StatusProvider = Callable[[], dict[str, Any]]
_DASHBOARD = """<!doctype html>
<meta charset="utf-8">
<title>TradeMind</title>
<style>
:root { color-scheme: light dark; --fg:#111; --bg:#fafafa; --card:#fff; --line:#e3e3e3; --muted:#666; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8e8; --bg:#131417; --card:#1c1e22; --line:#2c2f36; --muted:#9aa0a6; }
}
body { font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 24px; background: var(--bg); color: var(--fg); }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); margin-bottom: 20px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
.k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.v { font-size: 20px; font-variant-numeric: tabular-nums; margin-top: 2px; }
.pos { color: #1a8f3c; } .neg { color: #c62828; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; font-variant-numeric: tabular-nums; }
th, td { text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th:first-child, td:first-child { text-align: left; }
section { margin-top: 28px; }
.overflow { overflow-x: auto; }
</style>
<h1>TradeMind</h1>
<div class="sub" id="sub">lädt …</div>
<div class="grid" id="cards"></div>
<section><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
<script>
const num = (v, d = 2) => (v === null || v === undefined ? "" : Number(v).toFixed(d));
const cls = v => (v > 0 ? "pos" : v < 0 ? "neg" : "");
function card(k, v, extra = "") { return `<div class="card"><div class="k">${k}</div><div class="v ${extra}">${v}</div></div>`; }
function table(el, cols, rows) {
el.innerHTML = "<tr>" + cols.map(c => `<th>${c[0]}</th>`).join("") + "</tr>" +
(rows.length ? rows.map(r => "<tr>" + cols.map(c => `<td>${c[1](r)}</td>`).join("") + "</tr>").join("")
: `<tr><td colspan="${cols.length}" style="text-align:center;color:var(--muted)">keine</td></tr>`);
}
async function refresh() {
try {
const s = await (await fetch("status")).json();
const p = s.portfolio || {}, l = (s.strategy || {}).learner || {};
document.getElementById("sub").textContent =
`Modus ${s.mode} · ${s.exchange} · ${(s.symbols || []).join(", ")} · ${s.timeframe} · Uptime ${num(s.uptime_seconds / 60, 1)} min`;
document.getElementById("cards").innerHTML = [
card("Equity", num(p.equity) + " " + (s.quote_currency || "")),
card("Rendite", num(p.total_return_pct) + " %", cls(p.total_return_pct)),
card("Offene Positionen", p.open_positions ?? 0),
card("Trades", p.trades ?? 0),
card("Trefferquote", num((p.win_rate || 0) * 100, 1) + " %"),
card("Profit-Faktor", num(p.profit_factor)),
card("Max. Drawdown", num(p.max_drawdown_pct) + " %", "neg"),
card("Modell-Beobachtungen", l.samples_seen ?? ""),
card("Modell-Accuracy", l.online_accuracy != null ? num(l.online_accuracy * 100, 1) + " %" : ""),
card("Risiko", (s.risk || {}).halted ? "GESPERRT" : "aktiv", (s.risk || {}).halted ? "neg" : "pos"),
].join("");
table(document.getElementById("pos"),
[["Symbol", r => r.symbol], ["Menge", r => num(r.amount, 6)], ["Einstieg", r => num(r.entry_price, 6)],
["Kurs", r => num(r.mark_price, 6)], ["P/L", r => `<span class="${cls(r.unrealized_pnl)}">${num(r.unrealized_pnl)}</span>`],
["%", r => `<span class="${cls(r.unrealized_pct)}">${num(r.unrealized_pct)}</span>`], ["Bars", r => r.bars_held]],
s.positions || []);
table(document.getElementById("trades"),
[["Symbol", r => r.symbol], ["Grund", r => r.exit_reason],
["P/L", r => `<span class="${cls(r.pnl_quote)}">${num(r.pnl_quote)}</span>`],
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
(s.recent_trades || []).slice().reverse());
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
}
refresh(); setInterval(refresh, 5000);
</script>
"""
def _flatten_metrics(prefix: str, node: Any, out: list[tuple[str, float]]) -> None:
if isinstance(node, bool):
out.append((prefix, 1.0 if node else 0.0))
elif isinstance(node, (int, float)) and not isinstance(node, bool):
out.append((prefix, float(node)))
elif isinstance(node, dict):
for key, value in node.items():
safe = "".join(ch if ch.isalnum() else "_" for ch in str(key)).strip("_").lower()
if safe:
_flatten_metrics(f"{prefix}_{safe}", value, out)
class StatusServer:
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
def __init__(self, config: ServerConfig, status_provider: StatusProvider) -> None:
self.config = config
self._status = status_provider
self._runner: web.AppRunner | None = None
def _build_app(self) -> web.Application:
app = web.Application()
app.add_routes(
[
web.get("/", self._dashboard),
web.get("/health", self._health),
web.get("/ready", self._ready),
web.get("/status", self._status_json),
web.get("/positions", self._positions),
web.get("/trades", self._trades),
]
)
if self.config.enable_metrics:
app.router.add_get("/metrics", self._metrics)
return app
async def start(self) -> None:
app = self._build_app()
self._runner = web.AppRunner(app, access_log=None)
await self._runner.setup()
site = web.TCPSite(self._runner, self.config.host, self.config.port)
await site.start()
log.info("Status-Server läuft auf http://%s:%d", self.config.host, self.config.port)
async def close(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
# -------------------------------------------------------------- Handler
async def _dashboard(self, _: web.Request) -> web.Response:
return web.Response(text=_DASHBOARD, content_type="text/html")
async def _health(self, _: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
async def _ready(self, _: web.Request) -> web.Response:
state = self._status()
ready = bool(state.get("running")) and not state.get("startup_error")
return web.json_response({"ready": ready}, status=200 if ready else 503)
async def _status_json(self, _: web.Request) -> web.Response:
return web.json_response(self._status(), dumps=_dumps)
async def _positions(self, _: web.Request) -> web.Response:
return web.json_response(self._status().get("positions", []), dumps=_dumps)
async def _trades(self, request: web.Request) -> web.Response:
try:
limit = min(int(request.query.get("limit", "50")), 500)
except ValueError:
limit = 50
trades = self._status().get("recent_trades", [])
return web.json_response(trades[-limit:], dumps=_dumps)
async def _metrics(self, _: web.Request) -> web.Response:
state = self._status()
samples: list[tuple[str, float]] = []
for section in ("portfolio", "strategy", "risk"):
_flatten_metrics(f"trademind_{section}", state.get(section, {}), samples)
_flatten_metrics("trademind", {"uptime_seconds": state.get("uptime_seconds", 0)}, samples)
_flatten_metrics("trademind", {"loop_iterations": state.get("iterations", 0)}, samples)
_flatten_metrics("trademind", {"errors_total": state.get("errors", 0)}, samples)
body = "\n".join(f"{name} {value:.10g}" for name, value in samples) + "\n"
return web.Response(text=body, content_type="text/plain", charset="utf-8")
def _dumps(obj: Any) -> str:
import json
return json.dumps(obj, default=str, ensure_ascii=False)
+224
View File
@@ -0,0 +1,224 @@
"""Persistenz: Trades, Equity-Kurve und Laufzeitzustand in SQLite.
Die Datei liegt im Volume ``/data``, damit ein Container-Neustart nahtlos fortsetzt.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
from .models import Trade
log = logging.getLogger(__name__)
SCHEMA = """
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
mode TEXT NOT NULL,
exchange TEXT NOT NULL,
symbols TEXT NOT NULL,
timeframe TEXT NOT NULL,
strategy TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER,
position_id TEXT,
symbol TEXT NOT NULL,
mode TEXT NOT NULL,
amount REAL NOT NULL,
entry_price REAL NOT NULL,
exit_price REAL NOT NULL,
entry_timestamp INTEGER NOT NULL,
exit_timestamp INTEGER NOT NULL,
fees_quote REAL NOT NULL,
pnl_quote REAL NOT NULL,
pnl_pct REAL NOT NULL,
exit_reason TEXT NOT NULL,
bars_held INTEGER NOT NULL,
entry_confidence REAL NOT NULL,
exploratory INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES runs(id)
);
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
CREATE INDEX IF NOT EXISTS idx_trades_exit_ts ON trades(exit_timestamp);
CREATE TABLE IF NOT EXISTS equity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER,
timestamp INTEGER NOT NULL,
equity REAL NOT NULL,
cash REAL NOT NULL,
exposure REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_equity_ts ON equity(timestamp);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
"""
class Storage:
"""Dünner, thread-sicherer SQLite-Wrapper."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
with self._lock:
self._conn.executescript(SCHEMA)
self._conn.commit()
self.run_id: int | None = None
log.info("Datenbank bereit: %s", self.path)
# ------------------------------------------------------------------ Runs
def start_run(
self, mode: str, exchange: str, symbols: list[str], timeframe: str, strategy: str
) -> int:
with self._lock:
cur = self._conn.execute(
"INSERT INTO runs (started_at, mode, exchange, symbols, timeframe, strategy)"
" VALUES (?, ?, ?, ?, ?, ?)",
(int(time.time() * 1000), mode, exchange, ",".join(symbols), timeframe, strategy),
)
self._conn.commit()
self.run_id = int(cur.lastrowid)
return self.run_id
# ---------------------------------------------------------------- Trades
def record_trade(self, trade: Trade) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO trades (run_id, position_id, symbol, mode, amount, entry_price, exit_price,"
" entry_timestamp, exit_timestamp, fees_quote, pnl_quote, pnl_pct, exit_reason, bars_held,"
" entry_confidence, exploratory)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
self.run_id,
trade.position_id,
trade.symbol,
trade.mode,
trade.amount,
trade.entry_price,
trade.exit_price,
trade.entry_timestamp,
trade.exit_timestamp,
trade.fees_quote,
trade.pnl_quote,
trade.pnl_pct,
trade.exit_reason.value,
trade.bars_held,
trade.entry_confidence,
int(trade.exploratory),
),
)
self._conn.commit()
def record_equity(self, timestamp: int, equity: float, cash: float, exposure: float) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO equity (run_id, timestamp, equity, cash, exposure) VALUES (?, ?, ?, ?, ?)",
(self.run_id, timestamp, equity, cash, exposure),
)
self._conn.commit()
def trade_count(self) -> int:
with self._lock:
row = self._conn.execute("SELECT COUNT(*) AS n FROM trades").fetchone()
return int(row["n"])
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM trades ORDER BY exit_timestamp DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def performance_by_symbol(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT symbol, COUNT(*) AS trades, SUM(pnl_quote) AS net_pnl,"
" SUM(CASE WHEN pnl_quote > 0 THEN 1 ELSE 0 END) AS wins,"
" AVG(pnl_pct) AS avg_pnl_pct"
" FROM trades GROUP BY symbol ORDER BY net_pnl DESC"
).fetchall()
return [dict(r) for r in rows]
# ----------------------------------------------------------------- State
def save_state(self, key: str, value: Any) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO state (key, value, updated_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, json.dumps(value), int(time.time() * 1000)),
)
self._conn.commit()
def load_state(self, key: str, default: Any = None) -> Any:
with self._lock:
row = self._conn.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
if row is None:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
log.warning("Zustand '%s' ist beschädigt und wird ignoriert", key)
return default
def close(self) -> None:
with self._lock:
self._conn.commit()
self._conn.close()
class NullStorage:
"""Kein-Op-Variante (z. B. für Backtests ohne Persistenz)."""
run_id = None
def start_run(self, *args: Any, **kwargs: Any) -> int:
return 0
def record_trade(self, trade: Trade) -> None:
return None
def record_equity(self, *args: Any, **kwargs: Any) -> None:
return None
def trade_count(self) -> int:
return 0
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
return []
def performance_by_symbol(self) -> list[dict[str, Any]]:
return []
def save_state(self, key: str, value: Any) -> None:
return None
def load_state(self, key: str, default: Any = None) -> Any:
return default
def close(self) -> None:
return None
+240
View File
@@ -0,0 +1,240 @@
"""Strategien: regelbasiertes Grundgerüst und die lernende Variante darüber."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from .config import RuleConfig, StrategyConfig
from .features import FeatureMatrix, FeatureSnapshot
from .learner import AdaptiveLearner, NullLearner
from .models import Action, Candles, Position, Signal
log = logging.getLogger(__name__)
Learner = AdaptiveLearner | NullLearner
class Strategy(ABC):
"""Erzeugt Handelssignale aus einem Merkmals-Snapshot."""
name: str = "base"
@abstractmethod
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
...
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
close: float) -> None:
"""Hook für Lernvorgänge; die reine Regelstrategie nutzt ihn nicht."""
return None
def on_trade_closed(self, position: Position, pnl_quote: float) -> None:
return None
def snapshot(self) -> dict[str, object]:
return {"strategy": self.name}
class RuleStrategy(Strategy):
"""EMA-Crossover mit RSI- und Trendfilter; zusätzlich ein Rücksetzer-Einstieg.
Long-Einstieg bei
* EMA-Kreuzung nach oben, RSI unter der Überkauft-Schwelle, Kurs über dem Trendfilter, oder
* überverkauftem RSI, wenn der Kurs über dem Trendfilter liegt (Pullback im Aufwärtstrend).
Ausstieg bei EMA-Kreuzung nach unten oder überkauftem RSI.
"""
name = "rules"
def __init__(self, config: RuleConfig) -> None:
self.config = config
def _crossed_up(self, s: FeatureSnapshot) -> bool:
return s.ema_fast_prev <= s.ema_slow_prev and s.ema_fast > s.ema_slow
def _crossed_down(self, s: FeatureSnapshot) -> bool:
return s.ema_fast_prev >= s.ema_slow_prev and s.ema_fast < s.ema_slow
def _in_uptrend(self, s: FeatureSnapshot) -> bool:
return self.config.trend_filter_period == 0 or s.price >= s.trend_ema
def entry_candidate(self, s: FeatureSnapshot) -> Signal | None:
"""Ein potenzieller Einstieg ungefiltert durch das Modell."""
if not self._in_uptrend(s):
return None
if self._crossed_up(s) and s.rsi < self.config.rsi_overbought:
return Signal(action=Action.ENTER_LONG, reason="ema_cross_up", confidence=0.5)
if s.rsi <= self.config.rsi_oversold and s.ema_fast > s.ema_slow:
return Signal(action=Action.ENTER_LONG, reason="pullback_oversold", confidence=0.5)
return None
def exit_signal(self, s: FeatureSnapshot) -> Signal | None:
if self._crossed_down(s):
return Signal(action=Action.EXIT_LONG, reason="ema_cross_down", confidence=0.5)
# Nicht "RSI ist hoch" (das ist in einem Aufwärtstrend der Normalfall und würde
# jeden Einstieg sofort wieder schließen), sondern "RSI dreht aus dem überkauften
# Bereich nach unten" also nachlassendes Momentum.
if s.rsi_prev >= self.config.rsi_overbought > s.rsi:
return Signal(action=Action.EXIT_LONG, reason="rsi_momentum_fade", confidence=0.5)
return None
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
if position is not None:
if position.bars_held < self.config.min_holding_bars:
return Signal.hold("min_holding_bars")
return self.exit_signal(snapshot) or Signal.hold("position_held")
candidate = self.entry_candidate(snapshot)
if candidate is None:
return Signal.hold("no_setup")
candidate.features = snapshot.values
candidate.feature_names = snapshot.names
return candidate
class AdaptiveStrategy(Strategy):
"""Regelwerk als Signalgeber, lernendes Modell als Torwächter.
Jeder Kandidat des Regelwerks wird bewertet. Nur Signale mit ausreichender
Gewinnwahrscheinlichkeit werden gehandelt mit einer kleinen Explorationsquote,
damit das Modell auch über abgelehnte Setups etwas lernt.
Unabhängig von der Entscheidung wird jeder Kandidat zum verzögerten Labeln vorgemerkt
(Off-Policy-Lernen): Der Bot lernt also auch aus Trades, die er *nicht* gemacht hat.
"""
name = "adaptive"
def __init__(self, config: StrategyConfig, learner: Learner) -> None:
self.config = config
self.rules = RuleStrategy(config.rules)
self.learner = learner
self.candidates_seen = 0
self.candidates_accepted = 0
self.candidates_explored = 0
self.background_samples = 0
self._bar_index: dict[str, int] = {}
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
if position is not None:
return self.rules.evaluate(symbol, snapshot, position)
candidate = self.rules.entry_candidate(snapshot)
if candidate is None:
return Signal.hold("no_setup")
self.candidates_seen += 1
# Jeder Kandidat wird zum verzögerten Labeln vorgemerkt unabhängig davon,
# ob er anschließend gehandelt wird (Off-Policy-Lernen).
self.register_candidate(symbol, snapshot, self._bar_index.get(symbol, 0))
probability = self.learner.score(snapshot.values)
candidate.features = snapshot.values
candidate.feature_names = snapshot.names
candidate.confidence = probability
threshold = self.config.learner.entry_threshold
if not self.learner.ready:
# Aufwärmphase: Regelwerk entscheidet, das Modell sammelt Daten.
self.candidates_accepted += 1
candidate.reason = f"{candidate.reason}+warmup(p={probability:.2f})"
return candidate
if probability >= threshold:
self.candidates_accepted += 1
candidate.reason = f"{candidate.reason}+model(p={probability:.2f}>={threshold:.2f})"
return candidate
if self.learner.explore():
self.candidates_explored += 1
candidate.exploratory = True
candidate.reason = f"{candidate.reason}+explore(p={probability:.2f})"
return candidate
return Signal(
action=Action.HOLD,
confidence=probability,
reason=f"vom Modell abgelehnt (p={probability:.2f} < {threshold:.2f})",
features=snapshot.values,
feature_names=snapshot.names,
)
def register_candidate(
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
) -> None:
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
self.learner.register_candidate(symbol, snapshot.values, snapshot.price, bar_index, weight)
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
close: float) -> None:
self._bar_index[symbol] = bar_index
self.learner.resolve_pending(symbol, bar_index, high, low, close)
# Einstiegssignale sind selten regelmäßige Stichproben des Marktzustands geben
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
every = self.config.learner.background_sample_every_n_bars
if every and bar_index % every == 0:
self.background_samples += 1
self.register_candidate(
symbol, snapshot, bar_index, weight=self.config.learner.background_sample_weight
)
def on_trade_closed(self, position: Position, pnl_quote: float) -> None:
self.learner.learn_from_trade(position.entry_features, pnl_quote)
self.learner.maybe_save()
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen.
Es werden ausschließlich vergangene Kerzen verwendet dieselbe Logik wie im Backtest.
Gibt den Index der letzten verarbeiteten Kerze zurück.
"""
last_index = matrix.first_valid
for index in range(matrix.first_valid, len(matrix)):
snapshot = matrix.snapshot(index)
if snapshot is None:
continue
self.on_bar(
symbol,
snapshot,
index,
float(candles.high[index]),
float(candles.low[index]),
float(candles.close[index]),
)
if self.rules.entry_candidate(snapshot) is not None:
self.register_candidate(symbol, snapshot, index)
last_index = index
return last_index
def snapshot(self) -> dict[str, object]:
acceptance = self.candidates_accepted / self.candidates_seen if self.candidates_seen else 0.0
return {
"strategy": self.name,
"candidates_seen": self.candidates_seen,
"candidates_accepted": self.candidates_accepted,
"candidates_explored": self.candidates_explored,
"background_samples": self.background_samples,
"acceptance_rate": round(acceptance, 4),
"learner": self.learner.snapshot(),
}
def build_strategy(
config: StrategyConfig, n_features: int, seed: int | None = None, *, load_model: bool = True
) -> Strategy:
"""Erzeugt die konfigurierte Strategie samt Lernmodell."""
if config.name == "rules":
return RuleStrategy(config.rules)
if not config.learner.enabled:
log.info("Lernmodul deaktiviert Signale werden ungefiltert übernommen")
return AdaptiveStrategy(config, NullLearner())
learner = AdaptiveLearner(config.learner, n_features, seed=seed)
if load_model:
learner.load()
else:
log.info("Starte mit frisch initialisiertem Modell (vorhandene Gewichte werden ignoriert)")
return AdaptiveStrategy(config, learner)
View File
+84
View File
@@ -0,0 +1,84 @@
"""Gemeinsame Fixtures: synthetische Kursverläufe ohne Netzwerkzugriff."""
from __future__ import annotations
import numpy as np
import pytest
from trademind.config import Config, PaperConfig, RiskConfig, RuleConfig
from trademind.models import Candles
BAR_MS = 300_000 # 5 Minuten
def make_candles(
symbol: str = "BTC/USDT",
n: int = 600,
start_price: float = 30_000.0,
trend: float = 0.0002,
noise: float = 0.002,
cycle: float = 0.0,
cycle_period: int = 80,
seed: int = 7,
timeframe: str = "5m",
start_ts: int = 1_700_000_000_000,
) -> Candles:
"""Erzeugt eine plausible OHLCV-Serie (geometrischer Random Walk plus optionale Welle)."""
rng = np.random.default_rng(seed)
steps = rng.normal(loc=trend, scale=noise, size=n)
if cycle:
steps += cycle * np.sin(2 * np.pi * np.arange(n) / cycle_period)
close = start_price * np.exp(np.cumsum(steps))
open_ = np.concatenate([[start_price], close[:-1]])
spread = np.abs(rng.normal(0.0, noise * 0.8, size=n)) * close
high = np.maximum(open_, close) + spread
low = np.minimum(open_, close) - spread
low = np.maximum(low, close * 0.5)
volume = rng.lognormal(mean=3.0, sigma=0.4, size=n)
timestamp = start_ts + np.arange(n, dtype=np.int64) * BAR_MS
return Candles(
symbol=symbol,
timeframe=timeframe,
timestamp=timestamp,
open=open_,
high=high,
low=low,
close=close,
volume=volume,
)
@pytest.fixture
def candles() -> Candles:
return make_candles()
@pytest.fixture
def rules() -> RuleConfig:
return RuleConfig()
@pytest.fixture
def paper_config() -> PaperConfig:
return PaperConfig(starting_balance=10_000.0, fee_rate=0.001, slippage_bps=5.0)
@pytest.fixture
def risk_config() -> RiskConfig:
return RiskConfig(max_open_positions=2, max_position_pct=0.25, cooldown_bars_after_exit=0)
@pytest.fixture
def base_config(tmp_path) -> Config:
return Config.model_validate(
{
"mode": "backtest",
"market": {"symbols": ["BTC/USDT"], "timeframe": "5m", "history_bars": 300},
"paper": {"starting_balance": 10_000.0},
"storage": {"database_path": str(tmp_path / "test.sqlite3")},
"server": {"enabled": False},
"strategy": {"learner": {"model_path": str(tmp_path / "model.npz"), "warmup_samples": 20}},
}
)
+92
View File
@@ -0,0 +1,92 @@
import pytest
from trademind.broker import InsufficientFunds, OrderRejected, PaperBroker
from trademind.config import PaperConfig
from trademind.models import Side
@pytest.fixture
def broker(paper_config) -> PaperBroker:
return PaperBroker(paper_config)
async def test_buy_applies_slippage_and_fee(broker):
fill = await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0)
assert fill.price == pytest.approx(30_000.0 * 1.0005) # 5 bps Slippage
assert fill.fee_quote == pytest.approx(fill.notional * 0.001)
assert await broker.cash() == pytest.approx(10_000.0 - fill.notional - fill.fee_quote)
assert await broker.holdings("BTC/USDT") == pytest.approx(0.1)
async def test_sell_applies_slippage_in_the_other_direction(broker):
await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0)
fill = await broker.execute("BTC/USDT", Side.SELL, 0.1, 31_000.0)
assert fill.price == pytest.approx(31_000.0 * 0.9995)
assert await broker.holdings("BTC/USDT") == 0.0
async def test_round_trip_at_constant_price_loses_exactly_the_costs(broker):
price = 30_000.0
await broker.execute("BTC/USDT", Side.BUY, 0.1, price)
await broker.execute("BTC/USDT", Side.SELL, 0.1, price)
# 2 × 0,1 % Gebühr + 2 × 5 bps Slippage auf ein Volumen von ~3000
assert await broker.cash() == pytest.approx(10_000.0 - 9.0, abs=0.2)
async def test_buy_is_scaled_down_to_available_cash(broker):
fill = await broker.execute("BTC/USDT", Side.BUY, 10.0, 30_000.0) # 300k gewünscht
assert fill.amount < 10.0
assert await broker.cash() == pytest.approx(0.0, abs=1e-6)
async def test_buy_without_any_cash_raises():
broker = PaperBroker(PaperConfig(starting_balance=1.0, fee_rate=0.001))
broker.market_info = {"BTC/USDT": {"amount_precision": 4}}
with pytest.raises(InsufficientFunds):
await broker.execute("BTC/USDT", Side.BUY, 1.0, 30_000.0)
async def test_sell_without_position_raises(broker):
with pytest.raises(OrderRejected):
await broker.execute("BTC/USDT", Side.SELL, 1.0, 30_000.0)
async def test_sell_is_capped_at_held_amount(broker):
await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0)
fill = await broker.execute("BTC/USDT", Side.SELL, 5.0, 30_000.0)
assert fill.amount == pytest.approx(0.1)
async def test_volume_participation_caps_the_order(broker):
fill = await broker.execute("BTC/USDT", Side.BUY, 0.1, 30_000.0, bar_volume=0.2)
assert fill.amount == pytest.approx(0.02) # 10 % von 0,2
assert fill.requested_amount == pytest.approx(0.1)
async def test_amount_precision_rounds_down(broker):
broker.market_info = {"BTC/USDT": {"amount_precision": 3}}
fill = await broker.execute("BTC/USDT", Side.BUY, 0.123456, 30_000.0)
assert fill.amount == pytest.approx(0.123)
async def test_step_size_precision_is_supported(broker):
broker.market_info = {"BTC/USDT": {"amount_precision": 0.05}}
fill = await broker.execute("BTC/USDT", Side.BUY, 0.17, 100.0)
assert fill.amount == pytest.approx(0.15)
async def test_invalid_inputs_are_rejected(broker):
with pytest.raises(OrderRejected):
await broker.execute("BTC/USDT", Side.BUY, 0.0, 30_000.0)
with pytest.raises(OrderRejected):
await broker.execute("BTC/USDT", Side.BUY, 1.0, 0.0)
async def test_state_round_trip(broker):
await broker.execute("BTC/USDT", Side.BUY, 0.05, 30_000.0)
state = broker.state()
restored = PaperBroker(broker.config)
restored.restore(state["cash"], state["holdings"], state["total_fees"])
assert await restored.cash() == pytest.approx(await broker.cash())
assert await restored.holdings("BTC/USDT") == pytest.approx(0.05)
+96
View File
@@ -0,0 +1,96 @@
import pytest
from pydantic import ValidationError
from trademind.config import LIVE_CONFIRMATION_PHRASE, Config, Mode, load_config
MINIMAL = """
mode: paper
market:
symbols: [btc/usdt]
"""
def write(tmp_path, text: str):
path = tmp_path / "config.yaml"
path.write_text(text, encoding="utf-8")
return path
def test_minimal_config_uses_defaults(tmp_path):
config = load_config(write(tmp_path, MINIMAL))
assert config.mode is Mode.PAPER
assert config.market.symbols == ["BTC/USDT"] # normalisiert
assert config.exchange.id == "binance"
assert config.risk.max_open_positions == 3
def test_env_placeholders_are_resolved(tmp_path, monkeypatch):
monkeypatch.setenv("MY_KEY", "abc123")
config = load_config(
write(tmp_path, "mode: paper\nexchange:\n api_key: ${MY_KEY}\n api_secret: ${MISSING}\n")
)
assert config.exchange.api_key == "abc123"
assert config.exchange.api_secret is None
def test_env_placeholder_default_value(tmp_path, monkeypatch):
monkeypatch.delenv("TRADEMIND_EXCHANGE", raising=False)
config = load_config(write(tmp_path, "exchange:\n id: ${TRADEMIND_EXCHANGE:-kraken}\n"))
assert config.exchange.id == "kraken"
def test_double_underscore_env_override(tmp_path, monkeypatch):
monkeypatch.setenv("TRADEMIND__RISK__MAX_OPEN_POSITIONS", "7")
monkeypatch.setenv("TRADEMIND__MARKET__SYMBOLS", "BTC/USDT,ETH/USDT")
monkeypatch.setenv("TRADEMIND__EXCHANGE__SANDBOX", "false")
config = load_config(write(tmp_path, MINIMAL))
assert config.risk.max_open_positions == 7
assert config.market.symbols == ["BTC/USDT", "ETH/USDT"]
assert config.exchange.sandbox is False
def test_live_mode_requires_confirmation(tmp_path):
text = "mode: live\nexchange:\n api_key: k\n api_secret: s\n"
with pytest.raises(ValueError, match="live_confirmation"):
load_config(write(tmp_path, text))
def test_live_mode_requires_credentials(tmp_path):
text = f"mode: live\nlive_confirmation: {LIVE_CONFIRMATION_PHRASE}\n"
with pytest.raises(ValueError, match="api_key"):
load_config(write(tmp_path, text))
def test_live_mode_accepted_when_complete(tmp_path):
text = (
f"mode: live\nlive_confirmation: {LIVE_CONFIRMATION_PHRASE}\n"
"exchange:\n api_key: k\n api_secret: s\n"
)
config = load_config(write(tmp_path, text))
assert config.mode is Mode.LIVE
assert config.is_simulated is False
def test_unknown_key_is_rejected(tmp_path):
with pytest.raises(ValidationError):
load_config(write(tmp_path, "mode: paper\nrisk:\n typo_here: 5\n"))
def test_ema_periods_must_be_ordered(tmp_path):
with pytest.raises(ValueError, match="fast_ema"):
load_config(write(tmp_path, "strategy:\n rules:\n fast_ema: 30\n slow_ema: 10\n"))
def test_missing_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
load_config(tmp_path / "nope.yaml")
def test_empty_symbols_rejected(tmp_path):
with pytest.raises(ValidationError):
load_config(write(tmp_path, "market:\n symbols: []\n"))
def test_config_is_simulated_flag():
assert Config(mode=Mode.PAPER).is_simulated
assert Config(mode=Mode.BACKTEST).is_simulated
+334
View File
@@ -0,0 +1,334 @@
import numpy as np
import pytest
from trademind.backtest import BacktestRunner, trades_csv
from trademind.broker import PaperBroker
from trademind.config import Config
from trademind.data import DataFeed
from trademind.engine import TradingEngine
from trademind.features import N_FEATURES
from trademind.models import Candles, ExitReason, Side
from trademind.portfolio import Portfolio
from trademind.risk import RiskManager
from trademind.storage import NullStorage, Storage
from trademind.strategy import build_strategy
from .conftest import make_candles
class StaticFeed(DataFeed):
"""Liefert immer dasselbe Fenster für Tests, die den Feed nicht brauchen."""
def __init__(self, candles: Candles | None = None) -> None:
self.candles = candles
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
if self.candles is None:
raise AssertionError("Feed sollte in diesem Test nicht abgefragt werden")
return self.candles
class GrowingFeed(DataFeed):
"""Gibt bei jedem Abruf ein um eine Kerze längeres Fenster zurück (simuliert Live-Betrieb)."""
def __init__(self, candles: Candles, start: int) -> None:
self.full = candles
self.cursor = start
self.calls = 0
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
self.calls += 1
stop = min(self.cursor, len(self.full))
start = max(0, stop - limit)
return self.full.slice(start, stop)
def advance(self) -> None:
self.cursor += 1
def build_engine(config: Config, feed: DataFeed | None = None, storage=None) -> TradingEngine:
broker = PaperBroker(config.paper)
strategy = build_strategy(config.strategy, N_FEATURES, seed=3, load_model=False)
learner = getattr(strategy, "learner", None)
if learner is not None:
learner.autosave = False
return TradingEngine(
config=config,
broker=broker,
feed=feed or StaticFeed(),
strategy=strategy,
portfolio=Portfolio(config.paper.starting_balance, config.paper.quote_currency),
risk=RiskManager(config.risk),
storage=storage or NullStorage(),
)
def cyclical_series(symbol: str = "BTC/USDT", n: int = 900, seed: int = 4) -> Candles:
"""Schwingender Verlauf erzeugt zuverlässig Ein- und Ausstiegssignale."""
return make_candles(symbol=symbol, n=n, trend=0.0001, noise=0.0015, cycle=0.0025,
cycle_period=60, seed=seed)
# ------------------------------------------------------------------ Backtest
async def test_backtest_runs_and_produces_trades(base_config):
engine = build_engine(base_config)
await engine.prepare()
report = await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert report.bars > 0
assert report.portfolio["trades"] > 0
assert engine.portfolio.positions == {} # am Ende glattgestellt
assert report.portfolio["equity"] > 0
async def test_cash_and_equity_stay_consistent(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
cash = await engine.broker.cash()
realized = sum(t.pnl_quote for t in engine.portfolio.trades)
assert cash == pytest.approx(base_config.paper.starting_balance + realized, abs=1e-6)
assert cash >= 0.0
async def test_stop_loss_bounds_the_worst_trade(base_config):
config = Config.model_validate(
{**base_config.model_dump(), "risk": {**base_config.risk.model_dump(),
"stop_loss_atr_mult": 1.0,
"take_profit_atr_mult": 10.0}}
)
engine = build_engine(config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
stopped = [t for t in engine.portfolio.trades if t.exit_reason is ExitReason.STOP_LOSS]
assert stopped, "Bei engem Stop sollten Stop-Ausstiege vorkommen"
for trade in stopped:
assert trade.pnl_pct > -0.25 # ein Stop begrenzt den Verlust deutlich
async def test_position_limit_is_never_exceeded(base_config):
config = Config.model_validate(
{
**base_config.model_dump(),
"market": {**base_config.market.model_dump(), "symbols": ["BTC/USDT", "ETH/USDT", "SOL/USDT"]},
"risk": {**base_config.risk.model_dump(), "max_open_positions": 2},
}
)
engine = build_engine(config)
await engine.prepare()
observed_max = 0
original = engine.process_bar
async def spy(symbol, snapshot, bar):
nonlocal observed_max
await original(symbol, snapshot, bar)
observed_max = max(observed_max, len(engine.portfolio.positions))
engine.process_bar = spy # type: ignore[method-assign]
series = {
"BTC/USDT": cyclical_series("BTC/USDT", seed=4),
"ETH/USDT": cyclical_series("ETH/USDT", seed=5),
"SOL/USDT": cyclical_series("SOL/USDT", seed=6),
}
await BacktestRunner(engine, series, progress_every=0).run()
assert observed_max <= 2
async def test_learner_collects_samples_during_a_backtest(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
learner = engine.strategy.learner
assert learner.stats.samples_seen > 0
assert learner.stats.trade_samples > 0 # aus echten Trades gelernt
assert learner.stats.shadow_samples > 0 # und aus nicht gehandelten Signalen
assert learner.stats.updates > 0
async def test_rules_strategy_needs_no_learner(base_config):
config = Config.model_validate({**base_config.model_dump(), "strategy": {"name": "rules"}})
engine = build_engine(config)
await engine.prepare()
report = await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert report.strategy == {"strategy": "rules"}
async def test_higher_threshold_trades_less(base_config):
def run_config(threshold: float) -> Config:
learner = {**base_config.strategy.learner.model_dump(),
"entry_threshold": threshold, "exploration_rate": 0.0, "warmup_samples": 30}
return Config.model_validate(
{**base_config.model_dump(),
"strategy": {**base_config.strategy.model_dump(), "learner": learner}}
)
rates = []
for threshold in (0.0, 0.95):
engine = build_engine(run_config(threshold))
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert engine.strategy.candidates_seen >= 10, "zu wenige Signale für einen Vergleich"
rates.append(engine.strategy.candidates_accepted / engine.strategy.candidates_seen)
assert rates[0] == pytest.approx(1.0) # Schwelle 0 lässt alles durch
assert rates[1] < rates[0] # hohe Schwelle filtert
async def test_trades_csv_has_one_row_per_trade(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
lines = trades_csv(engine).strip().splitlines()
assert len(lines) == len(engine.portfolio.trades) + 1
async def test_short_series_is_rejected(base_config):
engine = build_engine(base_config)
await engine.prepare()
with pytest.raises(ValueError, match="genug Kerzen"):
await BacktestRunner(engine, {"BTC/USDT": make_candles(n=50)}, progress_every=0).run()
# ----------------------------------------------------------- Live-artiger Loop
async def test_tick_only_acts_on_new_bars(base_config):
series = cyclical_series(n=400)
feed = GrowingFeed(series, start=300)
engine = build_engine(base_config, feed=feed)
await engine.prepare()
await engine._tick(300)
first = dict(engine.bar_counter)
await engine._tick(300) # keine neue Kerze
assert engine.bar_counter == first
feed.advance()
await engine._tick(300)
assert engine.bar_counter["BTC/USDT"] == first["BTC/USDT"] + 1
async def test_bootstrap_trains_the_model_from_history(base_config):
"""Ein Kaltstart muss das Modell aus der Historie vorlernen, nicht tagelang warten."""
series = cyclical_series(n=900)
engine = build_engine(base_config, feed=GrowingFeed(series, start=900))
await engine.prepare()
assert engine.strategy.learner.ready is False
await engine.bootstrap_learner()
learner = engine.strategy.learner
assert learner.stats.samples_seen > base_config.strategy.learner.warmup_samples
assert learner.ready is True
assert engine.portfolio.trades == [] # Vorlernen handelt nicht
assert engine.bar_counter["BTC/USDT"] > 0 # Zähler schließt an die Historie an
assert engine.last_bar_ts["BTC/USDT"] == int(series.timestamp[engine.bar_counter["BTC/USDT"]])
async def test_bootstrap_is_skipped_for_a_trained_model(base_config):
engine = build_engine(base_config, feed=GrowingFeed(cyclical_series(n=900), start=900))
await engine.prepare()
for _ in range(base_config.strategy.learner.warmup_samples):
engine.strategy.learner.observe(np.zeros(N_FEATURES), 1.0)
seen = engine.strategy.learner.stats.samples_seen
await engine.bootstrap_learner()
assert engine.strategy.learner.stats.samples_seen == seen
async def test_bootstrap_survives_a_broken_feed(base_config):
class BrokenFeed(DataFeed):
async def fetch(self, symbol, timeframe, limit):
raise RuntimeError("Börse nicht erreichbar")
engine = build_engine(base_config, feed=BrokenFeed())
await engine.prepare()
await engine.bootstrap_learner() # darf nicht werfen
assert engine.strategy.learner.ready is False
async def test_tick_waits_for_enough_history(base_config):
feed = GrowingFeed(cyclical_series(n=400), start=60)
engine = build_engine(base_config, feed=feed)
await engine.prepare()
await engine._tick(300)
assert engine.bar_counter["BTC/USDT"] == 0 # Indikatoren noch nicht warm
# --------------------------------------------------------------- Persistenz
async def test_state_survives_a_restart(base_config, tmp_path):
storage = Storage(tmp_path / "state.sqlite3")
config = Config.model_validate({**base_config.model_dump(), "mode": "paper"})
engine = build_engine(config, feed=StaticFeed(), storage=storage)
await engine.prepare()
# Position künstlich eröffnen und Zustand sichern.
fill = await engine.broker.execute("BTC/USDT", Side.BUY, 0.05, 30_000.0)
engine.portfolio.open_position(
fill, stop_loss=29_000.0, take_profit=32_000.0,
features=np.ones(N_FEATURES), confidence=0.7, exploratory=False,
)
engine._cash = await engine.broker.cash()
engine._persist_state()
revived = build_engine(config, feed=StaticFeed(), storage=storage)
await revived.prepare()
assert "BTC/USDT" in revived.portfolio.positions
restored = revived.portfolio.positions["BTC/USDT"]
assert restored.amount == pytest.approx(0.05)
assert restored.stop_loss == pytest.approx(29_000.0)
assert restored.entry_features is not None
assert await revived.broker.cash() == pytest.approx(await engine.broker.cash())
storage.close()
async def test_state_from_another_mode_is_ignored(base_config, tmp_path):
storage = Storage(tmp_path / "state.sqlite3")
paper = Config.model_validate({**base_config.model_dump(), "mode": "paper"})
engine = build_engine(paper, storage=storage)
await engine.prepare()
engine._persist_state()
backtest = Config.model_validate({**base_config.model_dump(), "mode": "backtest"})
other = build_engine(backtest, storage=storage)
await other.prepare()
assert other.portfolio.positions == {}
storage.close()
async def test_trades_are_written_to_the_database(base_config, tmp_path):
storage = Storage(tmp_path / "trades.sqlite3")
engine = build_engine(base_config, storage=storage)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert storage.trade_count() == len(engine.portfolio.trades)
per_symbol = storage.performance_by_symbol()
assert per_symbol and per_symbol[0]["symbol"] == "BTC/USDT"
storage.close()
# ------------------------------------------------------------------- Status
async def test_status_payload_is_serialisable(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series(n=400)}, progress_every=0).run()
import json
status = engine.status()
json.dumps(status, default=str) # darf nicht werfen
assert status["mode"] == "backtest"
assert "portfolio" in status and "strategy" in status
assert len(status["feature_weights"]) == N_FEATURES
+83
View File
@@ -0,0 +1,83 @@
import numpy as np
from trademind.features import (
FEATURE_NAMES,
N_FEATURES,
build_feature_matrix,
compute_features,
required_bars,
)
from .conftest import make_candles
def test_matrix_has_expected_shape(candles, rules):
matrix = build_feature_matrix(candles, rules)
assert matrix is not None
assert matrix.values.shape == (len(candles), N_FEATURES)
assert matrix.first_valid == required_bars(rules) - 1
def test_all_feature_values_are_finite_and_bounded(candles, rules):
matrix = build_feature_matrix(candles, rules)
valid = matrix.values[matrix.first_valid :]
assert np.isfinite(valid).all()
assert np.abs(valid).max() <= 8.0
def test_too_short_history_returns_none(rules):
short = make_candles(n=50)
assert build_feature_matrix(short, rules) is None
assert compute_features(short, rules) is None
def test_snapshot_exposes_raw_indicators(candles, rules):
snapshot = compute_features(candles, rules)
assert snapshot is not None
assert snapshot.price == float(candles.close[-1])
assert snapshot.atr > 0
assert 0.0 <= snapshot.rsi <= 100.0
assert len(snapshot.values) == len(FEATURE_NAMES)
assert set(snapshot.as_dict()) == set(FEATURE_NAMES)
def test_snapshot_before_warmup_is_none(candles, rules):
matrix = build_feature_matrix(candles, rules)
assert matrix.snapshot(matrix.first_valid - 1) is None
assert matrix.snapshot(matrix.first_valid) is not None
def test_uptrend_produces_positive_trend_distance(rules):
up = make_candles(n=500, trend=0.001, noise=0.0005, seed=11)
snapshot = compute_features(up, rules)
features = snapshot.as_dict()
assert features["trend_dist"] > 0
assert features["ema_spread"] > 0
def test_downtrend_produces_negative_trend_distance(rules):
down = make_candles(n=500, trend=-0.001, noise=0.0005, seed=12)
features = compute_features(down, rules).as_dict()
assert features["trend_dist"] < 0
assert features["ema_spread"] < 0
def test_features_are_scale_invariant(rules):
"""Ein zehnfach höherer Kurs darf die normierten Merkmale kaum verändern."""
cheap = make_candles(n=400, start_price=100.0, seed=5)
expensive = make_candles(n=400, start_price=1_000.0, seed=5)
a = compute_features(cheap, rules).values
b = compute_features(expensive, rules).values
assert np.allclose(a, b, atol=1e-8)
def test_time_features_are_on_the_unit_circle(candles, rules):
snapshot = compute_features(candles, rules).as_dict()
radius = snapshot["time_sin"] ** 2 + snapshot["time_cos"] ** 2
assert radius == 1.0 or abs(radius - 1.0) < 1e-9
def test_matrix_rows_match_pointwise_snapshots(candles, rules):
matrix = build_feature_matrix(candles, rules)
index = matrix.first_valid + 25
assert np.allclose(matrix.snapshot(index).values, matrix.values[index])
+92
View File
@@ -0,0 +1,92 @@
import numpy as np
import pytest
from trademind.indicators import atr, bollinger, donchian_position, ema, macd, roc, rsi, sma, true_range
def test_ema_of_constant_series_is_constant():
values = np.full(50, 42.0)
assert np.allclose(ema(values, 10), 42.0)
def test_ema_reacts_faster_than_sma():
values = np.concatenate([np.full(30, 100.0), np.full(30, 110.0)])
fast = ema(values, 10)[35]
slow = sma(values, 10)[35]
assert fast > slow # EMA hat den Sprung stärker eingepreist
def test_sma_matches_manual_mean():
values = np.arange(1.0, 11.0)
result = sma(values, 3)
assert np.isnan(result[:2]).all()
assert result[2] == pytest.approx(2.0)
assert result[-1] == pytest.approx(9.0)
def test_rsi_bounds_and_extremes():
rising = np.arange(1.0, 60.0)
values = rsi(rising, 14)
finite = values[np.isfinite(values)]
assert finite.min() >= 0.0 and finite.max() <= 100.0
assert finite[-1] == pytest.approx(100.0) # nur Gewinne
falling = rising[::-1].copy()
assert rsi(falling, 14)[-1] == pytest.approx(0.0)
def test_rsi_of_flat_series_is_neutral():
values = rsi(np.full(60, 25.0), 14)
assert values[-1] == pytest.approx(50.0)
def test_true_range_covers_gaps():
high = np.array([10.0, 20.0])
low = np.array([9.0, 19.0])
close = np.array([9.5, 19.5])
tr = true_range(high, low, close)
assert tr[0] == pytest.approx(1.0)
assert tr[1] == pytest.approx(10.5) # Lücke gegenüber dem Vortagesschluss
def test_atr_is_positive_and_warm():
n = 100
rng = np.random.default_rng(3)
close = 100 + np.cumsum(rng.normal(0, 1, n))
high = close + 1.0
low = close - 1.0
values = atr(high, low, close, 14)
assert np.isnan(values[:13]).all()
assert (values[13:] > 0).all()
def test_macd_histogram_is_difference():
values = 100 + np.cumsum(np.random.default_rng(1).normal(0, 1, 200))
line, signal, hist = macd(values)
assert np.allclose(hist, line - signal)
def test_bollinger_bands_are_ordered():
values = 100 + np.cumsum(np.random.default_rng(2).normal(0, 1, 200))
lower, mid, upper = bollinger(values, 20, 2.0)
valid = ~np.isnan(mid)
assert (lower[valid] <= mid[valid]).all()
assert (mid[valid] <= upper[valid]).all()
def test_roc_is_relative_change():
values = np.array([100.0] * 10 + [110.0])
assert roc(values, 10)[-1] == pytest.approx(0.10)
def test_donchian_position_hits_extremes():
close = np.array([float(i) for i in range(1, 41)])
high = close + 0.0
low = close - 0.0
pos = donchian_position(high, low, close, 20)
assert pos[-1] == pytest.approx(1.0) # Schluss auf dem Hoch der Range
def test_indicators_reject_wrong_dimensions():
with pytest.raises(ValueError):
ema(np.zeros((5, 2)), 3)
+237
View File
@@ -0,0 +1,237 @@
import numpy as np
import pytest
from trademind.config import LearnerConfig
from trademind.learner import (
AdaptiveLearner,
NullLearner,
OnlineLogisticRegression,
ReplayBuffer,
RunningScaler,
sigmoid,
)
def make_learner(tmp_path, **overrides) -> AdaptiveLearner:
config = LearnerConfig(
model_path=str(tmp_path / "model.npz"),
warmup_samples=overrides.pop("warmup_samples", 20),
batch_size=overrides.pop("batch_size", 32),
train_every_n_samples=overrides.pop("train_every_n_samples", 1),
learning_rate=overrides.pop("learning_rate", 0.05),
**overrides,
)
return AdaptiveLearner(config, n_features=4, seed=1)
# ---------------------------------------------------------------- Bausteine
def test_sigmoid_is_bounded_and_stable():
assert sigmoid(0.0) == pytest.approx(0.5)
assert 0.0 < float(sigmoid(-1000.0)) < 1e-10
assert float(sigmoid(1000.0)) > 1 - 1e-10
def test_running_scaler_matches_numpy():
rng = np.random.default_rng(0)
data = rng.normal(5.0, 3.0, size=(500, 4))
scaler = RunningScaler(4)
for row in data:
scaler.update(row)
assert np.allclose(scaler.mean, data.mean(axis=0), atol=1e-9)
assert np.allclose(scaler.std, data.std(axis=0, ddof=1), atol=1e-9)
def test_scaler_clips_outliers():
scaler = RunningScaler(2)
for value in np.random.default_rng(1).normal(0, 1, size=(200, 2)):
scaler.update(value)
scaled = scaler.transform(np.array([[1e6, -1e6]]))
assert np.abs(scaled).max() <= 6.0
def test_replay_buffer_is_a_ring():
buffer = ReplayBuffer(3, 2, np.random.default_rng(0))
for i in range(5):
buffer.add(np.array([i, i]), float(i % 2), 1.0)
assert len(buffer) == 3
x, y, w = buffer.sample(3)
assert x.shape == (3, 2)
assert set(np.unique(x[:, 0])).issubset({2.0, 3.0, 4.0}) # nur die letzten drei
def test_logistic_regression_learns_a_separable_problem():
rng = np.random.default_rng(0)
model = OnlineLogisticRegression(2, learning_rate=0.1)
x = rng.normal(0, 1, size=(400, 2))
y = (x[:, 0] + x[:, 1] > 0).astype(float)
for _ in range(60):
model.partial_fit(x, y)
predictions = model.predict_proba(x) >= 0.5
assert (predictions == (y > 0.5)).mean() > 0.9
# -------------------------------------------------------------- Lernverhalten
def test_learner_is_not_ready_before_warmup(tmp_path):
learner = make_learner(tmp_path, warmup_samples=10)
assert learner.ready is False
for _ in range(10):
learner.observe(np.zeros(4), 1.0)
assert learner.ready is True
def test_learner_separates_good_from_bad_setups(tmp_path):
"""Feature 0 entscheidet über den Ausgang das muss das Modell finden."""
learner = make_learner(tmp_path, warmup_samples=10, learning_rate=0.1)
rng = np.random.default_rng(3)
for _ in range(800):
good = rng.random() < 0.5
features = np.array([1.0 if good else -1.0, *rng.normal(0, 0.5, 3)])
learner.observe(features, 1.0 if good else 0.0)
good_score = learner.score(np.array([1.0, 0.0, 0.0, 0.0]))
bad_score = learner.score(np.array([-1.0, 0.0, 0.0, 0.0]))
assert good_score > 0.7
assert bad_score < 0.3
assert learner.stats.accuracy > 0.8
def test_real_trades_are_weighted_higher(tmp_path):
learner = make_learner(tmp_path)
learner.learn_from_trade(np.array([1.0, 0.0, 0.0, 0.0]), pnl_quote=12.5)
assert learner.stats.trade_samples == 1
assert learner.stats.shadow_samples == 0
assert learner.buffer.w[0] == pytest.approx(learner.config.trade_sample_weight)
def test_trade_without_features_is_ignored(tmp_path):
learner = make_learner(tmp_path)
learner.learn_from_trade(None, pnl_quote=1.0)
assert learner.stats.samples_seen == 0
def test_wrong_feature_length_is_dropped(tmp_path):
learner = make_learner(tmp_path)
learner.observe(np.zeros(9), 1.0)
assert learner.stats.samples_seen == 0
def test_frozen_learner_scores_but_does_not_train(tmp_path):
learner = make_learner(tmp_path)
learner.frozen = True
before = learner.model.w.copy()
for _ in range(50):
learner.observe(np.array([1.0, 0.0, 0.0, 0.0]), 1.0)
assert np.allclose(learner.model.w, before)
assert learner.stats.samples_seen == 50 # Beobachtungen werden trotzdem gesammelt
# --------------------------------------------------- Verzögerte Shadow-Labels
def test_pending_label_resolves_on_target_hit(tmp_path):
learner = make_learner(tmp_path)
learner.config.label_target_bps = 100.0 # 1 %
learner.register_candidate("BTC/USDT", np.ones(4), price=100.0, bar_index=0)
assert learner.pending_count == 1
resolved = learner.resolve_pending("BTC/USDT", 1, high=101.5, low=99.9, close=101.0)
assert resolved == 1
assert learner.pending_count == 0
assert learner.buffer.y[0] == 1.0
def test_pending_label_resolves_on_stop_hit(tmp_path):
learner = make_learner(tmp_path)
learner.config.label_target_bps = 100.0
learner.register_candidate("BTC/USDT", np.ones(4), price=100.0, bar_index=0)
learner.resolve_pending("BTC/USDT", 1, high=100.2, low=98.5, close=98.7)
assert learner.buffer.y[0] == 0.0
def test_pending_label_expires_after_horizon(tmp_path):
learner = make_learner(tmp_path)
learner.config.label_horizon_bars = 3
learner.config.label_target_bps = 500.0 # wird nicht erreicht
learner.register_candidate("BTC/USDT", np.ones(4), price=100.0, bar_index=0)
for bar in range(1, 3):
learner.resolve_pending("BTC/USDT", bar, 100.1, 99.9, 100.05)
assert learner.pending_count == 1
learner.resolve_pending("BTC/USDT", 3, 100.1, 99.9, 100.05)
assert learner.pending_count == 0
assert learner.buffer.y[0] == 1.0 # Schluss über dem Einstieg
def test_pending_labels_are_kept_per_symbol(tmp_path):
learner = make_learner(tmp_path)
learner.register_candidate("BTC/USDT", np.ones(4), 100.0, 0)
learner.register_candidate("ETH/USDT", np.ones(4), 100.0, 0)
learner.resolve_pending("BTC/USDT", 1, 200.0, 199.0, 199.5)
assert learner.pending_count == 1 # ETH bleibt offen
# ------------------------------------------------------------- Persistenz
def test_save_and_load_round_trip(tmp_path):
learner = make_learner(tmp_path)
rng = np.random.default_rng(5)
for _ in range(200):
features = rng.normal(0, 1, 4)
learner.observe(features, 1.0 if features[0] > 0 else 0.0)
probe = np.array([0.7, -0.2, 0.1, 0.4])
expected = learner.score(probe)
path = learner.save()
assert path.is_file()
restored = make_learner(tmp_path)
assert restored.load() is True
assert restored.score(probe) == pytest.approx(expected)
assert restored.stats.samples_seen == learner.stats.samples_seen
assert len(restored.buffer) == len(learner.buffer)
def test_load_without_file_returns_false(tmp_path):
assert make_learner(tmp_path).load() is False
def test_model_with_wrong_feature_count_is_ignored(tmp_path):
learner = make_learner(tmp_path)
learner.observe(np.zeros(4), 1.0)
learner.save()
other = AdaptiveLearner(
LearnerConfig(model_path=str(tmp_path / "model.npz")), n_features=9, seed=1
)
assert other.load() is False
def test_corrupt_model_file_is_tolerated(tmp_path):
path = tmp_path / "model.npz"
path.write_bytes(b"kein gueltiges npz")
assert make_learner(tmp_path).load() is False
def test_autosave_can_be_disabled(tmp_path):
learner = make_learner(tmp_path)
learner.autosave = False
learner.config.save_every_n_updates = 1
for _ in range(50):
learner.observe(np.ones(4), 1.0)
learner.maybe_save()
assert not (tmp_path / "model.npz").exists()
# ------------------------------------------------------------- NullLearner
def test_null_learner_accepts_everything():
learner = NullLearner()
assert learner.ready is True
assert learner.score(np.zeros(3)) == 1.0
assert learner.explore() is False
learner.observe(np.zeros(3), 1.0)
assert learner.snapshot() == {"enabled": False}
+226
View File
@@ -0,0 +1,226 @@
import pytest
from trademind.config import RiskConfig
from trademind.models import ExitReason, Fill, Position, Side
from trademind.portfolio import Portfolio
from trademind.risk import RiskManager
DAY_ONE = 1_700_000_000_000
DAY_TWO = DAY_ONE + 86_400_000
def make_fill(symbol="BTC/USDT", side=Side.BUY, amount=0.1, price=30_000.0, fee=3.0, ts=DAY_ONE):
return Fill(symbol=symbol, side=side, amount=amount, price=price, fee_quote=fee, timestamp=ts)
def open_position(portfolio: Portfolio, **kwargs) -> Position:
return portfolio.open_position(
make_fill(**kwargs), stop_loss=None, take_profit=None, features=None,
confidence=0.6, exploratory=False,
)
# ------------------------------------------------------------------ Portfolio
def test_profitable_round_trip_accounts_for_both_fees():
portfolio = Portfolio(10_000.0)
open_position(portfolio)
exit_fill = make_fill(side=Side.SELL, price=31_000.0, fee=3.1)
trade = portfolio.close_position(exit_fill, ExitReason.TAKE_PROFIT)
assert trade.pnl_quote == pytest.approx((31_000 - 30_000) * 0.1 - 6.1)
assert trade.is_win
assert portfolio.stats.trades == 1
assert portfolio.stats.wins == 1
assert not portfolio.has_position("BTC/USDT")
def test_losing_trade_is_counted_as_loss():
portfolio = Portfolio(10_000.0)
open_position(portfolio)
trade = portfolio.close_position(make_fill(side=Side.SELL, price=29_000.0), ExitReason.STOP_LOSS)
assert trade.pnl_quote < 0
assert portfolio.stats.losses == 1
assert portfolio.stats.win_rate == 0.0
def test_profit_factor_and_expectancy():
portfolio = Portfolio(10_000.0)
for exit_price, reason in ((31_000.0, ExitReason.TAKE_PROFIT), (29_500.0, ExitReason.STOP_LOSS)):
open_position(portfolio)
portfolio.close_position(make_fill(side=Side.SELL, price=exit_price, fee=0.0), reason)
stats = portfolio.stats
assert stats.gross_profit == pytest.approx(97.0) # 100 3 Einstiegsgebühr
assert stats.gross_loss == pytest.approx(53.0) # 50 + 3
assert stats.profit_factor == pytest.approx(97.0 / 53.0)
assert stats.expectancy == pytest.approx((97.0 - 53.0) / 2)
def test_exposure_and_equity_use_mark_prices():
portfolio = Portfolio(10_000.0)
open_position(portfolio, amount=0.1, price=30_000.0)
portfolio.update_mark("BTC/USDT", 32_000.0)
assert portfolio.exposure() == pytest.approx(3_200.0)
assert portfolio.equity(7_000.0) == pytest.approx(10_200.0)
assert portfolio.unrealized_pnl() == pytest.approx(200.0)
def test_drawdown_tracks_the_peak():
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 12_000.0)
portfolio.record_equity(DAY_ONE + 1000, 9_000.0)
assert portfolio.peak_equity == pytest.approx(12_000.0)
assert portfolio.max_drawdown == pytest.approx(0.25)
def test_daily_pnl_resets_on_a_new_utc_day():
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 10_000.0)
portfolio.record_equity(DAY_ONE + 3_600_000, 9_500.0)
assert portfolio.daily_pnl_pct(9_500.0) == pytest.approx(-0.05)
portfolio.record_equity(DAY_TWO, 9_500.0)
assert portfolio.daily_pnl_pct(9_500.0) == pytest.approx(0.0)
def test_cooldown_counts_down_per_bar():
portfolio = Portfolio(10_000.0)
portfolio.start_cooldown("BTC/USDT", 2)
assert portfolio.in_cooldown("BTC/USDT")
portfolio.on_new_bar("BTC/USDT", 1.0, 1.0, 1.0)
assert portfolio.in_cooldown("BTC/USDT")
portfolio.on_new_bar("BTC/USDT", 1.0, 1.0, 1.0)
assert not portfolio.in_cooldown("BTC/USDT")
def test_bars_held_increases_while_a_position_is_open():
portfolio = Portfolio(10_000.0)
open_position(portfolio)
for _ in range(3):
portfolio.on_new_bar("BTC/USDT", 30_500.0, 29_800.0, 30_200.0)
assert portfolio.positions["BTC/USDT"].bars_held == 3
assert portfolio.positions["BTC/USDT"].highest_price == pytest.approx(30_500.0)
# ------------------------------------------------------------------- Risiko
def test_position_size_respects_the_position_cap():
risk = RiskManager(RiskConfig(max_position_pct=0.2, max_total_exposure_pct=1.0))
portfolio = Portfolio(10_000.0)
amount, reason = risk.position_size(portfolio, 10_000.0, price=100.0)
assert reason == ""
assert amount == pytest.approx(20.0) # 2000 USDT / 100
def test_position_size_respects_the_exposure_cap():
risk = RiskManager(RiskConfig(max_position_pct=0.5, max_total_exposure_pct=0.6))
portfolio = Portfolio(10_000.0)
open_position(portfolio, amount=0.15, price=30_000.0) # 4500 belegt
portfolio.update_mark("BTC/USDT", 30_000.0)
amount, _ = risk.position_size(portfolio, 5_500.0, price=100.0)
assert amount == pytest.approx(15.0) # 0,6 × 10 000 4 500 = 1 500
def test_position_size_rejected_below_minimum_notional():
risk = RiskManager(RiskConfig(max_position_pct=0.2, min_notional=100.0))
amount, reason = risk.position_size(Portfolio(100.0), 100.0, price=50.0)
assert amount == 0.0
assert "Minimum" in reason
def test_position_size_rejected_below_exchange_minimum():
risk = RiskManager(RiskConfig(max_position_pct=1.0, min_notional=1.0))
amount, reason = risk.position_size(Portfolio(50.0), 50.0, price=30_000.0, min_amount=0.01)
assert amount == 0.0
assert "Börsen-Minimum" in reason
def test_can_open_blocks_duplicates_cooldown_and_limits():
risk = RiskManager(RiskConfig(max_open_positions=1))
portfolio = Portfolio(10_000.0)
assert risk.can_open("BTC/USDT", portfolio, 10_000.0, 30_000.0)
open_position(portfolio)
assert not risk.can_open("BTC/USDT", portfolio, 7_000.0, 30_000.0) # schon offen
assert not risk.can_open("ETH/USDT", portfolio, 7_000.0, 2_000.0) # Positionslimit
portfolio.positions.clear()
portfolio.start_cooldown("BTC/USDT", 3)
decision = risk.can_open("BTC/USDT", portfolio, 10_000.0, 30_000.0)
assert not decision and "Cooldown" in decision.reason
def test_stop_levels_are_derived_from_atr():
risk = RiskManager(RiskConfig(stop_loss_atr_mult=2.0, take_profit_atr_mult=3.0))
stop, target = risk.stop_levels(100.0, atr=2.0)
assert stop == pytest.approx(96.0)
assert target == pytest.approx(106.0)
def test_stop_levels_can_be_switched_off():
risk = RiskManager(RiskConfig(stop_loss_atr_mult=0.0, take_profit_atr_mult=0.0))
assert risk.stop_levels(100.0, 2.0) == (None, None)
def test_exit_prefers_the_stop_when_both_are_touched():
risk = RiskManager(RiskConfig())
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, stop_loss=96.0, take_profit=106.0)
reason, price = risk.check_exit(position, high=107.0, low=95.0, close=101.0)
assert reason is ExitReason.STOP_LOSS
assert price == pytest.approx(96.0)
def test_take_profit_triggers_alone():
risk = RiskManager(RiskConfig())
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, stop_loss=96.0, take_profit=106.0)
reason, price = risk.check_exit(position, high=107.0, low=99.0, close=106.5)
assert reason is ExitReason.TAKE_PROFIT
assert price == pytest.approx(106.0)
def test_trailing_stop_only_moves_upwards():
risk = RiskManager(RiskConfig(trailing_stop_atr_mult=1.0))
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, highest_price=110.0)
risk.update_trailing(position, atr=2.0)
assert position.trailing_stop == pytest.approx(108.0)
position.highest_price = 105.0
risk.update_trailing(position, atr=2.0)
assert position.trailing_stop == pytest.approx(108.0) # zieht nicht zurück
def test_max_holding_bars_forces_an_exit():
risk = RiskManager(RiskConfig(max_holding_bars=5))
position = Position("BTC/USDT", 1.0, 100.0, DAY_ONE, bars_held=5)
reason, _ = risk.check_exit(position, 101.0, 99.0, 100.0)
assert reason is ExitReason.MAX_HOLDING
def test_daily_loss_halts_until_the_next_day():
risk = RiskManager(RiskConfig(max_daily_loss_pct=0.05, max_drawdown_pct=0.9))
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 10_000.0)
portfolio.record_equity(DAY_ONE + 60_000, 9_400.0)
assert risk.evaluate_halt(portfolio, 9_400.0, DAY_ONE + 60_000) is not None
assert risk.trading_halted
assert not risk.force_liquidation()
portfolio.record_equity(DAY_TWO, 9_400.0)
risk.evaluate_halt(portfolio, 9_400.0, DAY_TWO)
assert not risk.trading_halted
def test_max_drawdown_halts_permanently_and_liquidates():
risk = RiskManager(RiskConfig(max_drawdown_pct=0.2))
portfolio = Portfolio(10_000.0)
portfolio.record_equity(DAY_ONE, 10_000.0)
portfolio.record_equity(DAY_ONE + 1000, 7_000.0)
reason = risk.evaluate_halt(portfolio, 7_000.0, DAY_ONE + 1000)
assert reason and "Drawdown" in reason
assert risk.force_liquidation()
portfolio.record_equity(DAY_TWO, 7_000.0)
risk.evaluate_halt(portfolio, 7_000.0, DAY_TWO)
assert risk.trading_halted # bleibt bis zum Neustart gesperrt
+218
View File
@@ -0,0 +1,218 @@
import numpy as np
import pytest
from trademind.config import LearnerConfig, RuleConfig, StrategyConfig
from trademind.features import FEATURE_NAMES, N_FEATURES, FeatureSnapshot
from trademind.learner import AdaptiveLearner, NullLearner
from trademind.models import Action, Position
from trademind.strategy import AdaptiveStrategy, RuleStrategy, build_strategy
RULES = RuleConfig(rsi_overbought=70.0, rsi_oversold=35.0, min_holding_bars=3, trend_filter_period=100)
def snap(
*, price=100.0, rsi=50.0, rsi_prev=50.0, ema_fast=101.0, ema_slow=100.0,
ema_fast_prev=99.0, ema_slow_prev=100.0, trend_ema=95.0, atr=2.0,
) -> FeatureSnapshot:
return FeatureSnapshot(
values=np.zeros(N_FEATURES),
names=FEATURE_NAMES,
index=200,
price=price,
atr=atr,
rsi=rsi,
rsi_prev=rsi_prev,
ema_fast=ema_fast,
ema_slow=ema_slow,
ema_fast_prev=ema_fast_prev,
ema_slow_prev=ema_slow_prev,
trend_ema=trend_ema,
timestamp=1_700_000_000_000,
)
def position(bars_held: int = 10) -> Position:
return Position("BTC/USDT", 1.0, 100.0, 1_700_000_000_000, bars_held=bars_held)
# ------------------------------------------------------------------- Regeln
def test_ema_cross_up_in_uptrend_is_an_entry():
signal = RuleStrategy(RULES).evaluate("BTC/USDT", snap(), None)
assert signal.action is Action.ENTER_LONG
assert signal.reason == "ema_cross_up"
assert signal.features is not None
def test_no_entry_below_the_trend_filter():
signal = RuleStrategy(RULES).evaluate("BTC/USDT", snap(trend_ema=120.0), None)
assert signal.action is Action.HOLD
def test_no_entry_when_already_overbought():
signal = RuleStrategy(RULES).evaluate("BTC/USDT", snap(rsi=75.0), None)
assert signal.action is Action.HOLD
def test_oversold_pullback_is_an_entry():
signal = RuleStrategy(RULES).evaluate(
"BTC/USDT", snap(rsi=30.0, ema_fast_prev=101.0, ema_slow_prev=100.0), None
)
assert signal.action is Action.ENTER_LONG
assert signal.reason == "pullback_oversold"
def test_trend_filter_can_be_switched_off():
rules = RuleConfig(trend_filter_period=0)
signal = RuleStrategy(rules).evaluate("BTC/USDT", snap(trend_ema=120.0), None)
assert signal.action is Action.ENTER_LONG
def test_ema_cross_down_exits():
strategy = RuleStrategy(RULES)
s = snap(ema_fast=99.0, ema_slow=100.0, ema_fast_prev=101.0, ema_slow_prev=100.0)
assert strategy.evaluate("BTC/USDT", s, position()).action is Action.EXIT_LONG
def test_high_rsi_alone_does_not_exit():
"""Ein hoher RSI ist im Aufwärtstrend normal er darf keinen Ausstieg auslösen."""
strategy = RuleStrategy(RULES)
s = snap(rsi=85.0, rsi_prev=80.0)
assert strategy.evaluate("BTC/USDT", s, position()).action is Action.HOLD
def test_rsi_turning_down_out_of_overbought_exits():
strategy = RuleStrategy(RULES)
s = snap(rsi=68.0, rsi_prev=74.0)
signal = strategy.evaluate("BTC/USDT", s, position())
assert signal.action is Action.EXIT_LONG
assert signal.reason == "rsi_momentum_fade"
def test_minimum_holding_period_blocks_early_signal_exits():
strategy = RuleStrategy(RULES)
s = snap(ema_fast=99.0, ema_slow=100.0, ema_fast_prev=101.0, ema_slow_prev=100.0)
assert strategy.evaluate("BTC/USDT", s, position(bars_held=1)).action is Action.HOLD
assert strategy.evaluate("BTC/USDT", s, position(bars_held=3)).action is Action.EXIT_LONG
# ----------------------------------------------------------------- Adaptive
def make_adaptive(threshold=0.55, exploration=0.0, warmup=5) -> AdaptiveStrategy:
config = StrategyConfig(
name="adaptive",
rules=RULES,
learner=LearnerConfig(
entry_threshold=threshold,
exploration_rate=exploration,
warmup_samples=warmup,
background_sample_every_n_bars=0,
),
)
return AdaptiveStrategy(config, AdaptiveLearner(config.learner, N_FEATURES, seed=1))
def test_warmup_lets_every_rule_signal_through():
strategy = make_adaptive(threshold=0.99, warmup=1_000)
signal = strategy.evaluate("BTC/USDT", snap(), None)
assert signal.action is Action.ENTER_LONG
assert "warmup" in signal.reason
def test_model_can_veto_a_rule_signal():
strategy = make_adaptive(threshold=0.99, warmup=1)
strategy.learner.observe(np.zeros(N_FEATURES), 0.0)
signal = strategy.evaluate("BTC/USDT", snap(), None)
assert signal.action is Action.HOLD
assert "abgelehnt" in signal.reason
def test_low_threshold_lets_signals_pass():
strategy = make_adaptive(threshold=0.0, warmup=1)
strategy.learner.observe(np.zeros(N_FEATURES), 0.0)
assert strategy.evaluate("BTC/USDT", snap(), None).action is Action.ENTER_LONG
def test_exploration_overrides_a_veto():
strategy = make_adaptive(threshold=0.99, exploration=1.0, warmup=1)
strategy.learner.observe(np.zeros(N_FEATURES), 0.0)
signal = strategy.evaluate("BTC/USDT", snap(), None)
assert signal.action is Action.ENTER_LONG
assert signal.exploratory is True
def test_every_candidate_is_registered_for_labelling():
"""Auch abgelehnte Signale müssen gelabelt werden sonst lernt der Bot nichts dazu."""
strategy = make_adaptive(threshold=0.99, warmup=1)
strategy.learner.observe(np.zeros(N_FEATURES), 0.0)
strategy.evaluate("BTC/USDT", snap(), None)
assert strategy.learner.pending_count == 1
def test_no_candidate_no_pending_label():
strategy = make_adaptive()
strategy.evaluate("BTC/USDT", snap(trend_ema=120.0), None) # kein Setup
assert strategy.learner.pending_count == 0
def test_background_samples_are_registered_on_schedule():
config = StrategyConfig(
name="adaptive",
rules=RULES,
learner=LearnerConfig(background_sample_every_n_bars=5, background_sample_weight=0.5),
)
strategy = AdaptiveStrategy(config, AdaptiveLearner(config.learner, N_FEATURES, seed=1))
for bar in range(10):
strategy.on_bar("BTC/USDT", snap(), bar, 101.0, 99.0, 100.0)
assert strategy.background_samples == 2 # Bar 0 und Bar 5
def test_background_sampling_can_be_disabled():
strategy = make_adaptive() # background_sample_every_n_bars=0
for bar in range(20):
strategy.on_bar("BTC/USDT", snap(), bar, 101.0, 99.0, 100.0)
assert strategy.background_samples == 0
def test_closed_trade_feeds_the_learner():
strategy = make_adaptive()
strategy.learner.autosave = False
pos = position()
pos.entry_features = np.ones(N_FEATURES)
strategy.on_trade_closed(pos, pnl_quote=25.0)
assert strategy.learner.stats.trade_samples == 1
def test_snapshot_reports_acceptance():
strategy = make_adaptive(threshold=0.0, warmup=1)
for _ in range(3):
strategy.evaluate("BTC/USDT", snap(), None)
data = strategy.snapshot()
assert data["candidates_seen"] == 3
assert data["acceptance_rate"] == pytest.approx(1.0)
assert "learner" in data
# ------------------------------------------------------------------- Factory
def test_build_strategy_rules_variant():
strategy = build_strategy(StrategyConfig(name="rules"), N_FEATURES)
assert isinstance(strategy, RuleStrategy)
def test_build_strategy_without_learning_uses_null_learner(tmp_path):
config = StrategyConfig(
name="adaptive", learner=LearnerConfig(enabled=False, model_path=str(tmp_path / "m.npz"))
)
strategy = build_strategy(config, N_FEATURES)
assert isinstance(strategy.learner, NullLearner)
def test_build_strategy_can_skip_loading(tmp_path):
config = StrategyConfig(name="adaptive", learner=LearnerConfig(model_path=str(tmp_path / "m.npz")))
strategy = build_strategy(config, N_FEATURES, load_model=False)
assert isinstance(strategy.learner, AdaptiveLearner)
assert strategy.learner.stats.samples_seen == 0