7 Commits

Author SHA1 Message Date
Tobias Zimmermann d36ed142cf Terminmarktmerkmale, Ollama-Erklärungen, schnelleres Lernen
Drei Erweiterungen aus der vorangegangenen Analyse.

Lerngeschwindigkeit
- background_sample_every_n_bars von 10 auf 5. Gemessen stammen nur rund 3 % der
  Beobachtungen aus echten Trades; diese Stichproben sind der wirksamste Hebel.
  Nicht weiter gesenkt, weil benachbarte Kerzen stark korreliert sind und sich die
  Label-Fenster überlappen - mehr Gradientenschritte heißt dort nicht mehr
  Information.

Funding Rate und Open Interest als Merkmale (strategy.derivatives, standardmäßig aus)
- Vier zusätzliche Merkmale vom Perpetual zum jeweiligen Spot-Paar. Gehandelt wird
  weiterhin Spot, die Kennzahlen kommen über einen zweiten ccxt-Client mit
  defaultType=future.
- 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 ab, bleiben die
  Spalten neutral. Die Modelldimension bleibt dabei stabil.
- Gemessene API-Grenzen bei Binance: Open Interest reicht 30 Tage zurück, 500 Zeilen
  je Abruf; Funding Rate über ein Jahr. Beide Merkmale sind deshalb einzeln
  abschaltbar.

  ERGEBNIS: kein Nutzen. Zwei Backtests mit identischen Kerzen und Seed -
  5m/20 Tage: Rendite -1,79 % auf -1,90 %, Accuracy 51,2 % auf 50,4 %;
  15m/28 Tage: Rendite -2,22 % auf -2,67 %, LogLoss praktisch unverändert.
  Die Anbindung arbeitet einwandfrei (100 % Datenabdeckung), das Modell gewichtet
  die neuen Merkmale aber nur mit 0,01 bis 0,09 gegenüber 0,39 für ema_spread.
  Die Funktion bleibt aus und ist dafür da, das auf anderen Zeiträumen selbst zu
  prüfen - nicht weil sie sich bewährt hätte.

Ollama-Erklärungen (llm, standardmäßig aus)
- Neuer Dashboard-Bereich und POST /control/explain. Das Modell bekommt den Zustand
  als Text und gibt Text zurück; es entscheidet nichts, beeinflusst keine Order und
  wird nie aus dem Handels-Loop heraus aufgerufen. Der System-Prompt untersagt
  Anlageempfehlungen und Kursprognosen.
- Bewusst nicht als Entscheider: nicht reproduzierbar, kaum backtestbar, und es
  würde die Nachvollziehbarkeit des linearen Modells zerstören.
- Beim Test an qwen3.8:27b zeigte sich ein echter Fehler: Reasoning-Modelle legen
  ihre Denkschritte in ein eigenes Antwortfeld und verbrauchten dafür das gesamte
  Token-Budget, response blieb leer. llm.think ist jetzt standardmäßig false, die
  Fehlermeldung nennt Ursache und Ausweg statt nur "leere Antwort", und für ältere
  Ollama-Versionen ohne das Feld gibt es einen Wiederholungsversuch ohne es.

Bewusst nicht enthalten: News- und Google-Trends-Sentiment. Es fehlt eine Quelle mit
Point-in-Time-Historie; ohne die lässt sich das Merkmal nicht backtesten. Nach dem
Ergebnis oben wäre ein unvalidiertes Merkmal der falsche Schritt.

test_stop_loss_bounds_the_worst_trade läuft jetzt mit strategy.name: rules. Er hing
über die geänderte Voreinstellung an zufälligem Modellverhalten, geprüft werden soll
aber die Stop-Logik.

270 Tests (36 neue), ruff sauber. Gegen echte Binance-Daten und eine laufende
Ollama-Instanz geprüft, Dashboard-Bereich im Browser bedient.
2026-08-23 14:46:24 +02:00
Tobias Zimmermann db31bc93f7 Konfigurationsbereich auffindbar machen
Der Bereich war sichtbar, wurde aber übersehen: ein 12-Pixel-Knopf, der an der
Überschrift klebte, während "Handel pausieren" und "Training starten" jeweils in
einer eigenen Box mit normalgroßem Knopf sitzen. 71x26 px gegenüber 90x58 und
79x58 - das liest sich nicht als Bedienelement.

Der Bereich sieht jetzt aus wie die beiden anderen: eigene Panel-Box, Knopf
"Konfiguration bearbeiten" in gleicher Größe (114x58 px, 14px Schrift), daneben
eine Zeile, die sagt, was dahintersteckt. Geöffnet heißt der Knopf
"Konfiguration schließen".

Zwei neue Tests halten die drei Haupt-Bedienelemente auf gleicher Linie: keine
Inline-Styles, die Schrift oder Polsterung verkleinern, und jeder Knopf muss in
einer Panel-Box stehen statt in einer Überschrift.

233 Tests (6 neue), ruff sauber. Im Browser durchgespielt: öffnen, 76 Felder,
risk.cooldown_bars_after_exit geändert und sofort wirksam, schließen.
2026-08-23 14:06:40 +02:00
Tobias Zimmermann 17fef9abe5 Konfigurationsbereich im Dashboard sichtbar machen
Der Abschnitt war für Benutzer unerreichbar: Er startet mit hidden, und sichtbar
gemacht hat ihn nur renderConfig() - die ausschließlich nach einem Klick auf
"anzeigen" lief, einen Knopf innerhalb des versteckten Abschnitts. Er konnte sich
also nie selbst einblenden. refresh() rief renderConfig nicht auf.

Die Sichtbarkeit entscheidet jetzt der Statuslauf, der ohnehin alle fünf Sekunden
durchläuft, anhand von trading.control_enabled.

Warum das durchgerutscht ist: Die Prüfung des Formulars lief über
getElementById("cfg-toggle").click(). Das funktioniert auch bei unsichtbaren
Elementen - die gesamte Konfigurationsoberfläche wurde damit über einen Knopf
bedient, den kein Mensch je gesehen hätte. Dieselbe Lücke wie beim vorigen
Dashboard-Fehler: Mechanik geprüft, Sichtbarkeit nicht.

Neuer Test: Für jeden <section ... hidden> wird geprüft, dass er im Statuslauf
sichtbar gemacht wird, also in refresh() oder einer der von dort gerufenen
render-Funktionen. Gegenprobe gelaufen - vor dem Fix meldet er config-section als
unerreichbar, nach dem Fix sind alle drei Abschnitte in Ordnung.

227 Tests (5 neue), ruff sauber. Im Browser ohne jeden Skriptklick geprüft: Der
Abschnitt erscheint nach dem Laden, der Knopf liegt mit 71x26 px im Dokumentfluss,
keine Blockade durch display, visibility, pointer-events oder opacity in der
Elternkette, und das Panel öffnet mit 76 Eingabefeldern.
2026-08-23 13:54:07 +02:00
Tobias Zimmermann 9f10f9000e Konfiguration im Dashboard änderbar machen
Neuer Abschnitt "Konfiguration" im Dashboard, aus dem pydantic-Schema erzeugt:
76 Felder in 13 Bereichen mit Kurzbeschreibung, typgerechtem Eingabefeld und den
Grenzen aus dem Modell. 70 davon sind änderbar, 45 greifen sofort, 31 sind als
neustartpflichtig markiert. Dazu GET und POST /control/config sowie
/control/config/reset.

Overlay statt Direktschreiben
- config.yaml ist im Container read-only eingehängt. Änderungen landen deshalb in
  /data/config.overrides.yaml und werden beim Start über die Basiskonfiguration
  gelegt. Rangfolge: YAML, dann ${ENV}, dann TRADEMIND__-Variablen, dann Overlay.
- Gespeichert wird nur, was vom Basiswert abweicht. Ein auf den Ausgangswert
  zurückgestelltes Feld verschwindet wieder aus dem Overlay, damit spätere
  Änderungen an config.yaml dort erneut durchschlagen.
- Beschädigte oder ungültige Overlays werden protokolliert und ignoriert, statt
  den Start zu blockieren.

Übernehmen zur Laufzeit
- apply_config hängt die Laufzeitobjekte auf die neuen Teilkonfigurationen um
  (Risiko, Strategie, Regelwerk, Lernmodell, Paper-Broker, Notifier, Log-Level).
- Was nur beim Aufbau ausgewertet wird - Börsenclient, Symbole, Timeframe,
  Startkapital, Datenbank, Socket - meldet der Store als neustartpflichtig.

Nebenbei behoben: Der Handels-Loop las Abfrageintervall und Historienlänge nur
einmal vor der Schleife. Eine Änderung an poll_interval_seconds hätte nie
gegriffen; beide werden jetzt je Durchlauf frisch gelesen.

Drei Ausnahmen von "komplett", bewusst gesetzt
- exchange.api_key/api_secret/password/uid werden maskiert angezeigt und nicht
  entgegengenommen. Sonst könnte jeder mit Zugriff auf den Port die
  Börsenschlüssel auslesen oder austauschen.
- mode lässt sich zwischen paper und backtest umstellen, nicht auf live.
- live_confirmation ist nicht schreibbar.
Zusammen verhindern sie, dass sich der Bot über das Netz schrittweise auf
Echtgeldhandel umstellen lässt.

222 Tests (36 neue), ruff sauber. Darunter Prüfungen, dass Geheimnisse in keiner
Antwort auftauchen und dass Tippfehler in RESTART_REQUIRED oder NON_WRITABLE
auffallen. Im Browser durchgeklickt: Änderung sofort wirksam, neustartpflichtige
Felder korrekt gemeldet und nach Neustart aktiv, Zurücksetzen einzeln und
komplett, alle Schutzregeln mit HTTP 400 samt lesbarer Begründung.
2026-08-23 13:25:33 +02:00
Tobias Zimmermann 4551339632 Dashboard reparieren: Escape-Sequenz zerlegte das eingebettete Skript
Das Dashboard blieb bei "lädt ..." stehen und zeigte weder Kacheln noch die
Steuerungs-Abschnitte.

Ursache: _DASHBOARD ist ein Python-String. Der in d081edf ergänzte
prompt()-Dialog für die Live-Bestätigung enthält \n, das Python zu echten
Zeilenumbrüchen aufgelöst hat. Das dadurch offene JavaScript-String-Literal ist
ein Syntaxfehler, der den gesamten <script>-Block mitreißt - im Browser waren
refresh und $ schlicht undefined, es lief kein einziger Ausdruck. Behoben durch
ein r-Präfix am Template, mit Kommentar an Ort und Stelle.

Warum das durchgerutscht ist: Die bisherige Prüfung hat nur kontrolliert, ob die
Bedienelemente im HTML stehen und ob die HTTP-Endpunkte antworten. Beides war
grün, während die Seite tot war. Markup-Präsenz ersetzt nicht das Ausführen der
Seite.

Neu: tests/test_dashboard.py
- Mini-Lexer über das ausgelieferte Skript, der String-Literale findet, die vor
  dem Zeilenende nicht geschlossen werden (Backtick-Template-Literale dürfen
  mehrzeilig sein). Gegenprobe im Test enthalten; gegen den echten Defekt
  geprüft, er wird erkannt.
- Ausgeglichene Klammern, jede im Skript referenzierte Element-ID existiert im
  HTML, und \n kommt als zwei Zeichen beim Browser an.

186 Tests (12 neue), ruff sauber. Im Browser durchgeklickt: Skript lädt, 10
Kacheln, Handel pausieren/starten und der Lernschalter ändern den Serverzustand
und die Anzeige, historisches Training läuft durch bis "Fertig in 0.8s: +146
Beobachtungen, gespeichert", null Loop-Fehler.
2026-08-23 12:59:39 +02:00
Tobias Zimmermann d081edfbd3 Automatisierten Handel über das Dashboard starten und pausieren
Neuer Abschnitt "Automatisierter Handel" im Dashboard mit Zustandsanzeige und
Start/Pause-Knopf, dazu GET und POST /control/trading. Funktioniert in beiden
Modi, simuliert wie echt.

Pausiert unterbindet ausschließlich neue Einstiege
- Marktdaten, Signalauswertung, Labeling und Training laufen weiter, damit der
  Datenstrom ins Modell nie abreißt. Auf identischen Daten: aktiv 99
  Beobachtungen bei 9 Trades, pausiert 90 Beobachtungen bei 0 Trades - der
  Unterschied sind genau die Trade-Labels, die ohne Handel nicht entstehen.
- Offene Positionen bleiben unter Stop- und Ziel-Überwachung. Ein pausierter Bot
  lässt niemanden ungeschützt im Markt stehen.
- Neu: trading.autostart (Standard true, bisheriges Verhalten) und
  trading.require_confirmation_for_live.

Nebenbei behoben
- Bei aktiver Notbremse (Tagesverlust, Drawdown) wurden bisher gar keine Signale
  mehr ausgewertet. Der Bot hörte damit ausgerechnet in der interessantesten
  Phase auf zu lernen. Jetzt läuft die Auswertung durch, nur der Kauf unterbleibt.
- describe_config meldete "Steuerung möglich", während sie eine Zeile später
  abgeschaltet wurde. Übersicht und Schutzlogik teilen sich jetzt dieselbe
  Prüffunktion (control_effective / control_is_unsafe).

Absicherung für Echtgeld
- Starten im Live-Modus verlangt die Bestätigung START_LIVE_TRADING im Body
  (HTTP 428 ohne sie); das Dashboard fragt sie per Dialog ab. Pausieren geht
  immer ohne Bestätigung - anhalten muss schnell gehen.
- Live-Modus mit offenem Port und ohne control_token wird nicht bedient: Die
  Steuerung wird beim Start abgeschaltet, der Bot läuft normal weiter. Sonst
  könnte jeder im Netz echten Handel starten.

Bewusst nicht enthalten: ein Umschalter zwischen Paper und Live zur Laufzeit.
Der bräuchte einen neuen Broker, andere Zugangsdaten und eine neue Kapitalbasis -
und würde bedeuten, dass ein Web-Klick aus einer Simulation echten Handel macht.

174 Tests (21 neue), ruff sauber. Im gebauten Container geprüft: 401 ohne Token,
200 mit, idempotentes Pausieren, 400 bei kaputtem Payload, und im Live-Start mit
ungültigen Dummy-Schlüsseln greift die Abschaltung der Steuerung.
2026-08-23 12:40:56 +02:00
Tobias Zimmermann 3cd12b67aa Training über das Dashboard anstoßen
Neuer Abschnitt "Training" im Dashboard mit zwei Bedienelementen und den
zugehörigen Endpunkten /control/train/history, /control/train/live und
/control/training.

Historisches Nachtraining
- Kerzenanzahl je Symbol wählbar (500 bis 50 000), Fortschritt und Ergebnis
  werden im Dashboard angezeigt.
- Läuft mit derselben Logik wie ein Backtest, aber ohne zu handeln, und
  speichert das Modell anschließend.
- Handelsdurchlauf und Nachtraining teilen sich einen Mutex, damit sie nicht
  gleichzeitig auf Modell und Portfolio zugreifen. Die rechenintensive Schleife
  läuft in einem Worker-Thread, damit der Status-Server antwortbereit bleibt.
- Ein zweiter Start wird abgelehnt, solange einer eingereiht ist oder läuft.

Kontinuierliches Lernen
- Schalter für das Online-Lernen im laufenden Betrieb. Ausgeschaltet handelt der
  Bot weiter, verändert das Modell aber nicht mehr.

Label-Trennung
- Vorgemerkte Labels tragen jetzt ein Tag. Ein Nachtraining darf die offenen
  Labels des Live-Betriebs weder auflösen noch verwerfen; ohne die Trennung
  würden sie gegen historische Kurse ausgewertet und das Modell mit falschen
  Ergebnissen gefüttert.
- score() liest die Gewichte unter dem Lock, damit ein parallel laufendes
  Training keinen halb aktualisierten Vektor sichtbar macht.

Absicherung
- server.control_token (TRADEMIND_CONTROL_TOKEN) schützt alle Steuerbefehle über
  den Header X-TradeMind-Token; lesende Endpunkte bleiben offen. Ohne Token warnt
  der Bot beim Start, wenn der Port nicht nur lokal erreichbar ist.
- server.enable_control: false entfernt die Routen vollständig.

153 Tests (25 neue), ruff sauber. Im gebauten Container geprüft: 202/409 beim
Anstoßen, 401 ohne und mit falschem Token, +1099 Beobachtungen in 4,3 s bei
weiterlaufendem Handels-Loop ohne Fehler.
2026-08-23 12:15:28 +02:00
22 changed files with 4215 additions and 97 deletions
+224 -4
View File
@@ -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,010,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
```
+59 -1
View File
@@ -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
+5
View File
@@ -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>)
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
dev = [
"pytest>=8.0",
"pytest-asyncio>=0.23",
"pytest-aiohttp>=1.0",
"ruff>=0.4",
]
+114 -3
View File
@@ -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)
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+458
View File
@@ -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)
+228
View File
@@ -0,0 +1,228 @@
"""Zusatzdaten aus dem Terminmarkt: Funding Rate und Open Interest.
Beides sagt etwas über die Positionierung der Marktteilnehmer, was sich aus reinen
Kerzendaten nicht ablesen lässt:
* **Funding Rate** was Long-Positionen den Short-Positionen zahlen (oder umgekehrt).
Positiv und steigend heißt: Longs sind bereit, für ihre Position zu bezahlen.
* **Open Interest** wie viele Kontrakte offen sind. Zusammen mit der Kursrichtung
unterscheidet das neu aufgebaute Positionen von Glattstellungen.
Gehandelt wird weiterhin Spot; die Kennzahlen stammen vom zugehörigen Perpetual
(``BTC/USDT`` → ``BTC/USDT:USDT``).
Zwei Grenzen der Börsen-API, gemessen an Binance:
* Open Interest reicht nur **30 Tage** zurück, 500 Zeilen je Abruf.
* Funding Rate reicht über ein Jahr zurück, veröffentlicht alle 8 Stunden.
Deshalb sind beide Merkmale einzeln abschaltbar, und fehlende Abdeckung wird gemeldet
statt stillschweigend mit Nullen aufgefüllt.
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass
import numpy as np
from .config import DerivativesConfig
from .models import Candles
log = logging.getLogger(__name__)
MAX_PAGE = 500
FUNDING_INTERVAL_MS = 8 * 3_600_000
def perpetual_symbol(spot_symbol: str) -> str:
"""``BTC/USDT`` → ``BTC/USDT:USDT`` die übliche ccxt-Schreibweise für Perpetuals."""
if ":" in spot_symbol:
return spot_symbol
quote = spot_symbol.split("/")[-1]
return f"{spot_symbol}:{quote}"
def forward_fill_to_bars(
bar_timestamps: np.ndarray, source_ts: np.ndarray, source_values: np.ndarray
) -> tuple[np.ndarray, np.ndarray]:
"""Ordnet eine Zeitreihe den Kerzen zu ohne in die Zukunft zu schauen.
Für jede Kerze gilt der letzte Wert, der zum Kerzenzeitpunkt **bereits bekannt** war.
Gibt ``(werte, bekannt)`` zurück; ``bekannt`` markiert Kerzen ohne Vorgängerwert.
"""
bars = np.asarray(bar_timestamps, dtype=np.int64)
values = np.full(bars.size, np.nan, dtype=np.float64)
known = np.zeros(bars.size, dtype=bool)
if source_ts.size == 0:
return values, known
order = np.argsort(source_ts)
src_ts = np.asarray(source_ts, dtype=np.int64)[order]
src_val = np.asarray(source_values, dtype=np.float64)[order]
# searchsorted mit "right" liefert die Anzahl Quellwerte mit ts <= bar_ts.
idx = np.searchsorted(src_ts, bars, side="right") - 1
valid = idx >= 0
values[valid] = src_val[idx[valid]]
known[valid] = True
return values, known
@dataclass(slots=True)
class DerivativeSeries:
"""Auf die Kerzen ausgerichtete Terminmarktdaten."""
symbol: str
timestamp: np.ndarray
funding_rate: np.ndarray # Anteil je 8h-Periode, NaN wenn unbekannt
open_interest: np.ndarray # Kontrakte, NaN wenn unbekannt
funding_coverage: float = 0.0
oi_coverage: float = 0.0
@property
def usable(self) -> bool:
return self.funding_coverage > 0.0 or self.oi_coverage > 0.0
def describe(self) -> str:
return (
f"{self.symbol}: Funding {self.funding_coverage * 100:.0f} %, "
f"Open Interest {self.oi_coverage * 100:.0f} % der Kerzen abgedeckt"
)
class DerivativesProvider:
"""Holt Funding Rate und Open Interest und hält sie je Symbol vor.
Der Zwischenspeicher wächst inkrementell: Im Live-Betrieb wird je neuer Kerze nur
das kurze Stück seit dem letzten bekannten Zeitstempel nachgeladen.
"""
def __init__(self, exchange, config: DerivativesConfig) -> None:
self._exchange = exchange
self.config = config
self._funding: dict[str, tuple[np.ndarray, np.ndarray]] = {}
self._oi: dict[str, tuple[np.ndarray, np.ndarray]] = {}
self.failures: dict[str, str] = {}
async def close(self) -> None:
if self._exchange is not None:
await self._exchange.close()
# ------------------------------------------------------------------ Abruf
async def _fetch_funding(self, symbol: str, since: int, until: int) -> None:
perp = perpetual_symbol(symbol)
known_ts, known_val = self._funding.get(symbol, (np.empty(0, np.int64), np.empty(0)))
cursor = int(known_ts[-1]) + 1 if known_ts.size else since
collected: list[tuple[int, float]] = []
while cursor < until:
try:
rows = await self._exchange.fetch_funding_rate_history(perp, since=cursor, limit=1000)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - Zusatzdaten dürfen nie den Bot stoppen
self.failures[f"{symbol}/funding"] = f"{type(exc).__name__}: {exc}"
log.warning("%s: Funding-Rate nicht abrufbar (%s)", perp, exc)
return
if not rows:
break
for row in rows:
rate = row.get("fundingRate")
if row.get("timestamp") is not None and rate is not None:
collected.append((int(row["timestamp"]), float(rate)))
nxt = int(rows[-1]["timestamp"]) + 1
if nxt <= cursor:
break
cursor = nxt
await self._throttle()
if collected:
ts = np.concatenate([known_ts, np.array([c[0] for c in collected], dtype=np.int64)])
val = np.concatenate([known_val, np.array([c[1] for c in collected], dtype=np.float64)])
order = np.argsort(ts)
self._funding[symbol] = (ts[order], val[order])
async def _fetch_open_interest(self, symbol: str, timeframe: str, since: int, until: int) -> None:
perp = perpetual_symbol(symbol)
known_ts, known_val = self._oi.get(symbol, (np.empty(0, np.int64), np.empty(0)))
cursor = int(known_ts[-1]) + 1 if known_ts.size else since
collected: list[tuple[int, float]] = []
pages = 0
while cursor < until and pages < self.config.max_pages:
try:
rows = await self._exchange.fetch_open_interest_history(
perp, timeframe, since=cursor, limit=MAX_PAGE
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
# Häufigster Fall: Anfrage älter als das Fenster der Börse (30 Tage).
self.failures[f"{symbol}/open_interest"] = f"{type(exc).__name__}: {exc}"
log.warning("%s: Open Interest ab %d nicht abrufbar (%s)", perp, cursor, exc)
break
if not rows:
break
for row in rows:
amount = row.get("openInterestAmount") or row.get("openInterestValue")
if row.get("timestamp") is not None and amount is not None:
collected.append((int(row["timestamp"]), float(amount)))
nxt = int(rows[-1]["timestamp"]) + 1
if nxt <= cursor:
break
cursor = nxt
pages += 1
await self._throttle()
if collected:
ts = np.concatenate([known_ts, np.array([c[0] for c in collected], dtype=np.int64)])
val = np.concatenate([known_val, np.array([c[1] for c in collected], dtype=np.float64)])
order = np.argsort(ts)
unique = np.concatenate([[True], np.diff(ts[order]) > 0])
self._oi[symbol] = (ts[order][unique], val[order][unique])
async def _throttle(self) -> None:
delay = getattr(self._exchange, "rateLimit", 0) or 0
if delay:
await asyncio.sleep(delay / 1000)
# ------------------------------------------------------------ Bereitstellen
async def series_for(self, candles: Candles) -> DerivativeSeries:
"""Terminmarktdaten passend zu einer Kerzenserie, auf deren Zeitstempel ausgerichtet."""
symbol = candles.symbol
bars = np.asarray(candles.timestamp, dtype=np.int64)
empty = np.full(bars.size, np.nan)
series = DerivativeSeries(symbol=symbol, timestamp=bars, funding_rate=empty.copy(),
open_interest=empty.copy())
if bars.size == 0:
return series
since = int(bars[0]) - FUNDING_INTERVAL_MS # ein Intervall Vorlauf für den ersten Wert
until = int(bars[-1]) + 1
if self.config.funding_rate:
await self._fetch_funding(symbol, since, until)
ts, val = self._funding.get(symbol, (np.empty(0, np.int64), np.empty(0)))
values, known = forward_fill_to_bars(bars, ts, val)
series.funding_rate = values
series.funding_coverage = float(np.mean(known)) if bars.size else 0.0
if self.config.open_interest:
await self._fetch_open_interest(symbol, candles.timeframe, since, until)
ts, val = self._oi.get(symbol, (np.empty(0, np.int64), np.empty(0)))
values, known = forward_fill_to_bars(bars, ts, val)
series.open_interest = values
series.oi_coverage = float(np.mean(known)) if bars.size else 0.0
return series
def snapshot(self) -> dict[str, object]:
return {
"enabled": True,
"funding_symbols": sorted(self._funding),
"oi_symbols": sorted(self._oi),
"failures": dict(self.failures),
}
+448 -38
View File
@@ -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)
before = learner.stats.samples_seen
for symbol in self.config.market.symbols:
try:
candles = await self._fetch_history(symbol, bars)
except Exception as exc: # noqa: BLE001 - Vorlernen darf den Start nie verhindern
log.warning("%s: Historie für das Vorlernen nicht abrufbar (%s)", symbol, exc)
continue
matrix = build_feature_matrix(candles, self.config.strategy.rules)
if matrix is None:
log.warning("%s: zu wenig Historie zum Vorlernen (%d Kerzen)", symbol, len(candles))
continue
last_index = warmup(symbol, matrix, candles)
# Zähler und Zeitstempel fortschreiben, damit der Live-Loop nahtlos anschließt
# und die zuletzt genutzte Kerze nicht doppelt verarbeitet wird.
self.bar_counter[symbol] = last_index
self.last_bar_ts[symbol] = int(candles.timestamp[last_index])
await self._train_on_history(bars, adopt_timeline=True)
gained = learner.stats.samples_seen - before
log.info(
"Vorlernen abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
gained, learner.stats.samples_seen, "einsatzbereit" if learner.ready else "noch im Aufwärmen",
)
if gained:
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:
learner.save()
except OSError as exc: # pragma: no cover
log.error("Vorgelerntes Modell konnte nicht gespeichert werden: %s", exc)
for symbol in self.config.market.symbols:
try:
candles = await self._fetch_history(symbol, bars)
except Exception as exc: # noqa: BLE001 - darf den Bot nie stoppen
log.warning("%s: Historie nicht abrufbar (%s)", symbol, exc)
job.skipped.append(f"{symbol}: {exc}")
continue
matrix = await self.build_matrix(candles)
if matrix is None:
log.warning("%s: zu wenig Historie (%d Kerzen)", symbol, len(candles))
job.skipped.append(f"{symbol}: nur {len(candles)} Kerzen")
continue
last_index = await asyncio.to_thread(warmup, symbol, matrix, candles)
job.symbols_done.append(symbol)
job.bars_seen += len(matrix) - matrix.first_valid
if adopt_timeline:
# Zähler fortschreiben, damit der Live-Loop nahtlos anschließt und
# die zuletzt genutzte Kerze nicht doppelt verarbeitet wird.
self.bar_counter[symbol] = last_index
self.last_bar_ts[symbol] = int(candles.timestamp[last_index])
job.finish(before, learner.stats.samples_seen, learner.ready)
log.info(
"Historisches Training abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
job.samples_gained, learner.stats.samples_seen,
"einsatzbereit" if learner.ready else "noch im Aufwärmen",
)
if job.samples_gained:
try:
learner.save()
job.model_saved = True
except OSError as exc: # pragma: no cover - Dateisystemfehler
log.error("Modell konnte nicht gespeichert werden: %s", exc)
job.skipped.append(f"Speichern fehlgeschlagen: {exc}")
except asyncio.CancelledError:
job.fail("abgebrochen")
raise
except Exception as exc: # noqa: BLE001 - Fehler gehören in den Job-Status
log.exception("Historisches Training fehlgeschlagen")
job.fail(f"{type(exc).__name__}: {exc}")
return job
# ----------------------------------------------------- Steuerung (Dashboard)
@property
def learner(self): # noqa: ANN201 - AdaptiveLearner | NullLearner | None
return getattr(self.strategy, "learner", None)
def start_history_training(self, bars: int | None = None) -> dict[str, Any]:
"""Historisches Training im Hintergrund anstoßen (Aufruf kehrt sofort zurück)."""
if not self.config.server.enable_control:
return {"accepted": False, "reason": "Steuerung ist deaktiviert", **self.training.as_dict()}
# "queued" zählt mit: zwischen Anstoßen und Start des Tasks darf kein zweiter durch.
if self.training.state in ("queued", "running"):
return {"accepted": False, "reason": "Ein Training läuft bereits", **self.training.as_dict()}
if self.learner is None:
return {"accepted": False, "reason": "Kein lernfähiges Modell konfiguriert",
**self.training.as_dict()}
requested = int(bars or self.config.strategy.learner.bootstrap_bars or 3_000)
requested = max(MIN_TRAINING_BARS, min(requested, MAX_TRAINING_BARS))
self.training.queue(requested, self.config.market.symbols)
self._training_task = asyncio.create_task(self._train_on_history(requested))
self._training_task.add_done_callback(lambda _: setattr(self, "_training_task", None))
log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested)
return {"accepted": True, "reason": "", **self.training.as_dict()}
def apply_config(self, new_config: Config) -> list[str]:
"""Geänderte Konfiguration im laufenden Betrieb übernehmen.
Die Laufzeitobjekte halten Referenzen auf die Teilkonfigurationen; sie werden hier
umgehängt. Was nur beim Aufbau ausgewertet wird (Börsenclient, Broker-Startkapital,
Datenbank, Socket), lässt sich so nicht ändern das meldet der ConfigStore als
neustartpflichtig.
"""
previous_level = self.config.log_level
self.config = new_config
self.risk.config = new_config.risk
if isinstance(self.strategy, AdaptiveStrategy):
self.strategy.config = new_config.strategy
self.strategy.rules.config = new_config.strategy.rules
elif isinstance(self.strategy, RuleStrategy):
self.strategy.config = new_config.strategy.rules
learner = self.learner
if learner is not None:
learner.config = new_config.strategy.learner
if isinstance(self.broker, PaperBroker):
# Gebühren, Slippage und Volumengrenze werden je Order gelesen.
self.broker.config = new_config.paper.model_copy(
update={"quote_currency": self.broker.quote_currency,
"starting_balance": self.broker.starting_balance}
)
if self.notifier is not None:
self.notifier.config = new_config.notifications
if new_config.log_level != previous_level:
logging.getLogger().setLevel(new_config.log_level)
log.info("Log-Level auf %s gesetzt", new_config.log_level)
applied = ["risk", "strategy", "trading", "notifications", "market.poll_interval_seconds"]
log.info("Konfigurationsänderung übernommen (%s)", ", ".join(applied))
return applied
def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]:
"""Automatisierten Handel starten oder pausieren.
Pausiert werden ausschließlich neue Einstiege. Marktdaten, Signalauswertung und
Lernen laufen weiter, offene Positionen bleiben unter Stop-Loss-Überwachung ein
pausierter Bot lässt also niemanden ungeschützt im Markt stehen.
"""
if not self.config.server.enable_control:
return {"accepted": False, "reason": "Steuerung ist deaktiviert",
**self.trading_control_status()}
# Echtgeld zusätzlich absichern: Ein versehentlicher Klick soll nicht reichen.
if enabled and self.requires_trade_confirmation and confirm != LIVE_TRADING_CONFIRMATION:
return {
"accepted": False,
"reason": (
"Im Live-Modus wird echtes Geld eingesetzt. Zum Starten "
f"'confirm': '{LIVE_TRADING_CONFIRMATION}' mitsenden."
),
**self.trading_control_status(),
}
if self.trading_active == enabled:
return {"accepted": True, "reason": "Zustand war bereits gesetzt",
**self.trading_control_status()}
self.trading_active = enabled
self.trading_changed_at = time.time()
level = log.warning if (enabled and not self.config.is_simulated) else log.info
level(
"Automatisierter Handel %s (Modus %s)",
"GESTARTET" if enabled else "pausiert", self.config.mode.value,
)
if self.notifier is not None:
state = "gestartet" if enabled else "pausiert"
self.notifier.send_soon(f"⚙️ Automatisierter Handel {state} (Modus {self.config.mode.value})")
return {"accepted": True, "reason": "", **self.trading_control_status()}
@property
def requires_trade_confirmation(self) -> bool:
return not self.config.is_simulated and self.config.trading.require_confirmation_for_live
def trading_control_status(self) -> dict[str, Any]:
return {
"control_enabled": self.config.server.enable_control,
"mode": self.config.mode.value,
"simulated": self.config.is_simulated,
"active": self.trading_active,
"autostart": self.config.trading.autostart,
"requires_confirmation": self.requires_trade_confirmation,
"confirmation_phrase": LIVE_TRADING_CONFIRMATION if self.requires_trade_confirmation else None,
"paused_signals": self.paused_signals,
"open_positions": len(self.portfolio.positions),
"halted": self.risk.trading_halted,
"halt_reason": self.risk.halt.reason,
"changed_at": self.trading_changed_at,
}
def set_online_learning(self, enabled: bool) -> dict[str, Any]:
"""Kontinuierliches Lernen im laufenden Betrieb ein- oder ausschalten."""
if not self.config.server.enable_control:
return {"accepted": False, "reason": "Steuerung ist deaktiviert",
"online_learning": self.online_learning_enabled}
learner = self.learner
if learner is None:
return {"accepted": False, "reason": "Kein lernfähiges Modell konfiguriert",
"online_learning": False}
learner.frozen = not enabled
log.info("Kontinuierliches Lernen %s", "eingeschaltet" if enabled else "eingefroren")
return {"accepted": True, "reason": "", "online_learning": enabled}
@property
def online_learning_enabled(self) -> bool:
learner = self.learner
return bool(learner is not None and not learner.frozen)
# ------------------------------------------------- Konfiguration (Dashboard)
def config_state(self) -> dict[str, Any]:
if self.config_store is None:
return {"available": False, "sections": [], "pending_restart": []}
return {"available": self.config.server.enable_control, **self.config_store.describe()}
def update_config(self, patch: dict[str, Any]) -> dict[str, Any]:
"""Konfigurationsänderung prüfen, sichern und soweit möglich sofort übernehmen."""
if not self.config.server.enable_control or self.config_store is None:
return {"accepted": False, "reason": "Steuerung ist deaktiviert"}
try:
new_config, restart_needed = self.config_store.apply(patch)
except ConfigError as exc:
return {"accepted": False, "reason": str(exc)}
applied = self.apply_config(new_config)
return {
"accepted": True,
"reason": "",
"applied": applied,
"restart_required": restart_needed,
**self.config_state(),
}
def reset_config(self, paths: list[str] | None = None) -> dict[str, Any]:
if not self.config.server.enable_control or self.config_store is None:
return {"accepted": False, "reason": "Steuerung ist deaktiviert"}
new_config = self.config_store.reset(paths)
self.apply_config(new_config)
return {"accepted": True, "reason": "", **self.config_state()}
# ------------------------------------------------------ Erklärung (LLM)
async def explain(self) -> dict[str, Any]:
"""Den aktuellen Zustand vom Sprachmodell in Worte fassen lassen.
Läuft außerhalb des Handels-Loops und ohne dessen Mutex eine Erklärung darf
den Handel weder blockieren noch beeinflussen.
"""
if not self.config.llm.enabled or self.llm is None:
return {
"ok": False,
"error": "Erklärungen sind deaktiviert (llm.enabled)",
"model": self.config.llm.model,
}
prompt = build_status_prompt(self.status())
result = await self.llm.generate(prompt)
if not result.ok:
log.warning("Erklärung fehlgeschlagen: %s", result.error)
return result.as_dict()
def training_status(self) -> dict[str, Any]:
learner = self.learner
return {
"control_enabled": self.config.server.enable_control,
"learning_available": learner is not None,
"online_learning": self.online_learning_enabled,
"default_bars": self.config.strategy.learner.bootstrap_bars or 3_000,
"min_bars": MIN_TRAINING_BARS,
"max_bars": MAX_TRAINING_BARS,
**self.training.as_dict(),
}
async def build_matrix(self, candles: Candles):
"""Merkmalsmatrix bauen und dabei falls aktiviert Terminmarktdaten einbeziehen."""
derivatives = self.config.strategy.derivatives
series = None
if derivatives.enabled and self.derivatives is not None:
try:
series = await self.derivatives.series_for(candles)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - Zusatzdaten dürfen nie den Bot stoppen
log.warning("%s: Terminmarktdaten nicht verfügbar (%s)", candles.symbol, exc)
series = None
else:
if series is not None and not self._coverage_ok(series):
series = None
return build_feature_matrix(candles, self.config.strategy.rules, derivatives, series)
def _coverage_ok(self, series) -> bool:
"""Zu lückenhafte Reihen verwerfen neutrale Spalten sind ehrlicher als Rauschen."""
minimum = self.config.strategy.derivatives.min_coverage
checks = []
if self.config.strategy.derivatives.funding_rate:
checks.append(("Funding", series.funding_coverage))
if self.config.strategy.derivatives.open_interest:
checks.append(("Open Interest", series.oi_coverage))
for label, coverage in checks:
if coverage < minimum:
key = f"{series.symbol}/{label}"
if key not in self._coverage_warned:
self._coverage_warned.add(key)
log.warning(
"%s: %s deckt nur %.0f %% der Kerzen ab (nötig %.0f %%) "
"Terminmarktmerkmale bleiben für dieses Symbol neutral",
series.symbol, label, coverage * 100, minimum * 100,
)
return False
return True
async def _fetch_history(self, symbol: str, bars: int) -> Candles:
"""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,
},
}
+81 -5
View File
@@ -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),
)
+39 -11
View File
@@ -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,9 +280,14 @@ 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)."""
x = self.scaler.transform(np.asarray(features, dtype=np.float64).reshape(1, -1))
return float(self.model.predict_proba(x)[0])
"""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])
def explore(self) -> bool:
"""Epsilon-greedy: gelegentlich ein abgelehntes Signal trotzdem handeln."""
@@ -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
+265
View File
@@ -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
View File
@@ -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]] = []
+40 -20
View File
@@ -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,26 +189,43 @@ 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
for index in range(matrix.first_valid, len(matrix)):
snapshot = matrix.snapshot(index)
if snapshot is None:
continue
self.on_bar(
symbol,
snapshot,
index,
float(candles.high[index]),
float(candles.low[index]),
float(candles.close[index]),
)
if self.rules.entry_candidate(snapshot) is not None:
self.register_candidate(symbol, snapshot, index)
last_index = index
try:
for index in range(matrix.first_valid, len(matrix)):
snapshot = matrix.snapshot(index)
if snapshot is None:
continue
self.on_bar(
symbol,
snapshot,
index,
float(candles.high[index]),
float(candles.low[index]),
float(candles.close[index]),
)
if self.rules.entry_candidate(snapshot) is not None:
self.register_candidate(symbol, snapshot, index)
last_index = index
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]:
+295
View File
@@ -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)}"
+560
View File
@@ -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
+207
View File
@@ -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
+262
View File
@@ -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
+8 -3
View File
@@ -95,10 +95,15 @@ async def test_cash_and_equity_stay_consistent(base_config):
async def test_stop_loss_bounds_the_worst_trade(base_config):
# 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(),
"stop_loss_atr_mult": 1.0,
"take_profit_atr_mult": 10.0}}
{**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}}
)
engine = build_engine(config)
await engine.prepare()
+221
View File
@@ -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