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
|
||||
von anderen Rechnern erreichbar.
|
||||
|
||||
> **Sicherheit.** Alle Endpunkte sind lesend (`GET`, keine Steuerbefehle), aber **nicht
|
||||
> authentifiziert**. Wer den Port erreicht, sieht Kontostand, offene Positionen und die
|
||||
> gesamte Trade-Historie. In offenen Netzen deshalb entweder einen Reverse Proxy mit
|
||||
> **Sicherheit.** Die lesenden Endpunkte sind **nicht authentifiziert** — wer den Port
|
||||
> erreicht, sieht Kontostand, offene Positionen und die gesamte Trade-Historie. Dazu kommen
|
||||
> 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
|
||||
> 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
|
||||
```
|
||||
|
||||
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.
|
||||
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
|
||||
@@ -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
|
||||
|
||||
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 |
|
||||
|--------------|-------------------------------------------------------------|
|
||||
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand |
|
||||
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand, Training |
|
||||
| `/health` | Liveness — nutzt der Container-Healthcheck |
|
||||
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
|
||||
| `/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
|
||||
|
||||
```
|
||||
|
||||
@@ -55,6 +55,16 @@ paper:
|
||||
slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil
|
||||
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 ──────────────────────────────
|
||||
risk:
|
||||
max_position_pct: 0.20 # je Position, gemessen an der Equity
|
||||
@@ -85,6 +95,23 @@ strategy:
|
||||
trend_filter_period: 100 # 0 = Trendfilter aus
|
||||
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:
|
||||
enabled: true
|
||||
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
|
||||
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands verkürzen die
|
||||
# Aufwärmphase von Wochen auf Tage (0 = aus).
|
||||
background_sample_every_n_bars: 10
|
||||
# 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
|
||||
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, damit der Bot
|
||||
# nach Sekunden einsatzbereit ist statt nach Tagen (0 = aus).
|
||||
@@ -112,6 +144,9 @@ strategy:
|
||||
# ───────────────────────────────── Persistenz ───────────────────────────────
|
||||
storage:
|
||||
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 ─────────────────────
|
||||
server:
|
||||
@@ -120,6 +155,13 @@ server:
|
||||
port: 8080
|
||||
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 ────────────────────────
|
||||
notifications:
|
||||
# Slack- oder Discord-Webhook (leer lassen = aus)
|
||||
@@ -127,6 +169,22 @@ notifications:
|
||||
notify_on_trade: 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:
|
||||
bars: 5000
|
||||
|
||||
@@ -27,6 +27,11 @@ TRADEMIND_LIVE_CONFIRMATION=
|
||||
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
|
||||
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
|
||||
|
||||
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
|
||||
|
||||
@@ -22,6 +22,7 @@ dependencies = [
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
"pytest-aiohttp>=1.0",
|
||||
"ruff>=0.4",
|
||||
]
|
||||
|
||||
|
||||
+114
-3
@@ -9,10 +9,13 @@ from typing import Any
|
||||
|
||||
from .broker import Broker, LiveBroker, PaperBroker
|
||||
from .config import Config, Mode
|
||||
from .configstore import ConfigStore
|
||||
from .data import CcxtDataFeed, DataFeed
|
||||
from .derivatives import DerivativesProvider
|
||||
from .engine import TradingEngine
|
||||
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 .portfolio import Portfolio
|
||||
from .risk import RiskManager
|
||||
@@ -62,6 +65,8 @@ class Runtime:
|
||||
notifier: Notifier
|
||||
server: StatusServer | None
|
||||
exchange: Any | None
|
||||
derivatives: DerivativesProvider | None = None
|
||||
llm: OllamaClient | None = None
|
||||
|
||||
async def start_services(self) -> None:
|
||||
await self.notifier.start()
|
||||
@@ -72,6 +77,10 @@ class Runtime:
|
||||
if self.server is not None:
|
||||
await self.server.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:
|
||||
await self.exchange.close()
|
||||
else:
|
||||
@@ -80,6 +89,59 @@ class Runtime:
|
||||
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:
|
||||
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
|
||||
quotes.discard(None)
|
||||
@@ -99,8 +161,14 @@ async def build_runtime(
|
||||
with_storage: bool = True,
|
||||
load_model: bool = True,
|
||||
seed: int | None = None,
|
||||
config_store: ConfigStore | None = None,
|
||||
) -> Runtime:
|
||||
"""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(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)
|
||||
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)
|
||||
if learner is not None and config.mode is Mode.LIVE and config.strategy.learner.freeze_in_live:
|
||||
learner.frozen = True
|
||||
log.info("Live-Modus: Online-Lernen eingefroren (freeze_in_live=true)")
|
||||
|
||||
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)
|
||||
risk = RiskManager(config.risk)
|
||||
notifier = Notifier(config.notifications)
|
||||
@@ -145,9 +239,12 @@ async def build_runtime(
|
||||
risk=risk,
|
||||
storage=storage,
|
||||
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(
|
||||
mode=config.mode.value,
|
||||
@@ -169,6 +266,8 @@ async def build_runtime(
|
||||
notifier=notifier,
|
||||
server=server,
|
||||
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"Notbremsen Tagesverlust {config.risk.max_daily_loss_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"Modelldatei {config.strategy.learner.model_path}",
|
||||
]
|
||||
@@ -203,4 +305,13 @@ def describe_config(config: Config) -> str:
|
||||
)
|
||||
if config.server.enabled:
|
||||
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)
|
||||
|
||||
@@ -16,7 +16,7 @@ import numpy as np
|
||||
|
||||
from .data import format_ts
|
||||
from .engine import Bar, TradingEngine
|
||||
from .features import FeatureMatrix, build_feature_matrix
|
||||
from .features import FeatureMatrix
|
||||
from .models import Candles, ExitReason
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -114,12 +114,12 @@ class BacktestRunner:
|
||||
self.progress_every = progress_every
|
||||
self._matrices: dict[str, FeatureMatrix] = {}
|
||||
|
||||
def _prepare(self) -> int:
|
||||
rules = self.engine.config.strategy.rules
|
||||
async def _prepare(self) -> int:
|
||||
first_valid = 0
|
||||
usable: dict[str, Candles] = {}
|
||||
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:
|
||||
log.warning(
|
||||
"%s: nur %d Kerzen – zu wenig für die Indikatoren, Symbol wird übersprungen",
|
||||
@@ -136,7 +136,7 @@ class BacktestRunner:
|
||||
|
||||
async def run(self) -> BacktestReport:
|
||||
start_time = time.perf_counter()
|
||||
first_valid = self._prepare()
|
||||
first_valid = await self._prepare()
|
||||
length = min(len(c) for c in self.series.values())
|
||||
symbols = list(self.series)
|
||||
|
||||
|
||||
+12
-1
@@ -14,6 +14,7 @@ from . import __version__
|
||||
from .app import build_runtime, describe_config, setup_logging
|
||||
from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv
|
||||
from .config import Config, Mode, load_config
|
||||
from .configstore import ConfigStore
|
||||
from .data import align_series, load_csv, parse_iso8601
|
||||
from .engine import install_signal_handlers
|
||||
from .exchange import available_exchanges
|
||||
@@ -164,11 +165,21 @@ async def cmd_run(args: argparse.Namespace) -> int:
|
||||
return 2
|
||||
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")
|
||||
if config.mode is Mode.LIVE:
|
||||
log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.")
|
||||
|
||||
runtime = await build_runtime(config, with_server=not args.no_server)
|
||||
runtime = await build_runtime(config, with_server=not args.no_server, config_store=store)
|
||||
try:
|
||||
await runtime.start_services()
|
||||
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"
|
||||
|
||||
# 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):
|
||||
PAPER = "paper"
|
||||
@@ -122,9 +169,15 @@ class LearnerConfig(_Base):
|
||||
label_horizon_bars: int = Field(default=12, ge=1)
|
||||
label_target_bps: float = Field(default=30.0, ge=0)
|
||||
trade_sample_weight: float = Field(default=3.0, gt=0)
|
||||
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands beschleunigen
|
||||
# die Aufwärmphase erheblich (0 = aus).
|
||||
background_sample_every_n_bars: int = Field(default=10, ge=0)
|
||||
# Einstiegssignale sind selten – nur rund 3 % der Beobachtungen stammen aus echten
|
||||
# Trades. Regelmäßige Stichproben des Marktzustands sind daher der wirksamste Hebel
|
||||
# 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)
|
||||
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, statt
|
||||
# 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)
|
||||
|
||||
|
||||
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):
|
||||
name: str = "adaptive" # adaptive | rules
|
||||
rules: RuleConfig = Field(default_factory=RuleConfig)
|
||||
learner: LearnerConfig = Field(default_factory=LearnerConfig)
|
||||
derivatives: DerivativesConfig = Field(default_factory=DerivativesConfig)
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
@@ -147,8 +219,22 @@ class StrategyConfig(_Base):
|
||||
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):
|
||||
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):
|
||||
@@ -156,6 +242,15 @@ class ServerConfig(_Base):
|
||||
host: str = "0.0.0.0"
|
||||
port: int = Field(default=8080, ge=1, le=65535)
|
||||
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):
|
||||
@@ -164,6 +259,25 @@ class NotificationConfig(_Base):
|
||||
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):
|
||||
start: str | None = None # ISO-8601, z.B. 2024-01-01T00:00:00Z
|
||||
end: str | None = None
|
||||
@@ -178,11 +292,13 @@ class Config(_Base):
|
||||
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
|
||||
market: MarketConfig = Field(default_factory=MarketConfig)
|
||||
paper: PaperConfig = Field(default_factory=PaperConfig)
|
||||
trading: TradingConfig = Field(default_factory=TradingConfig)
|
||||
risk: RiskConfig = Field(default_factory=RiskConfig)
|
||||
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
server: ServerConfig = Field(default_factory=ServerConfig)
|
||||
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
|
||||
llm: OllamaConfig = Field(default_factory=OllamaConfig)
|
||||
backtest: BacktestConfig = Field(default_factory=BacktestConfig)
|
||||
|
||||
@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),
|
||||
}
|
||||
+436
-26
@@ -7,29 +7,115 @@ Backtest-Betrieb; nur die Datenquelle und die Ausführung werden ausgetauscht.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
import signal
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .broker import Broker, InsufficientFunds, OrderRejected, PaperBroker
|
||||
from .config import Config
|
||||
from .configstore import ConfigError, ConfigStore
|
||||
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 .notify import Notifier
|
||||
from .portfolio import Portfolio
|
||||
from .risk import RiskManager
|
||||
from .storage import NullStorage, Storage
|
||||
from .strategy import AdaptiveStrategy, Strategy
|
||||
from .strategy import AdaptiveStrategy, RuleStrategy, Strategy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
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)
|
||||
class Bar:
|
||||
@@ -66,8 +152,15 @@ class TradingEngine:
|
||||
risk: RiskManager,
|
||||
storage: Storage | NullStorage,
|
||||
notifier: Notifier | None = None,
|
||||
config_store: ConfigStore | None = None,
|
||||
derivatives: DerivativesProvider | None = None,
|
||||
llm: OllamaClient | None = None,
|
||||
) -> None:
|
||||
self.llm = llm
|
||||
self.config = config
|
||||
self.config_store = config_store
|
||||
self.derivatives = derivatives
|
||||
self._coverage_warned: set[str] = set()
|
||||
self.broker = broker
|
||||
self.feed = feed
|
||||
self.strategy = strategy
|
||||
@@ -87,6 +180,16 @@ class TradingEngine:
|
||||
self._stop_event: asyncio.Event | None = None
|
||||
self._persist_every = 10
|
||||
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
|
||||
|
||||
@@ -104,6 +207,11 @@ class TradingEngine:
|
||||
)
|
||||
|
||||
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:
|
||||
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
|
||||
for symbol in list(self.portfolio.positions):
|
||||
@@ -120,45 +228,316 @@ class TradingEngine:
|
||||
self.running = False
|
||||
|
||||
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
|
||||
mehrere Tage, bis das Modell genug Beobachtungen für die Aufwärmphase gesammelt hat.
|
||||
"""
|
||||
learner = getattr(self.strategy, "learner", None)
|
||||
warmup = getattr(self.strategy, "warmup_from_history", None)
|
||||
learner = self.learner
|
||||
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
|
||||
|
||||
log.info("Modell ist untrainiert – lerne aus bis zu %d historischen Kerzen vor …", bars)
|
||||
await self._train_on_history(bars, adopt_timeline=True)
|
||||
|
||||
async def _train_on_history(self, bars: int, *, adopt_timeline: bool = False) -> TrainingJob:
|
||||
"""Das Modell auf historischen Kerzen nachtrainieren – ohne zu handeln.
|
||||
|
||||
Läuft unter demselben Mutex wie der Handelsdurchlauf, damit sich beide nicht in
|
||||
die Quere kommen. Die rechenintensive Schleife wandert in einen Worker-Thread,
|
||||
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:
|
||||
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)
|
||||
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 = build_feature_matrix(candles, self.config.strategy.rules)
|
||||
matrix = await self.build_matrix(candles)
|
||||
if matrix is None:
|
||||
log.warning("%s: zu wenig Historie zum Vorlernen (%d Kerzen)", symbol, len(candles))
|
||||
log.warning("%s: zu wenig Historie (%d Kerzen)", symbol, len(candles))
|
||||
job.skipped.append(f"{symbol}: nur {len(candles)} Kerzen")
|
||||
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.
|
||||
|
||||
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])
|
||||
|
||||
gained = learner.stats.samples_seen - before
|
||||
job.finish(before, learner.stats.samples_seen, learner.ready)
|
||||
log.info(
|
||||
"Vorlernen abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
|
||||
gained, learner.stats.samples_seen, "einsatzbereit" if learner.ready else "noch im Aufwärmen",
|
||||
"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 gained:
|
||||
if job.samples_gained:
|
||||
try:
|
||||
learner.save()
|
||||
except OSError as exc: # pragma: no cover
|
||||
log.error("Vorgelerntes Modell konnte nicht gespeichert werden: %s", exc)
|
||||
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:
|
||||
"""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."""
|
||||
self._stop_event = asyncio.Event()
|
||||
self.running = True
|
||||
interval = self.config.market.poll_interval_seconds
|
||||
history = max(self.config.market.history_bars, required_bars(self.config.strategy.rules) + 10)
|
||||
|
||||
while self.running:
|
||||
cycle_start = time.monotonic()
|
||||
# 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:
|
||||
await self._tick(history)
|
||||
self.iterations += 1
|
||||
@@ -208,6 +590,11 @@ class TradingEngine:
|
||||
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
|
||||
|
||||
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()
|
||||
for symbol in self.config.market.symbols:
|
||||
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
|
||||
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
|
||||
if snapshot is None:
|
||||
log.info(
|
||||
@@ -252,7 +639,10 @@ class TradingEngine:
|
||||
if position is not None:
|
||||
if await self._manage_open_position(symbol, position, snapshot, bar):
|
||||
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)
|
||||
|
||||
async def _manage_open_position(
|
||||
@@ -278,12 +668,21 @@ class TradingEngine:
|
||||
return False
|
||||
|
||||
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)
|
||||
if signal.action is not Action.ENTER_LONG:
|
||||
if signal.confidence and log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("%s: kein Einstieg – %s", symbol, signal.reason)
|
||||
return
|
||||
|
||||
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)
|
||||
if not decision:
|
||||
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),
|
||||
"strategy": self.strategy.snapshot(),
|
||||
"risk": self.risk.snapshot(),
|
||||
"trading": self.trading_control_status(),
|
||||
"training": self.training_status(),
|
||||
"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
|
||||
|
||||
from .config import RuleConfig
|
||||
from .config import DerivativesConfig, RuleConfig
|
||||
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
|
||||
from .models import Candles
|
||||
|
||||
FEATURE_NAMES: tuple[str, ...] = (
|
||||
BASE_FEATURE_NAMES: tuple[str, ...] = (
|
||||
"ema_spread", # (EMA_fast - EMA_slow) / Preis [%]
|
||||
"ema_fast_dist", # (Preis - EMA_fast) / Preis [%]
|
||||
"trend_dist", # (Preis - EMA_trend) / Preis [%]
|
||||
@@ -38,11 +38,38 @@ FEATURE_NAMES: tuple[str, ...] = (
|
||||
"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
|
||||
_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)
|
||||
class FeatureSnapshot:
|
||||
"""Merkmalsvektor plus Roh-Kennzahlen, die Risiko und Regelwerk zusätzlich brauchen."""
|
||||
@@ -96,6 +123,7 @@ class FeatureMatrix:
|
||||
ema_slow: np.ndarray
|
||||
trend_ema: np.ndarray
|
||||
first_valid: int # ab hier sind die Zeilen belastbar
|
||||
names: tuple[str, ...] = BASE_FEATURE_NAMES
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.values.shape[0])
|
||||
@@ -112,7 +140,7 @@ class FeatureMatrix:
|
||||
prev = max(idx - 1, 0)
|
||||
return FeatureSnapshot(
|
||||
values=self.values[idx].copy(),
|
||||
names=FEATURE_NAMES,
|
||||
names=self.names,
|
||||
index=idx,
|
||||
price=float(self.price[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.
|
||||
|
||||
``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.
|
||||
"""
|
||||
n = len(candles)
|
||||
@@ -201,6 +274,8 @@ def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix |
|
||||
np.sin(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])
|
||||
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),
|
||||
trend_ema=_clean(trend_ema, close),
|
||||
first_valid=need - 1,
|
||||
names=feature_names(derivatives),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -235,7 +235,11 @@ class LearnerStats:
|
||||
|
||||
@dataclass(slots=True)
|
||||
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
|
||||
features: np.ndarray
|
||||
@@ -244,6 +248,7 @@ class PendingLabel:
|
||||
horizon_bars: int
|
||||
target_bps: float
|
||||
weight: float = 1.0
|
||||
tag: str = "live"
|
||||
|
||||
def matured(self, current_bar: int) -> bool:
|
||||
return current_bar - self.created_bar >= self.horizon_bars
|
||||
@@ -275,7 +280,12 @@ class AdaptiveLearner:
|
||||
return self.stats.samples_seen >= self.config.warmup_samples
|
||||
|
||||
def score(self, features: np.ndarray) -> float:
|
||||
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1)."""
|
||||
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1).
|
||||
|
||||
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])
|
||||
|
||||
@@ -345,7 +355,13 @@ class AdaptiveLearner:
|
||||
# -------------------------------------------------- Verzögerte Shadow-Labels
|
||||
|
||||
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:
|
||||
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
|
||||
self._pending.append(
|
||||
@@ -357,21 +373,26 @@ class AdaptiveLearner:
|
||||
horizon_bars=self.config.label_horizon_bars,
|
||||
target_bps=self.config.label_target_bps,
|
||||
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.
|
||||
|
||||
Label = 1, wenn der Kurs innerhalb des Horizonts das Ziel erreicht hat, ohne vorher
|
||||
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
|
||||
|
||||
``tag`` grenzt auf einen Lauf ein; ``None`` wertet alle aus.
|
||||
"""
|
||||
if not self._pending:
|
||||
return 0
|
||||
resolved = 0
|
||||
still_open: list[PendingLabel] = []
|
||||
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)
|
||||
continue
|
||||
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
|
||||
@@ -393,8 +414,15 @@ class AdaptiveLearner:
|
||||
self._pending = still_open
|
||||
return resolved
|
||||
|
||||
def drop_pending(self, symbol: str | None = None) -> None:
|
||||
self._pending = [p for p in self._pending if symbol is not None and p.symbol != symbol]
|
||||
def drop_pending(self, symbol: str | None = None, tag: str | None = None) -> int:
|
||||
"""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
|
||||
def pending_count(self) -> int:
|
||||
@@ -537,8 +565,8 @@ class NullLearner:
|
||||
def resolve_pending(self, *args: object, **kwargs: object) -> int:
|
||||
return 0
|
||||
|
||||
def drop_pending(self, *args: object, **kwargs: object) -> None:
|
||||
return None
|
||||
def drop_pending(self, *args: object, **kwargs: object) -> int:
|
||||
return 0
|
||||
|
||||
def learn_from_trade(self, *args: object, **kwargs: object) -> 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
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from typing import Any, Protocol
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -14,7 +15,34 @@ log = logging.getLogger(__name__)
|
||||
|
||||
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">
|
||||
<title>TradeMind</title>
|
||||
<style>
|
||||
@@ -35,10 +63,111 @@ _DASHBOARD = """<!doctype html>
|
||||
th:first-child, td:first-child { text-align: left; }
|
||||
section { margin-top: 28px; }
|
||||
.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>
|
||||
<h1>TradeMind</h1>
|
||||
<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>
|
||||
|
||||
<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">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
|
||||
<script>
|
||||
@@ -79,8 +208,266 @@ async function refresh() {
|
||||
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
|
||||
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
|
||||
(s.recent_trades || []).slice().reverse());
|
||||
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; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 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);
|
||||
</script>
|
||||
"""
|
||||
@@ -101,11 +488,21 @@ def _flatten_metrics(prefix: str, node: Any, out: list[tuple[str, float]]) -> No
|
||||
class StatusServer:
|
||||
"""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._status = status_provider
|
||||
self._controller = controller
|
||||
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:
|
||||
app = web.Application()
|
||||
app.add_routes(
|
||||
@@ -120,6 +517,20 @@ class StatusServer:
|
||||
)
|
||||
if self.config.enable_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
|
||||
|
||||
async def start(self) -> None:
|
||||
@@ -162,6 +573,156 @@ class StatusServer:
|
||||
trades = self._status().get("recent_trades", [])
|
||||
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:
|
||||
state = self._status()
|
||||
samples: list[tuple[str, float]] = []
|
||||
|
||||
@@ -115,6 +115,7 @@ class AdaptiveStrategy(Strategy):
|
||||
self.candidates_explored = 0
|
||||
self.background_samples = 0
|
||||
self._bar_index: dict[str, int] = {}
|
||||
self._label_tag = "live"
|
||||
|
||||
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
|
||||
if position is not None:
|
||||
@@ -163,12 +164,14 @@ class AdaptiveStrategy(Strategy):
|
||||
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
|
||||
) -> None:
|
||||
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
|
||||
self.learner.register_candidate(symbol, snapshot.values, snapshot.price, bar_index, weight)
|
||||
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,
|
||||
close: float) -> None:
|
||||
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
|
||||
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
|
||||
@@ -186,11 +189,19 @@ class AdaptiveStrategy(Strategy):
|
||||
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
|
||||
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
|
||||
|
||||
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen.
|
||||
Es werden ausschließlich vergangene Kerzen verwendet – dieselbe Logik wie im Backtest.
|
||||
Gibt den Index der letzten verarbeiteten Kerze zurück.
|
||||
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen,
|
||||
und ein laufender Bot lässt sich jederzeit nachtrainieren. Es werden ausschließlich
|
||||
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
|
||||
try:
|
||||
for index in range(matrix.first_valid, len(matrix)):
|
||||
snapshot = matrix.snapshot(index)
|
||||
if snapshot is None:
|
||||
@@ -206,6 +217,15 @@ class AdaptiveStrategy(Strategy):
|
||||
if self.rules.entry_candidate(snapshot) is not None:
|
||||
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
|
||||
|
||||
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,8 +95,13 @@ async def test_cash_and_equity_stay_consistent(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(
|
||||
{**base_config.model_dump(), "risk": {**base_config.risk.model_dump(),
|
||||
{**base_config.model_dump(),
|
||||
"strategy": {"name": "rules"},
|
||||
"risk": {**base_config.risk.model_dump(),
|
||||
"stop_loss_atr_mult": 1.0,
|
||||
"take_profit_atr_mult": 10.0}}
|
||||
)
|
||||
|
||||
@@ -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