Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus
Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich aus den Ergebnissen weiter antrainiert. Aufbau - Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden nur Datenquelle und Broker. - Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar. Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert. - Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen. - Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der Kurshistorie vorgelernt. - Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie Tagesverlust- und Drawdown-Notbremsen. - SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks. Deployment - Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet. - Modell und Datenbank liegen im Volume /data und überleben Neustarts. 128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten Container inklusive Healthcheck und Zustandswiederherstellung.
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
.git
|
||||||
|
.github
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
**/__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info
|
||||||
|
.pytest_cache
|
||||||
|
.ruff_cache
|
||||||
|
.mypy_cache
|
||||||
|
tests
|
||||||
|
data
|
||||||
|
out
|
||||||
|
backtests
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-wal
|
||||||
|
*.sqlite3-shm
|
||||||
|
*.npz
|
||||||
|
config/config.yaml
|
||||||
|
config/trademind.env
|
||||||
|
podman-compose.yml
|
||||||
|
deploy
|
||||||
+35
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
# TradeMind
|
||||||
|
|
||||||
|
Ein per Podman deploybarer Krypto-Trading-Bot mit drei Betriebsarten:
|
||||||
|
|
||||||
|
| Modus | Kurse | Orders | Lernen | Zweck |
|
||||||
|
|------------|--------------|------------------|--------|------------------------------------------|
|
||||||
|
| `paper` | live | simuliert | ja | Dauerbetrieb ohne Risiko — **Standard** |
|
||||||
|
| `backtest` | historisch | simuliert | ja | Strategie und Parameter bewerten |
|
||||||
|
| `live` | live | echt | optional | Echtgeldhandel |
|
||||||
|
|
||||||
|
Die Börsenanbindung läuft über [ccxt](https://github.com/ccxt/ccxt) — Binance, Kraken, Coinbase,
|
||||||
|
Bybit, OKX, KuCoin, Bitget, Gate.io, MEXC und rund 100 weitere sind allein über den
|
||||||
|
Konfigurationsschlüssel `exchange.id` erreichbar (`trademind exchanges` listet alle auf).
|
||||||
|
|
||||||
|
> **Hinweis zum Risiko.** Der Bot ist ein Werkzeug, keine Ertragsgarantie und keine
|
||||||
|
> Anlageberatung. Die mitgelieferte Strategie ist eine funktionsfähige Grundlage, kein
|
||||||
|
> erprobtes Handelssystem — im Backtest unten liegt sie deutlich hinter Buy & Hold.
|
||||||
|
> Vor jedem Live-Einsatz gehören eigene Backtests, ein langer Paper-Lauf und eine bewusste
|
||||||
|
> Entscheidung über den Kapitaleinsatz. Der Live-Modus ist doppelt abgesichert und muss
|
||||||
|
> ausdrücklich freigeschaltet werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo> && cd Trademind_Claude
|
||||||
|
cp config/config.example.yaml config/config.yaml
|
||||||
|
cp config/trademind.env.example config/trademind.env
|
||||||
|
podman build --format docker -t trademind:latest -f Containerfile .
|
||||||
|
podman-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
`--format docker` sorgt dafür, dass der eingebaute HEALTHCHECK ins Image übernommen wird —
|
||||||
|
das OCI-Format kennt die Anweisung nicht. Compose und Quadlet bringen ihren eigenen
|
||||||
|
Healthcheck mit, dort spielt das Format keine Rolle.
|
||||||
|
|
||||||
|
Dashboard: <http://127.0.0.1:8080/> · Logs: `podman-compose logs -f`
|
||||||
|
|
||||||
|
Ohne Zugangsdaten läuft der Bot im Paper-Modus auf echten Live-Kursen und beginnt sofort zu
|
||||||
|
lernen. Für den Start werden nur öffentliche Marktdaten gelesen — kein API-Schlüssel nötig.
|
||||||
|
|
||||||
|
### Ohne Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && . .venv/bin/activate # Windows: .venv\Scripts\activate
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
trademind backtest --config config/config.yaml --bars 6000 --fresh-model
|
||||||
|
trademind run --config config/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment mit Podman
|
||||||
|
|
||||||
|
### Variante 1 — podman-compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman-compose up -d # starten
|
||||||
|
podman-compose logs -f # verfolgen
|
||||||
|
podman-compose down # stoppen
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Port ist bewusst auf `127.0.0.1:8080` gebunden; das Dashboard ist damit nicht aus dem
|
||||||
|
Netz erreichbar. Für Zugriff von außen einen Reverse Proxy mit Authentifizierung davorsetzen.
|
||||||
|
|
||||||
|
> **Windows und macOS.** Dort läuft Podman in einer VM (`podman machine`). Auf Loopback
|
||||||
|
> veröffentlichte Ports bleiben in der VM und erreichen den Host nicht. Zum Testen entweder
|
||||||
|
> `podman machine ssh curl -s localhost:8080/health` verwenden oder in
|
||||||
|
> `podman-compose.yml` auf `"8080:8080"` umstellen. Auf Linux — dem Ziel für den
|
||||||
|
> Dauerbetrieb — funktioniert die Loopback-Bindung unmittelbar.
|
||||||
|
|
||||||
|
### Variante 2 — einzelner Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman volume create trademind-data
|
||||||
|
podman run -d --name trademind \
|
||||||
|
--restart=unless-stopped \
|
||||||
|
--env-file config/trademind.env \
|
||||||
|
-v ./config:/config:ro,Z \
|
||||||
|
-v trademind-data:/data:Z \
|
||||||
|
-p 127.0.0.1:8080:8080 \
|
||||||
|
--security-opt no-new-privileges \
|
||||||
|
trademind:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variante 3 — systemd über Quadlet (empfohlen für Dauerbetrieb)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/containers/systemd ~/.config/trademind
|
||||||
|
cp deploy/trademind.container deploy/trademind-data.volume ~/.config/containers/systemd/
|
||||||
|
cp config/config.yaml config/trademind.env ~/.config/trademind/
|
||||||
|
loginctl enable-linger $USER
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
systemctl --user start trademind
|
||||||
|
journalctl --user -u trademind -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Container läuft als UID 10001 ohne zusätzliche Rechte. `podman stop` sendet SIGTERM; die
|
||||||
|
Engine beendet den laufenden Durchlauf, sichert Modell und Zustand und fährt sauber herunter.
|
||||||
|
|
||||||
|
### Persistenz
|
||||||
|
|
||||||
|
Alles Wichtige liegt im Volume `/data`:
|
||||||
|
|
||||||
|
| Pfad | Inhalt |
|
||||||
|
|-----------------------------|-----------------------------------------------------------|
|
||||||
|
| `/data/trademind.sqlite3` | Trades, Equity-Kurve, Laufzeitzustand |
|
||||||
|
| `/data/models/adaptive.npz` | Modellgewichte, Normalisierung, Erfahrungsspeicher |
|
||||||
|
|
||||||
|
Ohne dieses Volume geht bei jedem Neustart der gesamte Lernfortschritt verloren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
Alles läuft über `config/config.yaml`. Geheimnisse kommen über Umgebungsvariablen dazu:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
exchange:
|
||||||
|
id: ${TRADEMIND_EXCHANGE:-binance}
|
||||||
|
api_key: ${TRADEMIND_API_KEY}
|
||||||
|
api_secret: ${TRADEMIND_API_SECRET}
|
||||||
|
password: ${TRADEMIND_API_PASSWORD} # OKX, KuCoin, Coinbase Advanced, Bitget
|
||||||
|
sandbox: true # Testnet der Börse
|
||||||
|
```
|
||||||
|
|
||||||
|
Drei Ebenen, in dieser Reihenfolge:
|
||||||
|
|
||||||
|
1. Werte in der YAML-Datei
|
||||||
|
2. `${VAR}` / `${VAR:-fallback}` — aus der Umgebung eingesetzt
|
||||||
|
3. `TRADEMIND__ABSCHNITT__SCHLUESSEL` — überschreibt einzelne Werte punktuell,
|
||||||
|
z. B. `TRADEMIND__RISK__MAX_OPEN_POSITIONS=5`
|
||||||
|
|
||||||
|
Die vollständig kommentierte Vorlage steht in
|
||||||
|
[config/config.example.yaml](config/config.example.yaml).
|
||||||
|
|
||||||
|
### Börse wechseln
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trademind exchanges --search kraken
|
||||||
|
```
|
||||||
|
|
||||||
|
Dann `exchange.id` setzen. Handelspaare in ccxt-Schreibweise (`BTC/USDT`, `ETH/EUR`);
|
||||||
|
alle Symbole müssen dieselbe Quote-Währung haben, sonst bricht der Start mit einer
|
||||||
|
verständlichen Meldung ab.
|
||||||
|
|
||||||
|
### Live-Modus freischalten
|
||||||
|
|
||||||
|
Zwei Bedingungen müssen erfüllt sein, sonst startet der Bot nicht:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mode: live
|
||||||
|
live_confirmation: I_UNDERSTAND_THE_RISK
|
||||||
|
exchange:
|
||||||
|
api_key: ...
|
||||||
|
api_secret: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
API-Schlüssel bitte ausschließlich mit **Handelsrecht** anlegen — Auszahlungen niemals
|
||||||
|
erlauben — und wenn die Börse es anbietet auf die Server-IP beschränken. Zuerst mit
|
||||||
|
`sandbox: true` gegen das Testnet fahren.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wie der Bot lernt
|
||||||
|
|
||||||
|
Die Strategie besteht aus zwei Schichten:
|
||||||
|
|
||||||
|
**1. Regelwerk** (`strategy.rules`) — erzeugt Kandidaten für Einstiege: EMA-Kreuzung nach
|
||||||
|
oben mit RSI- und Trendfilter, dazu Rücksetzer-Einstiege bei überverkauftem RSI im
|
||||||
|
Aufwärtstrend. Ausstiege über EMA-Kreuzung nach unten oder nachlassendes Momentum, dazu
|
||||||
|
immer ATR-basierter Stop und Kursziel.
|
||||||
|
|
||||||
|
**2. Lernmodell** (`strategy.learner`) — eine online trainierte logistische Regression über
|
||||||
|
18 skalenfreie Merkmale (EMA-Abstände, RSI und dessen Steigung, MACD-Histogramm, ATR in
|
||||||
|
Prozent, Volatilitätsverhältnis, Momentum, Donchian-Lage, Volumen-z-Score, Kerzenform,
|
||||||
|
Tageszeit). Sie schätzt für jeden Kandidaten die Gewinnwahrscheinlichkeit und lässt nur
|
||||||
|
Signale oberhalb von `entry_threshold` durch.
|
||||||
|
|
||||||
|
Trainiert wird aus drei Quellen:
|
||||||
|
|
||||||
|
| Quelle | Gewicht | Wofür |
|
||||||
|
|-----------------------|---------|--------------------------------------------------------------|
|
||||||
|
| Reale Trade-Ergebnisse| 3,0 | Das eigentliche Ziel: hat sich der Trade gelohnt? |
|
||||||
|
| Signal-Shadow-Labels | 1,0 | **Jeder** Kandidat wird nachträglich bewertet — auch abgelehnte |
|
||||||
|
| Hintergrund-Stichproben | 0,5 | Regelmäßige Marktzustände, damit genug Daten zusammenkommen |
|
||||||
|
|
||||||
|
Gelabelt wird nach der Triple-Barrier-Methode: Ein Signal gilt als Treffer, wenn der Kurs
|
||||||
|
innerhalb von `label_horizon_bars` das Ziel (`label_target_bps`) erreicht, ohne vorher um
|
||||||
|
denselben Betrag zu fallen. Läuft das Fenster ohne Berührung ab, entscheidet der Schlusskurs.
|
||||||
|
|
||||||
|
Zwei Details sind dabei wichtig:
|
||||||
|
|
||||||
|
- **Off-Policy-Lernen** — auch abgelehnte Signale werden gelabelt. Der Bot lernt also aus
|
||||||
|
Trades, die er *nicht* gemacht hat, und kann eine zu strenge Schwelle selbst korrigieren.
|
||||||
|
- **Exploration** — `exploration_rate` (Standard 5 %) handelt gelegentlich bewusst gegen das
|
||||||
|
Modell. Ohne das würde es seine eigenen Vorurteile nie widerlegen.
|
||||||
|
|
||||||
|
**Kaltstart.** Ein frisches Modell würde bei 5-Minuten-Kerzen Tage brauchen, um die
|
||||||
|
Aufwärmphase zu durchlaufen. Deshalb lernt der Bot beim ersten Start automatisch aus
|
||||||
|
`bootstrap_bars` historischen Kerzen vor — in der Praxis rund 3 Sekunden statt einer Woche:
|
||||||
|
|
||||||
|
```
|
||||||
|
Modell ist untrainiert – lerne aus bis zu 3000 historischen Kerzen vor …
|
||||||
|
Vorlernen abgeschlossen: 662 neue Beobachtungen (gesamt 662), Modell einsatzbereit
|
||||||
|
```
|
||||||
|
|
||||||
|
Solange das Modell nicht warm ist (`warmup_samples`), entscheidet allein das Regelwerk.
|
||||||
|
Die Qualität lässt sich im Status unter `strategy.learner.online_accuracy` verfolgen — das ist
|
||||||
|
eine *prequentielle* Messung: erst vorhersagen, dann lernen, also keine Selbstbewertung auf
|
||||||
|
bereits gesehenen Daten.
|
||||||
|
|
||||||
|
Im Live-Modus lässt sich das Weiterlernen mit `freeze_in_live: true` einfrieren, wenn ein
|
||||||
|
im Paper-Betrieb gereiftes Modell unverändert bleiben soll.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Risikomanagement
|
||||||
|
|
||||||
|
Vor jeder Order greifen mehrere unabhängige Grenzen:
|
||||||
|
|
||||||
|
- `max_position_pct` — Anteil der Equity je Position
|
||||||
|
- `max_total_exposure_pct` — Summe aller Positionen
|
||||||
|
- `max_open_positions` — parallele Positionen
|
||||||
|
- ATR-basierter Stop-Loss und Take-Profit, optional nachziehender Stop
|
||||||
|
- `cooldown_bars_after_exit` — Pause pro Symbol nach einem Ausstieg
|
||||||
|
- `min_notional` sowie die Mindestgrößen und Rundungsschritte der Börse
|
||||||
|
|
||||||
|
Dazu zwei Notbremsen:
|
||||||
|
|
||||||
|
- **`max_daily_loss_pct`** — Handel pausiert bis zum nächsten UTC-Tag.
|
||||||
|
- **`max_drawdown_pct`** — dauerhafter Stopp bis zum Neustart; offene Positionen werden
|
||||||
|
geschlossen.
|
||||||
|
|
||||||
|
Ausstiege werden nie durch die Liquiditätsgrenze gedrosselt: Das Risikomanagement muss
|
||||||
|
jederzeit vollständig aus einer Position herauskommen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Simulation
|
||||||
|
|
||||||
|
Der Paper-Broker bildet die Kosten nach, an denen Strategien in der Praxis scheitern:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
paper:
|
||||||
|
fee_rate: 0.001 # 0,1 % je Seite
|
||||||
|
slippage_bps: 5 # Ausführung 5 bps schlechter als der Referenzkurs
|
||||||
|
max_volume_participation: 0.1 # höchstens 10 % des Kerzenvolumens
|
||||||
|
```
|
||||||
|
|
||||||
|
Zusätzlich werden Mengen auf die Präzision der Börse abgerundet, Käufe auf das verfügbare
|
||||||
|
Guthaben begrenzt und Verkäufe auf den tatsächlichen Bestand. Ein Round-Trip ohne
|
||||||
|
Kursbewegung kostet damit realistisch ~0,3 %.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kommandos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trademind run --config config/config.yaml # Dauerbetrieb (paper oder live)
|
||||||
|
trademind backtest --config config/config.yaml # historischer Durchlauf
|
||||||
|
trademind validate --config config/config.yaml # Konfiguration + Börsenverbindung prüfen
|
||||||
|
trademind report --config config/config.yaml # Ergebnisse aus der Datenbank
|
||||||
|
trademind exchanges --search kraken # verfügbare Börsen
|
||||||
|
```
|
||||||
|
|
||||||
|
Im Container davor `podman exec -it trademind` setzen, z. B.:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman exec -it trademind trademind report --config /config/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backtest
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trademind backtest -c config/config.yaml --bars 20000 --fresh-model --save-model --out-dir out/
|
||||||
|
trademind backtest -c config/config.yaml --start 2025-01-01T00:00:00Z --end 2025-06-30T23:59:59Z
|
||||||
|
trademind backtest -c config/config.yaml --csv-dir data/csv --json
|
||||||
|
```
|
||||||
|
|
||||||
|
| Option | Wirkung |
|
||||||
|
|------------------|--------------------------------------------------------------------|
|
||||||
|
| `--fresh-model` | untrainiertes Modell — sonst wird auf dem gespeicherten aufgesetzt |
|
||||||
|
| `--save-model` | Ergebnis nach `learner.model_path` schreiben (Vortraining) |
|
||||||
|
| `--out-dir` | `trades.csv`, `equity.csv` und `report.json` ablegen |
|
||||||
|
| `--csv-dir` | eigene OHLCV-Daten statt Börsenabruf |
|
||||||
|
| `--seed` | reproduzierbare Läufe (Exploration ist zufällig) |
|
||||||
|
|
||||||
|
CSV-Format: `timestamp,open,high,low,close,volume`, eine Datei je Symbol
|
||||||
|
(`BTC_USDT.csv`). Der Zeitstempel darf in Sekunden, Millisekunden oder ISO-8601 stehen.
|
||||||
|
|
||||||
|
Der Backtest ist ein Walk-Forward-Lauf: Das Modell trainiert währenddessen ganz normal
|
||||||
|
weiter, es gibt also keine getrennte Trainings- und Testphase.
|
||||||
|
|
||||||
|
Ein Beispiellauf über 20 Tage BTC/USDT und ETH/USDT (5m, frisches Modell):
|
||||||
|
|
||||||
|
```
|
||||||
|
Endkapital 9818.92 USDT Trades 29 (4/25)
|
||||||
|
Gesamtrendite -1.81 % Trefferquote 13.8 %
|
||||||
|
Max. Drawdown 2.23 % Profit-Faktor 0.17
|
||||||
|
Buy & Hold BTC +23.81 % Gebühren 114.84 USDT
|
||||||
|
```
|
||||||
|
|
||||||
|
Genau dafür ist der Backtest da: Die Standardparameter schlagen in einem starken
|
||||||
|
Aufwärtstrend kein Buy & Hold, und der Bot sagt das offen. Kürzere Zeiträume, andere
|
||||||
|
Timeframes und Parameter gehören ausprobiert, bevor auch nur ein Paper-Euro fließt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
| Endpunkt | Inhalt |
|
||||||
|
|--------------|-------------------------------------------------------------|
|
||||||
|
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand |
|
||||||
|
| `/health` | Liveness — nutzt der Container-Healthcheck |
|
||||||
|
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
|
||||||
|
| `/status` | vollständiger Zustand als JSON |
|
||||||
|
| `/positions` | offene Positionen |
|
||||||
|
| `/trades` | letzte Trades (`?limit=100`) |
|
||||||
|
| `/metrics` | Prometheus-Textformat |
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s localhost:8080/status | jq '.portfolio, .strategy.learner'
|
||||||
|
curl -s localhost:8080/metrics | grep trademind_portfolio
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionale Benachrichtigungen über `notifications.webhook_url` — ein Payload bedient sowohl
|
||||||
|
Slack als auch Discord.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```
|
||||||
|
src/trademind/
|
||||||
|
├── cli.py Kommandozeile
|
||||||
|
├── app.py Zusammenbau aus der Konfiguration
|
||||||
|
├── config.py Konfigurationsmodell (pydantic) + ${ENV}-Auflösung
|
||||||
|
├── engine.py Handelsschleife und Bar-Verarbeitung
|
||||||
|
├── backtest.py Walk-Forward-Durchlauf und Auswertung
|
||||||
|
├── strategy.py Regelwerk und lernende Strategie
|
||||||
|
├── learner.py Online-Logistikregression, Replay-Buffer, Persistenz
|
||||||
|
├── features.py Merkmalsvektoren (vektorisiert)
|
||||||
|
├── indicators.py EMA, RSI, ATR, MACD, Bollinger, Donchian, ROC
|
||||||
|
├── risk.py Positionsgröße, Stops, Notbremsen
|
||||||
|
├── portfolio.py Positionen, Equity, Kennzahlen
|
||||||
|
├── broker.py Paper- und Live-Ausführung
|
||||||
|
├── exchange.py ccxt-Client und Börsen-Metadaten
|
||||||
|
├── data.py Marktdaten: live, CSV, Replay
|
||||||
|
├── storage.py SQLite-Persistenz
|
||||||
|
├── server.py HTTP-Status, Metriken, Dashboard
|
||||||
|
└── notify.py Webhook-Benachrichtigungen
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Bar-Verarbeitung in `engine.process_bar` ist für alle drei Modi identisch — ausgetauscht
|
||||||
|
werden nur Datenquelle und Broker. Was im Backtest passiert, passiert live genauso.
|
||||||
|
|
||||||
|
### Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pytest -q # 128 Tests
|
||||||
|
ruff check .
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bekannte Grenzen
|
||||||
|
|
||||||
|
- **Nur Long, nur Spot.** Keine Short-Positionen, keine Hebel, keine Futures.
|
||||||
|
- **Bar-getaktet.** Entscheidungen fallen bei Kerzenschluss, nicht tick-genau. Bei `1m` ist
|
||||||
|
die Latenz zwischen Signal und Ausführung spürbar.
|
||||||
|
- **Kein Order-Abgleich im Live-Modus.** Der Bot führt seine Positionsbuchhaltung intern;
|
||||||
|
manuelle Trades auf demselben Konto bringen sie durcheinander. Ein eigenes (Sub-)Konto
|
||||||
|
verwenden.
|
||||||
|
- **Lineares Modell.** Bewusst so gewählt: nachvollziehbar (`/status` zeigt alle Gewichte),
|
||||||
|
robust bei wenig Daten, kein Overfitting-Zoo. Es findet keine nichtlinearen Muster.
|
||||||
|
- **Marktrisiko bleibt.** Backtests sagen wenig über die Zukunft, und ein lernender Filter
|
||||||
|
macht aus einer schwachen Strategie keine starke.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Geheimnisse und Laufzeitschalter für TradeMind.
|
||||||
|
# Kopieren nach config/trademind.env und NIEMALS committen (.gitignore deckt das ab).
|
||||||
|
#
|
||||||
|
# podman run --env-file config/trademind.env ...
|
||||||
|
# podman-compose --env-file config/trademind.env up
|
||||||
|
|
||||||
|
# paper | backtest | live
|
||||||
|
TRADEMIND_MODE=paper
|
||||||
|
|
||||||
|
# Börse (ccxt-ID): binance, kraken, coinbase, bybit, okx, kucoin, bitget, gate, mexc, ...
|
||||||
|
TRADEMIND_EXCHANGE=binance
|
||||||
|
|
||||||
|
# true = Testnet/Sandbox der Börse verwenden
|
||||||
|
TRADEMIND_SANDBOX=true
|
||||||
|
|
||||||
|
# API-Zugangsdaten. Im Paper-Modus nicht nötig – dort werden nur öffentliche Kurse gelesen.
|
||||||
|
# Für Live: Schlüssel ausschließlich mit Handelsrecht anlegen, Auszahlungen NICHT erlauben,
|
||||||
|
# und wenn möglich die IP des Servers hinterlegen.
|
||||||
|
TRADEMIND_API_KEY=
|
||||||
|
TRADEMIND_API_SECRET=
|
||||||
|
# Passphrase – nur bei OKX, KuCoin, Coinbase Advanced, Bitget
|
||||||
|
TRADEMIND_API_PASSWORD=
|
||||||
|
|
||||||
|
# Muss für den Live-Modus wörtlich auf I_UNDERSTAND_THE_RISK stehen.
|
||||||
|
TRADEMIND_LIVE_CONFIRMATION=
|
||||||
|
|
||||||
|
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
|
||||||
|
TRADEMIND_WEBHOOK_URL=
|
||||||
|
|
||||||
|
TRADEMIND_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
|
||||||
|
# TRADEMIND__RISK__MAX_OPEN_POSITIONS=5
|
||||||
|
# TRADEMIND__MARKET__SYMBOLS=BTC/USDT,ETH/USDT,SOL/USDT
|
||||||
|
# TRADEMIND__STRATEGY__LEARNER__ENTRY_THRESHOLD=0.6
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""TradeMind – selbstlernender Krypto-Trading-Bot mit Paper-, Backtest- und Live-Modus."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Erlaubt ``python -m trademind ...``."""
|
||||||
|
|
||||||
|
from .cli import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -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)
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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})"
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
"""HTTP-Schnittstelle: Health-Checks, Status-JSON, Prometheus-Metriken und Mini-Dashboard."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .config import ServerConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
StatusProvider = Callable[[], dict[str, Any]]
|
||||||
|
|
||||||
|
_DASHBOARD = """<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>TradeMind</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; --fg:#111; --bg:#fafafa; --card:#fff; --line:#e3e3e3; --muted:#666; }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root { --fg:#e8e8e8; --bg:#131417; --card:#1c1e22; --line:#2c2f36; --muted:#9aa0a6; }
|
||||||
|
}
|
||||||
|
body { font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 24px; background: var(--bg); color: var(--fg); }
|
||||||
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||||
|
.sub { color: var(--muted); margin-bottom: 20px; }
|
||||||
|
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
|
||||||
|
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
|
||||||
|
.k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
|
||||||
|
.v { font-size: 20px; font-variant-numeric: tabular-nums; margin-top: 2px; }
|
||||||
|
.pos { color: #1a8f3c; } .neg { color: #c62828; }
|
||||||
|
table { border-collapse: collapse; width: 100%; margin-top: 10px; font-variant-numeric: tabular-nums; }
|
||||||
|
th, td { text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--line); }
|
||||||
|
th:first-child, td:first-child { text-align: left; }
|
||||||
|
section { margin-top: 28px; }
|
||||||
|
.overflow { overflow-x: auto; }
|
||||||
|
</style>
|
||||||
|
<h1>TradeMind</h1>
|
||||||
|
<div class="sub" id="sub">lädt …</div>
|
||||||
|
<div class="grid" id="cards"></div>
|
||||||
|
<section><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
|
||||||
|
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
|
||||||
|
<script>
|
||||||
|
const num = (v, d = 2) => (v === null || v === undefined ? "–" : Number(v).toFixed(d));
|
||||||
|
const cls = v => (v > 0 ? "pos" : v < 0 ? "neg" : "");
|
||||||
|
function card(k, v, extra = "") { return `<div class="card"><div class="k">${k}</div><div class="v ${extra}">${v}</div></div>`; }
|
||||||
|
function table(el, cols, rows) {
|
||||||
|
el.innerHTML = "<tr>" + cols.map(c => `<th>${c[0]}</th>`).join("") + "</tr>" +
|
||||||
|
(rows.length ? rows.map(r => "<tr>" + cols.map(c => `<td>${c[1](r)}</td>`).join("") + "</tr>").join("")
|
||||||
|
: `<tr><td colspan="${cols.length}" style="text-align:center;color:var(--muted)">keine</td></tr>`);
|
||||||
|
}
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const s = await (await fetch("status")).json();
|
||||||
|
const p = s.portfolio || {}, l = (s.strategy || {}).learner || {};
|
||||||
|
document.getElementById("sub").textContent =
|
||||||
|
`Modus ${s.mode} · ${s.exchange} · ${(s.symbols || []).join(", ")} · ${s.timeframe} · Uptime ${num(s.uptime_seconds / 60, 1)} min`;
|
||||||
|
document.getElementById("cards").innerHTML = [
|
||||||
|
card("Equity", num(p.equity) + " " + (s.quote_currency || "")),
|
||||||
|
card("Rendite", num(p.total_return_pct) + " %", cls(p.total_return_pct)),
|
||||||
|
card("Offene Positionen", p.open_positions ?? 0),
|
||||||
|
card("Trades", p.trades ?? 0),
|
||||||
|
card("Trefferquote", num((p.win_rate || 0) * 100, 1) + " %"),
|
||||||
|
card("Profit-Faktor", num(p.profit_factor)),
|
||||||
|
card("Max. Drawdown", num(p.max_drawdown_pct) + " %", "neg"),
|
||||||
|
card("Modell-Beobachtungen", l.samples_seen ?? "–"),
|
||||||
|
card("Modell-Accuracy", l.online_accuracy != null ? num(l.online_accuracy * 100, 1) + " %" : "–"),
|
||||||
|
card("Risiko", (s.risk || {}).halted ? "GESPERRT" : "aktiv", (s.risk || {}).halted ? "neg" : "pos"),
|
||||||
|
].join("");
|
||||||
|
table(document.getElementById("pos"),
|
||||||
|
[["Symbol", r => r.symbol], ["Menge", r => num(r.amount, 6)], ["Einstieg", r => num(r.entry_price, 6)],
|
||||||
|
["Kurs", r => num(r.mark_price, 6)], ["P/L", r => `<span class="${cls(r.unrealized_pnl)}">${num(r.unrealized_pnl)}</span>`],
|
||||||
|
["%", r => `<span class="${cls(r.unrealized_pct)}">${num(r.unrealized_pct)}</span>`], ["Bars", r => r.bars_held]],
|
||||||
|
s.positions || []);
|
||||||
|
table(document.getElementById("trades"),
|
||||||
|
[["Symbol", r => r.symbol], ["Grund", r => r.exit_reason],
|
||||||
|
["P/L", r => `<span class="${cls(r.pnl_quote)}">${num(r.pnl_quote)}</span>`],
|
||||||
|
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
|
||||||
|
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
|
||||||
|
(s.recent_trades || []).slice().reverse());
|
||||||
|
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
|
||||||
|
}
|
||||||
|
refresh(); setInterval(refresh, 5000);
|
||||||
|
</script>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten_metrics(prefix: str, node: Any, out: list[tuple[str, float]]) -> None:
|
||||||
|
if isinstance(node, bool):
|
||||||
|
out.append((prefix, 1.0 if node else 0.0))
|
||||||
|
elif isinstance(node, (int, float)) and not isinstance(node, bool):
|
||||||
|
out.append((prefix, float(node)))
|
||||||
|
elif isinstance(node, dict):
|
||||||
|
for key, value in node.items():
|
||||||
|
safe = "".join(ch if ch.isalnum() else "_" for ch in str(key)).strip("_").lower()
|
||||||
|
if safe:
|
||||||
|
_flatten_metrics(f"{prefix}_{safe}", value, out)
|
||||||
|
|
||||||
|
|
||||||
|
class StatusServer:
|
||||||
|
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
|
||||||
|
|
||||||
|
def __init__(self, config: ServerConfig, status_provider: StatusProvider) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._status = status_provider
|
||||||
|
self._runner: web.AppRunner | None = None
|
||||||
|
|
||||||
|
def _build_app(self) -> web.Application:
|
||||||
|
app = web.Application()
|
||||||
|
app.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/", self._dashboard),
|
||||||
|
web.get("/health", self._health),
|
||||||
|
web.get("/ready", self._ready),
|
||||||
|
web.get("/status", self._status_json),
|
||||||
|
web.get("/positions", self._positions),
|
||||||
|
web.get("/trades", self._trades),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
if self.config.enable_metrics:
|
||||||
|
app.router.add_get("/metrics", self._metrics)
|
||||||
|
return app
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
app = self._build_app()
|
||||||
|
self._runner = web.AppRunner(app, access_log=None)
|
||||||
|
await self._runner.setup()
|
||||||
|
site = web.TCPSite(self._runner, self.config.host, self.config.port)
|
||||||
|
await site.start()
|
||||||
|
log.info("Status-Server läuft auf http://%s:%d", self.config.host, self.config.port)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._runner is not None:
|
||||||
|
await self._runner.cleanup()
|
||||||
|
self._runner = None
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Handler
|
||||||
|
|
||||||
|
async def _dashboard(self, _: web.Request) -> web.Response:
|
||||||
|
return web.Response(text=_DASHBOARD, content_type="text/html")
|
||||||
|
|
||||||
|
async def _health(self, _: web.Request) -> web.Response:
|
||||||
|
return web.json_response({"status": "ok"})
|
||||||
|
|
||||||
|
async def _ready(self, _: web.Request) -> web.Response:
|
||||||
|
state = self._status()
|
||||||
|
ready = bool(state.get("running")) and not state.get("startup_error")
|
||||||
|
return web.json_response({"ready": ready}, status=200 if ready else 503)
|
||||||
|
|
||||||
|
async def _status_json(self, _: web.Request) -> web.Response:
|
||||||
|
return web.json_response(self._status(), dumps=_dumps)
|
||||||
|
|
||||||
|
async def _positions(self, _: web.Request) -> web.Response:
|
||||||
|
return web.json_response(self._status().get("positions", []), dumps=_dumps)
|
||||||
|
|
||||||
|
async def _trades(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
limit = min(int(request.query.get("limit", "50")), 500)
|
||||||
|
except ValueError:
|
||||||
|
limit = 50
|
||||||
|
trades = self._status().get("recent_trades", [])
|
||||||
|
return web.json_response(trades[-limit:], dumps=_dumps)
|
||||||
|
|
||||||
|
async def _metrics(self, _: web.Request) -> web.Response:
|
||||||
|
state = self._status()
|
||||||
|
samples: list[tuple[str, float]] = []
|
||||||
|
for section in ("portfolio", "strategy", "risk"):
|
||||||
|
_flatten_metrics(f"trademind_{section}", state.get(section, {}), samples)
|
||||||
|
_flatten_metrics("trademind", {"uptime_seconds": state.get("uptime_seconds", 0)}, samples)
|
||||||
|
_flatten_metrics("trademind", {"loop_iterations": state.get("iterations", 0)}, samples)
|
||||||
|
_flatten_metrics("trademind", {"errors_total": state.get("errors", 0)}, samples)
|
||||||
|
body = "\n".join(f"{name} {value:.10g}" for name, value in samples) + "\n"
|
||||||
|
return web.Response(text=body, content_type="text/plain", charset="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _dumps(obj: Any) -> str:
|
||||||
|
import json
|
||||||
|
|
||||||
|
return json.dumps(obj, default=str, ensure_ascii=False)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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}},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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)
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user