Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d36ed142cf | |||
| db31bc93f7 | |||
| 17fef9abe5 | |||
| 9f10f9000e | |||
| 4551339632 | |||
| d081edfbd3 | |||
| 3cd12b67aa |
@@ -64,9 +64,11 @@ podman-compose down # stoppen
|
|||||||
Port 8080 wird auf allen Interfaces veröffentlicht — das Dashboard ist damit standardmäßig
|
Port 8080 wird auf allen Interfaces veröffentlicht — das Dashboard ist damit standardmäßig
|
||||||
von anderen Rechnern erreichbar.
|
von anderen Rechnern erreichbar.
|
||||||
|
|
||||||
> **Sicherheit.** Alle Endpunkte sind lesend (`GET`, keine Steuerbefehle), aber **nicht
|
> **Sicherheit.** Die lesenden Endpunkte sind **nicht authentifiziert** — wer den Port
|
||||||
> authentifiziert**. Wer den Port erreicht, sieht Kontostand, offene Positionen und die
|
> erreicht, sieht Kontostand, offene Positionen und die gesamte Trade-Historie. Dazu kommen
|
||||||
> gesamte Trade-Historie. In offenen Netzen deshalb entweder einen Reverse Proxy mit
|
> die Steuerbefehle unter `/control/…` (Training anstoßen, Lernen umschalten), die sich mit
|
||||||
|
> `server.control_token` schützen lassen und das auch sollten. Gehandelt oder konfiguriert
|
||||||
|
> werden kann über HTTP in keinem Fall. In offenen Netzen deshalb einen Reverse Proxy mit
|
||||||
> Authentifizierung davorsetzen, den Zugriff per Firewall auf bekannte Quell-IPs begrenzen
|
> Authentifizierung davorsetzen, den Zugriff per Firewall auf bekannte Quell-IPs begrenzen
|
||||||
> oder auf rein lokalen Zugriff zurückstellen:
|
> oder auf rein lokalen Zugriff zurückstellen:
|
||||||
>
|
>
|
||||||
@@ -220,6 +222,9 @@ Modell ist untrainiert – lerne aus bis zu 3000 historischen Kerzen vor …
|
|||||||
Vorlernen abgeschlossen: 662 neue Beobachtungen (gesamt 662), Modell einsatzbereit
|
Vorlernen abgeschlossen: 662 neue Beobachtungen (gesamt 662), Modell einsatzbereit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nachtrainieren lässt sich jederzeit — per Knopf im Dashboard oder über
|
||||||
|
`trademind backtest --save-model`, siehe [Training aus dem Dashboard anstoßen](#training-aus-dem-dashboard-anstoßen).
|
||||||
|
|
||||||
Solange das Modell nicht warm ist (`warmup_samples`), entscheidet allein das Regelwerk.
|
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
|
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
|
eine *prequentielle* Messung: erst vorhersagen, dann lernen, also keine Selbstbewertung auf
|
||||||
@@ -230,6 +235,84 @@ im Paper-Betrieb gereiftes Modell unverändert bleiben soll.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Terminmarktdaten als Zusatzmerkmale
|
||||||
|
|
||||||
|
Optional fließen **Funding Rate** und **Open Interest** vom zugehörigen Perpetual
|
||||||
|
(`BTC/USDT` → `BTC/USDT:USDT`) als vier weitere Merkmale ein. Gehandelt wird weiterhin Spot.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
strategy:
|
||||||
|
derivatives:
|
||||||
|
enabled: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Die Zuordnung ist lookahead-frei: Für jede Kerze gilt nur der Wert, der zu diesem Zeitpunkt
|
||||||
|
**bereits veröffentlicht** war. Fällt eine Quelle aus oder deckt sie weniger als
|
||||||
|
`min_coverage` der Kerzen ab, bleiben die Spalten neutral — die Modelldimension ändert sich
|
||||||
|
nicht, der Bot läuft weiter.
|
||||||
|
|
||||||
|
> **Gemessenes Ergebnis: kein Nutzen.** Zwei Backtests mit identischen Kerzen und Seed:
|
||||||
|
>
|
||||||
|
> | Lauf | Rendite | Trefferquote | Modell-Accuracy | LogLoss |
|
||||||
|
> |---|---|---|---|---|
|
||||||
|
> | 5m, 20 Tage, ohne | −1,79 % | 18,9 % | 51,2 % | 0,7262 |
|
||||||
|
> | 5m, 20 Tage, mit | −1,90 % | 21,4 % | 50,4 % | 0,7445 |
|
||||||
|
> | 15m, 28 Tage, ohne | −2,22 % | 20,7 % | 46,8 % | 0,7856 |
|
||||||
|
> | 15m, 28 Tage, mit | −2,67 % | 18,8 % | 48,3 % | 0,7831 |
|
||||||
|
>
|
||||||
|
> Die Datenanbindung funktioniert (100 % Abdeckung in beiden Läufen), aber das Modell
|
||||||
|
> gewichtet die neuen Merkmale schwach (0,01–0,09 gegenüber 0,39 für `ema_spread`). Deshalb
|
||||||
|
> ist die Funktion **standardmäßig aus**. Sie ist da, damit du es auf deinen Zeiträumen
|
||||||
|
> selbst prüfen kannst — nicht, weil sie sich bewährt hätte.
|
||||||
|
|
||||||
|
**Grenzen der Börsen-API** (gemessen an Binance): Open Interest reicht nur **30 Tage**
|
||||||
|
zurück, 500 Zeilen je Abruf. Funding Rate reicht über ein Jahr. Längere Backtests deshalb
|
||||||
|
mit `open_interest: false` fahren.
|
||||||
|
|
||||||
|
Ein- und Ausschalten ändert die Anzahl der Merkmale (18 ↔ 22). Ein gespeichertes Modell mit
|
||||||
|
abweichender Dimension wird verworfen und das Training beginnt von vorn — die Schalter sind
|
||||||
|
darum als neustartpflichtig markiert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erklärungen über ein lokales Sprachmodell
|
||||||
|
|
||||||
|
Mit einer laufenden [Ollama](https://ollama.com)-Instanz erscheint im Dashboard der Bereich
|
||||||
|
**Erklärung**: Auf Knopfdruck fasst ein lokales Modell zusammen, was die Zahlen zeigen —
|
||||||
|
Merkmalsgewichte, Trefferquote, Risikolage, offene Positionen.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
llm:
|
||||||
|
enabled: true
|
||||||
|
model: llama3.2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Das Modell entscheidet nichts.** Es bekommt den Zustand als Text und gibt Text zurück; es
|
||||||
|
wird nie aus dem Handels-Loop heraus aufgerufen und beeinflusst keine Order. Bewusst so:
|
||||||
|
Ein Sprachmodell je Kerze entscheiden zu lassen wäre nicht reproduzierbar, kaum backtestbar
|
||||||
|
und würde die Nachvollziehbarkeit zerstören, die das lineare Modell heute bietet.
|
||||||
|
|
||||||
|
Der System-Prompt untersagt Anlageempfehlungen und Kursprognosen. Beispielausgabe:
|
||||||
|
|
||||||
|
> Der Bot befindet sich im simulierten Modus und hat bislang keine Handelsaktivität
|
||||||
|
> entfaltet […] Da die Trefferquote knapp über dem Zufallswert liegt und keine realen
|
||||||
|
> Handelsdaten vorliegen, ist die Belastbarkeit des Modells unter realen Marktbedingungen
|
||||||
|
> noch nicht nachgewiesen.
|
||||||
|
|
||||||
|
> **Reasoning-Modelle.** `qwen3`, `deepseek-r1` und Verwandte legen ihre Denkschritte in ein
|
||||||
|
> eigenes Antwortfeld und verbrauchen dafür das gesamte Token-Budget — die eigentliche
|
||||||
|
> Antwort bleibt leer. `llm.think: false` (Standard) schaltet das ab; getestet mit
|
||||||
|
> `qwen3.8:27b`, das damit in rund 20 Sekunden antwortet. Reicht das Budget trotzdem nicht,
|
||||||
|
> sagt die Fehlermeldung genau das und nennt den Schalter.
|
||||||
|
|
||||||
|
**Nicht enthalten:** News- oder Google-Trends-Sentiment als Merkmal. Der Grund ist nicht
|
||||||
|
technischer Natur — es fehlt eine Quelle mit Point-in-Time-Historie. Ohne die lässt sich ein
|
||||||
|
solches Merkmal nicht backtesten, und ein unvalidiertes Merkmal in ein Modell zu geben, das
|
||||||
|
bei 51 % Accuracy steht, macht es eher schlechter. Google Trends kommt zusätzlich mit
|
||||||
|
täglicher Auflösung und pro Abfrage neu skalierten Werten — für 5-Minuten-Kerzen unbrauchbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Risikomanagement
|
## Risikomanagement
|
||||||
|
|
||||||
Vor jeder Order greifen mehrere unabhängige Grenzen:
|
Vor jeder Order greifen mehrere unabhängige Grenzen:
|
||||||
@@ -326,7 +409,7 @@ Timeframes und Parameter gehören ausprobiert, bevor auch nur ein Paper-Euro fli
|
|||||||
|
|
||||||
| Endpunkt | Inhalt |
|
| Endpunkt | Inhalt |
|
||||||
|--------------|-------------------------------------------------------------|
|
|--------------|-------------------------------------------------------------|
|
||||||
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand |
|
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand, Training |
|
||||||
| `/health` | Liveness — nutzt der Container-Healthcheck |
|
| `/health` | Liveness — nutzt der Container-Healthcheck |
|
||||||
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
|
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
|
||||||
| `/status` | vollständiger Zustand als JSON |
|
| `/status` | vollständiger Zustand als JSON |
|
||||||
@@ -344,6 +427,143 @@ Slack als auch Discord.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Handel und Training aus dem Dashboard steuern
|
||||||
|
|
||||||
|
### Automatisierter Handel
|
||||||
|
|
||||||
|
Der Abschnitt **Automatisierter Handel** zeigt den Zustand und schaltet ihn um — in beiden
|
||||||
|
Modi, simuliert wie echt. Standardmäßig startet der Bot handelnd (`trading.autostart: true`);
|
||||||
|
mit `autostart: false` sammelt er zunächst nur Daten und wartet auf die Freigabe.
|
||||||
|
|
||||||
|
Pausiert bedeutet **nur: keine neuen Einstiege**. Alles andere läuft weiter:
|
||||||
|
|
||||||
|
| Auch pausiert | Pausiert ausgesetzt |
|
||||||
|
|---|---|
|
||||||
|
| Marktdaten werden abgerufen | Neue Positionen eröffnen |
|
||||||
|
| Signale werden ausgewertet und gelabelt | |
|
||||||
|
| Das Modell trainiert weiter | |
|
||||||
|
| Offene Positionen bleiben unter Stop-/Ziel-Überwachung | |
|
||||||
|
|
||||||
|
Der Bot lernt also durchgehend weiter, auch wenn er nicht handelt. Auf identischen Daten:
|
||||||
|
|
||||||
|
```
|
||||||
|
Handel aktiv Trades=9 Beobachtungen=99 Shadow=90 Trade-Labels=9 nur-gelernte Signale=0
|
||||||
|
Handel pausiert Trades=0 Beobachtungen=90 Shadow=90 Trade-Labels=0 nur-gelernte Signale=9
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Unterschied sind genau die neun Trade-Labels, die ohne Handel nicht entstehen können —
|
||||||
|
die Shadow-Labels aus dem Marktgeschehen laufen unverändert weiter.
|
||||||
|
|
||||||
|
> **Live-Modus.** Das Starten verlangt zusätzlich die Bestätigung `START_LIVE_TRADING`
|
||||||
|
> (das Dashboard fragt sie ab, per HTTP als `"confirm"` im Body). **Pausieren** geht immer
|
||||||
|
> ohne Bestätigung — im Zweifel muss man schnell anhalten können. Abschalten lässt sich die
|
||||||
|
> Rückfrage mit `trading.require_confirmation_for_live: false`.
|
||||||
|
>
|
||||||
|
> Die Kombination *Live-Modus + Port nicht nur lokal + kein `control_token`* wird **nicht
|
||||||
|
> bedient**: Der Bot schaltet die Steuerung dann beim Start ab und sagt das im Log. Sonst
|
||||||
|
> könnte jeder im Netz echten Handel starten. Mit Token oder auf `127.0.0.1` steht sie
|
||||||
|
> wieder zur Verfügung.
|
||||||
|
|
||||||
|
### Konfiguration
|
||||||
|
|
||||||
|
Der Abschnitt **Konfiguration** zeigt alle 76 Felder, nach Bereichen gruppiert, mit
|
||||||
|
Kurzbeschreibung und den Grenzen aus dem Schema. 70 davon sind direkt änderbar.
|
||||||
|
|
||||||
|
Weil `config.yaml` im Container read-only eingehängt ist, landen Änderungen als Overlay in
|
||||||
|
`/data/config.overrides.yaml` und werden beim Start über die Basiskonfiguration gelegt.
|
||||||
|
Die Rangfolge, von schwach nach stark:
|
||||||
|
|
||||||
|
1. `config.yaml`
|
||||||
|
2. `${ENV}`-Platzhalter darin
|
||||||
|
3. `TRADEMIND__ABSCHNITT__SCHLUESSEL`-Umgebungsvariablen
|
||||||
|
4. Overlay aus dem Dashboard
|
||||||
|
|
||||||
|
Gespeichert wird nur, was vom Basiswert abweicht — stellst du ein Feld auf seinen
|
||||||
|
Ausgangswert zurück, verschwindet es wieder aus dem Overlay und spätere Änderungen an
|
||||||
|
`config.yaml` schlagen dort wieder durch. Einzelne Felder oder alles auf einmal lassen sich
|
||||||
|
über die Knöpfe zurücksetzen; die Overlay-Datei zu löschen hat denselben Effekt.
|
||||||
|
|
||||||
|
Jedes Feld ist markiert, wenn es **einen Neustart braucht** (31 Felder — Börsenclient,
|
||||||
|
Symbole, Timeframe, Datenbank, Socket und alles andere, was nur beim Aufbau ausgewertet
|
||||||
|
wird). Der Rest greift sofort: Risikoregeln, Strategie-Parameter, Lernmodell, Gebühren und
|
||||||
|
Slippage der Simulation, Abfrageintervall, Benachrichtigungen, Log-Level.
|
||||||
|
|
||||||
|
Ungültige Eingaben werden abgelehnt, bevor irgendetwas übernommen wird — inklusive
|
||||||
|
feldübergreifender Regeln:
|
||||||
|
|
||||||
|
```
|
||||||
|
HTTP 400 — Ungültige Werte – strategy.rules: strategy.rules.fast_ema muss kleiner als slow_ema sein
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Drei Felder sind bewusst ausgenommen** und bleiben der Konfigurationsdatei
|
||||||
|
> beziehungsweise der Umgebung vorbehalten:
|
||||||
|
>
|
||||||
|
> | Feld | Grund |
|
||||||
|
> |---|---|
|
||||||
|
> | `exchange.api_key` / `api_secret` / `password` / `uid` | Werden nie ausgeliefert (maskiert als „gesetzt"/„nicht gesetzt") und nicht entgegengenommen. Sonst könnte jeder mit Zugriff auf den Port die Börsenschlüssel auslesen oder austauschen. |
|
||||||
|
> | `mode: live` | Wechsel zwischen `paper` und `backtest` geht; auf Echtgeld umstellen nicht. |
|
||||||
|
> | `live_confirmation` | Sonst wäre die Live-Freigabe aus dem Netz setzbar. |
|
||||||
|
>
|
||||||
|
> Zusammen verhindert das, dass jemand über das Dashboard schrittweise auf Echtgeldhandel
|
||||||
|
> umstellt.
|
||||||
|
|
||||||
|
Per HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST localhost:8080/control/config -H 'Content-Type: application/json' -H "X-TradeMind-Token: $TOKEN" -d '{"risk.max_open_positions": 5, "strategy.learner.entry_threshold": 0.6}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Training
|
||||||
|
|
||||||
|
Im Abschnitt **Training** stehen zwei Bedienelemente:
|
||||||
|
|
||||||
|
**Historisch nachtrainieren.** Anzahl Kerzen wählen (500 – 50 000 je Symbol), Knopf drücken.
|
||||||
|
Der Bot lädt die Historie, läuft sie mit derselben Logik wie ein Backtest durch — **ohne zu
|
||||||
|
handeln** — und speichert das Modell danach. Der Fortschritt erscheint direkt darunter:
|
||||||
|
|
||||||
|
```
|
||||||
|
Fertig in 4.3s: +1099 Beobachtungen (gesamt 1753), Modell einsatzbereit, gespeichert
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein zweiter Start wird abgelehnt, solange einer läuft. Handelsdurchlauf und Nachtraining
|
||||||
|
schließen sich gegenseitig aus, der Live-Betrieb pausiert also für die paar Sekunden.
|
||||||
|
Die Labels des laufenden Betriebs bleiben davon unberührt.
|
||||||
|
|
||||||
|
**Kontinuierliches Lernen.** Schaltet das Online-Lernen im laufenden Betrieb an und aus.
|
||||||
|
Ausgeschaltet handelt der Bot weiter, verändert das Modell aber nicht mehr — praktisch, um
|
||||||
|
einen erreichten Stand einzufrieren, ohne den Bot anzuhalten.
|
||||||
|
|
||||||
|
Dieselben Aktionen per HTTP:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST localhost:8080/control/train/history -H 'Content-Type: application/json' -H "X-TradeMind-Token: $TOKEN" -d '{"bars": 5000}'
|
||||||
|
```
|
||||||
|
|
||||||
|
| Endpunkt | Methode | Wirkung |
|
||||||
|
|--------------------------------|---------|--------------------------------------------|
|
||||||
|
| `/control/explain` | POST | Erklärung des aktuellen Zustands (braucht `llm.enabled`) |
|
||||||
|
| `/control/config` | GET | Alle Felder mit Wert, Typ, Grenzen und Markierungen |
|
||||||
|
| `/control/config` | POST | `{"risk.max_open_positions": 5}` — ändern und sichern |
|
||||||
|
| `/control/config/reset` | POST | `{}` oder `{"paths": [...]}` — Overlay verwerfen |
|
||||||
|
| `/control/trading` | GET | Zustand des automatisierten Handels |
|
||||||
|
| `/control/trading` | POST | `{"enabled": true}` — Handel starten/pausieren (live zusätzlich `"confirm"`) |
|
||||||
|
| `/control/training` | GET | Zustand des letzten/laufenden Trainings |
|
||||||
|
| `/control/train/history` | POST | `{"bars": 5000}` — Nachtraining anstoßen |
|
||||||
|
| `/control/train/live` | POST | `{"enabled": false}` — Lernen ein/aus |
|
||||||
|
|
||||||
|
> **Absicherung.** Anders als die lesenden Endpunkte verändern diese den Zustand des Bots.
|
||||||
|
> Setze deshalb `server.control_token` (bzw. `TRADEMIND_CONTROL_TOKEN`), sobald der Port
|
||||||
|
> nicht nur lokal erreichbar ist — die Steuerbefehle verlangen ihn dann im Header
|
||||||
|
> `X-TradeMind-Token`, während `/status` und Co. offen bleiben. Ohne Token warnt der Bot
|
||||||
|
> beim Start. Komplett abschalten lässt sich die Steuerung mit `server.enable_control: false`;
|
||||||
|
> die Routen sind dann nicht vorhanden (404).
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> openssl rand -hex 24
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -55,6 +55,16 @@ paper:
|
|||||||
slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil
|
slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil
|
||||||
max_volume_participation: 0.1 # max. 10 % des Kerzenvolumens pro Order
|
max_volume_participation: 0.1 # max. 10 % des Kerzenvolumens pro Order
|
||||||
|
|
||||||
|
# ────────────────────────── Automatisierter Handel ──────────────────────────
|
||||||
|
trading:
|
||||||
|
# true = der Bot handelt ab dem Start automatisch (bisheriges Verhalten)
|
||||||
|
# false = er sammelt Daten und lernt, eröffnet aber erst nach Freigabe im Dashboard
|
||||||
|
# Positionen. Für den ersten Live-Einsatz die sicherere Wahl.
|
||||||
|
autostart: true
|
||||||
|
# Nur mode: live – Start des Handels über das Dashboard verlangt die ausdrückliche
|
||||||
|
# Bestätigung START_LIVE_TRADING. Pausieren geht immer ohne Bestätigung.
|
||||||
|
require_confirmation_for_live: true
|
||||||
|
|
||||||
# ──────────────────────────────── Risikoregeln ──────────────────────────────
|
# ──────────────────────────────── Risikoregeln ──────────────────────────────
|
||||||
risk:
|
risk:
|
||||||
max_position_pct: 0.20 # je Position, gemessen an der Equity
|
max_position_pct: 0.20 # je Position, gemessen an der Equity
|
||||||
@@ -85,6 +95,23 @@ strategy:
|
|||||||
trend_filter_period: 100 # 0 = Trendfilter aus
|
trend_filter_period: 100 # 0 = Trendfilter aus
|
||||||
min_holding_bars: 3 # Signalausstiege erst danach; Stop/Ziel gelten immer
|
min_holding_bars: 3 # Signalausstiege erst danach; Stop/Ziel gelten immer
|
||||||
|
|
||||||
|
# Zusatzmerkmale aus dem Terminmarkt. Die Daten stammen vom Perpetual zum jeweiligen
|
||||||
|
# Spot-Paar (BTC/USDT -> BTC/USDT:USDT); gehandelt wird weiterhin Spot.
|
||||||
|
#
|
||||||
|
# In zwei Backtests (5m über 20 Tage, 15m über 28 Tage) brachten sie KEINE Verbesserung –
|
||||||
|
# die Anbindung funktioniert, die Merkmale trugen nichts bei. Deshalb standardmäßig aus.
|
||||||
|
# Zum Selberprüfen einschalten und gegen einen Lauf ohne vergleichen.
|
||||||
|
#
|
||||||
|
# Achtung: Ein- und Ausschalten ändert die Anzahl der Merkmale. Ein gespeichertes Modell
|
||||||
|
# mit anderer Dimension wird verworfen, das Training beginnt von vorn.
|
||||||
|
derivatives:
|
||||||
|
enabled: false
|
||||||
|
funding_rate: true # was Longs den Shorts zahlen, alle 8h
|
||||||
|
open_interest: true # offene Kontrakte – reicht bei Binance nur 30 Tage zurück
|
||||||
|
oi_change_bars: 12 # über wie viele Kerzen die OI-Veränderung gemessen wird
|
||||||
|
max_pages: 40 # Obergrenze für Seitenabrufe je Symbol
|
||||||
|
min_coverage: 0.9 # darunter gilt die Reihe als unbrauchbar und bleibt neutral
|
||||||
|
|
||||||
learner:
|
learner:
|
||||||
enabled: true
|
enabled: true
|
||||||
model_path: /data/models/adaptive.npz
|
model_path: /data/models/adaptive.npz
|
||||||
@@ -101,7 +128,12 @@ strategy:
|
|||||||
trade_sample_weight: 3.0 # echte Trades zählen dreifach gegenüber Shadow-Labels
|
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
|
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands verkürzen die
|
||||||
# Aufwärmphase von Wochen auf Tage (0 = aus).
|
# Aufwärmphase von Wochen auf Tage (0 = aus).
|
||||||
background_sample_every_n_bars: 10
|
# Der wirksamste Hebel für die Lerngeschwindigkeit: Nur rund 3 % der Beobachtungen
|
||||||
|
# stammen aus echten Trades, der Rest aus diesen Stichproben. Von 10 auf 5 halbiert
|
||||||
|
# die Zeit bis zur Einsatzbereitschaft.
|
||||||
|
# Nicht zu klein wählen – benachbarte Kerzen sind stark korreliert, unter dem halben
|
||||||
|
# label_horizon_bars überlappt praktisch jede Stichprobe mit ihrem Nachbarn.
|
||||||
|
background_sample_every_n_bars: 5
|
||||||
background_sample_weight: 0.5
|
background_sample_weight: 0.5
|
||||||
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, damit der Bot
|
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, damit der Bot
|
||||||
# nach Sekunden einsatzbereit ist statt nach Tagen (0 = aus).
|
# nach Sekunden einsatzbereit ist statt nach Tagen (0 = aus).
|
||||||
@@ -112,6 +144,9 @@ strategy:
|
|||||||
# ───────────────────────────────── Persistenz ───────────────────────────────
|
# ───────────────────────────────── Persistenz ───────────────────────────────
|
||||||
storage:
|
storage:
|
||||||
database_path: /data/trademind.sqlite3
|
database_path: /data/trademind.sqlite3
|
||||||
|
# Im Dashboard geänderte Werte. Liegt im beschreibbaren Datenvolume, weil diese Datei
|
||||||
|
# hier üblicherweise read-only eingehängt ist. Löschen = alle Änderungen zurücksetzen.
|
||||||
|
overrides_path: /data/config.overrides.yaml
|
||||||
|
|
||||||
# ─────────────────────────── Status-Server / Monitoring ─────────────────────
|
# ─────────────────────────── Status-Server / Monitoring ─────────────────────
|
||||||
server:
|
server:
|
||||||
@@ -120,6 +155,13 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
enable_metrics: true # /metrics im Prometheus-Textformat
|
enable_metrics: true # /metrics im Prometheus-Textformat
|
||||||
|
|
||||||
|
# Steuerbefehle im Dashboard: historisches Nachtraining anstoßen und das
|
||||||
|
# kontinuierliche Lernen ein-/ausschalten. Auf false setzen für rein lesenden Betrieb.
|
||||||
|
enable_control: true
|
||||||
|
# Schützt ALLE Steuerbefehle (Header X-TradeMind-Token). Lesende Endpunkte bleiben offen.
|
||||||
|
# Dringend empfohlen, sobald der Port nicht nur lokal erreichbar ist.
|
||||||
|
control_token: ${TRADEMIND_CONTROL_TOKEN}
|
||||||
|
|
||||||
# ──────────────────────────────── Benachrichtigungen ────────────────────────
|
# ──────────────────────────────── Benachrichtigungen ────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
# Slack- oder Discord-Webhook (leer lassen = aus)
|
# Slack- oder Discord-Webhook (leer lassen = aus)
|
||||||
@@ -127,6 +169,22 @@ notifications:
|
|||||||
notify_on_trade: true
|
notify_on_trade: true
|
||||||
notify_on_risk_halt: true
|
notify_on_risk_halt: true
|
||||||
|
|
||||||
|
# ──────────────── Erklärungen über ein lokales Sprachmodell (Ollama) ────────
|
||||||
|
# Fasst auf Knopfdruck zusammen, was die Zahlen zeigen. Hat KEINEN Einfluss auf den
|
||||||
|
# Handel – es entscheidet nichts, es formuliert nur.
|
||||||
|
llm:
|
||||||
|
enabled: false
|
||||||
|
base_url: http://127.0.0.1:11434
|
||||||
|
model: llama3.2
|
||||||
|
timeout_seconds: 120
|
||||||
|
temperature: 0.2
|
||||||
|
max_tokens: 700
|
||||||
|
max_answer_chars: 2000
|
||||||
|
# Reasoning-Modelle (qwen3, deepseek-r1) verbrauchen sonst das gesamte Token-Budget für
|
||||||
|
# ihre Denkschritte und liefern eine leere Antwort. Für eine Zustandsbeschreibung
|
||||||
|
# wird kein Reasoning gebraucht.
|
||||||
|
think: false
|
||||||
|
|
||||||
# ───────────────────────────── Backtest-Voreinstellungen ────────────────────
|
# ───────────────────────────── Backtest-Voreinstellungen ────────────────────
|
||||||
backtest:
|
backtest:
|
||||||
bars: 5000
|
bars: 5000
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ TRADEMIND_LIVE_CONFIRMATION=
|
|||||||
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
|
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
|
||||||
TRADEMIND_WEBHOOK_URL=
|
TRADEMIND_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Schützt die Steuerbefehle des Dashboards (Training anstoßen, Lernen umschalten).
|
||||||
|
# Leer = ungeschützt. Sobald der Port nicht nur lokal erreichbar ist, unbedingt setzen:
|
||||||
|
# openssl rand -hex 24
|
||||||
|
TRADEMIND_CONTROL_TOKEN=
|
||||||
|
|
||||||
TRADEMIND_LOG_LEVEL=INFO
|
TRADEMIND_LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
|
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-aiohttp>=1.0",
|
||||||
"ruff>=0.4",
|
"ruff>=0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+114
-3
@@ -9,10 +9,13 @@ from typing import Any
|
|||||||
|
|
||||||
from .broker import Broker, LiveBroker, PaperBroker
|
from .broker import Broker, LiveBroker, PaperBroker
|
||||||
from .config import Config, Mode
|
from .config import Config, Mode
|
||||||
|
from .configstore import ConfigStore
|
||||||
from .data import CcxtDataFeed, DataFeed
|
from .data import CcxtDataFeed, DataFeed
|
||||||
|
from .derivatives import DerivativesProvider
|
||||||
from .engine import TradingEngine
|
from .engine import TradingEngine
|
||||||
from .exchange import build_exchange, load_market_info
|
from .exchange import build_exchange, load_market_info
|
||||||
from .features import N_FEATURES
|
from .features import n_features
|
||||||
|
from .llm import OllamaClient
|
||||||
from .notify import Notifier
|
from .notify import Notifier
|
||||||
from .portfolio import Portfolio
|
from .portfolio import Portfolio
|
||||||
from .risk import RiskManager
|
from .risk import RiskManager
|
||||||
@@ -62,6 +65,8 @@ class Runtime:
|
|||||||
notifier: Notifier
|
notifier: Notifier
|
||||||
server: StatusServer | None
|
server: StatusServer | None
|
||||||
exchange: Any | None
|
exchange: Any | None
|
||||||
|
derivatives: DerivativesProvider | None = None
|
||||||
|
llm: OllamaClient | None = None
|
||||||
|
|
||||||
async def start_services(self) -> None:
|
async def start_services(self) -> None:
|
||||||
await self.notifier.start()
|
await self.notifier.start()
|
||||||
@@ -72,6 +77,10 @@ class Runtime:
|
|||||||
if self.server is not None:
|
if self.server is not None:
|
||||||
await self.server.close()
|
await self.server.close()
|
||||||
await self.notifier.close()
|
await self.notifier.close()
|
||||||
|
if self.llm is not None:
|
||||||
|
await self.llm.close()
|
||||||
|
if self.derivatives is not None:
|
||||||
|
await self.derivatives.close()
|
||||||
if self.exchange is not None:
|
if self.exchange is not None:
|
||||||
await self.exchange.close()
|
await self.exchange.close()
|
||||||
else:
|
else:
|
||||||
@@ -80,6 +89,59 @@ class Runtime:
|
|||||||
self.storage.close()
|
self.storage.close()
|
||||||
|
|
||||||
|
|
||||||
|
def control_is_unsafe(config: Config) -> bool:
|
||||||
|
"""Live-Handel, der ungeschützt aus dem Netz steuerbar wäre – wird nicht bedient."""
|
||||||
|
return (
|
||||||
|
config.server.enable_control
|
||||||
|
and config.mode is Mode.LIVE
|
||||||
|
and not config.server.control_token
|
||||||
|
and config.server.publicly_reachable
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def control_effective(config: Config) -> bool:
|
||||||
|
"""Ob die Steuerbefehle tatsächlich bereitgestellt werden."""
|
||||||
|
return config.server.enable_control and not control_is_unsafe(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_control_exposure(config: Config) -> Config:
|
||||||
|
"""Steuerbefehle absichern, bevor der Server startet.
|
||||||
|
|
||||||
|
Im Live-Modus könnte ein ungeschützter Endpunkt aus dem Netz echten Handel starten.
|
||||||
|
Diese Kombination wird deshalb nicht bedient: Die Steuerung wird abgeschaltet, der Bot
|
||||||
|
läuft aber normal weiter.
|
||||||
|
"""
|
||||||
|
if not config.server.enable_control:
|
||||||
|
log.info("Steuerbefehle sind deaktiviert – nur lesende Endpunkte")
|
||||||
|
return config
|
||||||
|
|
||||||
|
unprotected = not config.server.control_token and config.server.publicly_reachable
|
||||||
|
if control_is_unsafe(config):
|
||||||
|
log.error(
|
||||||
|
"Steuerung abgeschaltet: Im Live-Modus dürfen Handel und Training nicht ohne "
|
||||||
|
"Token aus dem Netz steuerbar sein (server.host=%s, kein server.control_token). "
|
||||||
|
"Token setzen oder server.host auf 127.0.0.1 begrenzen, dann steht die "
|
||||||
|
"Dashboard-Steuerung wieder zur Verfügung.",
|
||||||
|
config.server.host,
|
||||||
|
)
|
||||||
|
return config.model_copy(
|
||||||
|
update={"server": config.server.model_copy(update={"enable_control": False})}
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.server.control_token:
|
||||||
|
log.info("Steuerbefehle sind aktiv und durch ein Token geschützt")
|
||||||
|
elif unprotected:
|
||||||
|
log.warning(
|
||||||
|
"Steuerbefehle (Handel starten, Training anstoßen) sind ohne Token auf %s:%d "
|
||||||
|
"erreichbar. Jeder, der den Port erreicht, kann sie auslösen – bitte "
|
||||||
|
"server.control_token setzen oder den Zugriff per Firewall/Reverse Proxy begrenzen.",
|
||||||
|
config.server.host, config.server.port,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
log.info("Steuerbefehle sind aktiv (nur lokal erreichbar, kein Token gesetzt)")
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
|
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 = {market_info.get(s, {}).get("quote") for s in symbols}
|
||||||
quotes.discard(None)
|
quotes.discard(None)
|
||||||
@@ -99,8 +161,14 @@ async def build_runtime(
|
|||||||
with_storage: bool = True,
|
with_storage: bool = True,
|
||||||
load_model: bool = True,
|
load_model: bool = True,
|
||||||
seed: int | None = None,
|
seed: int | None = None,
|
||||||
|
config_store: ConfigStore | None = None,
|
||||||
) -> Runtime:
|
) -> Runtime:
|
||||||
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
|
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
|
||||||
|
serving = with_server and config.server.enabled
|
||||||
|
if serving:
|
||||||
|
# Muss vor dem Bau der Engine laufen – sie liest enable_control für ihre Steuerbefehle.
|
||||||
|
config = _guard_control_exposure(config)
|
||||||
|
|
||||||
storage: Storage | NullStorage = (
|
storage: Storage | NullStorage = (
|
||||||
Storage(config.storage.database_path) if with_storage else NullStorage()
|
Storage(config.storage.database_path) if with_storage else NullStorage()
|
||||||
)
|
)
|
||||||
@@ -126,12 +194,38 @@ async def build_runtime(
|
|||||||
broker = PaperBroker(paper_config, market_info=market_info)
|
broker = PaperBroker(paper_config, market_info=market_info)
|
||||||
starting_equity = paper_config.starting_balance
|
starting_equity = paper_config.starting_balance
|
||||||
|
|
||||||
strategy = build_strategy(config.strategy, N_FEATURES, seed=seed, load_model=load_model)
|
derivatives_provider: DerivativesProvider | None = None
|
||||||
|
derivatives_exchange = None
|
||||||
|
if config.strategy.derivatives.enabled:
|
||||||
|
# Funding Rate und Open Interest gibt es nur am Terminmarkt, also ein zweiter
|
||||||
|
# Client mit defaultType=future neben dem Spot-Client.
|
||||||
|
futures_config = config.exchange.model_copy(
|
||||||
|
update={"options": {**config.exchange.options, "defaultType": "future"}}
|
||||||
|
)
|
||||||
|
derivatives_exchange = build_exchange(futures_config, read_only=True)
|
||||||
|
derivatives_provider = DerivativesProvider(derivatives_exchange, config.strategy.derivatives)
|
||||||
|
log.info(
|
||||||
|
"Terminmarktmerkmale aktiv (%s%s) – Daten vom Perpetual zu %s",
|
||||||
|
"Funding Rate" if config.strategy.derivatives.funding_rate else "",
|
||||||
|
", Open Interest" if config.strategy.derivatives.open_interest else "",
|
||||||
|
", ".join(config.market.symbols),
|
||||||
|
)
|
||||||
|
|
||||||
|
strategy = build_strategy(
|
||||||
|
config.strategy,
|
||||||
|
n_features(config.strategy.derivatives),
|
||||||
|
seed=seed,
|
||||||
|
load_model=load_model,
|
||||||
|
)
|
||||||
learner = getattr(strategy, "learner", None)
|
learner = getattr(strategy, "learner", None)
|
||||||
if learner is not None and config.mode is Mode.LIVE and config.strategy.learner.freeze_in_live:
|
if learner is not None and config.mode is Mode.LIVE and config.strategy.learner.freeze_in_live:
|
||||||
learner.frozen = True
|
learner.frozen = True
|
||||||
log.info("Live-Modus: Online-Lernen eingefroren (freeze_in_live=true)")
|
log.info("Live-Modus: Online-Lernen eingefroren (freeze_in_live=true)")
|
||||||
|
|
||||||
|
llm_client = OllamaClient(config.llm) if config.llm.enabled else None
|
||||||
|
if llm_client is not None:
|
||||||
|
log.info("Erklärungen über Ollama aktiv (%s, Modell %s)", config.llm.base_url, config.llm.model)
|
||||||
|
|
||||||
portfolio = Portfolio(starting_equity=starting_equity, quote_currency=broker.quote_currency)
|
portfolio = Portfolio(starting_equity=starting_equity, quote_currency=broker.quote_currency)
|
||||||
risk = RiskManager(config.risk)
|
risk = RiskManager(config.risk)
|
||||||
notifier = Notifier(config.notifications)
|
notifier = Notifier(config.notifications)
|
||||||
@@ -145,9 +239,12 @@ async def build_runtime(
|
|||||||
risk=risk,
|
risk=risk,
|
||||||
storage=storage,
|
storage=storage,
|
||||||
notifier=notifier,
|
notifier=notifier,
|
||||||
|
config_store=config_store,
|
||||||
|
derivatives=derivatives_provider,
|
||||||
|
llm=llm_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
server = StatusServer(config.server, engine.status) if (with_server and config.server.enabled) else None
|
server = StatusServer(config.server, engine.status, controller=engine) if serving else None
|
||||||
|
|
||||||
storage.start_run(
|
storage.start_run(
|
||||||
mode=config.mode.value,
|
mode=config.mode.value,
|
||||||
@@ -169,6 +266,8 @@ async def build_runtime(
|
|||||||
notifier=notifier,
|
notifier=notifier,
|
||||||
server=server,
|
server=server,
|
||||||
exchange=exchange,
|
exchange=exchange,
|
||||||
|
derivatives=derivatives_provider,
|
||||||
|
llm=llm_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -192,6 +291,9 @@ def describe_config(config: Config) -> str:
|
|||||||
f"Stop {config.risk.stop_loss_atr_mult}×ATR, Ziel {config.risk.take_profit_atr_mult}×ATR",
|
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"Notbremsen Tagesverlust {config.risk.max_daily_loss_pct:.0%}, "
|
||||||
f"Drawdown {config.risk.max_drawdown_pct:.0%}",
|
f"Drawdown {config.risk.max_drawdown_pct:.0%}",
|
||||||
|
"Handel "
|
||||||
|
+ ("startet automatisch" if config.trading.autostart
|
||||||
|
else "startet pausiert – Freigabe über das Dashboard"),
|
||||||
f"Datenbank {config.storage.database_path}",
|
f"Datenbank {config.storage.database_path}",
|
||||||
f"Modelldatei {config.strategy.learner.model_path}",
|
f"Modelldatei {config.strategy.learner.model_path}",
|
||||||
]
|
]
|
||||||
@@ -203,4 +305,13 @@ def describe_config(config: Config) -> str:
|
|||||||
)
|
)
|
||||||
if config.server.enabled:
|
if config.server.enabled:
|
||||||
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
|
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
|
||||||
|
if control_effective(config):
|
||||||
|
guard = "Token gesetzt" if config.server.control_token else "OHNE Token"
|
||||||
|
lines.append(f"Steuerung Handel und Training über das Dashboard ({guard})")
|
||||||
|
elif control_is_unsafe(config):
|
||||||
|
lines.append(
|
||||||
|
"Steuerung ABGESCHALTET – Live-Modus ohne Token auf offenem Port"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
lines.append("Steuerung deaktiviert (nur lesende Endpunkte)")
|
||||||
return "\n".join(" " + line for line in lines)
|
return "\n".join(" " + line for line in lines)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import numpy as np
|
|||||||
|
|
||||||
from .data import format_ts
|
from .data import format_ts
|
||||||
from .engine import Bar, TradingEngine
|
from .engine import Bar, TradingEngine
|
||||||
from .features import FeatureMatrix, build_feature_matrix
|
from .features import FeatureMatrix
|
||||||
from .models import Candles, ExitReason
|
from .models import Candles, ExitReason
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -114,12 +114,12 @@ class BacktestRunner:
|
|||||||
self.progress_every = progress_every
|
self.progress_every = progress_every
|
||||||
self._matrices: dict[str, FeatureMatrix] = {}
|
self._matrices: dict[str, FeatureMatrix] = {}
|
||||||
|
|
||||||
def _prepare(self) -> int:
|
async def _prepare(self) -> int:
|
||||||
rules = self.engine.config.strategy.rules
|
|
||||||
first_valid = 0
|
first_valid = 0
|
||||||
usable: dict[str, Candles] = {}
|
usable: dict[str, Candles] = {}
|
||||||
for symbol, candles in self.series.items():
|
for symbol, candles in self.series.items():
|
||||||
matrix = build_feature_matrix(candles, rules)
|
# Über die Engine, damit Terminmarktdaten genauso einfließen wie im Live-Betrieb.
|
||||||
|
matrix = await self.engine.build_matrix(candles)
|
||||||
if matrix is None:
|
if matrix is None:
|
||||||
log.warning(
|
log.warning(
|
||||||
"%s: nur %d Kerzen – zu wenig für die Indikatoren, Symbol wird übersprungen",
|
"%s: nur %d Kerzen – zu wenig für die Indikatoren, Symbol wird übersprungen",
|
||||||
@@ -136,7 +136,7 @@ class BacktestRunner:
|
|||||||
|
|
||||||
async def run(self) -> BacktestReport:
|
async def run(self) -> BacktestReport:
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
first_valid = self._prepare()
|
first_valid = await self._prepare()
|
||||||
length = min(len(c) for c in self.series.values())
|
length = min(len(c) for c in self.series.values())
|
||||||
symbols = list(self.series)
|
symbols = list(self.series)
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -14,6 +14,7 @@ from . import __version__
|
|||||||
from .app import build_runtime, describe_config, setup_logging
|
from .app import build_runtime, describe_config, setup_logging
|
||||||
from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv
|
from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv
|
||||||
from .config import Config, Mode, load_config
|
from .config import Config, Mode, load_config
|
||||||
|
from .configstore import ConfigStore
|
||||||
from .data import align_series, load_csv, parse_iso8601
|
from .data import align_series, load_csv, parse_iso8601
|
||||||
from .engine import install_signal_handlers
|
from .engine import install_signal_handlers
|
||||||
from .exchange import available_exchanges
|
from .exchange import available_exchanges
|
||||||
@@ -164,11 +165,21 @@ async def cmd_run(args: argparse.Namespace) -> int:
|
|||||||
return 2
|
return 2
|
||||||
setup_logging(config.log_level)
|
setup_logging(config.log_level)
|
||||||
|
|
||||||
|
# Im Dauerbetrieb kommen gespeicherte Dashboard-Änderungen über den Store dazu.
|
||||||
|
store: ConfigStore | None = None
|
||||||
|
if not args.mode:
|
||||||
|
try:
|
||||||
|
store = ConfigStore.load(args.config)
|
||||||
|
config = store.config
|
||||||
|
except Exception as exc: # noqa: BLE001 - Overlay darf den Start nie verhindern
|
||||||
|
log.error("Gespeicherte Konfigurationsänderungen nicht nutzbar (%s)", exc)
|
||||||
|
setup_logging(config.log_level)
|
||||||
|
|
||||||
print("\nTradeMind startet:\n" + describe_config(config) + "\n")
|
print("\nTradeMind startet:\n" + describe_config(config) + "\n")
|
||||||
if config.mode is Mode.LIVE:
|
if config.mode is Mode.LIVE:
|
||||||
log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.")
|
log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.")
|
||||||
|
|
||||||
runtime = await build_runtime(config, with_server=not args.no_server)
|
runtime = await build_runtime(config, with_server=not args.no_server, config_store=store)
|
||||||
try:
|
try:
|
||||||
await runtime.start_services()
|
await runtime.start_services()
|
||||||
await runtime.engine.prepare()
|
await runtime.engine.prepare()
|
||||||
|
|||||||
+119
-3
@@ -15,6 +15,53 @@ _ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
|||||||
|
|
||||||
LIVE_CONFIRMATION_PHRASE = "I_UNDERSTAND_THE_RISK"
|
LIVE_CONFIRMATION_PHRASE = "I_UNDERSTAND_THE_RISK"
|
||||||
|
|
||||||
|
# Felder, die niemals über das Netz ausgeliefert oder entgegengenommen werden. Sie werden
|
||||||
|
# im Dashboard maskiert angezeigt und bleiben ausschließlich über die Umgebung setzbar.
|
||||||
|
SECRET_FIELDS: frozenset[str] = frozenset(
|
||||||
|
{"exchange.api_key", "exchange.api_secret", "exchange.password", "exchange.uid"}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Felder, deren Änderung erst nach einem Neustart greift: Sie werden beim Aufbau der
|
||||||
|
# Laufzeitobjekte einmalig ausgewertet (Börsenclient, Broker, Datenbank, Socket …).
|
||||||
|
RESTART_REQUIRED: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
"mode",
|
||||||
|
"live_confirmation",
|
||||||
|
"market.symbols",
|
||||||
|
"market.timeframe",
|
||||||
|
"market.history_bars",
|
||||||
|
"paper.starting_balance",
|
||||||
|
"paper.quote_currency",
|
||||||
|
# Wirkt erst beim nächsten Start – der aktuelle Handelszustand bleibt, wie er ist.
|
||||||
|
"trading.autostart",
|
||||||
|
"strategy.name",
|
||||||
|
# Ändert die Anzahl der Merkmale und damit die Modelldimension.
|
||||||
|
"strategy.derivatives.enabled",
|
||||||
|
"strategy.derivatives.funding_rate",
|
||||||
|
"strategy.derivatives.open_interest",
|
||||||
|
"strategy.learner.enabled",
|
||||||
|
"strategy.learner.model_path",
|
||||||
|
"strategy.learner.replay_size",
|
||||||
|
"storage.database_path",
|
||||||
|
"storage.overrides_path",
|
||||||
|
"server.enabled",
|
||||||
|
"server.host",
|
||||||
|
"server.port",
|
||||||
|
"server.enable_metrics",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ganze Abschnitte, die nur beim Start ausgewertet werden.
|
||||||
|
RESTART_REQUIRED_PREFIXES: tuple[str, ...] = ("exchange.", "backtest.")
|
||||||
|
|
||||||
|
|
||||||
|
def requires_restart(path: str) -> bool:
|
||||||
|
return path in RESTART_REQUIRED or path.startswith(RESTART_REQUIRED_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def is_secret(path: str) -> bool:
|
||||||
|
return path in SECRET_FIELDS
|
||||||
|
|
||||||
|
|
||||||
class Mode(str, Enum):
|
class Mode(str, Enum):
|
||||||
PAPER = "paper"
|
PAPER = "paper"
|
||||||
@@ -122,9 +169,15 @@ class LearnerConfig(_Base):
|
|||||||
label_horizon_bars: int = Field(default=12, ge=1)
|
label_horizon_bars: int = Field(default=12, ge=1)
|
||||||
label_target_bps: float = Field(default=30.0, ge=0)
|
label_target_bps: float = Field(default=30.0, ge=0)
|
||||||
trade_sample_weight: float = Field(default=3.0, gt=0)
|
trade_sample_weight: float = Field(default=3.0, gt=0)
|
||||||
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands beschleunigen
|
# Einstiegssignale sind selten – nur rund 3 % der Beobachtungen stammen aus echten
|
||||||
# die Aufwärmphase erheblich (0 = aus).
|
# Trades. Regelmäßige Stichproben des Marktzustands sind daher der wirksamste Hebel
|
||||||
background_sample_every_n_bars: int = Field(default=10, ge=0)
|
# für die Lerngeschwindigkeit (0 = aus).
|
||||||
|
#
|
||||||
|
# Achtung beim Verkleinern: Benachbarte Kerzen sind stark korreliert und die
|
||||||
|
# Label-Fenster überlappen sich. Bei 5 statt 10 verdoppeln sich die Trainingsschritte,
|
||||||
|
# der Informationsgehalt wächst aber deutlich langsamer. Unter dem halben
|
||||||
|
# label_horizon_bars überlappt praktisch jede Stichprobe mit ihrem Nachbarn.
|
||||||
|
background_sample_every_n_bars: int = Field(default=5, ge=0)
|
||||||
background_sample_weight: float = Field(default=0.5, gt=0)
|
background_sample_weight: float = Field(default=0.5, gt=0)
|
||||||
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, statt
|
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, statt
|
||||||
# tagelang auf genügend Live-Beobachtungen zu warten (0 = aus).
|
# tagelang auf genügend Live-Beobachtungen zu warten (0 = aus).
|
||||||
@@ -133,10 +186,29 @@ class LearnerConfig(_Base):
|
|||||||
save_every_n_updates: int = Field(default=50, ge=1)
|
save_every_n_updates: int = Field(default=50, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DerivativesConfig(_Base):
|
||||||
|
"""Zusatzmerkmale aus dem Terminmarkt (Funding Rate, Open Interest).
|
||||||
|
|
||||||
|
Die Daten stammen vom Perpetual zum jeweiligen Spot-Paar. Gehandelt wird weiterhin Spot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
funding_rate: bool = True
|
||||||
|
open_interest: bool = True
|
||||||
|
# Über wie viele Kerzen die Open-Interest-Veränderung gemessen wird.
|
||||||
|
oi_change_bars: int = Field(default=12, ge=1, le=500)
|
||||||
|
# Obergrenze für Seitenabrufe je Symbol – Binance liefert nur 500 Zeilen je Seite und
|
||||||
|
# deckt höchstens 30 Tage ab.
|
||||||
|
max_pages: int = Field(default=40, ge=1, le=500)
|
||||||
|
# Unter dieser Abdeckung gilt die Reihe als unbrauchbar und wird verworfen.
|
||||||
|
min_coverage: float = Field(default=0.9, ge=0.0, le=1.0)
|
||||||
|
|
||||||
|
|
||||||
class StrategyConfig(_Base):
|
class StrategyConfig(_Base):
|
||||||
name: str = "adaptive" # adaptive | rules
|
name: str = "adaptive" # adaptive | rules
|
||||||
rules: RuleConfig = Field(default_factory=RuleConfig)
|
rules: RuleConfig = Field(default_factory=RuleConfig)
|
||||||
learner: LearnerConfig = Field(default_factory=LearnerConfig)
|
learner: LearnerConfig = Field(default_factory=LearnerConfig)
|
||||||
|
derivatives: DerivativesConfig = Field(default_factory=DerivativesConfig)
|
||||||
|
|
||||||
@field_validator("name")
|
@field_validator("name")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -147,8 +219,22 @@ class StrategyConfig(_Base):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class TradingConfig(_Base):
|
||||||
|
"""Steuerung des automatisierten Handels."""
|
||||||
|
|
||||||
|
# true = der Bot handelt ab dem Start automatisch (bisheriges Verhalten).
|
||||||
|
# false = er sammelt Daten und lernt, eröffnet aber erst nach Freigabe Positionen.
|
||||||
|
autostart: bool = True
|
||||||
|
# Nur für mode: live – verlangt beim Einschalten über das Dashboard eine
|
||||||
|
# ausdrückliche Bestätigung im Request-Body.
|
||||||
|
require_confirmation_for_live: bool = True
|
||||||
|
|
||||||
|
|
||||||
class StorageConfig(_Base):
|
class StorageConfig(_Base):
|
||||||
database_path: str = "/data/trademind.sqlite3"
|
database_path: str = "/data/trademind.sqlite3"
|
||||||
|
# Im Dashboard geänderte Werte. Liegt bewusst im beschreibbaren Datenvolume, weil
|
||||||
|
# die Konfigurationsdatei üblicherweise read-only eingehängt ist.
|
||||||
|
overrides_path: str = "/data/config.overrides.yaml"
|
||||||
|
|
||||||
|
|
||||||
class ServerConfig(_Base):
|
class ServerConfig(_Base):
|
||||||
@@ -156,6 +242,15 @@ class ServerConfig(_Base):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = Field(default=8080, ge=1, le=65535)
|
port: int = Field(default=8080, ge=1, le=65535)
|
||||||
enable_metrics: bool = True
|
enable_metrics: bool = True
|
||||||
|
# Steuerbefehle (Training anstoßen, Lernen ein-/ausschalten) über das Dashboard.
|
||||||
|
enable_control: bool = True
|
||||||
|
# Wenn gesetzt, verlangen alle Steuerbefehle den Header X-TradeMind-Token.
|
||||||
|
# Dringend empfohlen, sobald der Port nicht nur lokal erreichbar ist.
|
||||||
|
control_token: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def publicly_reachable(self) -> bool:
|
||||||
|
return self.host not in ("127.0.0.1", "localhost", "::1")
|
||||||
|
|
||||||
|
|
||||||
class NotificationConfig(_Base):
|
class NotificationConfig(_Base):
|
||||||
@@ -164,6 +259,25 @@ class NotificationConfig(_Base):
|
|||||||
notify_on_risk_halt: bool = True
|
notify_on_risk_halt: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaConfig(_Base):
|
||||||
|
"""Lokales Sprachmodell zur Erklärung von Entscheidungen.
|
||||||
|
|
||||||
|
Nimmt keinerlei Einfluss auf den Handel – es formuliert nur, was die Zahlen zeigen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
enabled: bool = False
|
||||||
|
base_url: str = "http://127.0.0.1:11434"
|
||||||
|
model: str = "llama3.2"
|
||||||
|
timeout_seconds: float = Field(default=120.0, gt=0, le=600)
|
||||||
|
temperature: float = Field(default=0.2, ge=0.0, le=2.0)
|
||||||
|
max_tokens: int = Field(default=700, ge=32, le=4096)
|
||||||
|
max_answer_chars: int = Field(default=2000, ge=100, le=20_000)
|
||||||
|
# Reasoning-Modelle (qwen3, deepseek-r1) legen ihre Denkschritte in ein eigenes Feld und
|
||||||
|
# verbrauchen dafür das gesamte Token-Budget – die eigentliche Antwort bleibt dann leer.
|
||||||
|
# Für eine Zustandsbeschreibung wird kein Reasoning gebraucht, deshalb standardmäßig aus.
|
||||||
|
think: bool | None = False
|
||||||
|
|
||||||
|
|
||||||
class BacktestConfig(_Base):
|
class BacktestConfig(_Base):
|
||||||
start: str | None = None # ISO-8601, z.B. 2024-01-01T00:00:00Z
|
start: str | None = None # ISO-8601, z.B. 2024-01-01T00:00:00Z
|
||||||
end: str | None = None
|
end: str | None = None
|
||||||
@@ -178,11 +292,13 @@ class Config(_Base):
|
|||||||
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
|
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
|
||||||
market: MarketConfig = Field(default_factory=MarketConfig)
|
market: MarketConfig = Field(default_factory=MarketConfig)
|
||||||
paper: PaperConfig = Field(default_factory=PaperConfig)
|
paper: PaperConfig = Field(default_factory=PaperConfig)
|
||||||
|
trading: TradingConfig = Field(default_factory=TradingConfig)
|
||||||
risk: RiskConfig = Field(default_factory=RiskConfig)
|
risk: RiskConfig = Field(default_factory=RiskConfig)
|
||||||
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
|
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
|
||||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||||
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
|
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
|
||||||
|
llm: OllamaConfig = Field(default_factory=OllamaConfig)
|
||||||
backtest: BacktestConfig = Field(default_factory=BacktestConfig)
|
backtest: BacktestConfig = Field(default_factory=BacktestConfig)
|
||||||
|
|
||||||
@field_validator("log_level")
|
@field_validator("log_level")
|
||||||
|
|||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Konfiguration zur Laufzeit lesen, ändern und persistieren.
|
||||||
|
|
||||||
|
Die Konfigurationsdatei ist im Container üblicherweise read-only eingehängt. Änderungen
|
||||||
|
aus dem Dashboard landen deshalb als Overlay in einer eigenen Datei im Datenvolume und
|
||||||
|
werden beim Start über die Basiskonfiguration gelegt.
|
||||||
|
|
||||||
|
Rangfolge, von schwach nach stark:
|
||||||
|
|
||||||
|
1. ``config.yaml``
|
||||||
|
2. ``${ENV}``-Platzhalter darin
|
||||||
|
3. ``TRADEMIND__ABSCHNITT__SCHLUESSEL``-Umgebungsvariablen
|
||||||
|
4. Overlay aus dem Dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from types import UnionType
|
||||||
|
from typing import Any, Union, get_args, get_origin
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
Config,
|
||||||
|
Mode,
|
||||||
|
_apply_env_overrides,
|
||||||
|
_substitute_env,
|
||||||
|
is_secret,
|
||||||
|
requires_restart,
|
||||||
|
)
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SECRET_PLACEHOLDER = "••••••••"
|
||||||
|
|
||||||
|
# Über das Dashboard nicht schreibbar – siehe Begründung je Eintrag.
|
||||||
|
NON_WRITABLE: frozenset[str] = frozenset(
|
||||||
|
{
|
||||||
|
# Zugangsdaten gehören nicht über HTTP: weder ausgeliefert noch entgegengenommen.
|
||||||
|
"exchange.api_key",
|
||||||
|
"exchange.api_secret",
|
||||||
|
"exchange.password",
|
||||||
|
"exchange.uid",
|
||||||
|
# Wer live_confirmation setzen könnte, könnte den Bot aus dem Netz auf Echtgeld
|
||||||
|
# umstellen. Bleibt der Konfigurationsdatei vorbehalten.
|
||||||
|
"live_confirmation",
|
||||||
|
# Zirkulär: der Pfad bestimmt, wo die Overlays selbst liegen.
|
||||||
|
"storage.overrides_path",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Modi, die sich aus dem Dashboard heraus setzen lassen. "live" fehlt bewusst.
|
||||||
|
DASHBOARD_MODES: frozenset[str] = frozenset({Mode.PAPER.value, Mode.BACKTEST.value})
|
||||||
|
|
||||||
|
DESCRIPTIONS: dict[str, str] = {
|
||||||
|
"mode": "paper = simuliert auf Live-Kursen, backtest = historisch. "
|
||||||
|
"Umstellung auf live nur in der Konfigurationsdatei.",
|
||||||
|
"log_level": "Ausführlichkeit der Protokollierung.",
|
||||||
|
"live_confirmation": "Sicherheitsnetz für den Live-Modus.",
|
||||||
|
"exchange.id": "ccxt-Kennung der Börse, z. B. binance, kraken, okx.",
|
||||||
|
"exchange.sandbox": "Testnet der Börse verwenden, soweit unterstützt.",
|
||||||
|
"exchange.enable_rate_limit": "Anfragen drosseln, um Sperren zu vermeiden.",
|
||||||
|
"exchange.timeout_ms": "Zeitlimit einzelner Börsenanfragen.",
|
||||||
|
"market.symbols": "Handelspaare, kommagetrennt. Alle mit derselben Quote-Währung.",
|
||||||
|
"market.timeframe": "Kerzenlänge, z. B. 5m, 1h, 1d.",
|
||||||
|
"market.history_bars": "Kerzen je Abruf; die Indikatoren brauchen mindestens 140.",
|
||||||
|
"market.poll_interval_seconds": "Abstand zwischen zwei Abfragen nach neuen Kerzen.",
|
||||||
|
"trading.autostart": "Handelt der Bot direkt nach dem Start, oder wartet er auf Freigabe?",
|
||||||
|
"trading.require_confirmation_for_live": "Rückfrage, bevor Echtgeldhandel startet.",
|
||||||
|
"paper.starting_balance": "Startkapital der Simulation.",
|
||||||
|
"paper.fee_rate": "Gebühr je Seite, 0.001 = 0,1 %.",
|
||||||
|
"paper.slippage_bps": "Ausführungsnachteil in Basispunkten.",
|
||||||
|
"paper.max_volume_participation": "Höchstanteil am Kerzenvolumen je Order.",
|
||||||
|
"risk.max_position_pct": "Anteil der Equity je Position.",
|
||||||
|
"risk.max_total_exposure_pct": "Anteil der Equity über alle Positionen.",
|
||||||
|
"risk.max_open_positions": "Wie viele Positionen gleichzeitig offen sein dürfen.",
|
||||||
|
"risk.stop_loss_atr_mult": "Stop-Abstand als Vielfaches der ATR. 0 = kein Stop.",
|
||||||
|
"risk.take_profit_atr_mult": "Zielabstand als Vielfaches der ATR. 0 = kein Ziel.",
|
||||||
|
"risk.trailing_stop_atr_mult": "Nachziehender Stop. 0 = aus.",
|
||||||
|
"risk.max_holding_bars": "Zwangsausstieg nach so vielen Kerzen. 0 = unbegrenzt.",
|
||||||
|
"risk.max_daily_loss_pct": "Notbremse bis zum nächsten UTC-Tag.",
|
||||||
|
"risk.max_drawdown_pct": "Notbremse bis zum Neustart, schließt offene Positionen.",
|
||||||
|
"risk.min_notional": "Kleinste sinnvolle Ordergröße in Quote-Währung.",
|
||||||
|
"risk.cooldown_bars_after_exit": "Pause je Symbol nach einem Ausstieg.",
|
||||||
|
"strategy.name": "adaptive = Regelwerk plus Lernmodell, rules = nur Regelwerk.",
|
||||||
|
"strategy.rules.fast_ema": "Schnelle EMA-Periode.",
|
||||||
|
"strategy.rules.slow_ema": "Langsame EMA-Periode, muss größer als die schnelle sein.",
|
||||||
|
"strategy.rules.rsi_period": "RSI-Periode.",
|
||||||
|
"strategy.rules.rsi_oversold": "Schwelle für den Rücksetzer-Einstieg.",
|
||||||
|
"strategy.rules.rsi_overbought": "Schwelle für nachlassendes Momentum.",
|
||||||
|
"strategy.rules.atr_period": "ATR-Periode für Stops und Ziele.",
|
||||||
|
"strategy.rules.trend_filter_period": "Trendfilter-EMA. 0 = aus.",
|
||||||
|
"strategy.rules.min_holding_bars": "Signalausstiege erst danach; Stop und Ziel gelten immer.",
|
||||||
|
"strategy.learner.enabled": "Lernmodul überhaupt verwenden.",
|
||||||
|
"strategy.learner.entry_threshold": "Ab welcher Gewinnwahrscheinlichkeit gehandelt wird.",
|
||||||
|
"strategy.learner.exploration_rate": "Anteil bewusst gegen das Modell gehandelter Signale.",
|
||||||
|
"strategy.learner.learning_rate": "Schrittweite des Optimierers.",
|
||||||
|
"strategy.learner.l2": "Regularisierung gegen Überanpassung.",
|
||||||
|
"strategy.learner.batch_size": "Beobachtungen je Trainingsschritt.",
|
||||||
|
"strategy.learner.train_every_n_samples": "Wie oft trainiert wird.",
|
||||||
|
"strategy.learner.warmup_samples": "Bis dahin entscheidet allein das Regelwerk.",
|
||||||
|
"strategy.learner.label_horizon_bars": "Bewertungsfenster eines Signals.",
|
||||||
|
"strategy.learner.label_target_bps": "Kursziel, das als Treffer zählt (30 = 0,3 %).",
|
||||||
|
"strategy.learner.trade_sample_weight": "Gewicht echter Trades gegenüber Shadow-Labels.",
|
||||||
|
"strategy.learner.background_sample_every_n_bars": "Zusätzliche Stichproben. 0 = aus.",
|
||||||
|
"strategy.learner.background_sample_weight": "Gewicht dieser Stichproben.",
|
||||||
|
"strategy.learner.bootstrap_bars": "Vorlernen beim Kaltstart. 0 = aus.",
|
||||||
|
"strategy.learner.freeze_in_live": "Im Live-Modus nicht weiterlernen.",
|
||||||
|
"strategy.learner.save_every_n_updates": "Speicherintervall des Modells.",
|
||||||
|
"strategy.learner.model_path": "Ablageort der Modellgewichte.",
|
||||||
|
"strategy.learner.replay_size": "Größe des Erfahrungsspeichers.",
|
||||||
|
"storage.database_path": "SQLite-Datei für Trades und Equity.",
|
||||||
|
"server.enabled": "Status-Server und Dashboard bereitstellen.",
|
||||||
|
"server.host": "Adresse, auf der gelauscht wird.",
|
||||||
|
"server.port": "Port des Dashboards.",
|
||||||
|
"server.enable_metrics": "Prometheus-Endpunkt /metrics.",
|
||||||
|
"server.enable_control": "Steuerbefehle im Dashboard zulassen.",
|
||||||
|
"server.control_token": "Schützt alle Steuerbefehle. Leer = ungeschützt.",
|
||||||
|
"notifications.webhook_url": "Slack- oder Discord-Webhook. Leer = aus.",
|
||||||
|
"notifications.notify_on_trade": "Meldung bei Ein- und Ausstiegen.",
|
||||||
|
"notifications.notify_on_risk_halt": "Meldung, wenn eine Notbremse greift.",
|
||||||
|
"backtest.bars": "Kerzen je Backtest-Lauf.",
|
||||||
|
"backtest.start": "Startzeit ISO-8601, überschreibt bars.",
|
||||||
|
"backtest.end": "Endzeit ISO-8601.",
|
||||||
|
"backtest.csv_dir": "OHLCV aus CSV statt von der Börse.",
|
||||||
|
}
|
||||||
|
|
||||||
|
SECTION_TITLES: dict[str, str] = {
|
||||||
|
"": "Allgemein",
|
||||||
|
"exchange": "Börsenanbindung",
|
||||||
|
"market": "Marktauswahl",
|
||||||
|
"trading": "Automatisierter Handel",
|
||||||
|
"paper": "Simulation",
|
||||||
|
"risk": "Risikoregeln",
|
||||||
|
"strategy": "Strategie",
|
||||||
|
"strategy.rules": "Strategie – Regelwerk",
|
||||||
|
"strategy.learner": "Strategie – Lernmodell",
|
||||||
|
"storage": "Persistenz",
|
||||||
|
"server": "Status-Server",
|
||||||
|
"notifications": "Benachrichtigungen",
|
||||||
|
"backtest": "Backtest",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(RuntimeError):
|
||||||
|
"""Die gewünschte Änderung ist nicht zulässig."""
|
||||||
|
|
||||||
|
|
||||||
|
def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Nicht-destruktives Zusammenführen; ``patch`` gewinnt."""
|
||||||
|
out = dict(base)
|
||||||
|
for key, value in patch.items():
|
||||||
|
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||||
|
out[key] = deep_merge(out[key], value)
|
||||||
|
else:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
|
||||||
|
"""``{"risk": {"a": 1}}`` → ``{"risk.a": 1}``."""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
path = f"{prefix}{key}"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out.update(flatten(value, f"{path}."))
|
||||||
|
else:
|
||||||
|
out[path] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def unflatten(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Umkehrung von :func:`flatten`."""
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for path, value in data.items():
|
||||||
|
parts = path.split(".")
|
||||||
|
cursor = out
|
||||||
|
for part in parts[:-1]:
|
||||||
|
cursor = cursor.setdefault(part, {})
|
||||||
|
cursor[parts[-1]] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
||||||
|
"""``str | None`` → ``(str, True)``."""
|
||||||
|
if get_origin(annotation) in (Union, UnionType):
|
||||||
|
args = [a for a in get_args(annotation) if a is not type(None)]
|
||||||
|
if len(args) == 1:
|
||||||
|
return args[0], True
|
||||||
|
return annotation, False
|
||||||
|
|
||||||
|
|
||||||
|
def _constraints(field_info: Any) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for item in getattr(field_info, "metadata", ()): # annotated_types
|
||||||
|
for attr, key in (("ge", "min"), ("gt", "exclusive_min"), ("le", "max"), ("lt", "exclusive_max")):
|
||||||
|
value = getattr(item, attr, None)
|
||||||
|
if value is not None:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FieldSpec:
|
||||||
|
"""Beschreibt ein einzelnes Konfigurationsfeld für die Oberfläche."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
section: str
|
||||||
|
name: str
|
||||||
|
type: str # bool | int | float | str | list | enum
|
||||||
|
value: Any
|
||||||
|
writable: bool
|
||||||
|
restart: bool
|
||||||
|
secret: bool
|
||||||
|
overridden: bool
|
||||||
|
description: str = ""
|
||||||
|
choices: list[str] = field(default_factory=list)
|
||||||
|
constraints: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"path": self.path,
|
||||||
|
"section": self.section,
|
||||||
|
"name": self.name,
|
||||||
|
"type": self.type,
|
||||||
|
"value": self.value,
|
||||||
|
"writable": self.writable,
|
||||||
|
"restart": self.restart,
|
||||||
|
"secret": self.secret,
|
||||||
|
"overridden": self.overridden,
|
||||||
|
"description": self.description,
|
||||||
|
"choices": self.choices,
|
||||||
|
"constraints": self.constraints,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def describe_model(model: BaseModel, prefix: str = "", overridden: set[str] | None = None) -> list[FieldSpec]:
|
||||||
|
"""Läuft das Konfigurationsmodell ab und beschreibt jedes Blattfeld."""
|
||||||
|
overridden = overridden or set()
|
||||||
|
specs: list[FieldSpec] = []
|
||||||
|
for name, info in type(model).model_fields.items():
|
||||||
|
path = f"{prefix}{name}"
|
||||||
|
value = getattr(model, name)
|
||||||
|
if isinstance(value, BaseModel):
|
||||||
|
specs.extend(describe_model(value, f"{path}.", overridden))
|
||||||
|
continue
|
||||||
|
|
||||||
|
annotation, _ = _unwrap_optional(info.annotation)
|
||||||
|
choices: list[str] = []
|
||||||
|
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
||||||
|
kind = "enum"
|
||||||
|
choices = [m.value for m in annotation]
|
||||||
|
value = value.value if isinstance(value, enum.Enum) else value
|
||||||
|
elif annotation is bool:
|
||||||
|
kind = "bool"
|
||||||
|
elif annotation is int:
|
||||||
|
kind = "int"
|
||||||
|
elif annotation is float:
|
||||||
|
kind = "float"
|
||||||
|
elif get_origin(annotation) in (list, set, tuple):
|
||||||
|
kind = "list"
|
||||||
|
value = list(value) if value is not None else []
|
||||||
|
elif annotation is dict or get_origin(annotation) is dict:
|
||||||
|
kind = "json"
|
||||||
|
else:
|
||||||
|
kind = "str"
|
||||||
|
|
||||||
|
secret = is_secret(path)
|
||||||
|
specs.append(
|
||||||
|
FieldSpec(
|
||||||
|
path=path,
|
||||||
|
section=prefix.rstrip("."),
|
||||||
|
name=name,
|
||||||
|
type=kind,
|
||||||
|
value=SECRET_PLACEHOLDER if (secret and value) else (None if secret else value),
|
||||||
|
writable=path not in NON_WRITABLE,
|
||||||
|
restart=requires_restart(path),
|
||||||
|
secret=secret,
|
||||||
|
overridden=path in overridden,
|
||||||
|
description=DESCRIPTIONS.get(path, ""),
|
||||||
|
choices=[m for m in choices if path != "mode" or m in DASHBOARD_MODES] if choices else [],
|
||||||
|
constraints=_constraints(info),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigStore:
|
||||||
|
"""Hält Basiskonfiguration, Overlay und die daraus gebaute wirksame Konfiguration."""
|
||||||
|
|
||||||
|
def __init__(self, config_path: str | Path, base_raw: dict[str, Any], config: Config) -> None:
|
||||||
|
self.config_path = Path(config_path)
|
||||||
|
self._base_raw = base_raw
|
||||||
|
self.config = config
|
||||||
|
self.overrides: dict[str, Any] = {}
|
||||||
|
self.pending_restart: set[str] = set()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Laden
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, config_path: str | Path) -> ConfigStore:
|
||||||
|
path = Path(config_path)
|
||||||
|
if not path.is_file():
|
||||||
|
raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {path}")
|
||||||
|
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
raise ValueError(f"{path}: erwartet wurde ein YAML-Mapping auf oberster Ebene")
|
||||||
|
base_raw = _apply_env_overrides(_substitute_env(raw))
|
||||||
|
base_config = Config.model_validate(base_raw)
|
||||||
|
|
||||||
|
store = cls(path, base_raw, base_config)
|
||||||
|
store.overrides = store._read_overrides(base_config.storage.overrides_path)
|
||||||
|
if store.overrides:
|
||||||
|
try:
|
||||||
|
store.config = Config.model_validate(deep_merge(base_raw, store.overrides))
|
||||||
|
log.info(
|
||||||
|
"%d gespeicherte Dashboard-Änderung(en) übernommen aus %s",
|
||||||
|
len(flatten(store.overrides)), base_config.storage.overrides_path,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
log.error(
|
||||||
|
"Gespeicherte Dashboard-Änderungen sind ungültig und werden ignoriert: %s", exc
|
||||||
|
)
|
||||||
|
store.overrides = {}
|
||||||
|
return store
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_overrides(path: str | Path) -> dict[str, Any]:
|
||||||
|
p = Path(path)
|
||||||
|
if not p.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||||
|
except (OSError, yaml.YAMLError) as exc:
|
||||||
|
log.error("Overlay %s nicht lesbar (%s) – wird ignoriert", p, exc)
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
log.error("Overlay %s hat kein Mapping auf oberster Ebene – wird ignoriert", p)
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _write_overrides(self) -> None:
|
||||||
|
path = Path(self.config.storage.overrides_path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
header = (
|
||||||
|
"# Über das TradeMind-Dashboard geänderte Werte.\n"
|
||||||
|
"# Wird beim Start über config.yaml gelegt. Datei löschen = alles zurücksetzen.\n"
|
||||||
|
)
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
tmp.write_text(
|
||||||
|
header + yaml.safe_dump(self.overrides, allow_unicode=True, sort_keys=True),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tmp.replace(path)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Beschreiben
|
||||||
|
|
||||||
|
def describe(self) -> dict[str, Any]:
|
||||||
|
overridden = set(flatten(self.overrides))
|
||||||
|
specs = describe_model(self.config, overridden=overridden)
|
||||||
|
sections: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for spec in specs:
|
||||||
|
sections.setdefault(spec.section, []).append(spec.as_dict())
|
||||||
|
return {
|
||||||
|
"config_path": str(self.config_path),
|
||||||
|
"overrides_path": self.config.storage.overrides_path,
|
||||||
|
"override_count": len(overridden),
|
||||||
|
"pending_restart": sorted(self.pending_restart),
|
||||||
|
"sections": [
|
||||||
|
{"key": key, "title": SECTION_TITLES.get(key, key), "fields": fields}
|
||||||
|
for key, fields in sections.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Ändern
|
||||||
|
|
||||||
|
def _reject_unwritable(self, flat_patch: dict[str, Any]) -> None:
|
||||||
|
blocked = sorted(p for p in flat_patch if p in NON_WRITABLE)
|
||||||
|
if blocked:
|
||||||
|
raise ConfigError(
|
||||||
|
"Diese Felder lassen sich nicht über das Dashboard ändern: "
|
||||||
|
+ ", ".join(blocked)
|
||||||
|
+ ". Zugangsdaten und der Live-Schalter bleiben der Konfigurationsdatei "
|
||||||
|
"beziehungsweise der Umgebung vorbehalten."
|
||||||
|
)
|
||||||
|
mode = flat_patch.get("mode")
|
||||||
|
if mode is not None and str(mode) not in DASHBOARD_MODES:
|
||||||
|
raise ConfigError(
|
||||||
|
f"Der Modus '{mode}' lässt sich nicht über das Dashboard setzen. "
|
||||||
|
"Echtgeldhandel wird ausschließlich in der Konfigurationsdatei freigeschaltet."
|
||||||
|
)
|
||||||
|
|
||||||
|
def apply(self, patch: dict[str, Any]) -> tuple[Config, list[str]]:
|
||||||
|
"""Änderungen prüfen, persistieren und die neue Konfiguration liefern.
|
||||||
|
|
||||||
|
Gibt ``(neue_konfiguration, felder_die_einen_neustart_brauchen)`` zurück.
|
||||||
|
"""
|
||||||
|
flat_patch = flatten(patch)
|
||||||
|
if not flat_patch:
|
||||||
|
raise ConfigError("Keine Änderungen übermittelt")
|
||||||
|
self._reject_unwritable(flat_patch)
|
||||||
|
|
||||||
|
candidate_overrides = deep_merge(self.overrides, unflatten(flat_patch))
|
||||||
|
try:
|
||||||
|
new_config = Config.model_validate(deep_merge(self._base_raw, candidate_overrides))
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ConfigError(_readable_errors(exc)) from None
|
||||||
|
|
||||||
|
# Nur echte Abweichungen als Overlay behalten – so bleibt die Datei schlank und
|
||||||
|
# spätere Änderungen an config.yaml schlagen wieder durch.
|
||||||
|
current_flat = flatten(self.config.model_dump(mode="json"))
|
||||||
|
base_flat = flatten(Config.model_validate(self._base_raw).model_dump(mode="json"))
|
||||||
|
new_flat = flatten(new_config.model_dump(mode="json"))
|
||||||
|
cleaned = {
|
||||||
|
p: v
|
||||||
|
for p, v in flatten(candidate_overrides).items()
|
||||||
|
if new_flat.get(p) != base_flat.get(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = [p for p, v in new_flat.items() if current_flat.get(p) != v]
|
||||||
|
restart_needed = sorted(p for p in changed if requires_restart(p))
|
||||||
|
|
||||||
|
self.overrides = unflatten(cleaned)
|
||||||
|
self.config = new_config
|
||||||
|
self.pending_restart.update(restart_needed)
|
||||||
|
self._write_overrides()
|
||||||
|
log.info(
|
||||||
|
"Konfiguration geändert: %s%s",
|
||||||
|
", ".join(changed) or "(keine Abweichung)",
|
||||||
|
f" – Neustart nötig für: {', '.join(restart_needed)}" if restart_needed else "",
|
||||||
|
)
|
||||||
|
return new_config, restart_needed
|
||||||
|
|
||||||
|
def reset(self, paths: list[str] | None = None) -> Config:
|
||||||
|
"""Overlay ganz oder für einzelne Felder verwerfen."""
|
||||||
|
if paths:
|
||||||
|
flat = flatten(self.overrides)
|
||||||
|
for path in paths:
|
||||||
|
flat.pop(path, None)
|
||||||
|
self.overrides = unflatten(flat)
|
||||||
|
else:
|
||||||
|
self.overrides = {}
|
||||||
|
self.config = Config.model_validate(deep_merge(self._base_raw, self.overrides))
|
||||||
|
self._write_overrides()
|
||||||
|
log.info("Konfiguration zurückgesetzt (%s)", ", ".join(paths) if paths else "alle Felder")
|
||||||
|
return self.config
|
||||||
|
|
||||||
|
|
||||||
|
def _readable_errors(exc: ValidationError) -> str:
|
||||||
|
parts = []
|
||||||
|
for error in exc.errors():
|
||||||
|
location = ".".join(str(p) for p in error["loc"]) or "(Wurzel)"
|
||||||
|
parts.append(f"{location}: {error['msg']}")
|
||||||
|
return "Ungültige Werte – " + "; ".join(parts)
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"""Zusatzdaten aus dem Terminmarkt: Funding Rate und Open Interest.
|
||||||
|
|
||||||
|
Beides sagt etwas über die Positionierung der Marktteilnehmer, was sich aus reinen
|
||||||
|
Kerzendaten nicht ablesen lässt:
|
||||||
|
|
||||||
|
* **Funding Rate** – was Long-Positionen den Short-Positionen zahlen (oder umgekehrt).
|
||||||
|
Positiv und steigend heißt: Longs sind bereit, für ihre Position zu bezahlen.
|
||||||
|
* **Open Interest** – wie viele Kontrakte offen sind. Zusammen mit der Kursrichtung
|
||||||
|
unterscheidet das neu aufgebaute Positionen von Glattstellungen.
|
||||||
|
|
||||||
|
Gehandelt wird weiterhin Spot; die Kennzahlen stammen vom zugehörigen Perpetual
|
||||||
|
(``BTC/USDT`` → ``BTC/USDT:USDT``).
|
||||||
|
|
||||||
|
Zwei Grenzen der Börsen-API, gemessen an Binance:
|
||||||
|
|
||||||
|
* Open Interest reicht nur **30 Tage** zurück, 500 Zeilen je Abruf.
|
||||||
|
* Funding Rate reicht über ein Jahr zurück, veröffentlicht alle 8 Stunden.
|
||||||
|
|
||||||
|
Deshalb sind beide Merkmale einzeln abschaltbar, und fehlende Abdeckung wird gemeldet
|
||||||
|
statt stillschweigend mit Nullen aufgefüllt.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .config import DerivativesConfig
|
||||||
|
from .models import Candles
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_PAGE = 500
|
||||||
|
FUNDING_INTERVAL_MS = 8 * 3_600_000
|
||||||
|
|
||||||
|
|
||||||
|
def perpetual_symbol(spot_symbol: str) -> str:
|
||||||
|
"""``BTC/USDT`` → ``BTC/USDT:USDT`` – die übliche ccxt-Schreibweise für Perpetuals."""
|
||||||
|
if ":" in spot_symbol:
|
||||||
|
return spot_symbol
|
||||||
|
quote = spot_symbol.split("/")[-1]
|
||||||
|
return f"{spot_symbol}:{quote}"
|
||||||
|
|
||||||
|
|
||||||
|
def forward_fill_to_bars(
|
||||||
|
bar_timestamps: np.ndarray, source_ts: np.ndarray, source_values: np.ndarray
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Ordnet eine Zeitreihe den Kerzen zu – ohne in die Zukunft zu schauen.
|
||||||
|
|
||||||
|
Für jede Kerze gilt der letzte Wert, der zum Kerzenzeitpunkt **bereits bekannt** war.
|
||||||
|
Gibt ``(werte, bekannt)`` zurück; ``bekannt`` markiert Kerzen ohne Vorgängerwert.
|
||||||
|
"""
|
||||||
|
bars = np.asarray(bar_timestamps, dtype=np.int64)
|
||||||
|
values = np.full(bars.size, np.nan, dtype=np.float64)
|
||||||
|
known = np.zeros(bars.size, dtype=bool)
|
||||||
|
if source_ts.size == 0:
|
||||||
|
return values, known
|
||||||
|
|
||||||
|
order = np.argsort(source_ts)
|
||||||
|
src_ts = np.asarray(source_ts, dtype=np.int64)[order]
|
||||||
|
src_val = np.asarray(source_values, dtype=np.float64)[order]
|
||||||
|
|
||||||
|
# searchsorted mit "right" liefert die Anzahl Quellwerte mit ts <= bar_ts.
|
||||||
|
idx = np.searchsorted(src_ts, bars, side="right") - 1
|
||||||
|
valid = idx >= 0
|
||||||
|
values[valid] = src_val[idx[valid]]
|
||||||
|
known[valid] = True
|
||||||
|
return values, known
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class DerivativeSeries:
|
||||||
|
"""Auf die Kerzen ausgerichtete Terminmarktdaten."""
|
||||||
|
|
||||||
|
symbol: str
|
||||||
|
timestamp: np.ndarray
|
||||||
|
funding_rate: np.ndarray # Anteil je 8h-Periode, NaN wenn unbekannt
|
||||||
|
open_interest: np.ndarray # Kontrakte, NaN wenn unbekannt
|
||||||
|
funding_coverage: float = 0.0
|
||||||
|
oi_coverage: float = 0.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def usable(self) -> bool:
|
||||||
|
return self.funding_coverage > 0.0 or self.oi_coverage > 0.0
|
||||||
|
|
||||||
|
def describe(self) -> str:
|
||||||
|
return (
|
||||||
|
f"{self.symbol}: Funding {self.funding_coverage * 100:.0f} %, "
|
||||||
|
f"Open Interest {self.oi_coverage * 100:.0f} % der Kerzen abgedeckt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DerivativesProvider:
|
||||||
|
"""Holt Funding Rate und Open Interest und hält sie je Symbol vor.
|
||||||
|
|
||||||
|
Der Zwischenspeicher wächst inkrementell: Im Live-Betrieb wird je neuer Kerze nur
|
||||||
|
das kurze Stück seit dem letzten bekannten Zeitstempel nachgeladen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, exchange, config: DerivativesConfig) -> None:
|
||||||
|
self._exchange = exchange
|
||||||
|
self.config = config
|
||||||
|
self._funding: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
||||||
|
self._oi: dict[str, tuple[np.ndarray, np.ndarray]] = {}
|
||||||
|
self.failures: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._exchange is not None:
|
||||||
|
await self._exchange.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Abruf
|
||||||
|
|
||||||
|
async def _fetch_funding(self, symbol: str, since: int, until: int) -> None:
|
||||||
|
perp = perpetual_symbol(symbol)
|
||||||
|
known_ts, known_val = self._funding.get(symbol, (np.empty(0, np.int64), np.empty(0)))
|
||||||
|
cursor = int(known_ts[-1]) + 1 if known_ts.size else since
|
||||||
|
collected: list[tuple[int, float]] = []
|
||||||
|
while cursor < until:
|
||||||
|
try:
|
||||||
|
rows = await self._exchange.fetch_funding_rate_history(perp, since=cursor, limit=1000)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - Zusatzdaten dürfen nie den Bot stoppen
|
||||||
|
self.failures[f"{symbol}/funding"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
log.warning("%s: Funding-Rate nicht abrufbar (%s)", perp, exc)
|
||||||
|
return
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
rate = row.get("fundingRate")
|
||||||
|
if row.get("timestamp") is not None and rate is not None:
|
||||||
|
collected.append((int(row["timestamp"]), float(rate)))
|
||||||
|
nxt = int(rows[-1]["timestamp"]) + 1
|
||||||
|
if nxt <= cursor:
|
||||||
|
break
|
||||||
|
cursor = nxt
|
||||||
|
await self._throttle()
|
||||||
|
|
||||||
|
if collected:
|
||||||
|
ts = np.concatenate([known_ts, np.array([c[0] for c in collected], dtype=np.int64)])
|
||||||
|
val = np.concatenate([known_val, np.array([c[1] for c in collected], dtype=np.float64)])
|
||||||
|
order = np.argsort(ts)
|
||||||
|
self._funding[symbol] = (ts[order], val[order])
|
||||||
|
|
||||||
|
async def _fetch_open_interest(self, symbol: str, timeframe: str, since: int, until: int) -> None:
|
||||||
|
perp = perpetual_symbol(symbol)
|
||||||
|
known_ts, known_val = self._oi.get(symbol, (np.empty(0, np.int64), np.empty(0)))
|
||||||
|
cursor = int(known_ts[-1]) + 1 if known_ts.size else since
|
||||||
|
collected: list[tuple[int, float]] = []
|
||||||
|
pages = 0
|
||||||
|
while cursor < until and pages < self.config.max_pages:
|
||||||
|
try:
|
||||||
|
rows = await self._exchange.fetch_open_interest_history(
|
||||||
|
perp, timeframe, since=cursor, limit=MAX_PAGE
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# Häufigster Fall: Anfrage älter als das Fenster der Börse (30 Tage).
|
||||||
|
self.failures[f"{symbol}/open_interest"] = f"{type(exc).__name__}: {exc}"
|
||||||
|
log.warning("%s: Open Interest ab %d nicht abrufbar (%s)", perp, cursor, exc)
|
||||||
|
break
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
for row in rows:
|
||||||
|
amount = row.get("openInterestAmount") or row.get("openInterestValue")
|
||||||
|
if row.get("timestamp") is not None and amount is not None:
|
||||||
|
collected.append((int(row["timestamp"]), float(amount)))
|
||||||
|
nxt = int(rows[-1]["timestamp"]) + 1
|
||||||
|
if nxt <= cursor:
|
||||||
|
break
|
||||||
|
cursor = nxt
|
||||||
|
pages += 1
|
||||||
|
await self._throttle()
|
||||||
|
|
||||||
|
if collected:
|
||||||
|
ts = np.concatenate([known_ts, np.array([c[0] for c in collected], dtype=np.int64)])
|
||||||
|
val = np.concatenate([known_val, np.array([c[1] for c in collected], dtype=np.float64)])
|
||||||
|
order = np.argsort(ts)
|
||||||
|
unique = np.concatenate([[True], np.diff(ts[order]) > 0])
|
||||||
|
self._oi[symbol] = (ts[order][unique], val[order][unique])
|
||||||
|
|
||||||
|
async def _throttle(self) -> None:
|
||||||
|
delay = getattr(self._exchange, "rateLimit", 0) or 0
|
||||||
|
if delay:
|
||||||
|
await asyncio.sleep(delay / 1000)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Bereitstellen
|
||||||
|
|
||||||
|
async def series_for(self, candles: Candles) -> DerivativeSeries:
|
||||||
|
"""Terminmarktdaten passend zu einer Kerzenserie, auf deren Zeitstempel ausgerichtet."""
|
||||||
|
symbol = candles.symbol
|
||||||
|
bars = np.asarray(candles.timestamp, dtype=np.int64)
|
||||||
|
empty = np.full(bars.size, np.nan)
|
||||||
|
series = DerivativeSeries(symbol=symbol, timestamp=bars, funding_rate=empty.copy(),
|
||||||
|
open_interest=empty.copy())
|
||||||
|
if bars.size == 0:
|
||||||
|
return series
|
||||||
|
|
||||||
|
since = int(bars[0]) - FUNDING_INTERVAL_MS # ein Intervall Vorlauf für den ersten Wert
|
||||||
|
until = int(bars[-1]) + 1
|
||||||
|
|
||||||
|
if self.config.funding_rate:
|
||||||
|
await self._fetch_funding(symbol, since, until)
|
||||||
|
ts, val = self._funding.get(symbol, (np.empty(0, np.int64), np.empty(0)))
|
||||||
|
values, known = forward_fill_to_bars(bars, ts, val)
|
||||||
|
series.funding_rate = values
|
||||||
|
series.funding_coverage = float(np.mean(known)) if bars.size else 0.0
|
||||||
|
|
||||||
|
if self.config.open_interest:
|
||||||
|
await self._fetch_open_interest(symbol, candles.timeframe, since, until)
|
||||||
|
ts, val = self._oi.get(symbol, (np.empty(0, np.int64), np.empty(0)))
|
||||||
|
values, known = forward_fill_to_bars(bars, ts, val)
|
||||||
|
series.open_interest = values
|
||||||
|
series.oi_coverage = float(np.mean(known)) if bars.size else 0.0
|
||||||
|
|
||||||
|
return series
|
||||||
|
|
||||||
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"enabled": True,
|
||||||
|
"funding_symbols": sorted(self._funding),
|
||||||
|
"oi_symbols": sorted(self._oi),
|
||||||
|
"failures": dict(self.failures),
|
||||||
|
}
|
||||||
+448
-38
@@ -7,29 +7,115 @@ Backtest-Betrieb; nur die Datenquelle und die Ausführung werden ausgetauscht.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .broker import Broker, InsufficientFunds, OrderRejected, PaperBroker
|
from .broker import Broker, InsufficientFunds, OrderRejected, PaperBroker
|
||||||
from .config import Config
|
from .config import Config
|
||||||
|
from .configstore import ConfigError, ConfigStore
|
||||||
from .data import DataFeed
|
from .data import DataFeed
|
||||||
from .features import FEATURE_NAMES, FeatureSnapshot, build_feature_matrix, required_bars
|
from .derivatives import DerivativesProvider
|
||||||
|
from .features import FeatureSnapshot, build_feature_matrix, feature_names, required_bars
|
||||||
|
from .llm import OllamaClient, build_status_prompt
|
||||||
from .models import Action, Candles, ExitReason, Position, Side, Signal
|
from .models import Action, Candles, ExitReason, Position, Side, Signal
|
||||||
from .notify import Notifier
|
from .notify import Notifier
|
||||||
from .portfolio import Portfolio
|
from .portfolio import Portfolio
|
||||||
from .risk import RiskManager
|
from .risk import RiskManager
|
||||||
from .storage import NullStorage, Storage
|
from .storage import NullStorage, Storage
|
||||||
from .strategy import AdaptiveStrategy, Strategy
|
from .strategy import AdaptiveStrategy, RuleStrategy, Strategy
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
STATE_KEY = "engine_state"
|
STATE_KEY = "engine_state"
|
||||||
|
|
||||||
|
# Grenzen für ein über das Dashboard angestoßenes Training.
|
||||||
|
MIN_TRAINING_BARS = 500
|
||||||
|
MAX_TRAINING_BARS = 50_000
|
||||||
|
|
||||||
|
# Muss mitgesendet werden, um den Handel im Live-Modus über das Dashboard zu starten.
|
||||||
|
LIVE_TRADING_CONFIRMATION = "START_LIVE_TRADING"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainingJob:
|
||||||
|
"""Zustand eines historischen Nachtrainings – wird im Dashboard angezeigt."""
|
||||||
|
|
||||||
|
state: str = "idle" # idle | queued | running | done | error
|
||||||
|
bars_requested: int = 0
|
||||||
|
bars_seen: int = 0
|
||||||
|
symbols: list[str] = field(default_factory=list)
|
||||||
|
symbols_done: list[str] = field(default_factory=list)
|
||||||
|
skipped: list[str] = field(default_factory=list)
|
||||||
|
samples_before: int = 0
|
||||||
|
samples_after: int = 0
|
||||||
|
model_ready: bool = False
|
||||||
|
model_saved: bool = False
|
||||||
|
error: str | None = None
|
||||||
|
started_at: float | None = None
|
||||||
|
finished_at: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def samples_gained(self) -> int:
|
||||||
|
return max(0, self.samples_after - self.samples_before)
|
||||||
|
|
||||||
|
def queue(self, bars: int, symbols: list[str]) -> None:
|
||||||
|
self.state = "queued"
|
||||||
|
self.bars_requested = bars
|
||||||
|
self.bars_seen = 0
|
||||||
|
self.symbols = list(symbols)
|
||||||
|
self.symbols_done = []
|
||||||
|
self.skipped = []
|
||||||
|
self.samples_before = 0
|
||||||
|
self.samples_after = 0
|
||||||
|
self.model_saved = False
|
||||||
|
self.error = None
|
||||||
|
self.started_at = time.time()
|
||||||
|
self.finished_at = None
|
||||||
|
|
||||||
|
def start(self, bars: int, symbols: list[str]) -> None:
|
||||||
|
if self.state != "queued":
|
||||||
|
self.queue(bars, symbols)
|
||||||
|
self.state = "running"
|
||||||
|
|
||||||
|
def finish(self, before: int, after: int, ready: bool) -> None:
|
||||||
|
self.samples_before = before
|
||||||
|
self.samples_after = after
|
||||||
|
self.model_ready = ready
|
||||||
|
self.state = "done"
|
||||||
|
self.finished_at = time.time()
|
||||||
|
|
||||||
|
def fail(self, message: str) -> None:
|
||||||
|
self.state = "error"
|
||||||
|
self.error = message
|
||||||
|
self.finished_at = time.time()
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
duration = (
|
||||||
|
round((self.finished_at or time.time()) - self.started_at, 1)
|
||||||
|
if self.started_at is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"bars_requested": self.bars_requested,
|
||||||
|
"bars_seen": self.bars_seen,
|
||||||
|
"symbols": self.symbols,
|
||||||
|
"symbols_done": self.symbols_done,
|
||||||
|
"skipped": self.skipped,
|
||||||
|
"samples_gained": self.samples_gained,
|
||||||
|
"samples_total": self.samples_after,
|
||||||
|
"model_ready": self.model_ready,
|
||||||
|
"model_saved": self.model_saved,
|
||||||
|
"error": self.error,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Bar:
|
class Bar:
|
||||||
@@ -66,8 +152,15 @@ class TradingEngine:
|
|||||||
risk: RiskManager,
|
risk: RiskManager,
|
||||||
storage: Storage | NullStorage,
|
storage: Storage | NullStorage,
|
||||||
notifier: Notifier | None = None,
|
notifier: Notifier | None = None,
|
||||||
|
config_store: ConfigStore | None = None,
|
||||||
|
derivatives: DerivativesProvider | None = None,
|
||||||
|
llm: OllamaClient | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
self.llm = llm
|
||||||
self.config = config
|
self.config = config
|
||||||
|
self.config_store = config_store
|
||||||
|
self.derivatives = derivatives
|
||||||
|
self._coverage_warned: set[str] = set()
|
||||||
self.broker = broker
|
self.broker = broker
|
||||||
self.feed = feed
|
self.feed = feed
|
||||||
self.strategy = strategy
|
self.strategy = strategy
|
||||||
@@ -87,6 +180,16 @@ class TradingEngine:
|
|||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._persist_every = 10
|
self._persist_every = 10
|
||||||
self._since_persist = 0
|
self._since_persist = 0
|
||||||
|
# Handelsdurchlauf und historisches Nachtraining schließen sich gegenseitig aus.
|
||||||
|
self._engine_lock = asyncio.Lock()
|
||||||
|
self._training_task: asyncio.Task[TrainingJob] | None = None
|
||||||
|
self.training = TrainingJob()
|
||||||
|
# Automatisierter Handel. Pausiert unterbindet nur neue Einstiege – Marktdaten,
|
||||||
|
# Signalauswertung und Lernen laufen weiter, offene Positionen werden weiter
|
||||||
|
# überwacht (Stop-Loss und Take-Profit greifen also auch im Pausenzustand).
|
||||||
|
self.trading_active = config.trading.autostart
|
||||||
|
self.trading_changed_at: float | None = None
|
||||||
|
self.paused_signals = 0
|
||||||
|
|
||||||
# ------------------------------------------------------------ Lebenszyklus
|
# ------------------------------------------------------------ Lebenszyklus
|
||||||
|
|
||||||
@@ -104,6 +207,11 @@ class TradingEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def shutdown(self, liquidate: bool = False) -> None:
|
async def shutdown(self, liquidate: bool = False) -> None:
|
||||||
|
if self._training_task is not None and not self._training_task.done():
|
||||||
|
log.info("Laufendes Nachtraining wird abgebrochen …")
|
||||||
|
self._training_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._training_task
|
||||||
if liquidate and self.portfolio.positions:
|
if liquidate and self.portfolio.positions:
|
||||||
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
|
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
|
||||||
for symbol in list(self.portfolio.positions):
|
for symbol in list(self.portfolio.positions):
|
||||||
@@ -120,45 +228,316 @@ class TradingEngine:
|
|||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
async def bootstrap_learner(self) -> None:
|
async def bootstrap_learner(self) -> None:
|
||||||
"""Ein noch untrainiertes Modell aus der Kurshistorie vorlernen.
|
"""Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen.
|
||||||
|
|
||||||
Ohne diesen Schritt bräuchte ein frisch gestarteter Bot bei 5-Minuten-Kerzen
|
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.
|
mehrere Tage, bis das Modell genug Beobachtungen für die Aufwärmphase gesammelt hat.
|
||||||
"""
|
"""
|
||||||
learner = getattr(self.strategy, "learner", None)
|
learner = self.learner
|
||||||
warmup = getattr(self.strategy, "warmup_from_history", None)
|
|
||||||
bars = self.config.strategy.learner.bootstrap_bars
|
bars = self.config.strategy.learner.bootstrap_bars
|
||||||
if learner is None or warmup is None or not bars or learner.ready:
|
if learner is None or not bars or learner.ready:
|
||||||
return
|
return
|
||||||
|
|
||||||
log.info("Modell ist untrainiert – lerne aus bis zu %d historischen Kerzen vor …", bars)
|
log.info("Modell ist untrainiert – lerne aus bis zu %d historischen Kerzen vor …", bars)
|
||||||
before = learner.stats.samples_seen
|
await self._train_on_history(bars, adopt_timeline=True)
|
||||||
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
|
async def _train_on_history(self, bars: int, *, adopt_timeline: bool = False) -> TrainingJob:
|
||||||
log.info(
|
"""Das Modell auf historischen Kerzen nachtrainieren – ohne zu handeln.
|
||||||
"Vorlernen abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
|
|
||||||
gained, learner.stats.samples_seen, "einsatzbereit" if learner.ready else "noch im Aufwärmen",
|
Läuft unter demselben Mutex wie der Handelsdurchlauf, damit sich beide nicht in
|
||||||
)
|
die Quere kommen. Die rechenintensive Schleife wandert in einen Worker-Thread,
|
||||||
if gained:
|
sonst würde der Status-Server für Sekunden blockieren.
|
||||||
|
"""
|
||||||
|
learner = self.learner
|
||||||
|
warmup = getattr(self.strategy, "warmup_from_history", None)
|
||||||
|
job = self.training
|
||||||
|
if learner is None or warmup is None:
|
||||||
|
job.fail("Kein lernfähiges Modell konfiguriert (strategy.name/learner.enabled prüfen)")
|
||||||
|
return job
|
||||||
|
|
||||||
|
async with self._engine_lock:
|
||||||
|
job.start(bars, self.config.market.symbols)
|
||||||
|
before = learner.stats.samples_seen
|
||||||
try:
|
try:
|
||||||
learner.save()
|
for symbol in self.config.market.symbols:
|
||||||
except OSError as exc: # pragma: no cover
|
try:
|
||||||
log.error("Vorgelerntes Modell konnte nicht gespeichert werden: %s", exc)
|
candles = await self._fetch_history(symbol, bars)
|
||||||
|
except Exception as exc: # noqa: BLE001 - darf den Bot nie stoppen
|
||||||
|
log.warning("%s: Historie nicht abrufbar (%s)", symbol, exc)
|
||||||
|
job.skipped.append(f"{symbol}: {exc}")
|
||||||
|
continue
|
||||||
|
matrix = await self.build_matrix(candles)
|
||||||
|
if matrix is None:
|
||||||
|
log.warning("%s: zu wenig Historie (%d Kerzen)", symbol, len(candles))
|
||||||
|
job.skipped.append(f"{symbol}: nur {len(candles)} Kerzen")
|
||||||
|
continue
|
||||||
|
|
||||||
|
last_index = await asyncio.to_thread(warmup, symbol, matrix, candles)
|
||||||
|
job.symbols_done.append(symbol)
|
||||||
|
job.bars_seen += len(matrix) - matrix.first_valid
|
||||||
|
if adopt_timeline:
|
||||||
|
# Zähler 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])
|
||||||
|
|
||||||
|
job.finish(before, learner.stats.samples_seen, learner.ready)
|
||||||
|
log.info(
|
||||||
|
"Historisches Training abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
|
||||||
|
job.samples_gained, learner.stats.samples_seen,
|
||||||
|
"einsatzbereit" if learner.ready else "noch im Aufwärmen",
|
||||||
|
)
|
||||||
|
if job.samples_gained:
|
||||||
|
try:
|
||||||
|
learner.save()
|
||||||
|
job.model_saved = True
|
||||||
|
except OSError as exc: # pragma: no cover - Dateisystemfehler
|
||||||
|
log.error("Modell konnte nicht gespeichert werden: %s", exc)
|
||||||
|
job.skipped.append(f"Speichern fehlgeschlagen: {exc}")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
job.fail("abgebrochen")
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - Fehler gehören in den Job-Status
|
||||||
|
log.exception("Historisches Training fehlgeschlagen")
|
||||||
|
job.fail(f"{type(exc).__name__}: {exc}")
|
||||||
|
return job
|
||||||
|
|
||||||
|
# ----------------------------------------------------- Steuerung (Dashboard)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def learner(self): # noqa: ANN201 - AdaptiveLearner | NullLearner | None
|
||||||
|
return getattr(self.strategy, "learner", None)
|
||||||
|
|
||||||
|
def start_history_training(self, bars: int | None = None) -> dict[str, Any]:
|
||||||
|
"""Historisches Training im Hintergrund anstoßen (Aufruf kehrt sofort zurück)."""
|
||||||
|
if not self.config.server.enable_control:
|
||||||
|
return {"accepted": False, "reason": "Steuerung ist deaktiviert", **self.training.as_dict()}
|
||||||
|
# "queued" zählt mit: zwischen Anstoßen und Start des Tasks darf kein zweiter durch.
|
||||||
|
if self.training.state in ("queued", "running"):
|
||||||
|
return {"accepted": False, "reason": "Ein Training läuft bereits", **self.training.as_dict()}
|
||||||
|
if self.learner is None:
|
||||||
|
return {"accepted": False, "reason": "Kein lernfähiges Modell konfiguriert",
|
||||||
|
**self.training.as_dict()}
|
||||||
|
|
||||||
|
requested = int(bars or self.config.strategy.learner.bootstrap_bars or 3_000)
|
||||||
|
requested = max(MIN_TRAINING_BARS, min(requested, MAX_TRAINING_BARS))
|
||||||
|
self.training.queue(requested, self.config.market.symbols)
|
||||||
|
self._training_task = asyncio.create_task(self._train_on_history(requested))
|
||||||
|
self._training_task.add_done_callback(lambda _: setattr(self, "_training_task", None))
|
||||||
|
log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested)
|
||||||
|
return {"accepted": True, "reason": "", **self.training.as_dict()}
|
||||||
|
|
||||||
|
def apply_config(self, new_config: Config) -> list[str]:
|
||||||
|
"""Geänderte Konfiguration im laufenden Betrieb übernehmen.
|
||||||
|
|
||||||
|
Die Laufzeitobjekte halten Referenzen auf die Teilkonfigurationen; sie werden hier
|
||||||
|
umgehängt. Was nur beim Aufbau ausgewertet wird (Börsenclient, Broker-Startkapital,
|
||||||
|
Datenbank, Socket), lässt sich so nicht ändern – das meldet der ConfigStore als
|
||||||
|
neustartpflichtig.
|
||||||
|
"""
|
||||||
|
previous_level = self.config.log_level
|
||||||
|
self.config = new_config
|
||||||
|
self.risk.config = new_config.risk
|
||||||
|
if isinstance(self.strategy, AdaptiveStrategy):
|
||||||
|
self.strategy.config = new_config.strategy
|
||||||
|
self.strategy.rules.config = new_config.strategy.rules
|
||||||
|
elif isinstance(self.strategy, RuleStrategy):
|
||||||
|
self.strategy.config = new_config.strategy.rules
|
||||||
|
learner = self.learner
|
||||||
|
if learner is not None:
|
||||||
|
learner.config = new_config.strategy.learner
|
||||||
|
if isinstance(self.broker, PaperBroker):
|
||||||
|
# Gebühren, Slippage und Volumengrenze werden je Order gelesen.
|
||||||
|
self.broker.config = new_config.paper.model_copy(
|
||||||
|
update={"quote_currency": self.broker.quote_currency,
|
||||||
|
"starting_balance": self.broker.starting_balance}
|
||||||
|
)
|
||||||
|
if self.notifier is not None:
|
||||||
|
self.notifier.config = new_config.notifications
|
||||||
|
|
||||||
|
if new_config.log_level != previous_level:
|
||||||
|
logging.getLogger().setLevel(new_config.log_level)
|
||||||
|
log.info("Log-Level auf %s gesetzt", new_config.log_level)
|
||||||
|
|
||||||
|
applied = ["risk", "strategy", "trading", "notifications", "market.poll_interval_seconds"]
|
||||||
|
log.info("Konfigurationsänderung übernommen (%s)", ", ".join(applied))
|
||||||
|
return applied
|
||||||
|
|
||||||
|
def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Automatisierten Handel starten oder pausieren.
|
||||||
|
|
||||||
|
Pausiert werden ausschließlich neue Einstiege. Marktdaten, Signalauswertung und
|
||||||
|
Lernen laufen weiter, offene Positionen bleiben unter Stop-Loss-Überwachung – ein
|
||||||
|
pausierter Bot lässt also niemanden ungeschützt im Markt stehen.
|
||||||
|
"""
|
||||||
|
if not self.config.server.enable_control:
|
||||||
|
return {"accepted": False, "reason": "Steuerung ist deaktiviert",
|
||||||
|
**self.trading_control_status()}
|
||||||
|
|
||||||
|
# Echtgeld zusätzlich absichern: Ein versehentlicher Klick soll nicht reichen.
|
||||||
|
if enabled and self.requires_trade_confirmation and confirm != LIVE_TRADING_CONFIRMATION:
|
||||||
|
return {
|
||||||
|
"accepted": False,
|
||||||
|
"reason": (
|
||||||
|
"Im Live-Modus wird echtes Geld eingesetzt. Zum Starten "
|
||||||
|
f"'confirm': '{LIVE_TRADING_CONFIRMATION}' mitsenden."
|
||||||
|
),
|
||||||
|
**self.trading_control_status(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.trading_active == enabled:
|
||||||
|
return {"accepted": True, "reason": "Zustand war bereits gesetzt",
|
||||||
|
**self.trading_control_status()}
|
||||||
|
|
||||||
|
self.trading_active = enabled
|
||||||
|
self.trading_changed_at = time.time()
|
||||||
|
level = log.warning if (enabled and not self.config.is_simulated) else log.info
|
||||||
|
level(
|
||||||
|
"Automatisierter Handel %s (Modus %s)",
|
||||||
|
"GESTARTET" if enabled else "pausiert", self.config.mode.value,
|
||||||
|
)
|
||||||
|
if self.notifier is not None:
|
||||||
|
state = "gestartet" if enabled else "pausiert"
|
||||||
|
self.notifier.send_soon(f"⚙️ Automatisierter Handel {state} (Modus {self.config.mode.value})")
|
||||||
|
return {"accepted": True, "reason": "", **self.trading_control_status()}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def requires_trade_confirmation(self) -> bool:
|
||||||
|
return not self.config.is_simulated and self.config.trading.require_confirmation_for_live
|
||||||
|
|
||||||
|
def trading_control_status(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"control_enabled": self.config.server.enable_control,
|
||||||
|
"mode": self.config.mode.value,
|
||||||
|
"simulated": self.config.is_simulated,
|
||||||
|
"active": self.trading_active,
|
||||||
|
"autostart": self.config.trading.autostart,
|
||||||
|
"requires_confirmation": self.requires_trade_confirmation,
|
||||||
|
"confirmation_phrase": LIVE_TRADING_CONFIRMATION if self.requires_trade_confirmation else None,
|
||||||
|
"paused_signals": self.paused_signals,
|
||||||
|
"open_positions": len(self.portfolio.positions),
|
||||||
|
"halted": self.risk.trading_halted,
|
||||||
|
"halt_reason": self.risk.halt.reason,
|
||||||
|
"changed_at": self.trading_changed_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_online_learning(self, enabled: bool) -> dict[str, Any]:
|
||||||
|
"""Kontinuierliches Lernen im laufenden Betrieb ein- oder ausschalten."""
|
||||||
|
if not self.config.server.enable_control:
|
||||||
|
return {"accepted": False, "reason": "Steuerung ist deaktiviert",
|
||||||
|
"online_learning": self.online_learning_enabled}
|
||||||
|
learner = self.learner
|
||||||
|
if learner is None:
|
||||||
|
return {"accepted": False, "reason": "Kein lernfähiges Modell konfiguriert",
|
||||||
|
"online_learning": False}
|
||||||
|
learner.frozen = not enabled
|
||||||
|
log.info("Kontinuierliches Lernen %s", "eingeschaltet" if enabled else "eingefroren")
|
||||||
|
return {"accepted": True, "reason": "", "online_learning": enabled}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def online_learning_enabled(self) -> bool:
|
||||||
|
learner = self.learner
|
||||||
|
return bool(learner is not None and not learner.frozen)
|
||||||
|
|
||||||
|
# ------------------------------------------------- Konfiguration (Dashboard)
|
||||||
|
|
||||||
|
def config_state(self) -> dict[str, Any]:
|
||||||
|
if self.config_store is None:
|
||||||
|
return {"available": False, "sections": [], "pending_restart": []}
|
||||||
|
return {"available": self.config.server.enable_control, **self.config_store.describe()}
|
||||||
|
|
||||||
|
def update_config(self, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Konfigurationsänderung prüfen, sichern und – soweit möglich – sofort übernehmen."""
|
||||||
|
if not self.config.server.enable_control or self.config_store is None:
|
||||||
|
return {"accepted": False, "reason": "Steuerung ist deaktiviert"}
|
||||||
|
try:
|
||||||
|
new_config, restart_needed = self.config_store.apply(patch)
|
||||||
|
except ConfigError as exc:
|
||||||
|
return {"accepted": False, "reason": str(exc)}
|
||||||
|
applied = self.apply_config(new_config)
|
||||||
|
return {
|
||||||
|
"accepted": True,
|
||||||
|
"reason": "",
|
||||||
|
"applied": applied,
|
||||||
|
"restart_required": restart_needed,
|
||||||
|
**self.config_state(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def reset_config(self, paths: list[str] | None = None) -> dict[str, Any]:
|
||||||
|
if not self.config.server.enable_control or self.config_store is None:
|
||||||
|
return {"accepted": False, "reason": "Steuerung ist deaktiviert"}
|
||||||
|
new_config = self.config_store.reset(paths)
|
||||||
|
self.apply_config(new_config)
|
||||||
|
return {"accepted": True, "reason": "", **self.config_state()}
|
||||||
|
|
||||||
|
# ------------------------------------------------------ Erklärung (LLM)
|
||||||
|
|
||||||
|
async def explain(self) -> dict[str, Any]:
|
||||||
|
"""Den aktuellen Zustand vom Sprachmodell in Worte fassen lassen.
|
||||||
|
|
||||||
|
Läuft außerhalb des Handels-Loops und ohne dessen Mutex – eine Erklärung darf
|
||||||
|
den Handel weder blockieren noch beeinflussen.
|
||||||
|
"""
|
||||||
|
if not self.config.llm.enabled or self.llm is None:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "Erklärungen sind deaktiviert (llm.enabled)",
|
||||||
|
"model": self.config.llm.model,
|
||||||
|
}
|
||||||
|
prompt = build_status_prompt(self.status())
|
||||||
|
result = await self.llm.generate(prompt)
|
||||||
|
if not result.ok:
|
||||||
|
log.warning("Erklärung fehlgeschlagen: %s", result.error)
|
||||||
|
return result.as_dict()
|
||||||
|
|
||||||
|
def training_status(self) -> dict[str, Any]:
|
||||||
|
learner = self.learner
|
||||||
|
return {
|
||||||
|
"control_enabled": self.config.server.enable_control,
|
||||||
|
"learning_available": learner is not None,
|
||||||
|
"online_learning": self.online_learning_enabled,
|
||||||
|
"default_bars": self.config.strategy.learner.bootstrap_bars or 3_000,
|
||||||
|
"min_bars": MIN_TRAINING_BARS,
|
||||||
|
"max_bars": MAX_TRAINING_BARS,
|
||||||
|
**self.training.as_dict(),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def build_matrix(self, candles: Candles):
|
||||||
|
"""Merkmalsmatrix bauen und dabei – falls aktiviert – Terminmarktdaten einbeziehen."""
|
||||||
|
derivatives = self.config.strategy.derivatives
|
||||||
|
series = None
|
||||||
|
if derivatives.enabled and self.derivatives is not None:
|
||||||
|
try:
|
||||||
|
series = await self.derivatives.series_for(candles)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - Zusatzdaten dürfen nie den Bot stoppen
|
||||||
|
log.warning("%s: Terminmarktdaten nicht verfügbar (%s)", candles.symbol, exc)
|
||||||
|
series = None
|
||||||
|
else:
|
||||||
|
if series is not None and not self._coverage_ok(series):
|
||||||
|
series = None
|
||||||
|
return build_feature_matrix(candles, self.config.strategy.rules, derivatives, series)
|
||||||
|
|
||||||
|
def _coverage_ok(self, series) -> bool:
|
||||||
|
"""Zu lückenhafte Reihen verwerfen – neutrale Spalten sind ehrlicher als Rauschen."""
|
||||||
|
minimum = self.config.strategy.derivatives.min_coverage
|
||||||
|
checks = []
|
||||||
|
if self.config.strategy.derivatives.funding_rate:
|
||||||
|
checks.append(("Funding", series.funding_coverage))
|
||||||
|
if self.config.strategy.derivatives.open_interest:
|
||||||
|
checks.append(("Open Interest", series.oi_coverage))
|
||||||
|
for label, coverage in checks:
|
||||||
|
if coverage < minimum:
|
||||||
|
key = f"{series.symbol}/{label}"
|
||||||
|
if key not in self._coverage_warned:
|
||||||
|
self._coverage_warned.add(key)
|
||||||
|
log.warning(
|
||||||
|
"%s: %s deckt nur %.0f %% der Kerzen ab (nötig %.0f %%) – "
|
||||||
|
"Terminmarktmerkmale bleiben für dieses Symbol neutral",
|
||||||
|
series.symbol, label, coverage * 100, minimum * 100,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
async def _fetch_history(self, symbol: str, bars: int) -> Candles:
|
async def _fetch_history(self, symbol: str, bars: int) -> Candles:
|
||||||
"""Längere Historie holen, wenn der Feed das kann – sonst das normale Fenster."""
|
"""Längere Historie holen, wenn der Feed das kann – sonst das normale Fenster."""
|
||||||
@@ -179,11 +558,14 @@ class TradingEngine:
|
|||||||
"""Endlosschleife für Paper- und Live-Betrieb."""
|
"""Endlosschleife für Paper- und Live-Betrieb."""
|
||||||
self._stop_event = asyncio.Event()
|
self._stop_event = asyncio.Event()
|
||||||
self.running = True
|
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:
|
while self.running:
|
||||||
cycle_start = time.monotonic()
|
cycle_start = time.monotonic()
|
||||||
|
# Je Durchlauf frisch gelesen, damit Änderungen aus dem Dashboard sofort greifen.
|
||||||
|
interval = self.config.market.poll_interval_seconds
|
||||||
|
history = max(
|
||||||
|
self.config.market.history_bars, required_bars(self.config.strategy.rules) + 10
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
await self._tick(history)
|
await self._tick(history)
|
||||||
self.iterations += 1
|
self.iterations += 1
|
||||||
@@ -208,6 +590,11 @@ class TradingEngine:
|
|||||||
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
|
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
|
||||||
|
|
||||||
async def _tick(self, history: int) -> None:
|
async def _tick(self, history: int) -> None:
|
||||||
|
# Wartet, falls gerade ein historisches Nachtraining läuft.
|
||||||
|
async with self._engine_lock:
|
||||||
|
await self._tick_locked(history)
|
||||||
|
|
||||||
|
async def _tick_locked(self, history: int) -> None:
|
||||||
self._cash = await self.broker.cash()
|
self._cash = await self.broker.cash()
|
||||||
for symbol in self.config.market.symbols:
|
for symbol in self.config.market.symbols:
|
||||||
candles = await self.feed.fetch(symbol, self.config.market.timeframe, history)
|
candles = await self.feed.fetch(symbol, self.config.market.timeframe, history)
|
||||||
@@ -221,7 +608,7 @@ class TradingEngine:
|
|||||||
continue # noch dieselbe Kerze – nichts Neues zu entscheiden
|
continue # noch dieselbe Kerze – nichts Neues zu entscheiden
|
||||||
self.last_bar_ts[symbol] = bar.timestamp
|
self.last_bar_ts[symbol] = bar.timestamp
|
||||||
|
|
||||||
matrix = build_feature_matrix(candles, self.config.strategy.rules)
|
matrix = await self.build_matrix(candles)
|
||||||
snapshot = matrix.snapshot(-1) if matrix is not None else None
|
snapshot = matrix.snapshot(-1) if matrix is not None else None
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
log.info(
|
log.info(
|
||||||
@@ -252,7 +639,10 @@ class TradingEngine:
|
|||||||
if position is not None:
|
if position is not None:
|
||||||
if await self._manage_open_position(symbol, position, snapshot, bar):
|
if await self._manage_open_position(symbol, position, snapshot, bar):
|
||||||
return
|
return
|
||||||
elif not self.risk.trading_halted:
|
else:
|
||||||
|
# Auch bei pausiertem Handel und bei aktiver Notbremse ausgewertet: Nur so
|
||||||
|
# bekommt das Modell weiter Kandidaten zum Labeln. Die Entscheidung, ob
|
||||||
|
# tatsächlich gekauft wird, fällt in _maybe_enter.
|
||||||
await self._maybe_enter(symbol, snapshot, bar)
|
await self._maybe_enter(symbol, snapshot, bar)
|
||||||
|
|
||||||
async def _manage_open_position(
|
async def _manage_open_position(
|
||||||
@@ -278,12 +668,21 @@ class TradingEngine:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
|
async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
|
||||||
|
# Die Auswertung läuft immer – sie meldet den Kandidaten zum verzögerten Labeln an.
|
||||||
signal = self.strategy.evaluate(symbol, snapshot, None)
|
signal = self.strategy.evaluate(symbol, snapshot, None)
|
||||||
if signal.action is not Action.ENTER_LONG:
|
if signal.action is not Action.ENTER_LONG:
|
||||||
if signal.confidence and log.isEnabledFor(logging.DEBUG):
|
if signal.confidence and log.isEnabledFor(logging.DEBUG):
|
||||||
log.debug("%s: kein Einstieg – %s", symbol, signal.reason)
|
log.debug("%s: kein Einstieg – %s", symbol, signal.reason)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if not self.trading_active:
|
||||||
|
self.paused_signals += 1
|
||||||
|
log.info(
|
||||||
|
"%s: Einstiegssignal (%s) – Handel ist pausiert, es wird nur gelernt",
|
||||||
|
symbol, signal.reason,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close)
|
decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close)
|
||||||
if not decision:
|
if not decision:
|
||||||
log.debug("%s: Einstieg durch Risikoprüfung verhindert – %s", symbol, decision.reason)
|
log.debug("%s: Einstieg durch Risikoprüfung verhindert – %s", symbol, decision.reason)
|
||||||
@@ -456,9 +855,20 @@ class TradingEngine:
|
|||||||
"recent_trades": self.portfolio.recent_trades(25),
|
"recent_trades": self.portfolio.recent_trades(25),
|
||||||
"strategy": self.strategy.snapshot(),
|
"strategy": self.strategy.snapshot(),
|
||||||
"risk": self.risk.snapshot(),
|
"risk": self.risk.snapshot(),
|
||||||
|
"trading": self.trading_control_status(),
|
||||||
|
"training": self.training_status(),
|
||||||
"feature_weights": (
|
"feature_weights": (
|
||||||
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
|
learner.feature_importance(feature_names(self.config.strategy.derivatives))
|
||||||
|
if learner is not None
|
||||||
|
else {}
|
||||||
),
|
),
|
||||||
|
"derivatives": (
|
||||||
|
self.derivatives.snapshot() if self.derivatives is not None else {"enabled": False}
|
||||||
|
),
|
||||||
|
"llm": {
|
||||||
|
"enabled": self.config.llm.enabled and self.llm is not None,
|
||||||
|
"model": self.config.llm.model,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .config import RuleConfig
|
from .config import DerivativesConfig, RuleConfig
|
||||||
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
|
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
|
||||||
from .models import Candles
|
from .models import Candles
|
||||||
|
|
||||||
FEATURE_NAMES: tuple[str, ...] = (
|
BASE_FEATURE_NAMES: tuple[str, ...] = (
|
||||||
"ema_spread", # (EMA_fast - EMA_slow) / Preis [%]
|
"ema_spread", # (EMA_fast - EMA_slow) / Preis [%]
|
||||||
"ema_fast_dist", # (Preis - EMA_fast) / Preis [%]
|
"ema_fast_dist", # (Preis - EMA_fast) / Preis [%]
|
||||||
"trend_dist", # (Preis - EMA_trend) / Preis [%]
|
"trend_dist", # (Preis - EMA_trend) / Preis [%]
|
||||||
@@ -38,11 +38,38 @@ FEATURE_NAMES: tuple[str, ...] = (
|
|||||||
"time_cos",
|
"time_cos",
|
||||||
)
|
)
|
||||||
|
|
||||||
N_FEATURES = len(FEATURE_NAMES)
|
# Zusatzmerkmale aus dem Terminmarkt – nur aktiv, wenn strategy.derivatives.enabled.
|
||||||
|
FUNDING_FEATURE_NAMES: tuple[str, ...] = (
|
||||||
|
"funding_bps", # Funding Rate der laufenden Periode, in Basispunkten
|
||||||
|
"funding_trend", # Abweichung vom Mittel der letzten Perioden
|
||||||
|
)
|
||||||
|
OI_FEATURE_NAMES: tuple[str, ...] = (
|
||||||
|
"oi_change", # Veränderung des Open Interest über oi_change_bars [%]
|
||||||
|
"oi_price_divergence", # OI-Veränderung × Kursrichtung: neue Positionen oder Glattstellung
|
||||||
|
)
|
||||||
|
|
||||||
|
# Rückwärtskompatibler Name für die Basisausstattung.
|
||||||
|
FEATURE_NAMES = BASE_FEATURE_NAMES
|
||||||
|
N_FEATURES = len(BASE_FEATURE_NAMES)
|
||||||
MIN_BARS = 140
|
MIN_BARS = 140
|
||||||
_CLIP_LIMIT = 8.0
|
_CLIP_LIMIT = 8.0
|
||||||
|
|
||||||
|
|
||||||
|
def feature_names(derivatives: DerivativesConfig | None = None) -> tuple[str, ...]:
|
||||||
|
"""Merkmalsnamen für die gegebene Konfiguration – die Anzahl hängt davon ab."""
|
||||||
|
names = BASE_FEATURE_NAMES
|
||||||
|
if derivatives is not None and derivatives.enabled:
|
||||||
|
if derivatives.funding_rate:
|
||||||
|
names += FUNDING_FEATURE_NAMES
|
||||||
|
if derivatives.open_interest:
|
||||||
|
names += OI_FEATURE_NAMES
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def n_features(derivatives: DerivativesConfig | None = None) -> int:
|
||||||
|
return len(feature_names(derivatives))
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class FeatureSnapshot:
|
class FeatureSnapshot:
|
||||||
"""Merkmalsvektor plus Roh-Kennzahlen, die Risiko und Regelwerk zusätzlich brauchen."""
|
"""Merkmalsvektor plus Roh-Kennzahlen, die Risiko und Regelwerk zusätzlich brauchen."""
|
||||||
@@ -96,6 +123,7 @@ class FeatureMatrix:
|
|||||||
ema_slow: np.ndarray
|
ema_slow: np.ndarray
|
||||||
trend_ema: np.ndarray
|
trend_ema: np.ndarray
|
||||||
first_valid: int # ab hier sind die Zeilen belastbar
|
first_valid: int # ab hier sind die Zeilen belastbar
|
||||||
|
names: tuple[str, ...] = BASE_FEATURE_NAMES
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return int(self.values.shape[0])
|
return int(self.values.shape[0])
|
||||||
@@ -112,7 +140,7 @@ class FeatureMatrix:
|
|||||||
prev = max(idx - 1, 0)
|
prev = max(idx - 1, 0)
|
||||||
return FeatureSnapshot(
|
return FeatureSnapshot(
|
||||||
values=self.values[idx].copy(),
|
values=self.values[idx].copy(),
|
||||||
names=FEATURE_NAMES,
|
names=self.names,
|
||||||
index=idx,
|
index=idx,
|
||||||
price=float(self.price[idx]),
|
price=float(self.price[idx]),
|
||||||
atr=float(self.atr[idx]),
|
atr=float(self.atr[idx]),
|
||||||
@@ -127,9 +155,54 @@ class FeatureMatrix:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix | None:
|
def _derivative_columns(
|
||||||
|
close: np.ndarray, series, config: DerivativesConfig
|
||||||
|
) -> list[np.ndarray]:
|
||||||
|
"""Spalten aus Funding Rate und Open Interest, in derselben Reihenfolge wie die Namen."""
|
||||||
|
columns: list[np.ndarray] = []
|
||||||
|
n = close.size
|
||||||
|
|
||||||
|
if config.funding_rate:
|
||||||
|
rate = np.asarray(getattr(series, "funding_rate", None), dtype=np.float64) \
|
||||||
|
if series is not None else np.full(n, np.nan)
|
||||||
|
rate = _clean(rate, 0.0)
|
||||||
|
# Anteil je 8h → Basispunkte. Typisch ±1 bp, in Extremphasen ±10 bp.
|
||||||
|
funding_bps = rate * 10_000.0
|
||||||
|
# Abweichung vom gleitenden Mittel der letzten Perioden: Zuspitzung oder Entspannung.
|
||||||
|
baseline = _clean(sma(funding_bps, 24), 0.0)
|
||||||
|
columns += [funding_bps, funding_bps - baseline]
|
||||||
|
|
||||||
|
if config.open_interest:
|
||||||
|
oi = np.asarray(getattr(series, "open_interest", None), dtype=np.float64) \
|
||||||
|
if series is not None else np.full(n, np.nan)
|
||||||
|
oi = _clean(oi, 0.0)
|
||||||
|
lag = min(config.oi_change_bars, max(n - 1, 1))
|
||||||
|
previous = np.concatenate([np.full(min(lag, n), oi[0] if n else 0.0), oi[:-lag]])[:n]
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
oi_change = np.where(previous > 0, (oi - previous) / previous * 100.0, 0.0)
|
||||||
|
prev_close = np.concatenate([np.full(min(lag, n), close[0] if n else 0.0), close[:-lag]])[:n]
|
||||||
|
with np.errstate(divide="ignore", invalid="ignore"):
|
||||||
|
price_change = np.where(prev_close > 0, (close - prev_close) / prev_close * 100.0, 0.0)
|
||||||
|
# Gleiches Vorzeichen = frisches Geld in Richtung des Trends, Gegenzeichen =
|
||||||
|
# Glattstellungen. Das Produkt fasst beides in einer Zahl zusammen.
|
||||||
|
divergence = np.sign(oi_change) * np.abs(price_change)
|
||||||
|
columns += [_clean(oi_change), _clean(divergence)]
|
||||||
|
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def build_feature_matrix(
|
||||||
|
candles: Candles,
|
||||||
|
rules: RuleConfig,
|
||||||
|
derivatives: DerivativesConfig | None = None,
|
||||||
|
series=None,
|
||||||
|
) -> FeatureMatrix | None:
|
||||||
"""Berechnet Indikatoren und Merkmalsvektoren für die gesamte Serie.
|
"""Berechnet Indikatoren und Merkmalsvektoren für die gesamte Serie.
|
||||||
|
|
||||||
|
``series`` ist eine :class:`~trademind.derivatives.DerivativeSeries` passend zu den
|
||||||
|
Kerzen; fehlt sie bei aktivierten Terminmarktmerkmalen, werden die Spalten neutral
|
||||||
|
gefüllt, damit die Modelldimension gleich bleibt.
|
||||||
|
|
||||||
Gibt ``None`` zurück, wenn die Historie kürzer als ``required_bars`` ist.
|
Gibt ``None`` zurück, wenn die Historie kürzer als ``required_bars`` ist.
|
||||||
"""
|
"""
|
||||||
n = len(candles)
|
n = len(candles)
|
||||||
@@ -201,6 +274,8 @@ def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix |
|
|||||||
np.sin(angle),
|
np.sin(angle),
|
||||||
np.cos(angle),
|
np.cos(angle),
|
||||||
]
|
]
|
||||||
|
if derivatives is not None and derivatives.enabled:
|
||||||
|
columns += _derivative_columns(close, series, derivatives)
|
||||||
values = np.column_stack([_clean(col) for col in columns])
|
values = np.column_stack([_clean(col) for col in columns])
|
||||||
np.clip(values, -_CLIP_LIMIT, _CLIP_LIMIT, out=values)
|
np.clip(values, -_CLIP_LIMIT, _CLIP_LIMIT, out=values)
|
||||||
|
|
||||||
@@ -214,6 +289,7 @@ def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix |
|
|||||||
ema_slow=_clean(ema_slow, close),
|
ema_slow=_clean(ema_slow, close),
|
||||||
trend_ema=_clean(trend_ema, close),
|
trend_ema=_clean(trend_ema, close),
|
||||||
first_valid=need - 1,
|
first_valid=need - 1,
|
||||||
|
names=feature_names(derivatives),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+39
-11
@@ -235,7 +235,11 @@ class LearnerStats:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PendingLabel:
|
class PendingLabel:
|
||||||
"""Ein Kandidatensignal, dessen Ausgang erst in der Zukunft feststeht."""
|
"""Ein Kandidatensignal, dessen Ausgang erst in der Zukunft feststeht.
|
||||||
|
|
||||||
|
``tag`` trennt Läufe voneinander: Ein historisches Nachtraining darf die Labels des
|
||||||
|
laufenden Live-Betriebs weder auflösen noch verwerfen.
|
||||||
|
"""
|
||||||
|
|
||||||
symbol: str
|
symbol: str
|
||||||
features: np.ndarray
|
features: np.ndarray
|
||||||
@@ -244,6 +248,7 @@ class PendingLabel:
|
|||||||
horizon_bars: int
|
horizon_bars: int
|
||||||
target_bps: float
|
target_bps: float
|
||||||
weight: float = 1.0
|
weight: float = 1.0
|
||||||
|
tag: str = "live"
|
||||||
|
|
||||||
def matured(self, current_bar: int) -> bool:
|
def matured(self, current_bar: int) -> bool:
|
||||||
return current_bar - self.created_bar >= self.horizon_bars
|
return current_bar - self.created_bar >= self.horizon_bars
|
||||||
@@ -275,9 +280,14 @@ class AdaptiveLearner:
|
|||||||
return self.stats.samples_seen >= self.config.warmup_samples
|
return self.stats.samples_seen >= self.config.warmup_samples
|
||||||
|
|
||||||
def score(self, features: np.ndarray) -> float:
|
def score(self, features: np.ndarray) -> float:
|
||||||
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1)."""
|
"""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])
|
Unter dem Lock, damit ein parallel laufendes Nachtraining keinen halb
|
||||||
|
aktualisierten Gewichtsvektor sichtbar macht.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
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:
|
def explore(self) -> bool:
|
||||||
"""Epsilon-greedy: gelegentlich ein abgelehntes Signal trotzdem handeln."""
|
"""Epsilon-greedy: gelegentlich ein abgelehntes Signal trotzdem handeln."""
|
||||||
@@ -345,7 +355,13 @@ class AdaptiveLearner:
|
|||||||
# -------------------------------------------------- Verzögerte Shadow-Labels
|
# -------------------------------------------------- Verzögerte Shadow-Labels
|
||||||
|
|
||||||
def register_candidate(
|
def register_candidate(
|
||||||
self, symbol: str, features: np.ndarray, price: float, bar_index: int, weight: float = 1.0
|
self,
|
||||||
|
symbol: str,
|
||||||
|
features: np.ndarray,
|
||||||
|
price: float,
|
||||||
|
bar_index: int,
|
||||||
|
weight: float = 1.0,
|
||||||
|
tag: str = "live",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
|
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
|
||||||
self._pending.append(
|
self._pending.append(
|
||||||
@@ -357,21 +373,26 @@ class AdaptiveLearner:
|
|||||||
horizon_bars=self.config.label_horizon_bars,
|
horizon_bars=self.config.label_horizon_bars,
|
||||||
target_bps=self.config.label_target_bps,
|
target_bps=self.config.label_target_bps,
|
||||||
weight=weight,
|
weight=weight,
|
||||||
|
tag=tag,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def resolve_pending(self, symbol: str, bar_index: int, high: float, low: float, close: float) -> int:
|
def resolve_pending(
|
||||||
|
self, symbol: str, bar_index: int, high: float, low: float, close: float, tag: str | None = None
|
||||||
|
) -> int:
|
||||||
"""Fällige Shadow-Labels auswerten. Gibt die Anzahl neuer Beobachtungen zurück.
|
"""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
|
Label = 1, wenn der Kurs innerhalb des Horizonts das Ziel erreicht hat, ohne vorher
|
||||||
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
|
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
|
||||||
|
|
||||||
|
``tag`` grenzt auf einen Lauf ein; ``None`` wertet alle aus.
|
||||||
"""
|
"""
|
||||||
if not self._pending:
|
if not self._pending:
|
||||||
return 0
|
return 0
|
||||||
resolved = 0
|
resolved = 0
|
||||||
still_open: list[PendingLabel] = []
|
still_open: list[PendingLabel] = []
|
||||||
for item in self._pending:
|
for item in self._pending:
|
||||||
if item.symbol != symbol:
|
if item.symbol != symbol or (tag is not None and item.tag != tag):
|
||||||
still_open.append(item)
|
still_open.append(item)
|
||||||
continue
|
continue
|
||||||
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
|
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
|
||||||
@@ -393,8 +414,15 @@ class AdaptiveLearner:
|
|||||||
self._pending = still_open
|
self._pending = still_open
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def drop_pending(self, symbol: str | None = None) -> None:
|
def drop_pending(self, symbol: str | None = None, tag: str | None = None) -> int:
|
||||||
self._pending = [p for p in self._pending if symbol is not None and p.symbol != symbol]
|
"""Vorgemerkte Labels verwerfen. Ohne Argumente alle, sonst nur die passenden."""
|
||||||
|
|
||||||
|
def matches(item: PendingLabel) -> bool:
|
||||||
|
return (symbol is None or item.symbol == symbol) and (tag is None or item.tag == tag)
|
||||||
|
|
||||||
|
before = len(self._pending)
|
||||||
|
self._pending = [p for p in self._pending if not matches(p)]
|
||||||
|
return before - len(self._pending)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
@@ -537,8 +565,8 @@ class NullLearner:
|
|||||||
def resolve_pending(self, *args: object, **kwargs: object) -> int:
|
def resolve_pending(self, *args: object, **kwargs: object) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def drop_pending(self, *args: object, **kwargs: object) -> None:
|
def drop_pending(self, *args: object, **kwargs: object) -> int:
|
||||||
return None
|
return 0
|
||||||
|
|
||||||
def learn_from_trade(self, *args: object, **kwargs: object) -> None:
|
def learn_from_trade(self, *args: object, **kwargs: object) -> None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"""Ollama-Anbindung: erklärt Entscheidungen in Worten.
|
||||||
|
|
||||||
|
Bewusst **nicht** als Entscheider. Ein Sprachmodell je Kerze über Käufe entscheiden zu
|
||||||
|
lassen wäre nicht reproduzierbar, kaum backtestbar und in numerischer Zeitreihenvorhersage
|
||||||
|
schwach – und es würde die Nachvollziehbarkeit zerstören, die das lineare Modell heute
|
||||||
|
bietet (``/status`` zeigt jedes einzelne Gewicht).
|
||||||
|
|
||||||
|
Wozu es taugt: aus Zahlen einen lesbaren Satz machen. Der Bot liefert die tatsächlichen
|
||||||
|
Entscheidungsgrundlagen – Regelbegründung, Modellkonfidenz, Merkmalsgewichte, Risikolage –
|
||||||
|
und das Modell formuliert daraus eine Erklärung. Es bekommt keinerlei Einfluss auf den
|
||||||
|
Handel und wird nie aus dem Handels-Loop heraus aufgerufen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from .config import OllamaConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"Du erklärst die Entscheidungen eines Krypto-Trading-Bots auf Deutsch. "
|
||||||
|
"Beschreibe ausschließlich, was die übergebenen Zahlen zeigen. "
|
||||||
|
"Gib keine Anlageempfehlung, keine Kursprognose und keine Einschätzung, ob jemand "
|
||||||
|
"kaufen oder verkaufen sollte. Erfinde keine Zahlen, die nicht dastehen. "
|
||||||
|
"Wenn die Daten für eine Aussage nicht ausreichen, sage das. "
|
||||||
|
"Antworte in höchstens drei kurzen Absätzen, sachlich und ohne Aufzählungszeichen."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class LlmResult:
|
||||||
|
ok: bool
|
||||||
|
text: str = ""
|
||||||
|
error: str = ""
|
||||||
|
model: str = ""
|
||||||
|
duration_seconds: float = 0.0
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"ok": self.ok,
|
||||||
|
"text": self.text,
|
||||||
|
"error": self.error,
|
||||||
|
"model": self.model,
|
||||||
|
"duration_seconds": round(self.duration_seconds, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaClient:
|
||||||
|
"""Dünner Client für ``/api/generate``. Fehler werden gemeldet, nie geworfen."""
|
||||||
|
|
||||||
|
def __init__(self, config: OllamaConfig) -> None:
|
||||||
|
self.config = config
|
||||||
|
self._session: aiohttp.ClientSession | None = None
|
||||||
|
|
||||||
|
async def _ensure_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=self.config.timeout_seconds)
|
||||||
|
self._session = aiohttp.ClientSession(timeout=timeout)
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._session is not None and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
async def available_models(self) -> list[str]:
|
||||||
|
session = await self._ensure_session()
|
||||||
|
try:
|
||||||
|
async with session.get(f"{self.config.base_url.rstrip('/')}/api/tags") as response:
|
||||||
|
if response.status != 200:
|
||||||
|
return []
|
||||||
|
payload = await response.json()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - Erreichbarkeit ist optional
|
||||||
|
log.debug("Ollama nicht erreichbar: %s", exc)
|
||||||
|
return []
|
||||||
|
return [m.get("name", "") for m in payload.get("models", []) if m.get("name")]
|
||||||
|
|
||||||
|
async def generate(self, prompt: str, system: str = SYSTEM_PROMPT) -> LlmResult:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
started = loop.time()
|
||||||
|
session = await self._ensure_session()
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
"model": self.config.model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"system": system,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": self.config.temperature,
|
||||||
|
"num_predict": self.config.max_tokens,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if self.config.think is not None:
|
||||||
|
body["think"] = self.config.think
|
||||||
|
url = f"{self.config.base_url.rstrip('/')}/api/generate"
|
||||||
|
try:
|
||||||
|
async with session.post(url, json=body) as response:
|
||||||
|
if response.status == 400 and "think" in body:
|
||||||
|
# Ältere Ollama-Versionen kennen das Feld nicht – ohne es erneut versuchen.
|
||||||
|
body.pop("think")
|
||||||
|
async with session.post(url, json=body) as retry:
|
||||||
|
if retry.status != 200:
|
||||||
|
detail = (await retry.text())[:200]
|
||||||
|
return LlmResult(False, error=f"HTTP {retry.status}: {detail}",
|
||||||
|
model=self.config.model,
|
||||||
|
duration_seconds=loop.time() - started)
|
||||||
|
payload = await retry.json()
|
||||||
|
elif response.status != 200:
|
||||||
|
detail = (await response.text())[:200]
|
||||||
|
return LlmResult(False, error=f"HTTP {response.status}: {detail}",
|
||||||
|
model=self.config.model,
|
||||||
|
duration_seconds=loop.time() - started)
|
||||||
|
else:
|
||||||
|
payload = await response.json()
|
||||||
|
except TimeoutError:
|
||||||
|
return LlmResult(
|
||||||
|
False,
|
||||||
|
error=f"Zeitüberschreitung nach {self.config.timeout_seconds:g}s – "
|
||||||
|
"größere Modelle brauchen länger, llm.timeout_seconds erhöhen",
|
||||||
|
model=self.config.model,
|
||||||
|
duration_seconds=loop.time() - started,
|
||||||
|
)
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
return LlmResult(
|
||||||
|
False,
|
||||||
|
error=f"Ollama unter {self.config.base_url} nicht erreichbar ({exc})",
|
||||||
|
model=self.config.model,
|
||||||
|
duration_seconds=loop.time() - started,
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 - eine Erklärung darf nie den Bot stören
|
||||||
|
return LlmResult(False, error=f"{type(exc).__name__}: {exc}",
|
||||||
|
model=self.config.model, duration_seconds=loop.time() - started)
|
||||||
|
|
||||||
|
text = str(payload.get("response", "")).strip()
|
||||||
|
# Manche Modelle betten den Denkprozess als <think>-Block in die Antwort ein.
|
||||||
|
text = _strip_thinking(text)
|
||||||
|
if not text:
|
||||||
|
return LlmResult(
|
||||||
|
False,
|
||||||
|
error=_diagnose_empty(payload, self.config),
|
||||||
|
model=self.config.model,
|
||||||
|
duration_seconds=loop.time() - started,
|
||||||
|
)
|
||||||
|
return LlmResult(True, text=text[: self.config.max_answer_chars],
|
||||||
|
model=self.config.model, duration_seconds=loop.time() - started)
|
||||||
|
|
||||||
|
|
||||||
|
def _diagnose_empty(payload: dict[str, Any], config: OllamaConfig) -> str:
|
||||||
|
"""Sagt, *warum* nichts zurückkam – „leere Antwort" allein hilft niemandem weiter."""
|
||||||
|
thinking = str(payload.get("thinking") or "")
|
||||||
|
reason = payload.get("done_reason")
|
||||||
|
if thinking and reason == "length":
|
||||||
|
return (
|
||||||
|
f"Das Modell hat alle {config.max_tokens} Tokens für seine Denkschritte verbraucht "
|
||||||
|
f"({len(thinking)} Zeichen im Feld 'thinking'), ohne eine Antwort zu formulieren. "
|
||||||
|
"llm.think auf false setzen oder llm.max_tokens erhöhen."
|
||||||
|
)
|
||||||
|
if thinking:
|
||||||
|
return (
|
||||||
|
"Das Modell hat nur Denkschritte geliefert, keine Antwort. "
|
||||||
|
"llm.think auf false setzen."
|
||||||
|
)
|
||||||
|
if reason == "length":
|
||||||
|
return f"Antwort nach {config.max_tokens} Tokens abgeschnitten – llm.max_tokens erhöhen."
|
||||||
|
return f"Leere Antwort vom Modell (done_reason={reason!r})"
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_thinking(text: str) -> str:
|
||||||
|
while "<think>" in text and "</think>" in text:
|
||||||
|
start = text.index("<think>")
|
||||||
|
end = text.index("</think>") + len("</think>")
|
||||||
|
text = (text[:start] + text[end:]).strip()
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _fmt(value: Any, digits: int = 2, suffix: str = "") -> str:
|
||||||
|
if value is None:
|
||||||
|
return "unbekannt"
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "ja" if value else "nein"
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return f"{value:.{digits}f}{suffix}"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_status_prompt(status: dict[str, Any], top_weights: int = 8) -> str:
|
||||||
|
"""Baut aus dem Statusabbild einen Prompt mit den tatsächlichen Entscheidungsgrundlagen."""
|
||||||
|
portfolio = status.get("portfolio", {})
|
||||||
|
strategy = status.get("strategy", {})
|
||||||
|
learner = strategy.get("learner", {}) if isinstance(strategy, dict) else {}
|
||||||
|
risk = status.get("risk", {})
|
||||||
|
trading = status.get("trading", {})
|
||||||
|
weights = status.get("feature_weights", {}) or {}
|
||||||
|
positions = status.get("positions", []) or []
|
||||||
|
trades = (status.get("recent_trades", []) or [])[-5:]
|
||||||
|
|
||||||
|
ranked = sorted(weights.items(), key=lambda kv: abs(kv[1]), reverse=True)[:top_weights]
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"Zustand eines Krypto-Trading-Bots. Erkläre in eigenen Worten, was er gerade tut,",
|
||||||
|
"worauf sein Modell derzeit achtet und wie belastbar das ist.",
|
||||||
|
"",
|
||||||
|
f"Betriebsart: {status.get('mode')} ({'simuliert' if trading.get('simulated') else 'echtes Geld'}), "
|
||||||
|
f"Handel {'aktiv' if trading.get('active') else 'pausiert'}",
|
||||||
|
f"Börse: {status.get('exchange')}, Symbole: {', '.join(status.get('symbols', []))}, "
|
||||||
|
f"Kerzenlänge {status.get('timeframe')}",
|
||||||
|
"",
|
||||||
|
"PORTFOLIO",
|
||||||
|
f" Equity {_fmt(portfolio.get('equity'))} {status.get('quote_currency', '')}, "
|
||||||
|
f"Rendite {_fmt(portfolio.get('total_return_pct'))} %",
|
||||||
|
f" Abgeschlossene Trades {portfolio.get('trades', 0)}, "
|
||||||
|
f"Trefferquote {_fmt((portfolio.get('win_rate') or 0) * 100, 1)} %, "
|
||||||
|
f"Profit-Faktor {_fmt(portfolio.get('profit_factor'))}",
|
||||||
|
f" Maximaler Drawdown {_fmt(portfolio.get('max_drawdown_pct'))} %, "
|
||||||
|
f"offene Positionen {portfolio.get('open_positions', 0)}",
|
||||||
|
"",
|
||||||
|
"LERNMODELL (logistische Regression über normierte Merkmale)",
|
||||||
|
f" Beobachtungen {learner.get('samples_seen', 0)}, davon aus echten Trades "
|
||||||
|
f"{learner.get('trade_samples', 0)}",
|
||||||
|
f" Trefferquote der Vorhersagen {_fmt((learner.get('online_accuracy') or 0) * 100, 1)} % "
|
||||||
|
f"(50 % entspricht Raten)",
|
||||||
|
f" Von {strategy.get('candidates_seen', 0)} Signalen des Regelwerks wurden "
|
||||||
|
f"{strategy.get('candidates_accepted', 0)} durchgelassen",
|
||||||
|
"",
|
||||||
|
"STÄRKSTE MERKMALSGEWICHTE (positiv = spricht für einen Einstieg)",
|
||||||
|
]
|
||||||
|
lines += [f" {name}: {value:+.3f}" for name, value in ranked] or [" noch keine"]
|
||||||
|
|
||||||
|
lines += ["", "RISIKO", f" Notbremse aktiv: {_fmt(risk.get('halted'))}"]
|
||||||
|
if risk.get("halt_reason"):
|
||||||
|
lines.append(f" Grund: {risk['halt_reason']}")
|
||||||
|
lines.append(
|
||||||
|
f" Grenzen: höchstens {risk.get('max_open_positions')} Positionen, "
|
||||||
|
f"{_fmt((risk.get('max_position_pct') or 0) * 100, 0)} % der Equity je Position"
|
||||||
|
)
|
||||||
|
|
||||||
|
if positions:
|
||||||
|
lines += ["", "OFFENE POSITIONEN"]
|
||||||
|
for p in positions:
|
||||||
|
lines.append(
|
||||||
|
f" {p.get('symbol')}: Einstieg {_fmt(p.get('entry_price'), 6)}, "
|
||||||
|
f"aktuell {_fmt(p.get('mark_price'), 6)}, "
|
||||||
|
f"P/L {_fmt(p.get('unrealized_pct'))} %, seit {p.get('bars_held')} Kerzen, "
|
||||||
|
f"Modellkonfidenz beim Einstieg {_fmt(p.get('confidence'))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if trades:
|
||||||
|
lines += ["", "LETZTE TRADES"]
|
||||||
|
for t in trades:
|
||||||
|
lines.append(
|
||||||
|
f" {t.get('symbol')}: {_fmt((t.get('pnl_pct') or 0) * 100)} %, "
|
||||||
|
f"Ausstieg wegen {t.get('exit_reason')}, {t.get('bars_held')} Kerzen gehalten"
|
||||||
|
)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
+564
-3
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -14,7 +15,34 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
StatusProvider = Callable[[], dict[str, Any]]
|
StatusProvider = Callable[[], dict[str, Any]]
|
||||||
|
|
||||||
_DASHBOARD = """<!doctype html>
|
TOKEN_HEADER = "X-TradeMind-Token"
|
||||||
|
|
||||||
|
|
||||||
|
class Controller(Protocol):
|
||||||
|
"""Was der Server zum Steuern braucht – von :class:`~trademind.engine.TradingEngine` erfüllt."""
|
||||||
|
|
||||||
|
def start_history_training(self, bars: int | None = None) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def set_online_learning(self, enabled: bool) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def training_status(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def trading_control_status(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
async def explain(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def config_state(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def update_config(self, patch: dict[str, Any]) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
def reset_config(self, paths: list[str] | None = None) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
# Roh-String: Escape-Sequenzen wie \n gehören dem eingebetteten JavaScript, nicht Python.
|
||||||
|
# Ohne das r wird aus \n ein echter Zeilenumbruch mitten im JS-String-Literal – das Skript
|
||||||
|
# lässt sich dann nicht mehr parsen und das Dashboard bleibt bei "lädt …" stehen.
|
||||||
|
_DASHBOARD = r"""<!doctype html>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>TradeMind</title>
|
<title>TradeMind</title>
|
||||||
<style>
|
<style>
|
||||||
@@ -35,10 +63,111 @@ _DASHBOARD = """<!doctype html>
|
|||||||
th:first-child, td:first-child { text-align: left; }
|
th:first-child, td:first-child { text-align: left; }
|
||||||
section { margin-top: 28px; }
|
section { margin-top: 28px; }
|
||||||
.overflow { overflow-x: auto; }
|
.overflow { overflow-x: auto; }
|
||||||
|
h2 { font-size: 15px; margin: 0 0 6px; }
|
||||||
|
.panel { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||||
|
button { font: inherit; padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line);
|
||||||
|
background: var(--fg); color: var(--bg); cursor: pointer; }
|
||||||
|
button.ghost { background: transparent; color: var(--fg); }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
input { font: inherit; padding: 6px 9px; border-radius: 7px; border: 1px solid var(--line);
|
||||||
|
background: var(--bg); color: var(--fg); }
|
||||||
|
input[type=number] { width: 8em; }
|
||||||
|
label { color: var(--muted); font-size: 13px; }
|
||||||
|
.hint { color: var(--muted); font-size: 12px; margin-top: 8px; }
|
||||||
|
.hint.err { color: #c62828; }
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||||
|
.dot.on { background: #1a8f3c; } .dot.off { background: #999; } .dot.busy { background: #e0a100; }
|
||||||
|
.banner { background: #c62828; color: #fff; padding: 10px 14px; border-radius: 8px;
|
||||||
|
margin-bottom: 16px; font-weight: 600; }
|
||||||
|
button.danger { background: #c62828; color: #fff; border-color: #c62828; }
|
||||||
|
select { font: inherit; padding: 6px 9px; border-radius: 7px; border: 1px solid var(--line);
|
||||||
|
background: var(--bg); color: var(--fg); }
|
||||||
|
table.cfg td { text-align: left; vertical-align: top; padding: 5px 8px; }
|
||||||
|
table.cfg td:last-child { text-align: right; white-space: nowrap; }
|
||||||
|
table.cfg code { font-size: 12px; }
|
||||||
|
table.cfg input, table.cfg select { min-width: 12em; }
|
||||||
|
.tag { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--line);
|
||||||
|
color: var(--muted); margin-left: 6px; white-space: nowrap; }
|
||||||
|
.tag.warn { border-color: #e0a100; color: #b07d00; }
|
||||||
</style>
|
</style>
|
||||||
<h1>TradeMind</h1>
|
<h1>TradeMind</h1>
|
||||||
<div class="sub" id="sub">lädt …</div>
|
<div class="sub" id="sub">lädt …</div>
|
||||||
|
<div id="live-banner" class="banner" hidden>⚠ LIVE-MODUS — Orders werden mit echtem Geld ausgeführt.</div>
|
||||||
<div class="grid" id="cards"></div>
|
<div class="grid" id="cards"></div>
|
||||||
|
|
||||||
|
<section id="trading-section" hidden>
|
||||||
|
<h2>Automatisierter Handel</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<span id="trade-dot" class="dot off"></span>
|
||||||
|
<strong id="trade-state">–</strong>
|
||||||
|
<button id="trade-btn">–</button>
|
||||||
|
<span class="hint" id="trade-hint" style="margin:0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="trade-detail"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="training-section" hidden>
|
||||||
|
<h2>Training</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<label for="bars">Historisch nachtrainieren über</label>
|
||||||
|
<input type="number" id="bars" min="500" max="50000" step="500" value="3000">
|
||||||
|
<label>Kerzen je Symbol</label>
|
||||||
|
<button id="train-btn">Training starten</button>
|
||||||
|
</div>
|
||||||
|
<div class="hint" id="train-hint">–</div>
|
||||||
|
<div class="row" style="margin-top:14px; border-top:1px solid var(--line); padding-top:14px">
|
||||||
|
<span id="live-dot" class="dot off"></span>
|
||||||
|
<label style="color:var(--fg)">Kontinuierliches Lernen im Live-Betrieb</label>
|
||||||
|
<button id="live-btn" class="ghost">umschalten</button>
|
||||||
|
<span class="hint" id="live-hint" style="margin:0"></span>
|
||||||
|
</div>
|
||||||
|
<div class="row" id="token-row" hidden style="margin-top:12px">
|
||||||
|
<label for="token">Steuer-Token</label>
|
||||||
|
<input type="password" id="token" placeholder="X-TradeMind-Token" autocomplete="off">
|
||||||
|
<button id="token-btn" class="ghost">merken</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="explain-section" hidden>
|
||||||
|
<h2>Erklärung</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<button id="explain-btn">Lage erklären lassen</button>
|
||||||
|
<span class="hint" id="explain-hint" style="margin:0">
|
||||||
|
Ein lokales Sprachmodell fasst zusammen, was die Zahlen zeigen. Es hat keinerlei
|
||||||
|
Einfluss auf den Handel.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="explain-out" hidden style="margin-top:12px; white-space:pre-wrap; line-height:1.55"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="config-section" hidden>
|
||||||
|
<h2>Konfiguration</h2>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="row">
|
||||||
|
<button id="cfg-toggle">Konfiguration bearbeiten</button>
|
||||||
|
<span class="hint" id="cfg-summary" style="margin:0">
|
||||||
|
Alle Einstellungen ändern – Risiko, Strategie, Lernmodell, Börse.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div id="cfg-panel" hidden style="margin-top:14px; border-top:1px solid var(--line); padding-top:14px">
|
||||||
|
<div class="hint" id="cfg-meta"></div>
|
||||||
|
<div id="cfg-sections"></div>
|
||||||
|
<div class="row" style="margin-top:14px; border-top:1px solid var(--line); padding-top:14px">
|
||||||
|
<button id="cfg-save">Änderungen speichern</button>
|
||||||
|
<button id="cfg-revert" class="ghost">Eingaben verwerfen</button>
|
||||||
|
<button id="cfg-reset" class="ghost">Alle Overlays zurücksetzen</button>
|
||||||
|
<span class="hint" id="cfg-hint" style="margin:0"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
|
<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>
|
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
|
||||||
<script>
|
<script>
|
||||||
@@ -79,8 +208,266 @@ async function refresh() {
|
|||||||
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</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]],
|
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
|
||||||
(s.recent_trades || []).slice().reverse());
|
(s.recent_trades || []).slice().reverse());
|
||||||
|
renderTrading(s.trading || {});
|
||||||
|
renderTraining(s.training || {});
|
||||||
|
// Der Abschnitt muss aus dem Statuslauf heraus sichtbar werden – sein Aufklapp-Knopf
|
||||||
|
// sitzt darin, er könnte sich sonst nie selbst einblenden.
|
||||||
|
$("config-section").hidden = !(s.trading || {}).control_enabled;
|
||||||
|
$("explain-section").hidden = !((s.trading || {}).control_enabled && (s.llm || {}).enabled);
|
||||||
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
|
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Steuerung
|
||||||
|
const $ = id => document.getElementById(id);
|
||||||
|
let barsTouched = false;
|
||||||
|
$("bars").addEventListener("input", () => { barsTouched = true; });
|
||||||
|
|
||||||
|
function token() { return sessionStorage.getItem("tmToken") || ""; }
|
||||||
|
function headers() {
|
||||||
|
const h = { "Content-Type": "application/json" };
|
||||||
|
if (token()) h["X-TradeMind-Token"] = token();
|
||||||
|
return h;
|
||||||
|
}
|
||||||
|
function setHint(el, text, isError) {
|
||||||
|
el.textContent = text;
|
||||||
|
el.classList.toggle("err", !!isError);
|
||||||
|
}
|
||||||
|
async function post(path, body) {
|
||||||
|
const r = await fetch(path, { method: "POST", headers: headers(), body: JSON.stringify(body) });
|
||||||
|
const data = await r.json().catch(() => ({}));
|
||||||
|
if (r.status === 401) { $("token-row").hidden = false; throw new Error(data.error || "Token nötig"); }
|
||||||
|
if (!r.ok && !data.accepted) throw new Error(data.reason || data.error || ("HTTP " + r.status));
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastTrading = {};
|
||||||
|
function renderTrading(t) {
|
||||||
|
lastTrading = t;
|
||||||
|
if (!t.control_enabled) return;
|
||||||
|
$("trading-section").hidden = false;
|
||||||
|
$("live-banner").hidden = t.simulated !== false;
|
||||||
|
|
||||||
|
const halted = !!t.halted;
|
||||||
|
$("trade-dot").className = "dot " + (halted ? "busy" : t.active ? "on" : "off");
|
||||||
|
$("trade-state").textContent = halted
|
||||||
|
? "durch Risikoregel gestoppt"
|
||||||
|
: (t.active ? "läuft" : "pausiert") + (t.simulated ? " (simuliert)" : " (ECHTES GELD)");
|
||||||
|
|
||||||
|
const btn = $("trade-btn");
|
||||||
|
btn.textContent = t.active ? "Handel pausieren" : "Handel starten";
|
||||||
|
btn.className = (!t.active && !t.simulated) ? "danger" : (t.active ? "ghost" : "");
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
setHint($("trade-hint"), halted ? t.halt_reason : "", halted);
|
||||||
|
const bits = [];
|
||||||
|
if (!t.active) bits.push("Es werden keine neuen Positionen eröffnet. Marktdaten, Signalauswertung "
|
||||||
|
+ "und Lernen laufen weiter, offene Positionen bleiben unter Stop-Überwachung.");
|
||||||
|
if (t.paused_signals) bits.push(`${t.paused_signals} Signal(e) während der Pause nur gelernt.`);
|
||||||
|
if (t.open_positions) bits.push(`${t.open_positions} Position(en) offen.`);
|
||||||
|
if (!t.autostart) bits.push("Autostart ist aus – nach einem Neustart muss neu gestartet werden.");
|
||||||
|
$("trade-detail").textContent = bits.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("trade-btn").addEventListener("click", async () => {
|
||||||
|
const turnOn = !lastTrading.active;
|
||||||
|
const body = { enabled: turnOn };
|
||||||
|
if (turnOn && lastTrading.requires_confirmation) {
|
||||||
|
const phrase = lastTrading.confirmation_phrase || "";
|
||||||
|
const answer = prompt(
|
||||||
|
"LIVE-MODUS: Es werden Orders mit echtem Geld ausgeführt.\n\n"
|
||||||
|
+ "Zum Bestätigen exakt eingeben:\n" + phrase);
|
||||||
|
if (answer === null) return;
|
||||||
|
body.confirm = answer;
|
||||||
|
}
|
||||||
|
$("trade-btn").disabled = true;
|
||||||
|
try { await post("control/trading", body); setHint($("trade-hint"), ""); }
|
||||||
|
catch (e) { setHint($("trade-hint"), e.message, true); }
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderTraining(t) {
|
||||||
|
if (!t.control_enabled) return;
|
||||||
|
$("training-section").hidden = false;
|
||||||
|
if (!barsTouched && t.default_bars && $("bars").value === "3000") $("bars").value = t.default_bars;
|
||||||
|
$("bars").min = t.min_bars ?? 500;
|
||||||
|
$("bars").max = t.max_bars ?? 50000;
|
||||||
|
|
||||||
|
const busy = t.state === "running" || t.state === "queued";
|
||||||
|
$("train-btn").disabled = busy || !t.learning_available;
|
||||||
|
$("train-btn").textContent = busy ? "läuft …" : "Training starten";
|
||||||
|
|
||||||
|
const hint = $("train-hint");
|
||||||
|
if (!t.learning_available) setHint(hint, "Kein lernfähiges Modell konfiguriert (strategy.name: adaptive).", true);
|
||||||
|
else if (t.state === "queued") setHint(hint, "Eingereiht – wartet auf den laufenden Handelsdurchlauf …");
|
||||||
|
else if (t.state === "running")
|
||||||
|
setHint(hint, `Läuft … ${t.symbols_done.length}/${t.symbols.length} Symbole, ${t.bars_seen} Kerzen verarbeitet`);
|
||||||
|
else if (t.state === "done")
|
||||||
|
setHint(hint, `Fertig in ${t.duration_seconds}s: +${t.samples_gained} Beobachtungen `
|
||||||
|
+ `(gesamt ${t.samples_total}), Modell ${t.model_ready ? "einsatzbereit" : "noch im Aufwärmen"}`
|
||||||
|
+ `${t.model_saved ? ", gespeichert" : ""}`
|
||||||
|
+ `${t.skipped.length ? " · übersprungen: " + t.skipped.join("; ") : ""}`);
|
||||||
|
else if (t.state === "error") setHint(hint, "Fehlgeschlagen: " + t.error, true);
|
||||||
|
else setHint(hint, "Noch kein Training in dieser Sitzung angestoßen.");
|
||||||
|
|
||||||
|
$("live-dot").className = "dot " + (t.online_learning ? "on" : "off");
|
||||||
|
$("live-btn").disabled = !t.learning_available;
|
||||||
|
$("live-btn").textContent = t.online_learning ? "ausschalten" : "einschalten";
|
||||||
|
setHint($("live-hint"), t.online_learning
|
||||||
|
? "aktiv – jeder abgeschlossene Trade fließt ins Modell"
|
||||||
|
: "eingefroren – es wird gehandelt, aber nicht gelernt");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("train-btn").addEventListener("click", async () => {
|
||||||
|
$("train-btn").disabled = true;
|
||||||
|
try {
|
||||||
|
await post("control/train/history", { bars: Number($("bars").value) });
|
||||||
|
setHint($("train-hint"), "Angestoßen …");
|
||||||
|
} catch (e) { setHint($("train-hint"), e.message, true); $("train-btn").disabled = false; }
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("live-btn").addEventListener("click", async () => {
|
||||||
|
const turnOn = $("live-btn").textContent === "einschalten";
|
||||||
|
try { await post("control/train/live", { enabled: turnOn }); }
|
||||||
|
catch (e) { setHint($("live-hint"), e.message, true); }
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("token-btn").addEventListener("click", () => {
|
||||||
|
sessionStorage.setItem("tmToken", $("token").value);
|
||||||
|
$("token").value = "";
|
||||||
|
setHint($("train-hint"), "Token gemerkt (nur in diesem Browser-Tab).");
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Erklärung
|
||||||
|
$("explain-btn").addEventListener("click", async () => {
|
||||||
|
const btn = $("explain-btn"), out = $("explain-out");
|
||||||
|
btn.disabled = true;
|
||||||
|
setHint($("explain-hint"), "Das Modell denkt nach – das dauert je nach Modellgröße etwas …");
|
||||||
|
try {
|
||||||
|
const r = await fetch("control/explain", { method: "POST", headers: headers() });
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
if (d.ok) {
|
||||||
|
out.hidden = false;
|
||||||
|
out.textContent = d.text;
|
||||||
|
setHint($("explain-hint"), `${d.model}, ${d.duration_seconds}s`);
|
||||||
|
} else {
|
||||||
|
setHint($("explain-hint"), d.error || `HTTP ${r.status}`, true);
|
||||||
|
}
|
||||||
|
} catch (e) { setHint($("explain-hint"), e.message, true); }
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Konfiguration
|
||||||
|
let cfgOpen = false, cfgLoaded = null;
|
||||||
|
|
||||||
|
function fieldId(path) { return "cfg__" + path.replace(/\./g, "__"); }
|
||||||
|
|
||||||
|
function fieldRow(f) {
|
||||||
|
const id = fieldId(f.path);
|
||||||
|
const badges = [];
|
||||||
|
if (f.restart) badges.push('<span class="tag warn">Neustart nötig</span>');
|
||||||
|
if (f.overridden) badges.push('<span class="tag">geändert</span>');
|
||||||
|
if (!f.writable) badges.push('<span class="tag">nur Datei</span>');
|
||||||
|
|
||||||
|
let input;
|
||||||
|
const off = f.writable ? "" : " disabled";
|
||||||
|
if (f.secret) {
|
||||||
|
input = `<input type="text" value="${f.value ? "gesetzt" : "nicht gesetzt"}" disabled>`;
|
||||||
|
} else if (f.type === "bool") {
|
||||||
|
input = `<select id="${id}"${off}><option value="true"${f.value ? " selected" : ""}>ja</option>`
|
||||||
|
+ `<option value="false"${f.value ? "" : " selected"}>nein</option></select>`;
|
||||||
|
} else if (f.type === "enum") {
|
||||||
|
input = `<select id="${id}"${off}>`
|
||||||
|
+ f.choices.map(c => `<option${c === f.value ? " selected" : ""}>${c}</option>`).join("")
|
||||||
|
+ "</select>";
|
||||||
|
} else if (f.type === "int" || f.type === "float") {
|
||||||
|
const c = f.constraints || {};
|
||||||
|
const step = f.type === "int" ? "1" : "any";
|
||||||
|
const min = c.min !== undefined ? ` min="${c.min}"` : "";
|
||||||
|
const max = c.max !== undefined ? ` max="${c.max}"` : "";
|
||||||
|
input = `<input type="number" id="${id}" step="${step}"${min}${max} value="${f.value ?? ""}"${off}>`;
|
||||||
|
} else if (f.type === "list") {
|
||||||
|
input = `<input type="text" id="${id}" value="${(f.value || []).join(", ")}"${off}>`;
|
||||||
|
} else if (f.type === "json") {
|
||||||
|
input = `<input type="text" id="${id}" value="${JSON.stringify(f.value ?? {})}"${off}>`;
|
||||||
|
} else {
|
||||||
|
input = `<input type="text" id="${id}" value="${f.value ?? ""}"${off}>`;
|
||||||
|
}
|
||||||
|
return `<tr><td><code>${f.path}</code>${badges.join(" ")}<div class="hint" style="margin:0">`
|
||||||
|
+ `${f.description || ""}</div></td><td>${input}</td></tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConfig(cfg) {
|
||||||
|
if (!cfg.available) { $("config-section").hidden = true; return; }
|
||||||
|
cfgLoaded = cfg;
|
||||||
|
$("cfg-meta").textContent =
|
||||||
|
`${cfg.config_path} · ${cfg.override_count} geänderte(s) Feld(er) in ${cfg.overrides_path}`
|
||||||
|
+ (cfg.pending_restart.length ? ` · Neustart ausstehend für: ${cfg.pending_restart.join(", ")}` : "");
|
||||||
|
$("cfg-sections").innerHTML = cfg.sections.map(s =>
|
||||||
|
`<h3 style="font-size:13px;margin:16px 0 4px;color:var(--muted)">${s.title}</h3>`
|
||||||
|
+ `<div class="overflow"><table class="cfg">${s.fields.map(fieldRow).join("")}</table></div>`
|
||||||
|
).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectChanges() {
|
||||||
|
const out = {};
|
||||||
|
for (const s of cfgLoaded.sections) for (const f of s.fields) {
|
||||||
|
if (!f.writable || f.secret) continue;
|
||||||
|
const el = $(fieldId(f.path));
|
||||||
|
if (!el) continue;
|
||||||
|
let value = el.value;
|
||||||
|
if (f.type === "bool") value = value === "true";
|
||||||
|
else if (f.type === "int") value = value === "" ? null : parseInt(value, 10);
|
||||||
|
else if (f.type === "float") value = value === "" ? null : parseFloat(value);
|
||||||
|
else if (f.type === "list") value = value.split(",").map(v => v.trim()).filter(Boolean);
|
||||||
|
else if (f.type === "json") { try { value = JSON.parse(value); } catch { continue; } }
|
||||||
|
else if (value === "") value = null;
|
||||||
|
if (JSON.stringify(value) !== JSON.stringify(f.value)) out[f.path] = value;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
try { renderConfig(await (await fetch("control/config", { headers: headers() })).json()); }
|
||||||
|
catch (e) { setHint($("cfg-hint"), "Konfiguration nicht lesbar: " + e.message, true); }
|
||||||
|
}
|
||||||
|
|
||||||
|
$("cfg-toggle").addEventListener("click", async () => {
|
||||||
|
cfgOpen = !cfgOpen;
|
||||||
|
$("cfg-panel").hidden = !cfgOpen;
|
||||||
|
$("cfg-toggle").textContent = cfgOpen ? "Konfiguration schließen" : "Konfiguration bearbeiten";
|
||||||
|
$("cfg-toggle").className = cfgOpen ? "ghost" : "";
|
||||||
|
if (cfgOpen) await loadConfig();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("cfg-save").addEventListener("click", async () => {
|
||||||
|
const changes = collectChanges();
|
||||||
|
if (!Object.keys(changes).length) { setHint($("cfg-hint"), "Nichts geändert."); return; }
|
||||||
|
$("cfg-save").disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await post("control/config", changes);
|
||||||
|
const n = Object.keys(changes).length;
|
||||||
|
setHint($("cfg-hint"), `${n} Feld(er) gespeichert.`
|
||||||
|
+ (r.restart_required && r.restart_required.length
|
||||||
|
? ` Neustart nötig für: ${r.restart_required.join(", ")}` : " Sofort wirksam."));
|
||||||
|
renderConfig(r);
|
||||||
|
} catch (e) { setHint($("cfg-hint"), e.message, true); }
|
||||||
|
$("cfg-save").disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$("cfg-revert").addEventListener("click", () => { renderConfig(cfgLoaded); setHint($("cfg-hint"), ""); });
|
||||||
|
|
||||||
|
$("cfg-reset").addEventListener("click", async () => {
|
||||||
|
if (!confirm("Alle im Dashboard geänderten Werte verwerfen und zur Konfigurationsdatei "
|
||||||
|
+ "zurückkehren?")) return;
|
||||||
|
try {
|
||||||
|
renderConfig(await post("control/config/reset", {}));
|
||||||
|
setHint($("cfg-hint"), "Zurückgesetzt.");
|
||||||
|
} catch (e) { setHint($("cfg-hint"), e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
refresh(); setInterval(refresh, 5000);
|
refresh(); setInterval(refresh, 5000);
|
||||||
</script>
|
</script>
|
||||||
"""
|
"""
|
||||||
@@ -101,11 +488,21 @@ def _flatten_metrics(prefix: str, node: Any, out: list[tuple[str, float]]) -> No
|
|||||||
class StatusServer:
|
class StatusServer:
|
||||||
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
|
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
|
||||||
|
|
||||||
def __init__(self, config: ServerConfig, status_provider: StatusProvider) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ServerConfig,
|
||||||
|
status_provider: StatusProvider,
|
||||||
|
controller: Controller | None = None,
|
||||||
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self._status = status_provider
|
self._status = status_provider
|
||||||
|
self._controller = controller
|
||||||
self._runner: web.AppRunner | None = None
|
self._runner: web.AppRunner | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_available(self) -> bool:
|
||||||
|
return self.config.enable_control and self._controller is not None
|
||||||
|
|
||||||
def _build_app(self) -> web.Application:
|
def _build_app(self) -> web.Application:
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.add_routes(
|
app.add_routes(
|
||||||
@@ -120,6 +517,20 @@ class StatusServer:
|
|||||||
)
|
)
|
||||||
if self.config.enable_metrics:
|
if self.config.enable_metrics:
|
||||||
app.router.add_get("/metrics", self._metrics)
|
app.router.add_get("/metrics", self._metrics)
|
||||||
|
if self.control_available:
|
||||||
|
app.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/control/training", self._training_state),
|
||||||
|
web.post("/control/train/history", self._train_history),
|
||||||
|
web.post("/control/train/live", self._train_live),
|
||||||
|
web.get("/control/trading", self._trading_state),
|
||||||
|
web.post("/control/trading", self._set_trading),
|
||||||
|
web.post("/control/explain", self._explain),
|
||||||
|
web.get("/control/config", self._config_state),
|
||||||
|
web.post("/control/config", self._update_config),
|
||||||
|
web.post("/control/config/reset", self._reset_config),
|
||||||
|
]
|
||||||
|
)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -162,6 +573,156 @@ class StatusServer:
|
|||||||
trades = self._status().get("recent_trades", [])
|
trades = self._status().get("recent_trades", [])
|
||||||
return web.json_response(trades[-limit:], dumps=_dumps)
|
return web.json_response(trades[-limit:], dumps=_dumps)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Steuerung
|
||||||
|
|
||||||
|
def _authorized(self, request: web.Request) -> bool:
|
||||||
|
token = self.config.control_token
|
||||||
|
if not token:
|
||||||
|
return True
|
||||||
|
supplied = request.headers.get(TOKEN_HEADER, "")
|
||||||
|
return hmac.compare_digest(supplied, token)
|
||||||
|
|
||||||
|
async def _guard(self, request: web.Request) -> web.Response | None:
|
||||||
|
"""Gibt eine Fehlerantwort zurück, wenn der Aufruf nicht erlaubt ist."""
|
||||||
|
if self._controller is None or not self.config.enable_control:
|
||||||
|
return web.json_response({"error": "Steuerung ist deaktiviert"}, status=404)
|
||||||
|
if not self._authorized(request):
|
||||||
|
log.warning("Steuerbefehl ohne gültiges Token abgelehnt (%s)", request.remote)
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Ungültiges oder fehlendes Token im Header {TOKEN_HEADER}"}, status=401
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _training_state(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
return web.json_response(self._controller.training_status(), dumps=_dumps)
|
||||||
|
|
||||||
|
async def _train_history(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
bars: int | None = None
|
||||||
|
if request.can_read_body:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if isinstance(payload, dict) and payload.get("bars") is not None:
|
||||||
|
try:
|
||||||
|
bars = int(payload["bars"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response({"error": "'bars' muss eine Zahl sein"}, status=400)
|
||||||
|
|
||||||
|
result = self._controller.start_history_training(bars)
|
||||||
|
return web.json_response(result, status=202 if result.get("accepted") else 409, dumps=_dumps)
|
||||||
|
|
||||||
|
async def _train_live(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("enabled"), bool):
|
||||||
|
return web.json_response({"error": "'enabled' (true/false) wird erwartet"}, status=400)
|
||||||
|
|
||||||
|
result = self._controller.set_online_learning(payload["enabled"])
|
||||||
|
return web.json_response(result, status=200 if result.get("accepted") else 409, dumps=_dumps)
|
||||||
|
|
||||||
|
async def _trading_state(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
return web.json_response(self._controller.trading_control_status(), dumps=_dumps)
|
||||||
|
|
||||||
|
async def _set_trading(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("enabled"), bool):
|
||||||
|
return web.json_response({"error": "'enabled' (true/false) wird erwartet"}, status=400)
|
||||||
|
|
||||||
|
confirm = payload.get("confirm")
|
||||||
|
if confirm is not None and not isinstance(confirm, str):
|
||||||
|
return web.json_response({"error": "'confirm' muss eine Zeichenkette sein"}, status=400)
|
||||||
|
|
||||||
|
result = self._controller.set_trading(payload["enabled"], confirm)
|
||||||
|
if result.get("accepted"):
|
||||||
|
return web.json_response(result, status=200, dumps=_dumps)
|
||||||
|
# Fehlende Live-Bestätigung ist kein Konflikt, sondern eine unvollständige Anfrage.
|
||||||
|
status = 428 if result.get("requires_confirmation") and payload["enabled"] else 409
|
||||||
|
return web.json_response(result, status=status, dumps=_dumps)
|
||||||
|
|
||||||
|
async def _explain(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
result = await self._controller.explain()
|
||||||
|
return web.json_response(result, status=200 if result.get("ok") else 503, dumps=_dumps)
|
||||||
|
|
||||||
|
async def _config_state(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
return web.json_response(self._controller.config_state(), dumps=_dumps)
|
||||||
|
|
||||||
|
async def _update_config(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return web.json_response({"error": "Objekt mit Feldpfaden erwartet"}, status=400)
|
||||||
|
|
||||||
|
# Sowohl {"risk.max_open_positions": 5} als auch {"risk": {"max_open_positions": 5}}.
|
||||||
|
patch = payload.get("values") if isinstance(payload.get("values"), dict) else payload
|
||||||
|
result = self._controller.update_config(patch)
|
||||||
|
return web.json_response(result, status=200 if result.get("accepted") else 400, dumps=_dumps)
|
||||||
|
|
||||||
|
async def _reset_config(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
paths: list[str] | None = None
|
||||||
|
if request.can_read_body:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if isinstance(payload, dict) and payload.get("paths") is not None:
|
||||||
|
raw = payload["paths"]
|
||||||
|
if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
|
||||||
|
return web.json_response({"error": "'paths' muss eine Liste von Texten sein"},
|
||||||
|
status=400)
|
||||||
|
paths = raw
|
||||||
|
|
||||||
|
result = self._controller.reset_config(paths)
|
||||||
|
return web.json_response(result, status=200 if result.get("accepted") else 409, dumps=_dumps)
|
||||||
|
|
||||||
async def _metrics(self, _: web.Request) -> web.Response:
|
async def _metrics(self, _: web.Request) -> web.Response:
|
||||||
state = self._status()
|
state = self._status()
|
||||||
samples: list[tuple[str, float]] = []
|
samples: list[tuple[str, float]] = []
|
||||||
|
|||||||
+40
-20
@@ -115,6 +115,7 @@ class AdaptiveStrategy(Strategy):
|
|||||||
self.candidates_explored = 0
|
self.candidates_explored = 0
|
||||||
self.background_samples = 0
|
self.background_samples = 0
|
||||||
self._bar_index: dict[str, int] = {}
|
self._bar_index: dict[str, int] = {}
|
||||||
|
self._label_tag = "live"
|
||||||
|
|
||||||
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
|
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
|
||||||
if position is not None:
|
if position is not None:
|
||||||
@@ -163,12 +164,14 @@ class AdaptiveStrategy(Strategy):
|
|||||||
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
|
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
|
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
|
||||||
self.learner.register_candidate(symbol, snapshot.values, snapshot.price, bar_index, weight)
|
self.learner.register_candidate(
|
||||||
|
symbol, snapshot.values, snapshot.price, bar_index, weight, tag=self._label_tag
|
||||||
|
)
|
||||||
|
|
||||||
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
|
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
|
||||||
close: float) -> None:
|
close: float) -> None:
|
||||||
self._bar_index[symbol] = bar_index
|
self._bar_index[symbol] = bar_index
|
||||||
self.learner.resolve_pending(symbol, bar_index, high, low, close)
|
self.learner.resolve_pending(symbol, bar_index, high, low, close, tag=self._label_tag)
|
||||||
|
|
||||||
# Einstiegssignale sind selten – regelmäßige Stichproben des Marktzustands geben
|
# Einstiegssignale sind selten – regelmäßige Stichproben des Marktzustands geben
|
||||||
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
|
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
|
||||||
@@ -186,26 +189,43 @@ class AdaptiveStrategy(Strategy):
|
|||||||
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
|
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
|
||||||
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
|
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
|
||||||
|
|
||||||
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen.
|
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen,
|
||||||
Es werden ausschließlich vergangene Kerzen verwendet – dieselbe Logik wie im Backtest.
|
und ein laufender Bot lässt sich jederzeit nachtrainieren. Es werden ausschließlich
|
||||||
Gibt den Index der letzten verarbeiteten Kerze zurück.
|
vergangene Kerzen verwendet – dieselbe Logik wie im Backtest, nur ohne Handel.
|
||||||
|
|
||||||
|
Die dabei erzeugten Labels laufen unter dem Tag ``history`` und bleiben damit von
|
||||||
|
den offenen Labels des Live-Betriebs getrennt. Gibt den Index der letzten
|
||||||
|
verarbeiteten Kerze zurück.
|
||||||
"""
|
"""
|
||||||
|
previous_tag = self._label_tag
|
||||||
|
previous_bar_index = self._bar_index.get(symbol)
|
||||||
|
self._label_tag = "history"
|
||||||
last_index = matrix.first_valid
|
last_index = matrix.first_valid
|
||||||
for index in range(matrix.first_valid, len(matrix)):
|
try:
|
||||||
snapshot = matrix.snapshot(index)
|
for index in range(matrix.first_valid, len(matrix)):
|
||||||
if snapshot is None:
|
snapshot = matrix.snapshot(index)
|
||||||
continue
|
if snapshot is None:
|
||||||
self.on_bar(
|
continue
|
||||||
symbol,
|
self.on_bar(
|
||||||
snapshot,
|
symbol,
|
||||||
index,
|
snapshot,
|
||||||
float(candles.high[index]),
|
index,
|
||||||
float(candles.low[index]),
|
float(candles.high[index]),
|
||||||
float(candles.close[index]),
|
float(candles.low[index]),
|
||||||
)
|
float(candles.close[index]),
|
||||||
if self.rules.entry_candidate(snapshot) is not None:
|
)
|
||||||
self.register_candidate(symbol, snapshot, index)
|
if self.rules.entry_candidate(snapshot) is not None:
|
||||||
last_index = index
|
self.register_candidate(symbol, snapshot, index)
|
||||||
|
last_index = index
|
||||||
|
finally:
|
||||||
|
self._label_tag = previous_tag
|
||||||
|
# Nicht aufgelöste Historien-Labels verwerfen; sie würden sonst gegen die
|
||||||
|
# nächste Live-Kerze ausgewertet und das Modell mit falschen Labels füttern.
|
||||||
|
self.learner.drop_pending(symbol=symbol, tag="history")
|
||||||
|
if previous_bar_index is None:
|
||||||
|
self._bar_index.pop(symbol, None)
|
||||||
|
else:
|
||||||
|
self._bar_index[symbol] = previous_bar_index
|
||||||
return last_index
|
return last_index
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
"""Konfiguration zur Laufzeit ändern: Overlay, Validierung, Schema, Schutzregeln."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from trademind.config import Config, Mode, is_secret, requires_restart
|
||||||
|
from trademind.configstore import (
|
||||||
|
NON_WRITABLE,
|
||||||
|
SECRET_PLACEHOLDER,
|
||||||
|
ConfigError,
|
||||||
|
ConfigStore,
|
||||||
|
deep_merge,
|
||||||
|
describe_model,
|
||||||
|
flatten,
|
||||||
|
unflatten,
|
||||||
|
)
|
||||||
|
|
||||||
|
BASE_YAML = """
|
||||||
|
mode: paper
|
||||||
|
market:
|
||||||
|
symbols: [BTC/USDT]
|
||||||
|
risk:
|
||||||
|
max_open_positions: 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path) -> ConfigStore:
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(
|
||||||
|
BASE_YAML + f"storage:\n overrides_path: {tmp_path / 'overrides.yaml'}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
return ConfigStore.load(config_file)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Hilfsteile
|
||||||
|
|
||||||
|
|
||||||
|
def test_flatten_round_trip():
|
||||||
|
nested = {"risk": {"max_open_positions": 3, "inner": {"a": 1}}, "mode": "paper"}
|
||||||
|
flat = flatten(nested)
|
||||||
|
assert flat == {"risk.max_open_positions": 3, "risk.inner.a": 1, "mode": "paper"}
|
||||||
|
assert unflatten(flat) == nested
|
||||||
|
|
||||||
|
|
||||||
|
def test_deep_merge_keeps_untouched_branches():
|
||||||
|
base = {"risk": {"a": 1, "b": 2}, "market": {"symbols": ["X"]}}
|
||||||
|
merged = deep_merge(base, {"risk": {"b": 99}})
|
||||||
|
assert merged == {"risk": {"a": 1, "b": 99}, "market": {"symbols": ["X"]}}
|
||||||
|
assert base["risk"]["b"] == 2, "Original darf nicht verändert werden"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Schema
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_covers_every_leaf_field():
|
||||||
|
specs = describe_model(Config())
|
||||||
|
paths = {s.path for s in specs}
|
||||||
|
for expected in (
|
||||||
|
"mode", "log_level", "exchange.id", "market.timeframe", "trading.autostart",
|
||||||
|
"paper.fee_rate", "risk.max_open_positions", "strategy.name",
|
||||||
|
"strategy.rules.fast_ema", "strategy.learner.entry_threshold",
|
||||||
|
"storage.database_path", "server.port", "notifications.notify_on_trade",
|
||||||
|
"backtest.bars",
|
||||||
|
):
|
||||||
|
assert expected in paths, f"{expected} fehlt in der Beschreibung"
|
||||||
|
# Verschachtelte Modelle müssen aufgelöst sein; ein echtes Dict-Feld (exchange.options)
|
||||||
|
# ist dagegen ein Blatt und wird als Typ "json" ausgeliefert.
|
||||||
|
dict_valued = [s.path for s in specs if isinstance(s.value, dict) and s.type != "json"]
|
||||||
|
assert not dict_valued, f"Unaufgelöste Teilmodelle: {dict_valued}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_types_are_detected():
|
||||||
|
by_path = {s.path: s for s in describe_model(Config())}
|
||||||
|
assert by_path["risk.max_open_positions"].type == "int"
|
||||||
|
assert by_path["paper.fee_rate"].type == "float"
|
||||||
|
assert by_path["exchange.sandbox"].type == "bool"
|
||||||
|
assert by_path["market.symbols"].type == "list"
|
||||||
|
assert by_path["mode"].type == "enum"
|
||||||
|
assert by_path["exchange.id"].type == "str"
|
||||||
|
assert by_path["exchange.options"].type == "json"
|
||||||
|
|
||||||
|
|
||||||
|
def test_constraints_are_exposed():
|
||||||
|
by_path = {s.path: s for s in describe_model(Config())}
|
||||||
|
assert by_path["risk.max_position_pct"].constraints["max"] == 1.0
|
||||||
|
assert by_path["server.port"].constraints["min"] == 1
|
||||||
|
assert by_path["risk.max_open_positions"].constraints["min"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_choices_exclude_live():
|
||||||
|
by_path = {s.path: s for s in describe_model(Config())}
|
||||||
|
assert set(by_path["mode"].choices) == {"paper", "backtest"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_and_secret_flags():
|
||||||
|
by_path = {s.path: s for s in describe_model(Config())}
|
||||||
|
assert by_path["exchange.id"].restart is True
|
||||||
|
assert by_path["market.symbols"].restart is True
|
||||||
|
assert by_path["risk.max_open_positions"].restart is False
|
||||||
|
assert by_path["strategy.rules.fast_ema"].restart is False
|
||||||
|
assert by_path["exchange.api_key"].secret is True
|
||||||
|
assert by_path["exchange.api_key"].writable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_secret_values_never_leave_the_process():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{"exchange": {"api_key": "geheim-123", "api_secret": "auch-geheim"}}
|
||||||
|
)
|
||||||
|
for spec in describe_model(config):
|
||||||
|
if is_secret(spec.path):
|
||||||
|
assert spec.value in (SECRET_PLACEHOLDER, None)
|
||||||
|
assert "geheim" not in str(spec.value), f"{spec.path} verrät ein Geheimnis"
|
||||||
|
|
||||||
|
|
||||||
|
def test_describe_marks_overridden_fields(store):
|
||||||
|
store.apply({"risk.max_open_positions": 5})
|
||||||
|
by_path = {f["path"]: f for s in store.describe()["sections"] for f in s["fields"]}
|
||||||
|
assert by_path["risk.max_open_positions"]["overridden"] is True
|
||||||
|
assert by_path["risk.min_notional"]["overridden"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Ändern
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_is_applied_and_persisted(store, tmp_path):
|
||||||
|
config, restart = store.apply({"risk.max_open_positions": 7})
|
||||||
|
assert config.risk.max_open_positions == 7
|
||||||
|
assert restart == []
|
||||||
|
|
||||||
|
saved = yaml.safe_load((tmp_path / "overrides.yaml").read_text(encoding="utf-8"))
|
||||||
|
assert saved == {"risk": {"max_open_positions": 7}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_survives_a_reload(store, tmp_path):
|
||||||
|
store.apply({"risk.max_open_positions": 9, "strategy.learner.entry_threshold": 0.8})
|
||||||
|
revived = ConfigStore.load(tmp_path / "config.yaml")
|
||||||
|
assert revived.config.risk.max_open_positions == 9
|
||||||
|
assert revived.config.strategy.learner.entry_threshold == 0.8
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_patch_form_is_accepted(store):
|
||||||
|
config, _ = store.apply({"risk": {"max_open_positions": 4}})
|
||||||
|
assert config.risk.max_open_positions == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_required_fields_are_reported(store):
|
||||||
|
_, restart = store.apply({"market.timeframe": "15m", "risk.max_open_positions": 2})
|
||||||
|
assert restart == ["market.timeframe"]
|
||||||
|
assert "market.timeframe" in store.pending_restart
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_real_deviations_are_stored(store, tmp_path):
|
||||||
|
"""Ein auf den Ausgangswert zurückgesetztes Feld darf kein Overlay hinterlassen."""
|
||||||
|
store.apply({"risk.max_open_positions": 7})
|
||||||
|
store.apply({"risk.max_open_positions": 3}) # 3 steht so in der Basisdatei
|
||||||
|
saved = yaml.safe_load((tmp_path / "overrides.yaml").read_text(encoding="utf-8")) or {}
|
||||||
|
assert flatten(saved) == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_value_is_rejected_with_a_readable_message(store):
|
||||||
|
with pytest.raises(ConfigError, match="risk.max_position_pct"):
|
||||||
|
store.apply({"risk.max_position_pct": 5.0})
|
||||||
|
assert store.config.risk.max_position_pct == 0.2, "Alter Wert muss erhalten bleiben"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cross_field_rule_is_enforced(store):
|
||||||
|
with pytest.raises(ConfigError, match="fast_ema"):
|
||||||
|
store.apply({"strategy.rules.fast_ema": 50})
|
||||||
|
assert store.config.strategy.rules.fast_ema == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_field_is_rejected(store):
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
store.apply({"risk.gibtsnicht": 1})
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_patch_is_rejected(store):
|
||||||
|
with pytest.raises(ConfigError, match="Keine Änderungen"):
|
||||||
|
store.apply({})
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Schutzregeln
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", sorted(NON_WRITABLE))
|
||||||
|
def test_protected_fields_cannot_be_written(store, path):
|
||||||
|
with pytest.raises(ConfigError, match="lassen sich nicht"):
|
||||||
|
store.apply({path: "beliebig"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_credentials_cannot_be_set_through_the_store(store):
|
||||||
|
with pytest.raises(ConfigError):
|
||||||
|
store.apply({"exchange.api_secret": "gestohlen"})
|
||||||
|
assert store.config.exchange.api_secret is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_to_live_is_refused(store):
|
||||||
|
with pytest.raises(ConfigError, match="Echtgeldhandel"):
|
||||||
|
store.apply({"mode": "live"})
|
||||||
|
assert store.config.mode is Mode.PAPER
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_between_safe_modes_is_allowed(store):
|
||||||
|
config, restart = store.apply({"mode": "backtest"})
|
||||||
|
assert config.mode is Mode.BACKTEST
|
||||||
|
assert restart == ["mode"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Zurücksetzen
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_all(store):
|
||||||
|
store.apply({"risk.max_open_positions": 8, "risk.min_notional": 50.0})
|
||||||
|
config = store.reset()
|
||||||
|
assert config.risk.max_open_positions == 3
|
||||||
|
assert config.risk.min_notional == 10.0
|
||||||
|
assert store.overrides == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_single_field(store):
|
||||||
|
store.apply({"risk.max_open_positions": 8, "risk.min_notional": 50.0})
|
||||||
|
config = store.reset(["risk.max_open_positions"])
|
||||||
|
assert config.risk.max_open_positions == 3
|
||||||
|
assert config.risk.min_notional == 50.0
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- Robustheit
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_overlay_is_ignored(tmp_path):
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
overrides = tmp_path / "overrides.yaml"
|
||||||
|
config_file.write_text(BASE_YAML + f"storage:\n overrides_path: {overrides}\n", encoding="utf-8")
|
||||||
|
overrides.write_text("das ist: [kein gueltiges: yaml", encoding="utf-8")
|
||||||
|
|
||||||
|
store = ConfigStore.load(config_file)
|
||||||
|
assert store.config.risk.max_open_positions == 3
|
||||||
|
assert store.overrides == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_with_invalid_values_is_ignored(tmp_path):
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
overrides = tmp_path / "overrides.yaml"
|
||||||
|
config_file.write_text(BASE_YAML + f"storage:\n overrides_path: {overrides}\n", encoding="utf-8")
|
||||||
|
overrides.write_text("risk:\n max_open_positions: -5\n", encoding="utf-8")
|
||||||
|
|
||||||
|
store = ConfigStore.load(config_file)
|
||||||
|
assert store.config.risk.max_open_positions == 3
|
||||||
|
assert store.overrides == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_config_file_raises(tmp_path):
|
||||||
|
with pytest.raises(FileNotFoundError):
|
||||||
|
ConfigStore.load(tmp_path / "gibtsnicht.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_override_is_beaten_by_the_dashboard(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("TRADEMIND__RISK__MAX_OPEN_POSITIONS", "6")
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(
|
||||||
|
BASE_YAML + f"storage:\n overrides_path: {tmp_path / 'o.yaml'}\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
store = ConfigStore.load(config_file)
|
||||||
|
assert store.config.risk.max_open_positions == 6
|
||||||
|
|
||||||
|
store.apply({"risk.max_open_positions": 2})
|
||||||
|
assert ConfigStore.load(config_file).config.risk.max_open_positions == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_restart_path_exists_in_the_model():
|
||||||
|
"""Schutz vor Tippfehlern in RESTART_REQUIRED."""
|
||||||
|
paths = {s.path for s in describe_model(Config())}
|
||||||
|
for path in paths:
|
||||||
|
requires_restart(path) # darf nicht werfen
|
||||||
|
from trademind.config import RESTART_REQUIRED
|
||||||
|
|
||||||
|
unknown = RESTART_REQUIRED - paths
|
||||||
|
assert not unknown, f"Unbekannte Pfade in RESTART_REQUIRED: {sorted(unknown)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_protected_path_exists_in_the_model():
|
||||||
|
paths = {s.path for s in describe_model(Config())}
|
||||||
|
unknown = NON_WRITABLE - paths
|
||||||
|
assert not unknown, f"Unbekannte Pfade in NON_WRITABLE: {sorted(unknown)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_description_matches_a_real_field():
|
||||||
|
from trademind.configstore import DESCRIPTIONS
|
||||||
|
|
||||||
|
paths = {s.path for s in describe_model(Config())}
|
||||||
|
unknown = set(DESCRIPTIONS) - paths
|
||||||
|
assert not unknown, f"Beschreibungen ohne Feld: {sorted(unknown)}"
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
"""Steuerung über das Dashboard: historisches Nachtraining und Live-Lernschalter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from trademind.app import _guard_control_exposure
|
||||||
|
from trademind.backtest import BacktestRunner
|
||||||
|
from trademind.config import LIVE_CONFIRMATION_PHRASE, Config, ServerConfig
|
||||||
|
from trademind.engine import LIVE_TRADING_CONFIRMATION, Bar
|
||||||
|
from trademind.features import N_FEATURES, compute_features
|
||||||
|
from trademind.models import ExitReason, Side
|
||||||
|
from trademind.server import TOKEN_HEADER, StatusServer
|
||||||
|
|
||||||
|
from .conftest import make_candles
|
||||||
|
from .test_engine import GrowingFeed, StaticFeed, build_engine, cyclical_series
|
||||||
|
|
||||||
|
|
||||||
|
def control_config(base_config: Config, **server: object) -> Config:
|
||||||
|
return Config.model_validate(
|
||||||
|
{**base_config.model_dump(), "mode": "paper", "server": {"enabled": True, **server}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Historisches Training
|
||||||
|
|
||||||
|
|
||||||
|
async def test_history_training_can_be_started_and_completes(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
|
||||||
|
result = engine.start_history_training(bars=900)
|
||||||
|
assert result["accepted"] is True
|
||||||
|
assert engine.training.state == "queued"
|
||||||
|
|
||||||
|
await engine._training_task
|
||||||
|
job = engine.training
|
||||||
|
assert job.state == "done"
|
||||||
|
assert job.samples_gained > 0
|
||||||
|
assert job.symbols_done == ["BTC/USDT"]
|
||||||
|
assert job.bars_seen > 0
|
||||||
|
assert engine.strategy.learner.stats.samples_seen == job.as_dict()["samples_total"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bars_are_clamped_to_sane_limits(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
|
||||||
|
engine.start_history_training(bars=10)
|
||||||
|
assert engine.training.bars_requested == 500 # Untergrenze
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
engine.start_history_training(bars=10_000_000)
|
||||||
|
assert engine.training.bars_requested == 50_000 # Obergrenze
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_second_training_is_rejected_while_one_runs(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
second = engine.start_history_training(bars=900)
|
||||||
|
assert second["accepted"] is False
|
||||||
|
assert "läuft bereits" in second["reason"]
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_training_does_not_trade(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
assert engine.portfolio.trades == []
|
||||||
|
assert engine.portfolio.positions == {}
|
||||||
|
assert await engine.broker.cash() == pytest.approx(base_config.paper.starting_balance)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_training_leaves_live_labels_untouched(base_config):
|
||||||
|
"""Historisches Training darf offene Live-Labels weder auflösen noch verwerfen."""
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
learner = engine.strategy.learner
|
||||||
|
|
||||||
|
learner.register_candidate("BTC/USDT", np.ones(N_FEATURES), 100.0, bar_index=5, tag="live")
|
||||||
|
assert learner.pending_count == 1
|
||||||
|
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
remaining = [p for p in learner._pending if p.tag == "live"]
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0].entry_price == 100.0
|
||||||
|
assert not [p for p in learner._pending if p.tag == "history"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_training_does_not_shift_the_live_timeline(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
engine.bar_counter["BTC/USDT"] = 42
|
||||||
|
engine.last_bar_ts["BTC/USDT"] = 1_234_567
|
||||||
|
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
assert engine.bar_counter["BTC/USDT"] == 42
|
||||||
|
assert engine.last_bar_ts["BTC/USDT"] == 1_234_567
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bootstrap_still_adopts_the_timeline(base_config):
|
||||||
|
"""Beim Kaltstart soll der Live-Loop dagegen an die Historie anschließen."""
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
await engine.bootstrap_learner()
|
||||||
|
assert engine.bar_counter["BTC/USDT"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_training_and_tick_do_not_overlap(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
tick = asyncio.create_task(engine._tick(300))
|
||||||
|
await asyncio.gather(engine._training_task, tick)
|
||||||
|
|
||||||
|
assert engine.training.state == "done"
|
||||||
|
assert engine.errors == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_training_failure_is_reported_not_raised(base_config):
|
||||||
|
class BrokenFeed(StaticFeed):
|
||||||
|
async def fetch(self, symbol, timeframe, limit):
|
||||||
|
raise RuntimeError("Börse nicht erreichbar")
|
||||||
|
|
||||||
|
engine = build_engine(control_config(base_config), feed=BrokenFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
job = engine.training
|
||||||
|
assert job.state == "done" # der Lauf endet geordnet
|
||||||
|
assert job.symbols_done == []
|
||||||
|
assert any("nicht erreichbar" in s for s in job.skipped)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_short_history_is_skipped(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed(make_candles(n=60)))
|
||||||
|
await engine.prepare()
|
||||||
|
engine.start_history_training(bars=900)
|
||||||
|
await engine._training_task
|
||||||
|
assert any("Kerzen" in s for s in engine.training.skipped)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rules_strategy_cannot_be_trained(base_config):
|
||||||
|
config = Config.model_validate(
|
||||||
|
{**control_config(base_config).model_dump(), "strategy": {"name": "rules"}}
|
||||||
|
)
|
||||||
|
engine = build_engine(config, feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
result = engine.start_history_training()
|
||||||
|
assert result["accepted"] is False
|
||||||
|
assert "lernfähiges Modell" in result["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------- Live-Lernen umschalten
|
||||||
|
|
||||||
|
|
||||||
|
async def test_online_learning_can_be_toggled(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
assert engine.online_learning_enabled is True
|
||||||
|
|
||||||
|
assert engine.set_online_learning(False)["online_learning"] is False
|
||||||
|
assert engine.strategy.learner.frozen is True
|
||||||
|
assert engine.online_learning_enabled is False
|
||||||
|
|
||||||
|
assert engine.set_online_learning(True)["online_learning"] is True
|
||||||
|
assert engine.strategy.learner.frozen is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_frozen_learner_stops_updating_weights(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
learner = engine.strategy.learner
|
||||||
|
for _ in range(60):
|
||||||
|
learner.observe(np.ones(N_FEATURES), 1.0)
|
||||||
|
|
||||||
|
engine.set_online_learning(False)
|
||||||
|
weights = learner.model.w.copy()
|
||||||
|
for _ in range(60):
|
||||||
|
learner.observe(np.ones(N_FEATURES), 0.0)
|
||||||
|
assert np.allclose(learner.model.w, weights)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_control_disabled_rejects_commands(base_config):
|
||||||
|
engine = build_engine(control_config(base_config, enable_control=False), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
assert engine.start_history_training()["accepted"] is False
|
||||||
|
assert engine.set_online_learning(False)["accepted"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ Automatisierter Handel
|
||||||
|
|
||||||
|
|
||||||
|
def trading_config(base_config: Config, **trading: object) -> Config:
|
||||||
|
return Config.model_validate(
|
||||||
|
{**control_config(base_config).model_dump(), "trading": trading}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trading_is_active_by_default(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
assert engine.trading_active is True
|
||||||
|
assert engine.trading_control_status()["simulated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_autostart_false_starts_paused(base_config):
|
||||||
|
engine = build_engine(trading_config(base_config, autostart=False), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
assert engine.trading_active is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trading_can_be_paused_and_resumed(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
|
||||||
|
assert engine.set_trading(False)["active"] is False
|
||||||
|
assert engine.trading_active is False
|
||||||
|
assert engine.set_trading(True)["active"] is True
|
||||||
|
assert engine.trading_active is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repeated_state_is_accepted_without_change(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
result = engine.set_trading(True)
|
||||||
|
assert result["accepted"] is True
|
||||||
|
assert "bereits" in result["reason"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_bot_opens_no_positions(base_config):
|
||||||
|
engine = build_engine(trading_config(base_config, autostart=False))
|
||||||
|
await engine.prepare()
|
||||||
|
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
|
||||||
|
|
||||||
|
assert engine.portfolio.trades == []
|
||||||
|
assert engine.portfolio.positions == {}
|
||||||
|
assert engine.paused_signals > 0, "es hätte Signale geben müssen, die nur gelernt wurden"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paused_bot_keeps_learning(base_config):
|
||||||
|
"""Kern der Anforderung: Pausiert fließen weiter Daten ins Modell."""
|
||||||
|
paused = build_engine(trading_config(base_config, autostart=False))
|
||||||
|
await paused.prepare()
|
||||||
|
await BacktestRunner(paused, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
|
||||||
|
|
||||||
|
assert paused.strategy.learner.stats.samples_seen > 0
|
||||||
|
assert paused.strategy.learner.stats.shadow_samples > 0
|
||||||
|
assert paused.strategy.learner.stats.updates > 0
|
||||||
|
assert paused.strategy.candidates_seen > 0
|
||||||
|
# Ohne Trades gibt es logischerweise keine Trade-Labels, nur Shadow-Labels.
|
||||||
|
assert paused.strategy.learner.stats.trade_samples == 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_active_bot_trades_on_the_same_data(base_config):
|
||||||
|
active = build_engine(control_config(base_config))
|
||||||
|
await active.prepare()
|
||||||
|
await BacktestRunner(active, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
|
||||||
|
assert active.portfolio.stats.trades > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pausing_keeps_stop_loss_active(base_config):
|
||||||
|
"""Eine offene Position darf beim Pausieren nicht ungeschützt stehenbleiben."""
|
||||||
|
engine = build_engine(control_config(base_config))
|
||||||
|
await engine.prepare()
|
||||||
|
fill = await engine.broker.execute("BTC/USDT", Side.BUY, 0.05, 30_000.0)
|
||||||
|
engine.portfolio.open_position(
|
||||||
|
fill, stop_loss=29_500.0, take_profit=None,
|
||||||
|
features=np.ones(N_FEATURES), confidence=0.6, exploratory=False,
|
||||||
|
)
|
||||||
|
engine.set_trading(False)
|
||||||
|
|
||||||
|
snapshot = compute_features(cyclical_series(), base_config.strategy.rules)
|
||||||
|
bar = Bar(timestamp=1, open=29_600.0, high=29_700.0, low=29_000.0, close=29_400.0, volume=5.0)
|
||||||
|
await engine.process_bar("BTC/USDT", snapshot, bar)
|
||||||
|
|
||||||
|
assert engine.portfolio.positions == {}
|
||||||
|
assert engine.portfolio.trades[-1].exit_reason is ExitReason.STOP_LOSS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_mode_requires_confirmation(base_config, monkeypatch):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
monkeypatch.setattr(type(engine.config), "is_simulated", property(lambda self: False))
|
||||||
|
engine.trading_active = False
|
||||||
|
|
||||||
|
denied = engine.set_trading(True)
|
||||||
|
assert denied["accepted"] is False
|
||||||
|
assert LIVE_TRADING_CONFIRMATION in denied["reason"]
|
||||||
|
assert engine.trading_active is False
|
||||||
|
|
||||||
|
assert engine.set_trading(True, "falsch")["accepted"] is False
|
||||||
|
assert engine.trading_active is False
|
||||||
|
|
||||||
|
ok = engine.set_trading(True, LIVE_TRADING_CONFIRMATION)
|
||||||
|
assert ok["accepted"] is True
|
||||||
|
assert engine.trading_active is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_pausing_live_never_needs_confirmation(base_config, monkeypatch):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
monkeypatch.setattr(type(engine.config), "is_simulated", property(lambda self: False))
|
||||||
|
assert engine.set_trading(False)["accepted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paper_mode_needs_no_confirmation(base_config):
|
||||||
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
engine.set_trading(False)
|
||||||
|
assert engine.set_trading(True)["accepted"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_trading_control_rejected_when_disabled(base_config):
|
||||||
|
engine = build_engine(control_config(base_config, enable_control=False), feed=StaticFeed())
|
||||||
|
await engine.prepare()
|
||||||
|
assert engine.set_trading(False)["accepted"] is False
|
||||||
|
assert engine.trading_active is True
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- HTTP-Schicht
|
||||||
|
|
||||||
|
|
||||||
|
class SlowFeed(GrowingFeed):
|
||||||
|
"""Verzögert den Abruf, damit sich Anfragen zuverlässig überlappen."""
|
||||||
|
|
||||||
|
async def fetch(self, symbol: str, timeframe: str, limit: int):
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
return await super().fetch(symbol, timeframe, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client(aiohttp_client, base_config):
|
||||||
|
"""Server mit angeschlossener Engine; gibt (client, engine) zurück."""
|
||||||
|
|
||||||
|
async def _make(feed=None, **server_options: object):
|
||||||
|
config = control_config(base_config, **server_options)
|
||||||
|
engine = build_engine(config, feed=feed or GrowingFeed(cyclical_series(n=900), start=900))
|
||||||
|
await engine.prepare()
|
||||||
|
srv = StatusServer(config.server, engine.status, controller=engine)
|
||||||
|
return await aiohttp_client(srv._build_app()), engine
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
async def test_dashboard_and_status_expose_training(client):
|
||||||
|
http, _ = await client()
|
||||||
|
page = await (await http.get("/")).text()
|
||||||
|
assert "Training starten" in page
|
||||||
|
assert "Kontinuierliches Lernen" in page
|
||||||
|
|
||||||
|
status = await (await http.get("/status")).json()
|
||||||
|
assert status["training"]["control_enabled"] is True
|
||||||
|
assert status["training"]["online_learning"] is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_history_training_returns_202(client):
|
||||||
|
http, engine = await client()
|
||||||
|
response = await http.post("/control/train/history", json={"bars": 900})
|
||||||
|
assert response.status == 202
|
||||||
|
assert (await response.json())["accepted"] is True
|
||||||
|
await engine._training_task
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_history_training_conflict_returns_409(client):
|
||||||
|
http, engine = await client(feed=SlowFeed(cyclical_series(n=900), start=900))
|
||||||
|
first = await http.post("/control/train/history", json={"bars": 900})
|
||||||
|
assert first.status == 202
|
||||||
|
|
||||||
|
second = await http.post("/control/train/history", json={"bars": 900})
|
||||||
|
assert second.status == 409
|
||||||
|
assert "läuft bereits" in (await second.json())["reason"]
|
||||||
|
|
||||||
|
await engine._training_task
|
||||||
|
assert engine.training.state == "done"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_post_live_toggle(client):
|
||||||
|
http, engine = await client()
|
||||||
|
response = await http.post("/control/train/live", json={"enabled": False})
|
||||||
|
assert response.status == 200
|
||||||
|
assert (await response.json())["online_learning"] is False
|
||||||
|
assert engine.strategy.learner.frozen is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_live_toggle_rejects_bad_payload(client):
|
||||||
|
http, _ = await client()
|
||||||
|
assert (await http.post("/control/train/live", json={"enabled": "ja"})).status == 400
|
||||||
|
assert (await http.post("/control/train/live", data="kein json")).status == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_token_is_required_when_configured(client):
|
||||||
|
http, engine = await client(control_token="geheim")
|
||||||
|
|
||||||
|
assert (await http.post("/control/train/live", json={"enabled": False})).status == 401
|
||||||
|
assert (await http.get("/control/training")).status == 401
|
||||||
|
assert engine.strategy.learner.frozen is False # nichts passiert
|
||||||
|
|
||||||
|
ok = await http.post(
|
||||||
|
"/control/train/live", json={"enabled": False}, headers={TOKEN_HEADER: "geheim"}
|
||||||
|
)
|
||||||
|
assert ok.status == 200
|
||||||
|
assert engine.strategy.learner.frozen is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_wrong_token_is_rejected(client):
|
||||||
|
http, _ = await client(control_token="geheim")
|
||||||
|
response = await http.post(
|
||||||
|
"/control/train/live", json={"enabled": False}, headers={TOKEN_HEADER: "falsch"}
|
||||||
|
)
|
||||||
|
assert response.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
async def test_control_routes_absent_when_disabled(client):
|
||||||
|
http, _ = await client(enable_control=False)
|
||||||
|
assert (await http.post("/control/train/history", json={})).status == 404
|
||||||
|
assert (await http.get("/control/training")).status == 404
|
||||||
|
assert (await http.get("/status")).status == 200 # lesende Endpunkte bleiben
|
||||||
|
|
||||||
|
|
||||||
|
async def test_read_only_endpoints_need_no_token(client):
|
||||||
|
http, _ = await client(control_token="geheim")
|
||||||
|
for path in ("/health", "/status", "/metrics", "/positions", "/trades"):
|
||||||
|
assert (await http.get(path)).status == 200, path
|
||||||
|
|
||||||
|
|
||||||
|
async def test_training_state_endpoint(client):
|
||||||
|
http, _ = await client()
|
||||||
|
data = await (await http.get("/control/training")).json()
|
||||||
|
assert data["state"] == "idle"
|
||||||
|
assert data["learning_available"] is True
|
||||||
|
assert data["min_bars"] == 500
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_trading_toggle(client):
|
||||||
|
http, engine = await client()
|
||||||
|
response = await http.post("/control/trading", json={"enabled": False})
|
||||||
|
assert response.status == 200
|
||||||
|
assert (await response.json())["active"] is False
|
||||||
|
assert engine.trading_active is False
|
||||||
|
|
||||||
|
state = await (await http.get("/control/trading")).json()
|
||||||
|
assert state["active"] is False
|
||||||
|
assert state["mode"] == "paper"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_trading_rejects_bad_payload(client):
|
||||||
|
http, _ = await client()
|
||||||
|
assert (await http.post("/control/trading", json={})).status == 400
|
||||||
|
assert (await http.post("/control/trading", json={"enabled": 1})).status == 400
|
||||||
|
assert (await http.post("/control/trading", json={"enabled": True, "confirm": 5})).status == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_trading_needs_token(client):
|
||||||
|
http, engine = await client(control_token="geheim")
|
||||||
|
assert (await http.post("/control/trading", json={"enabled": False})).status == 401
|
||||||
|
assert engine.trading_active is True
|
||||||
|
|
||||||
|
ok = await http.post(
|
||||||
|
"/control/trading", json={"enabled": False}, headers={TOKEN_HEADER: "geheim"}
|
||||||
|
)
|
||||||
|
assert ok.status == 200
|
||||||
|
assert engine.trading_active is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_live_start_without_confirmation_returns_428(client, monkeypatch):
|
||||||
|
http, engine = await client()
|
||||||
|
monkeypatch.setattr(type(engine.config), "is_simulated", property(lambda self: False))
|
||||||
|
engine.trading_active = False
|
||||||
|
|
||||||
|
response = await http.post("/control/trading", json={"enabled": True})
|
||||||
|
assert response.status == 428
|
||||||
|
assert engine.trading_active is False
|
||||||
|
|
||||||
|
ok = await http.post(
|
||||||
|
"/control/trading", json={"enabled": True, "confirm": LIVE_TRADING_CONFIRMATION}
|
||||||
|
)
|
||||||
|
assert ok.status == 200
|
||||||
|
assert engine.trading_active is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_status_and_dashboard_expose_trading(client):
|
||||||
|
http, _ = await client()
|
||||||
|
status = await (await http.get("/status")).json()
|
||||||
|
assert status["trading"]["active"] is True
|
||||||
|
assert status["trading"]["simulated"] is True
|
||||||
|
|
||||||
|
page = await (await http.get("/")).text()
|
||||||
|
assert "Automatisierter Handel" in page
|
||||||
|
assert "control/trading" in page
|
||||||
|
assert "LIVE-MODUS" in page
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- Absicherung beim Zusammenbau
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_without_token_on_open_port_disables_control():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"mode": "live",
|
||||||
|
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
|
||||||
|
"exchange": {"api_key": "k", "api_secret": "s"},
|
||||||
|
"server": {"enabled": True, "host": "0.0.0.0", "enable_control": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
guarded = _guard_control_exposure(config)
|
||||||
|
assert guarded.server.enable_control is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_with_token_keeps_control():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"mode": "live",
|
||||||
|
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
|
||||||
|
"exchange": {"api_key": "k", "api_secret": "s"},
|
||||||
|
"server": {"enabled": True, "host": "0.0.0.0", "enable_control": True,
|
||||||
|
"control_token": "geheim"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert _guard_control_exposure(config).server.enable_control is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_on_loopback_keeps_control():
|
||||||
|
config = Config.model_validate(
|
||||||
|
{
|
||||||
|
"mode": "live",
|
||||||
|
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
|
||||||
|
"exchange": {"api_key": "k", "api_secret": "s"},
|
||||||
|
"server": {"enabled": True, "host": "127.0.0.1", "enable_control": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert _guard_control_exposure(config).server.enable_control is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_paper_on_open_port_keeps_control(base_config):
|
||||||
|
config = control_config(base_config, host="0.0.0.0", enable_control=True)
|
||||||
|
assert _guard_control_exposure(config).server.enable_control is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_app_builds_without_controller(base_config):
|
||||||
|
srv = StatusServer(ServerConfig(), lambda: {}, controller=None)
|
||||||
|
app = srv._build_app()
|
||||||
|
assert isinstance(app, web.Application)
|
||||||
|
assert srv.control_available is False
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
r"""Das eingebettete Dashboard-Skript muss syntaktisch heil beim Browser ankommen.
|
||||||
|
|
||||||
|
Hintergrund: ``_DASHBOARD`` ist ein Python-String. Fehlt das r-Präfix, macht Python aus
|
||||||
|
einem ``\n``, das eigentlich dem JavaScript gehört, einen echten Zeilenumbruch. Das offene
|
||||||
|
String-Literal reißt dann das gesamte ``<script>`` mit – die Seite lädt, zeigt aber nur
|
||||||
|
"lädt …", weil kein einziger Ausdruck ausgeführt wird. Genau das ist schon passiert.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from trademind.server import _DASHBOARD
|
||||||
|
|
||||||
|
SCRIPT_RE = re.compile(r"<script>(.*?)</script>", re.DOTALL)
|
||||||
|
|
||||||
|
|
||||||
|
def script_body() -> str:
|
||||||
|
match = SCRIPT_RE.search(_DASHBOARD)
|
||||||
|
assert match is not None, "Das Dashboard enthält keinen <script>-Block"
|
||||||
|
return match.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
def unterminated_string(js: str) -> str | None:
|
||||||
|
"""Findet ein String-Literal, das vor dem Zeilenende nicht geschlossen wird.
|
||||||
|
|
||||||
|
Nur Template-Literale (Backticks) dürfen über mehrere Zeilen gehen. Gibt eine
|
||||||
|
Beschreibung der Fundstelle zurück oder ``None``, wenn alles sauber ist.
|
||||||
|
"""
|
||||||
|
state: str | None = None # None | " | ' | ` | // | /*
|
||||||
|
opened_at = 0
|
||||||
|
line = 1
|
||||||
|
i = 0
|
||||||
|
while i < len(js):
|
||||||
|
char = js[i]
|
||||||
|
nxt = js[i + 1 : i + 2]
|
||||||
|
if state is None:
|
||||||
|
if char == "/" and nxt == "/":
|
||||||
|
state, i = "//", i + 1
|
||||||
|
elif char == "/" and nxt == "*":
|
||||||
|
state, i = "/*", i + 1
|
||||||
|
elif char in "\"'`":
|
||||||
|
state, opened_at = char, line
|
||||||
|
elif char == "\n":
|
||||||
|
line += 1
|
||||||
|
elif state == "//":
|
||||||
|
if char == "\n":
|
||||||
|
state, line = None, line + 1
|
||||||
|
elif state == "/*":
|
||||||
|
if char == "*" and nxt == "/":
|
||||||
|
state, i = None, i + 1
|
||||||
|
elif char == "\n":
|
||||||
|
line += 1
|
||||||
|
else: # innerhalb eines String-Literals
|
||||||
|
if char == "\\":
|
||||||
|
i += 1 # nächstes Zeichen ist escaped
|
||||||
|
elif char == state:
|
||||||
|
state = None
|
||||||
|
elif char == "\n":
|
||||||
|
if state != "`":
|
||||||
|
return f"String mit {state} in Zeile {opened_at} endet nicht auf derselben Zeile"
|
||||||
|
line += 1
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if state in ("\"", "'", "`"):
|
||||||
|
return f"String mit {state} ab Zeile {opened_at} wird nie geschlossen"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_has_no_unterminated_string_literal():
|
||||||
|
assert unterminated_string(script_body()) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_javascript_escapes_survive_python():
|
||||||
|
r"""Ein \n im Quelltext muss als zwei Zeichen beim Browser ankommen."""
|
||||||
|
assert "\\n" in _DASHBOARD, "Escape-Sequenz wurde von Python aufgelöst – r-String fehlt?"
|
||||||
|
|
||||||
|
|
||||||
|
def test_braces_are_balanced():
|
||||||
|
js = script_body()
|
||||||
|
depth = 0
|
||||||
|
for char in js:
|
||||||
|
depth += char == "{"
|
||||||
|
depth -= char == "}"
|
||||||
|
assert depth >= 0, "Mehr schließende als öffnende Klammern"
|
||||||
|
assert depth == 0, f"Klammern unausgeglichen (Differenz {depth})"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_referenced_element_exists():
|
||||||
|
"""Die Skript-Initialisierung greift auf feste IDs zu – fehlt eine, bricht alles ab."""
|
||||||
|
js = script_body()
|
||||||
|
referenced = set(re.findall(r"""\$\(["']([\w-]+)["']\)""", js))
|
||||||
|
referenced |= set(re.findall(r"""getElementById\(["']([\w-]+)["']\)""", js))
|
||||||
|
assert referenced, "Keine Element-Referenzen gefunden – Test greift ins Leere"
|
||||||
|
for element_id in sorted(referenced):
|
||||||
|
assert f'id="{element_id}"' in _DASHBOARD, f"Element #{element_id} fehlt im HTML"
|
||||||
|
|
||||||
|
|
||||||
|
def function_body(js: str, name: str) -> str:
|
||||||
|
"""Rumpf einer Funktion per Klammerzählung ausschneiden."""
|
||||||
|
start = js.index(f"function {name}(")
|
||||||
|
open_brace = js.index("{", start)
|
||||||
|
depth = 0
|
||||||
|
for i in range(open_brace, len(js)):
|
||||||
|
depth += js[i] == "{"
|
||||||
|
depth -= js[i] == "}"
|
||||||
|
if depth == 0:
|
||||||
|
return js[open_brace : i + 1]
|
||||||
|
raise AssertionError(f"Funktion {name} ist nicht geschlossen")
|
||||||
|
|
||||||
|
|
||||||
|
def hidden_section_ids() -> list[str]:
|
||||||
|
return re.findall(r'<section id="([\w-]+)"[^>]*\shidden', _DASHBOARD)
|
||||||
|
|
||||||
|
|
||||||
|
def status_loop_code() -> str:
|
||||||
|
"""Der Code, der bei jedem Statuslauf durchläuft: refresh und die daraus gerufenen
|
||||||
|
render-Funktionen. Nur was hier steht, kann einen Abschnitt von selbst einblenden."""
|
||||||
|
js = script_body()
|
||||||
|
body = function_body(js, "refresh")
|
||||||
|
code = [body]
|
||||||
|
for name in sorted(set(re.findall(r"\b(render\w+)\(", body))):
|
||||||
|
code.append(function_body(js, name))
|
||||||
|
return "\n".join(code)
|
||||||
|
|
||||||
|
|
||||||
|
def test_there_are_hidden_sections_to_check():
|
||||||
|
assert hidden_section_ids(), "Test greift ins Leere – keine versteckten Abschnitte gefunden"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("section_id", hidden_section_ids())
|
||||||
|
def test_hidden_sections_are_unhidden_by_the_status_loop(section_id: str):
|
||||||
|
"""Ein Abschnitt, dessen Aufklapp-Knopf in ihm selbst sitzt, blendet sich sonst nie ein.
|
||||||
|
|
||||||
|
Genau das ist passiert: Der Konfigurationsbereich war für Benutzer unerreichbar, weil
|
||||||
|
nur ein Klick darin ihn sichtbar gemacht hätte. Sichtbarkeit gehört deshalb in
|
||||||
|
``refresh()``, das bei jedem Statuslauf durchläuft.
|
||||||
|
"""
|
||||||
|
code = status_loop_code()
|
||||||
|
assert f'$("{section_id}").hidden' in code, (
|
||||||
|
f"#{section_id} startet versteckt und wird im Statuslauf nicht sichtbar gemacht – "
|
||||||
|
"ein Knopf innerhalb des Abschnitts kann ihn nicht einblenden"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
PRIMARY_BUTTONS = ["trade-btn", "train-btn", "cfg-toggle"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("button_id", PRIMARY_BUTTONS)
|
||||||
|
def test_primary_buttons_are_not_shrunk_by_inline_styles(button_id: str):
|
||||||
|
"""Die Haupt-Bedienelemente sollen gleich aussehen.
|
||||||
|
|
||||||
|
Der Konfigurationsknopf war einmal ein 12-Pixel-Anhängsel an einer Überschrift und
|
||||||
|
wurde schlicht übersehen. Inline-Styles, die Schrift oder Polsterung verkleinern,
|
||||||
|
gehören hier nicht hin.
|
||||||
|
"""
|
||||||
|
match = re.search(rf'<button id="{button_id}"([^>]*)>', _DASHBOARD)
|
||||||
|
assert match is not None, f"Knopf #{button_id} fehlt"
|
||||||
|
attributes = match.group(1)
|
||||||
|
for verboten in ("font-size", "padding"):
|
||||||
|
assert verboten not in attributes, (
|
||||||
|
f"#{button_id} verkleinert sich per Inline-Style ({verboten}) gegenüber den anderen"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("button_id", PRIMARY_BUTTONS)
|
||||||
|
def test_primary_buttons_sit_inside_a_panel(button_id: str):
|
||||||
|
"""Ein Knopf direkt in der Überschrift liest sich nicht als Bedienelement."""
|
||||||
|
match = re.search(rf'<button id="{button_id}"[^>]*>', _DASHBOARD)
|
||||||
|
before = _DASHBOARD[: match.start()]
|
||||||
|
letzte_ueberschrift = before.rfind("<h2>")
|
||||||
|
letztes_panel = before.rfind('class="panel"')
|
||||||
|
assert letztes_panel > letzte_ueberschrift, (
|
||||||
|
f"#{button_id} steht in einer Überschrift statt in einer Panel-Box"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_functions_are_called_by_the_status_loop():
|
||||||
|
body = function_body(script_body(), "refresh")
|
||||||
|
for name in ("renderTrading", "renderTraining"):
|
||||||
|
assert f"{name}(" in body, f"{name} wird nie aufgerufen"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"needle",
|
||||||
|
[
|
||||||
|
"Automatisierter Handel",
|
||||||
|
"Training starten",
|
||||||
|
"Kontinuierliches Lernen",
|
||||||
|
"control/trading",
|
||||||
|
"control/train/history",
|
||||||
|
"control/train/live",
|
||||||
|
"X-TradeMind-Token",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_dashboard_wires_up_the_controls(needle: str):
|
||||||
|
assert needle in _DASHBOARD
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_example_is_detected():
|
||||||
|
"""Gegenprobe: Der Prüfer muss den ursprünglichen Fehler erkennen."""
|
||||||
|
broken = 'const a = prompt("erste Zeile\nzweite Zeile");'
|
||||||
|
assert unterminated_string(broken) is not None
|
||||||
|
assert unterminated_string('const a = prompt("erste Zeile\\nzweite Zeile");') is None
|
||||||
|
assert unterminated_string("const a = `mehrere\nZeilen sind hier erlaubt`;") is None
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Terminmarktdaten: Zuordnung ohne Blick in die Zukunft, Merkmale, Ausfallverhalten."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from trademind.config import Config, DerivativesConfig
|
||||||
|
from trademind.derivatives import (
|
||||||
|
DerivativeSeries,
|
||||||
|
DerivativesProvider,
|
||||||
|
forward_fill_to_bars,
|
||||||
|
perpetual_symbol,
|
||||||
|
)
|
||||||
|
from trademind.features import (
|
||||||
|
BASE_FEATURE_NAMES,
|
||||||
|
build_feature_matrix,
|
||||||
|
feature_names,
|
||||||
|
n_features,
|
||||||
|
)
|
||||||
|
from trademind.models import Candles
|
||||||
|
|
||||||
|
from .conftest import make_candles
|
||||||
|
|
||||||
|
BAR = 300_000
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ Symbolik
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("spot", "perp"),
|
||||||
|
[
|
||||||
|
("BTC/USDT", "BTC/USDT:USDT"),
|
||||||
|
("ETH/USDC", "ETH/USDC:USDC"),
|
||||||
|
("SOL/USDT:USDT", "SOL/USDT:USDT"), # schon ein Perpetual
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_perpetual_symbol(spot: str, perp: str):
|
||||||
|
assert perpetual_symbol(spot) == perp
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- Zuordnung ohne Lookahead
|
||||||
|
|
||||||
|
|
||||||
|
def test_forward_fill_uses_only_past_values():
|
||||||
|
bars = np.array([1000, 2000, 3000, 4000], dtype=np.int64)
|
||||||
|
src_ts = np.array([1500, 3500], dtype=np.int64)
|
||||||
|
src_val = np.array([10.0, 20.0])
|
||||||
|
|
||||||
|
values, known = forward_fill_to_bars(bars, src_ts, src_val)
|
||||||
|
|
||||||
|
assert not known[0], "vor dem ersten Quellwert darf nichts bekannt sein"
|
||||||
|
assert values[1] == 10.0 # 1500 <= 2000
|
||||||
|
assert values[2] == 10.0 # 3500 liegt in der Zukunft von 3000
|
||||||
|
assert values[3] == 20.0 # 3500 <= 4000
|
||||||
|
assert list(known) == [False, True, True, True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_value_exactly_on_the_bar_counts_as_known():
|
||||||
|
values, known = forward_fill_to_bars(
|
||||||
|
np.array([2000], dtype=np.int64), np.array([2000], dtype=np.int64), np.array([7.0])
|
||||||
|
)
|
||||||
|
assert known[0] and values[0] == 7.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_unsorted_source_is_handled():
|
||||||
|
values, _ = forward_fill_to_bars(
|
||||||
|
np.array([5000], dtype=np.int64),
|
||||||
|
np.array([3000, 1000, 2000], dtype=np.int64),
|
||||||
|
np.array([30.0, 10.0, 20.0]),
|
||||||
|
)
|
||||||
|
assert values[0] == 30.0, "der jüngste Wert vor der Kerze zählt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_source_yields_nothing_known():
|
||||||
|
values, known = forward_fill_to_bars(
|
||||||
|
np.arange(3, dtype=np.int64), np.empty(0, np.int64), np.empty(0)
|
||||||
|
)
|
||||||
|
assert not known.any()
|
||||||
|
assert np.isnan(values).all()
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------- Merkmalsanzahl
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_count_depends_on_configuration():
|
||||||
|
assert n_features(None) == len(BASE_FEATURE_NAMES) == 18
|
||||||
|
assert n_features(DerivativesConfig(enabled=False)) == 18
|
||||||
|
assert n_features(DerivativesConfig(enabled=True)) == 22
|
||||||
|
assert n_features(DerivativesConfig(enabled=True, open_interest=False)) == 20
|
||||||
|
assert n_features(DerivativesConfig(enabled=True, funding_rate=False)) == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_feature_names_are_unique_and_ordered():
|
||||||
|
names = feature_names(DerivativesConfig(enabled=True))
|
||||||
|
assert names[:18] == BASE_FEATURE_NAMES
|
||||||
|
assert len(set(names)) == len(names)
|
||||||
|
assert names[18:] == ("funding_bps", "funding_trend", "oi_change", "oi_price_divergence")
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------ Merkmalsberechnung
|
||||||
|
|
||||||
|
|
||||||
|
def series_for(candles, funding: float = 0.0001, oi_growth: float = 0.0) -> DerivativeSeries:
|
||||||
|
n = len(candles)
|
||||||
|
oi = 100_000.0 * (1.0 + oi_growth * np.arange(n) / max(n - 1, 1))
|
||||||
|
return DerivativeSeries(
|
||||||
|
symbol=candles.symbol,
|
||||||
|
timestamp=candles.timestamp,
|
||||||
|
funding_rate=np.full(n, funding),
|
||||||
|
open_interest=oi,
|
||||||
|
funding_coverage=1.0,
|
||||||
|
oi_coverage=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_matrix_gains_columns_when_enabled(candles, rules):
|
||||||
|
plain = build_feature_matrix(candles, rules)
|
||||||
|
enriched = build_feature_matrix(
|
||||||
|
candles, rules, DerivativesConfig(enabled=True), series_for(candles)
|
||||||
|
)
|
||||||
|
assert plain.values.shape[1] == 18
|
||||||
|
assert enriched.values.shape[1] == 22
|
||||||
|
assert np.allclose(plain.values, enriched.values[:, :18]), "Basismerkmale dürfen sich nicht ändern"
|
||||||
|
|
||||||
|
|
||||||
|
def test_funding_is_converted_to_basis_points(candles, rules):
|
||||||
|
matrix = build_feature_matrix(
|
||||||
|
candles, rules, DerivativesConfig(enabled=True), series_for(candles, funding=0.0003)
|
||||||
|
)
|
||||||
|
snapshot = matrix.snapshot(-1).as_dict()
|
||||||
|
assert snapshot["funding_bps"] == pytest.approx(3.0) # 0,03 % = 3 bps
|
||||||
|
assert snapshot["funding_trend"] == pytest.approx(0.0, abs=1e-9) # konstant, kein Trend
|
||||||
|
|
||||||
|
|
||||||
|
def test_rising_open_interest_shows_up_as_positive_change(candles, rules):
|
||||||
|
matrix = build_feature_matrix(
|
||||||
|
candles, rules, DerivativesConfig(enabled=True), series_for(candles, oi_growth=0.5)
|
||||||
|
)
|
||||||
|
assert matrix.snapshot(-1).as_dict()["oi_change"] > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_divergence_sign_follows_open_interest(rules):
|
||||||
|
up = make_candles(n=400, trend=0.002, noise=0.0002, seed=3)
|
||||||
|
rising = build_feature_matrix(
|
||||||
|
up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=0.5)
|
||||||
|
).snapshot(-1).as_dict()
|
||||||
|
falling = build_feature_matrix(
|
||||||
|
up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=-0.3)
|
||||||
|
).snapshot(-1).as_dict()
|
||||||
|
# Steigender Kurs mit steigendem OI = neue Positionen, mit fallendem OI = Glattstellung.
|
||||||
|
assert rising["oi_price_divergence"] > 0
|
||||||
|
assert falling["oi_price_divergence"] < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_series_keeps_the_dimension_stable(candles, rules):
|
||||||
|
"""Fällt die Datenquelle aus, bleiben die Spalten erhalten – neutral gefüllt."""
|
||||||
|
matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), None)
|
||||||
|
assert matrix.values.shape[1] == 22
|
||||||
|
assert np.isfinite(matrix.values).all()
|
||||||
|
snapshot = matrix.snapshot(-1).as_dict()
|
||||||
|
assert snapshot["funding_bps"] == 0.0
|
||||||
|
assert snapshot["oi_change"] == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_values_stay_within_the_clip_limit(candles, rules):
|
||||||
|
"""Auch absurde Terminmarktwerte dürfen die Normierung nicht sprengen."""
|
||||||
|
n = len(candles)
|
||||||
|
extreme = DerivativeSeries(
|
||||||
|
symbol=candles.symbol,
|
||||||
|
timestamp=candles.timestamp,
|
||||||
|
funding_rate=np.full(n, 0.75), # 7500 bps
|
||||||
|
open_interest=np.geomspace(1.0, 1e9, n),
|
||||||
|
funding_coverage=1.0,
|
||||||
|
oi_coverage=1.0,
|
||||||
|
)
|
||||||
|
matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), extreme)
|
||||||
|
assert np.abs(matrix.values).max() <= 8.0
|
||||||
|
assert np.isfinite(matrix.values).all()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- Ausfallverhalten
|
||||||
|
|
||||||
|
|
||||||
|
class FlakyExchange:
|
||||||
|
"""Börse, die für Funding funktioniert und bei Open Interest scheitert."""
|
||||||
|
|
||||||
|
rateLimit = 0
|
||||||
|
|
||||||
|
def __init__(self, funding_rows=None):
|
||||||
|
self.funding_rows = funding_rows or []
|
||||||
|
self.oi_calls = 0
|
||||||
|
|
||||||
|
async def fetch_funding_rate_history(self, symbol, since=None, limit=None):
|
||||||
|
rows = [r for r in self.funding_rows if since is None or r["timestamp"] >= since]
|
||||||
|
return rows[:limit] if limit else rows
|
||||||
|
|
||||||
|
async def fetch_open_interest_history(self, symbol, timeframe, since=None, limit=None):
|
||||||
|
self.oi_calls += 1
|
||||||
|
raise RuntimeError("startTime is invalid")
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_failing_source_is_recorded_not_raised(candles):
|
||||||
|
start = int(candles.timestamp[0])
|
||||||
|
rows = [{"timestamp": start - 3_600_000, "fundingRate": 0.0002}]
|
||||||
|
provider = DerivativesProvider(FlakyExchange(rows), DerivativesConfig(enabled=True))
|
||||||
|
|
||||||
|
series = await provider.series_for(candles)
|
||||||
|
|
||||||
|
assert series.funding_coverage == 1.0
|
||||||
|
assert series.oi_coverage == 0.0
|
||||||
|
assert any("open_interest" in key for key in provider.failures)
|
||||||
|
assert provider.snapshot()["failures"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_provider_returns_empty_series_for_empty_candles():
|
||||||
|
blank = Candles.from_rows("BTC/USDT", "5m", [])
|
||||||
|
provider = DerivativesProvider(FlakyExchange(), DerivativesConfig(enabled=True))
|
||||||
|
series = await provider.series_for(blank)
|
||||||
|
assert series.timestamp.size == 0
|
||||||
|
assert not series.usable
|
||||||
|
|
||||||
|
|
||||||
|
async def test_incremental_fetch_does_not_refetch_everything(candles):
|
||||||
|
start = int(candles.timestamp[0])
|
||||||
|
rows = [{"timestamp": start - 3_600_000 + i * 8 * 3_600_000, "fundingRate": 0.0001}
|
||||||
|
for i in range(4)]
|
||||||
|
|
||||||
|
class Counting(FlakyExchange):
|
||||||
|
def __init__(self, rows):
|
||||||
|
super().__init__(rows)
|
||||||
|
self.funding_calls = 0
|
||||||
|
|
||||||
|
async def fetch_funding_rate_history(self, symbol, since=None, limit=None):
|
||||||
|
self.funding_calls += 1
|
||||||
|
return await super().fetch_funding_rate_history(symbol, since, limit)
|
||||||
|
|
||||||
|
exchange = Counting(rows)
|
||||||
|
provider = DerivativesProvider(exchange, DerivativesConfig(enabled=True, open_interest=False))
|
||||||
|
await provider.series_for(candles)
|
||||||
|
first = exchange.funding_calls
|
||||||
|
await provider.series_for(candles)
|
||||||
|
assert exchange.funding_calls - first <= 1, "der zweite Lauf darf nur nachladen"
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- Konfiguration
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivatives_switches_require_a_restart():
|
||||||
|
from trademind.config import requires_restart
|
||||||
|
|
||||||
|
for path in ("strategy.derivatives.enabled", "strategy.derivatives.funding_rate",
|
||||||
|
"strategy.derivatives.open_interest"):
|
||||||
|
assert requires_restart(path), f"{path} ändert die Modelldimension"
|
||||||
|
|
||||||
|
|
||||||
|
def test_derivatives_are_off_by_default():
|
||||||
|
assert Config().strategy.derivatives.enabled is False
|
||||||
@@ -95,10 +95,15 @@ async def test_cash_and_equity_stay_consistent(base_config):
|
|||||||
|
|
||||||
|
|
||||||
async def test_stop_loss_bounds_the_worst_trade(base_config):
|
async def test_stop_loss_bounds_the_worst_trade(base_config):
|
||||||
|
# Bewusst ohne Lernmodell: Geprüft wird die Stop-Logik, nicht welche Signale das
|
||||||
|
# Modell gerade durchlässt. Mit "adaptive" hinge das Ergebnis daran, wie weit das
|
||||||
|
# Modell aufgewärmt ist – das hat mit Stops nichts zu tun.
|
||||||
config = Config.model_validate(
|
config = Config.model_validate(
|
||||||
{**base_config.model_dump(), "risk": {**base_config.risk.model_dump(),
|
{**base_config.model_dump(),
|
||||||
"stop_loss_atr_mult": 1.0,
|
"strategy": {"name": "rules"},
|
||||||
"take_profit_atr_mult": 10.0}}
|
"risk": {**base_config.risk.model_dump(),
|
||||||
|
"stop_loss_atr_mult": 1.0,
|
||||||
|
"take_profit_atr_mult": 10.0}}
|
||||||
)
|
)
|
||||||
engine = build_engine(config)
|
engine = build_engine(config)
|
||||||
await engine.prepare()
|
await engine.prepare()
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""Ollama-Anbindung: Antwortverarbeitung, Fehlerdiagnose, Prompt-Aufbau."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from trademind.config import OllamaConfig
|
||||||
|
from trademind.llm import SYSTEM_PROMPT, OllamaClient, build_status_prompt
|
||||||
|
|
||||||
|
STATUS = {
|
||||||
|
"mode": "paper",
|
||||||
|
"exchange": "binance",
|
||||||
|
"symbols": ["BTC/USDT", "ETH/USDT"],
|
||||||
|
"timeframe": "5m",
|
||||||
|
"quote_currency": "USDT",
|
||||||
|
"portfolio": {"equity": 10123.45, "total_return_pct": 1.23, "trades": 7, "win_rate": 0.42,
|
||||||
|
"profit_factor": 1.1, "max_drawdown_pct": 2.5, "open_positions": 1},
|
||||||
|
"strategy": {"candidates_seen": 40, "candidates_accepted": 9,
|
||||||
|
"learner": {"samples_seen": 900, "trade_samples": 7, "online_accuracy": 0.53}},
|
||||||
|
"risk": {"halted": False, "halt_reason": "", "max_open_positions": 3, "max_position_pct": 0.2},
|
||||||
|
"trading": {"active": True, "simulated": True},
|
||||||
|
"feature_weights": {"ema_spread": 0.39, "trend_dist": -0.29, "rsi_norm": 0.01},
|
||||||
|
"positions": [{"symbol": "BTC/USDT", "entry_price": 70000.0, "mark_price": 70500.0,
|
||||||
|
"unrealized_pct": 0.71, "bars_held": 4, "confidence": 0.62}],
|
||||||
|
"recent_trades": [{"symbol": "ETH/USDT", "pnl_pct": -0.004, "exit_reason": "stop_loss",
|
||||||
|
"bars_held": 9}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- Prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_contains_the_real_numbers():
|
||||||
|
prompt = build_status_prompt(STATUS)
|
||||||
|
for needle in ("paper", "binance", "BTC/USDT", "10123.45", "ema_spread", "stop_loss"):
|
||||||
|
assert needle in prompt, f"{needle} fehlt im Prompt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_ranks_weights_by_magnitude():
|
||||||
|
prompt = build_status_prompt(STATUS, top_weights=2)
|
||||||
|
assert "ema_spread" in prompt and "trend_dist" in prompt
|
||||||
|
assert "rsi_norm" not in prompt, "das schwächste Gewicht sollte wegfallen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prompt_survives_a_bare_status():
|
||||||
|
prompt = build_status_prompt({})
|
||||||
|
assert "PORTFOLIO" in prompt and "LERNMODELL" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
def test_system_prompt_forbids_advice():
|
||||||
|
for needle in ("keine Anlageempfehlung", "keine Kursprognose", "Erfinde keine Zahlen"):
|
||||||
|
assert needle in SYSTEM_PROMPT
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Falsches Ollama
|
||||||
|
|
||||||
|
|
||||||
|
def fake_ollama(handler):
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/api/generate", handler)
|
||||||
|
app.router.add_get("/api/tags", lambda _: web.json_response({"models": [{"name": "testmodell"}]}))
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client_for(aiohttp_server):
|
||||||
|
async def _make(handler, **overrides) -> OllamaClient:
|
||||||
|
server = await aiohttp_server(fake_ollama(handler))
|
||||||
|
options = {"model": "testmodell", "timeout_seconds": 5, **overrides}
|
||||||
|
config = OllamaConfig(
|
||||||
|
enabled=True, base_url=str(server.make_url("/")).rstrip("/"), **options
|
||||||
|
)
|
||||||
|
return OllamaClient(config)
|
||||||
|
|
||||||
|
return _make
|
||||||
|
|
||||||
|
|
||||||
|
async def test_plain_answer_is_returned(client_for):
|
||||||
|
async def handler(request):
|
||||||
|
assert (await request.json())["stream"] is False
|
||||||
|
return web.json_response({"response": "Alles ruhig.", "done_reason": "stop"})
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert result.ok and result.text == "Alles ruhig."
|
||||||
|
assert result.model == "testmodell"
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_thinking_block_is_stripped(client_for):
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response(
|
||||||
|
{"response": "<think>erst überlegen</think>Das Ergebnis.", "done_reason": "stop"}
|
||||||
|
)
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert result.ok and result.text == "Das Ergebnis."
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reasoning_model_without_answer_is_diagnosed(client_for):
|
||||||
|
"""Der reale Fall: qwen3 verbraucht das Token-Budget für 'thinking'."""
|
||||||
|
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response({"response": "", "thinking": "x" * 1800, "done_reason": "length"})
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert not result.ok
|
||||||
|
assert "Denkschritte" in result.error
|
||||||
|
assert "llm.think" in result.error, "die Meldung muss den Ausweg nennen"
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_truncated_answer_is_diagnosed(client_for):
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response({"response": "", "done_reason": "length"})
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert not result.ok and "max_tokens" in result.error
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_think_flag_is_sent(client_for):
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
seen.update(await request.json())
|
||||||
|
return web.json_response({"response": "ok", "done_reason": "stop"})
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
await client.generate("frage")
|
||||||
|
assert seen["think"] is False, "Reasoning ist standardmäßig aus"
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_old_ollama_without_think_field_still_works(client_for):
|
||||||
|
"""Ältere Versionen lehnen das Feld ab – dann ohne es erneut versuchen."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def handler(request):
|
||||||
|
body = await request.json()
|
||||||
|
calls.append("think" in body)
|
||||||
|
if "think" in body:
|
||||||
|
return web.json_response({"error": "unknown field think"}, status=400)
|
||||||
|
return web.json_response({"response": "Klappt doch.", "done_reason": "stop"})
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert result.ok and result.text == "Klappt doch."
|
||||||
|
assert calls == [True, False]
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_http_error_is_reported(client_for):
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response({"error": "model not found"}, status=404)
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert not result.ok and "404" in result.error
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_timeout_is_reported_with_a_hint(client_for):
|
||||||
|
async def handler(_):
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
return web.json_response({"response": "zu spät"})
|
||||||
|
|
||||||
|
client = await client_for(handler, timeout_seconds=0.2)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert not result.ok
|
||||||
|
assert "Zeitüberschreitung" in result.error and "timeout_seconds" in result.error
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unreachable_server_is_reported(aiohttp_server):
|
||||||
|
"""Server starten, wieder beenden, dann anfragen – die Verbindung wird abgelehnt."""
|
||||||
|
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response({"response": "nie erreicht"})
|
||||||
|
|
||||||
|
server = await aiohttp_server(fake_ollama(handler))
|
||||||
|
url = str(server.make_url("/")).rstrip("/")
|
||||||
|
await server.close()
|
||||||
|
|
||||||
|
client = OllamaClient(OllamaConfig(enabled=True, base_url=url, timeout_seconds=5))
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert not result.ok
|
||||||
|
assert "nicht erreichbar" in result.error and url in result.error
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_answer_is_capped(client_for):
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response({"response": "y" * 5000, "done_reason": "stop"})
|
||||||
|
|
||||||
|
client = await client_for(handler, max_answer_chars=100)
|
||||||
|
result = await client.generate("frage")
|
||||||
|
assert result.ok and len(result.text) == 100
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_model_listing(client_for):
|
||||||
|
async def handler(_):
|
||||||
|
return web.json_response({"response": "ok"})
|
||||||
|
|
||||||
|
client = await client_for(handler)
|
||||||
|
assert await client.available_models() == ["testmodell"]
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_is_off_by_default():
|
||||||
|
from trademind.config import Config
|
||||||
|
|
||||||
|
assert Config().llm.enabled is False
|
||||||
Reference in New Issue
Block a user