From 65ed73977e0606a518edc479411d96ea87996b62 Mon Sep 17 00:00:00 2001 From: Tobias Zimmermann Date: Sat, 22 Aug 2026 08:53:04 +0200 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20TradeMind=20=E2=80=93=20Kry?= =?UTF-8?q?pto-Trading-Bot=20mit=20Lernmodus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .containerignore | 25 ++ .gitignore | 35 +++ Containerfile | 69 +++++ README.md | 386 ++++++++++++++++++++++++ config/config.example.yaml | 135 +++++++++ config/trademind.env.example | 35 +++ deploy/trademind-data.volume | 8 + deploy/trademind.container | 59 ++++ podman-compose.yml | 50 ++++ pyproject.toml | 50 ++++ src/trademind/__init__.py | 5 + src/trademind/__main__.py | 6 + src/trademind/app.py | 206 +++++++++++++ src/trademind/backtest.py | 251 ++++++++++++++++ src/trademind/broker.py | 345 +++++++++++++++++++++ src/trademind/cli.py | 368 +++++++++++++++++++++++ src/trademind/config.py | 285 ++++++++++++++++++ src/trademind/data.py | 251 ++++++++++++++++ src/trademind/engine.py | 519 ++++++++++++++++++++++++++++++++ src/trademind/exchange.py | 123 ++++++++ src/trademind/features.py | 225 ++++++++++++++ src/trademind/indicators.py | 156 ++++++++++ src/trademind/learner.py | 563 +++++++++++++++++++++++++++++++++++ src/trademind/models.py | 188 ++++++++++++ src/trademind/notify.py | 100 +++++++ src/trademind/portfolio.py | 300 +++++++++++++++++++ src/trademind/risk.py | 184 ++++++++++++ src/trademind/server.py | 180 +++++++++++ src/trademind/storage.py | 224 ++++++++++++++ src/trademind/strategy.py | 240 +++++++++++++++ tests/__init__.py | 0 tests/conftest.py | 84 ++++++ tests/test_broker.py | 92 ++++++ tests/test_config.py | 96 ++++++ tests/test_engine.py | 334 +++++++++++++++++++++ tests/test_features.py | 83 ++++++ tests/test_indicators.py | 92 ++++++ tests/test_learner.py | 237 +++++++++++++++ tests/test_risk_portfolio.py | 226 ++++++++++++++ tests/test_strategy.py | 218 ++++++++++++++ 40 files changed, 7033 insertions(+) create mode 100644 .containerignore create mode 100644 .gitignore create mode 100644 Containerfile create mode 100644 README.md create mode 100644 config/config.example.yaml create mode 100644 config/trademind.env.example create mode 100644 deploy/trademind-data.volume create mode 100644 deploy/trademind.container create mode 100644 podman-compose.yml create mode 100644 pyproject.toml create mode 100644 src/trademind/__init__.py create mode 100644 src/trademind/__main__.py create mode 100644 src/trademind/app.py create mode 100644 src/trademind/backtest.py create mode 100644 src/trademind/broker.py create mode 100644 src/trademind/cli.py create mode 100644 src/trademind/config.py create mode 100644 src/trademind/data.py create mode 100644 src/trademind/engine.py create mode 100644 src/trademind/exchange.py create mode 100644 src/trademind/features.py create mode 100644 src/trademind/indicators.py create mode 100644 src/trademind/learner.py create mode 100644 src/trademind/models.py create mode 100644 src/trademind/notify.py create mode 100644 src/trademind/portfolio.py create mode 100644 src/trademind/risk.py create mode 100644 src/trademind/server.py create mode 100644 src/trademind/storage.py create mode 100644 src/trademind/strategy.py create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_broker.py create mode 100644 tests/test_config.py create mode 100644 tests/test_engine.py create mode 100644 tests/test_features.py create mode 100644 tests/test_indicators.py create mode 100644 tests/test_learner.py create mode 100644 tests/test_risk_portfolio.py create mode 100644 tests/test_strategy.py diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..91974c3 --- /dev/null +++ b/.containerignore @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..23cea22 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Containerfile b/Containerfile new file mode 100644 index 0000000..fab1783 --- /dev/null +++ b/Containerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..6034380 --- /dev/null +++ b/README.md @@ -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 && 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: · 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 diff --git a/config/config.example.yaml b/config/config.example.yaml new file mode 100644 index 0000000..5bac676 --- /dev/null +++ b/config/config.example.yaml @@ -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) diff --git a/config/trademind.env.example b/config/trademind.env.example new file mode 100644 index 0000000..00e5215 --- /dev/null +++ b/config/trademind.env.example @@ -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____) +# TRADEMIND__RISK__MAX_OPEN_POSITIONS=5 +# TRADEMIND__MARKET__SYMBOLS=BTC/USDT,ETH/USDT,SOL/USDT +# TRADEMIND__STRATEGY__LEARNER__ENTRY_THRESHOLD=0.6 diff --git a/deploy/trademind-data.volume b/deploy/trademind-data.volume new file mode 100644 index 0000000..b555fa5 --- /dev/null +++ b/deploy/trademind-data.volume @@ -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 diff --git a/deploy/trademind.container b/deploy/trademind.container new file mode 100644 index 0000000..28965c7 --- /dev/null +++ b/deploy/trademind.container @@ -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 diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..c39496a --- /dev/null +++ b/podman-compose.yml @@ -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: diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..453d505 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/trademind/__init__.py b/src/trademind/__init__.py new file mode 100644 index 0000000..3044e76 --- /dev/null +++ b/src/trademind/__init__.py @@ -0,0 +1,5 @@ +"""TradeMind – selbstlernender Krypto-Trading-Bot mit Paper-, Backtest- und Live-Modus.""" + +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/src/trademind/__main__.py b/src/trademind/__main__.py new file mode 100644 index 0000000..1886f41 --- /dev/null +++ b/src/trademind/__main__.py @@ -0,0 +1,6 @@ +"""Erlaubt ``python -m trademind ...``.""" + +from .cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/trademind/app.py b/src/trademind/app.py new file mode 100644 index 0000000..ccf4f50 --- /dev/null +++ b/src/trademind/app.py @@ -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) diff --git a/src/trademind/backtest.py b/src/trademind/backtest.py new file mode 100644 index 0000000..3160b65 --- /dev/null +++ b/src/trademind/backtest.py @@ -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), + } diff --git a/src/trademind/broker.py b/src/trademind/broker.py new file mode 100644 index 0000000..87d577c --- /dev/null +++ b/src/trademind/broker.py @@ -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) diff --git a/src/trademind/cli.py b/src/trademind/cli.py new file mode 100644 index 0000000..0a6b947 --- /dev/null +++ b/src/trademind/cli.py @@ -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()) diff --git a/src/trademind/config.py b/src/trademind/config.py new file mode 100644 index 0000000..cf2fdec --- /dev/null +++ b/src/trademind/config.py @@ -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) diff --git a/src/trademind/data.py b/src/trademind/data.py new file mode 100644 index 0000000..0af9760 --- /dev/null +++ b/src/trademind/data.py @@ -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 diff --git a/src/trademind/engine.py b/src/trademind/engine.py new file mode 100644 index 0000000..b845d13 --- /dev/null +++ b/src/trademind/engine.py @@ -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"] diff --git a/src/trademind/exchange.py b/src/trademind/exchange.py new file mode 100644 index 0000000..39f6a4b --- /dev/null +++ b/src/trademind/exchange.py @@ -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 diff --git a/src/trademind/features.py b/src/trademind/features.py new file mode 100644 index 0000000..b19d9f3 --- /dev/null +++ b/src/trademind/features.py @@ -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) diff --git a/src/trademind/indicators.py b/src/trademind/indicators.py new file mode 100644 index 0000000..0e964ce --- /dev/null +++ b/src/trademind/indicators.py @@ -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 diff --git a/src/trademind/learner.py b/src/trademind/learner.py new file mode 100644 index 0000000..e843edd --- /dev/null +++ b/src/trademind/learner.py @@ -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 diff --git a/src/trademind/models.py b/src/trademind/models.py new file mode 100644 index 0000000..2a0ae5e --- /dev/null +++ b/src/trademind/models.py @@ -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) diff --git a/src/trademind/notify.py b/src/trademind/notify.py new file mode 100644 index 0000000..b4e0b48 --- /dev/null +++ b/src/trademind/notify.py @@ -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})" + ) diff --git a/src/trademind/portfolio.py b/src/trademind/portfolio.py new file mode 100644 index 0000000..d6fc87f --- /dev/null +++ b/src/trademind/portfolio.py @@ -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 diff --git a/src/trademind/risk.py b/src/trademind/risk.py new file mode 100644 index 0000000..9483ff4 --- /dev/null +++ b/src/trademind/risk.py @@ -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, + } diff --git a/src/trademind/server.py b/src/trademind/server.py new file mode 100644 index 0000000..ef9d5b4 --- /dev/null +++ b/src/trademind/server.py @@ -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 = """ + +TradeMind + +

TradeMind

+
lädt …
+
+

Offene Positionen

+

Letzte Trades

+ +""" + + +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) diff --git a/src/trademind/storage.py b/src/trademind/storage.py new file mode 100644 index 0000000..a9ab559 --- /dev/null +++ b/src/trademind/storage.py @@ -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 diff --git a/src/trademind/strategy.py b/src/trademind/strategy.py new file mode 100644 index 0000000..06fedba --- /dev/null +++ b/src/trademind/strategy.py @@ -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) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b80cdd4 --- /dev/null +++ b/tests/conftest.py @@ -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}}, + } + ) diff --git a/tests/test_broker.py b/tests/test_broker.py new file mode 100644 index 0000000..60d0f3d --- /dev/null +++ b/tests/test_broker.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e0e1146 --- /dev/null +++ b/tests/test_config.py @@ -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 diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..15a8dc7 --- /dev/null +++ b/tests/test_engine.py @@ -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 diff --git a/tests/test_features.py b/tests/test_features.py new file mode 100644 index 0000000..d580490 --- /dev/null +++ b/tests/test_features.py @@ -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]) diff --git a/tests/test_indicators.py b/tests/test_indicators.py new file mode 100644 index 0000000..59223da --- /dev/null +++ b/tests/test_indicators.py @@ -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) diff --git a/tests/test_learner.py b/tests/test_learner.py new file mode 100644 index 0000000..ded6da2 --- /dev/null +++ b/tests/test_learner.py @@ -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} diff --git a/tests/test_risk_portfolio.py b/tests/test_risk_portfolio.py new file mode 100644 index 0000000..12ddcaa --- /dev/null +++ b/tests/test_risk_portfolio.py @@ -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 diff --git a/tests/test_strategy.py b/tests/test_strategy.py new file mode 100644 index 0000000..ba4c9af --- /dev/null +++ b/tests/test_strategy.py @@ -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