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.
This commit is contained in:
@@ -64,9 +64,11 @@ podman-compose down # stoppen
|
|||||||
Port 8080 wird auf allen Interfaces veröffentlicht — das Dashboard ist damit standardmäßig
|
Port 8080 wird auf allen Interfaces veröffentlicht — das Dashboard ist damit standardmäßig
|
||||||
von anderen Rechnern erreichbar.
|
von anderen Rechnern erreichbar.
|
||||||
|
|
||||||
> **Sicherheit.** Alle Endpunkte sind lesend (`GET`, keine Steuerbefehle), aber **nicht
|
> **Sicherheit.** Die lesenden Endpunkte sind **nicht authentifiziert** — wer den Port
|
||||||
> authentifiziert**. Wer den Port erreicht, sieht Kontostand, offene Positionen und die
|
> erreicht, sieht Kontostand, offene Positionen und die gesamte Trade-Historie. Dazu kommen
|
||||||
> gesamte Trade-Historie. In offenen Netzen deshalb entweder einen Reverse Proxy mit
|
> die Steuerbefehle unter `/control/…` (Training anstoßen, Lernen umschalten), die sich mit
|
||||||
|
> `server.control_token` schützen lassen und das auch sollten. Gehandelt oder konfiguriert
|
||||||
|
> werden kann über HTTP in keinem Fall. In offenen Netzen deshalb einen Reverse Proxy mit
|
||||||
> Authentifizierung davorsetzen, den Zugriff per Firewall auf bekannte Quell-IPs begrenzen
|
> Authentifizierung davorsetzen, den Zugriff per Firewall auf bekannte Quell-IPs begrenzen
|
||||||
> oder auf rein lokalen Zugriff zurückstellen:
|
> oder auf rein lokalen Zugriff zurückstellen:
|
||||||
>
|
>
|
||||||
@@ -220,6 +222,9 @@ Modell ist untrainiert – lerne aus bis zu 3000 historischen Kerzen vor …
|
|||||||
Vorlernen abgeschlossen: 662 neue Beobachtungen (gesamt 662), Modell einsatzbereit
|
Vorlernen abgeschlossen: 662 neue Beobachtungen (gesamt 662), Modell einsatzbereit
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Nachtrainieren lässt sich jederzeit — per Knopf im Dashboard oder über
|
||||||
|
`trademind backtest --save-model`, siehe [Training aus dem Dashboard anstoßen](#training-aus-dem-dashboard-anstoßen).
|
||||||
|
|
||||||
Solange das Modell nicht warm ist (`warmup_samples`), entscheidet allein das Regelwerk.
|
Solange das Modell nicht warm ist (`warmup_samples`), entscheidet allein das Regelwerk.
|
||||||
Die Qualität lässt sich im Status unter `strategy.learner.online_accuracy` verfolgen — das ist
|
Die Qualität lässt sich im Status unter `strategy.learner.online_accuracy` verfolgen — das ist
|
||||||
eine *prequentielle* Messung: erst vorhersagen, dann lernen, also keine Selbstbewertung auf
|
eine *prequentielle* Messung: erst vorhersagen, dann lernen, also keine Selbstbewertung auf
|
||||||
@@ -326,7 +331,7 @@ Timeframes und Parameter gehören ausprobiert, bevor auch nur ein Paper-Euro fli
|
|||||||
|
|
||||||
| Endpunkt | Inhalt |
|
| Endpunkt | Inhalt |
|
||||||
|--------------|-------------------------------------------------------------|
|
|--------------|-------------------------------------------------------------|
|
||||||
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand |
|
| `/` | Dashboard: Equity, Positionen, Trades, Modellzustand, Training |
|
||||||
| `/health` | Liveness — nutzt der Container-Healthcheck |
|
| `/health` | Liveness — nutzt der Container-Healthcheck |
|
||||||
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
|
| `/ready` | Readiness (503, solange der Bot nicht sauber läuft) |
|
||||||
| `/status` | vollständiger Zustand als JSON |
|
| `/status` | vollständiger Zustand als JSON |
|
||||||
@@ -344,6 +349,51 @@ Slack als auch Discord.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Training aus dem Dashboard anstoßen
|
||||||
|
|
||||||
|
Im Abschnitt **Training** des Dashboards 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/training` | GET | Zustand des letzten/laufenden Trainings |
|
||||||
|
| `/control/train/history` | POST | `{"bars": 5000}` — Nachtraining anstoßen |
|
||||||
|
| `/control/train/live` | POST | `{"enabled": false}` — Lernen ein/aus |
|
||||||
|
|
||||||
|
> **Absicherung.** Anders als die lesenden Endpunkte verändern diese den Zustand des Bots.
|
||||||
|
> Setze deshalb `server.control_token` (bzw. `TRADEMIND_CONTROL_TOKEN`), sobald der Port
|
||||||
|
> nicht nur lokal erreichbar ist — die Steuerbefehle verlangen ihn dann im Header
|
||||||
|
> `X-TradeMind-Token`, während `/status` und Co. offen bleiben. Ohne Token warnt der Bot
|
||||||
|
> beim Start. Komplett abschalten lässt sich die Steuerung mit `server.enable_control: false`;
|
||||||
|
> die Routen sind dann nicht vorhanden (404).
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> openssl rand -hex 24
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Aufbau
|
## Aufbau
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
enable_metrics: true # /metrics im Prometheus-Textformat
|
enable_metrics: true # /metrics im Prometheus-Textformat
|
||||||
|
|
||||||
|
# Steuerbefehle im Dashboard: historisches Nachtraining anstoßen und das
|
||||||
|
# kontinuierliche Lernen ein-/ausschalten. Auf false setzen für rein lesenden Betrieb.
|
||||||
|
enable_control: true
|
||||||
|
# Schützt ALLE Steuerbefehle (Header X-TradeMind-Token). Lesende Endpunkte bleiben offen.
|
||||||
|
# Dringend empfohlen, sobald der Port nicht nur lokal erreichbar ist.
|
||||||
|
control_token: ${TRADEMIND_CONTROL_TOKEN}
|
||||||
|
|
||||||
# ──────────────────────────────── Benachrichtigungen ────────────────────────
|
# ──────────────────────────────── Benachrichtigungen ────────────────────────
|
||||||
notifications:
|
notifications:
|
||||||
# Slack- oder Discord-Webhook (leer lassen = aus)
|
# Slack- oder Discord-Webhook (leer lassen = aus)
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ TRADEMIND_LIVE_CONFIRMATION=
|
|||||||
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
|
# Optionaler Slack-/Discord-Webhook für Trade-Meldungen
|
||||||
TRADEMIND_WEBHOOK_URL=
|
TRADEMIND_WEBHOOK_URL=
|
||||||
|
|
||||||
|
# Schützt die Steuerbefehle des Dashboards (Training anstoßen, Lernen umschalten).
|
||||||
|
# Leer = ungeschützt. Sobald der Port nicht nur lokal erreichbar ist, unbedingt setzen:
|
||||||
|
# openssl rand -hex 24
|
||||||
|
TRADEMIND_CONTROL_TOKEN=
|
||||||
|
|
||||||
TRADEMIND_LOG_LEVEL=INFO
|
TRADEMIND_LOG_LEVEL=INFO
|
||||||
|
|
||||||
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
|
# Punktuelle Overrides ohne Änderung der YAML-Datei (Muster: TRADEMIND__<ABSCHNITT>__<SCHLUESSEL>)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
"pytest-asyncio>=0.23",
|
"pytest-asyncio>=0.23",
|
||||||
|
"pytest-aiohttp>=1.0",
|
||||||
"ruff>=0.4",
|
"ruff>=0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -80,6 +80,24 @@ class Runtime:
|
|||||||
self.storage.close()
|
self.storage.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _warn_about_exposure(config: Config) -> None:
|
||||||
|
"""Warnen, wenn Steuerbefehle ungeschützt aus dem Netz erreichbar sind."""
|
||||||
|
if not config.server.enable_control:
|
||||||
|
return
|
||||||
|
if config.server.control_token:
|
||||||
|
log.info("Steuerbefehle sind aktiv und durch ein Token geschützt")
|
||||||
|
return
|
||||||
|
if config.server.publicly_reachable:
|
||||||
|
log.warning(
|
||||||
|
"Steuerbefehle (Training anstoßen, Lernen umschalten) 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)")
|
||||||
|
|
||||||
|
|
||||||
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
|
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
|
||||||
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
|
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
|
||||||
quotes.discard(None)
|
quotes.discard(None)
|
||||||
@@ -147,7 +165,10 @@ async def build_runtime(
|
|||||||
notifier=notifier,
|
notifier=notifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
server = StatusServer(config.server, engine.status) if (with_server and config.server.enabled) else None
|
server = None
|
||||||
|
if with_server and config.server.enabled:
|
||||||
|
server = StatusServer(config.server, engine.status, controller=engine)
|
||||||
|
_warn_about_exposure(config)
|
||||||
|
|
||||||
storage.start_run(
|
storage.start_run(
|
||||||
mode=config.mode.value,
|
mode=config.mode.value,
|
||||||
@@ -203,4 +224,9 @@ def describe_config(config: Config) -> str:
|
|||||||
)
|
)
|
||||||
if config.server.enabled:
|
if config.server.enabled:
|
||||||
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
|
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
|
||||||
|
if config.server.enable_control:
|
||||||
|
guard = "Token gesetzt" if config.server.control_token else "OHNE Token"
|
||||||
|
lines.append(f"Steuerung Training über das Dashboard möglich ({guard})")
|
||||||
|
else:
|
||||||
|
lines.append("Steuerung deaktiviert (nur lesende Endpunkte)")
|
||||||
return "\n".join(" " + line for line in lines)
|
return "\n".join(" " + line for line in lines)
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ class ServerConfig(_Base):
|
|||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = Field(default=8080, ge=1, le=65535)
|
port: int = Field(default=8080, ge=1, le=65535)
|
||||||
enable_metrics: bool = True
|
enable_metrics: bool = True
|
||||||
|
# Steuerbefehle (Training anstoßen, Lernen ein-/ausschalten) über das Dashboard.
|
||||||
|
enable_control: bool = True
|
||||||
|
# Wenn gesetzt, verlangen alle Steuerbefehle den Header X-TradeMind-Token.
|
||||||
|
# Dringend empfohlen, sobald der Port nicht nur lokal erreichbar ist.
|
||||||
|
control_token: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def publicly_reachable(self) -> bool:
|
||||||
|
return self.host not in ("127.0.0.1", "localhost", "::1")
|
||||||
|
|
||||||
|
|
||||||
class NotificationConfig(_Base):
|
class NotificationConfig(_Base):
|
||||||
|
|||||||
+201
-18
@@ -7,10 +7,11 @@ Backtest-Betrieb; nur die Datenquelle und die Ausführung werden ausgetauscht.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import signal
|
import signal
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -30,6 +31,85 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
STATE_KEY = "engine_state"
|
STATE_KEY = "engine_state"
|
||||||
|
|
||||||
|
# Grenzen für ein über das Dashboard angestoßenes Training.
|
||||||
|
MIN_TRAINING_BARS = 500
|
||||||
|
MAX_TRAINING_BARS = 50_000
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainingJob:
|
||||||
|
"""Zustand eines historischen Nachtrainings – wird im Dashboard angezeigt."""
|
||||||
|
|
||||||
|
state: str = "idle" # idle | queued | running | done | error
|
||||||
|
bars_requested: int = 0
|
||||||
|
bars_seen: int = 0
|
||||||
|
symbols: list[str] = field(default_factory=list)
|
||||||
|
symbols_done: list[str] = field(default_factory=list)
|
||||||
|
skipped: list[str] = field(default_factory=list)
|
||||||
|
samples_before: int = 0
|
||||||
|
samples_after: int = 0
|
||||||
|
model_ready: bool = False
|
||||||
|
model_saved: bool = False
|
||||||
|
error: str | None = None
|
||||||
|
started_at: float | None = None
|
||||||
|
finished_at: float | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def samples_gained(self) -> int:
|
||||||
|
return max(0, self.samples_after - self.samples_before)
|
||||||
|
|
||||||
|
def queue(self, bars: int, symbols: list[str]) -> None:
|
||||||
|
self.state = "queued"
|
||||||
|
self.bars_requested = bars
|
||||||
|
self.bars_seen = 0
|
||||||
|
self.symbols = list(symbols)
|
||||||
|
self.symbols_done = []
|
||||||
|
self.skipped = []
|
||||||
|
self.samples_before = 0
|
||||||
|
self.samples_after = 0
|
||||||
|
self.model_saved = False
|
||||||
|
self.error = None
|
||||||
|
self.started_at = time.time()
|
||||||
|
self.finished_at = None
|
||||||
|
|
||||||
|
def start(self, bars: int, symbols: list[str]) -> None:
|
||||||
|
if self.state != "queued":
|
||||||
|
self.queue(bars, symbols)
|
||||||
|
self.state = "running"
|
||||||
|
|
||||||
|
def finish(self, before: int, after: int, ready: bool) -> None:
|
||||||
|
self.samples_before = before
|
||||||
|
self.samples_after = after
|
||||||
|
self.model_ready = ready
|
||||||
|
self.state = "done"
|
||||||
|
self.finished_at = time.time()
|
||||||
|
|
||||||
|
def fail(self, message: str) -> None:
|
||||||
|
self.state = "error"
|
||||||
|
self.error = message
|
||||||
|
self.finished_at = time.time()
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, Any]:
|
||||||
|
duration = (
|
||||||
|
round((self.finished_at or time.time()) - self.started_at, 1)
|
||||||
|
if self.started_at is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"state": self.state,
|
||||||
|
"bars_requested": self.bars_requested,
|
||||||
|
"bars_seen": self.bars_seen,
|
||||||
|
"symbols": self.symbols,
|
||||||
|
"symbols_done": self.symbols_done,
|
||||||
|
"skipped": self.skipped,
|
||||||
|
"samples_gained": self.samples_gained,
|
||||||
|
"samples_total": self.samples_after,
|
||||||
|
"model_ready": self.model_ready,
|
||||||
|
"model_saved": self.model_saved,
|
||||||
|
"error": self.error,
|
||||||
|
"duration_seconds": duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class Bar:
|
class Bar:
|
||||||
@@ -87,6 +167,10 @@ class TradingEngine:
|
|||||||
self._stop_event: asyncio.Event | None = None
|
self._stop_event: asyncio.Event | None = None
|
||||||
self._persist_every = 10
|
self._persist_every = 10
|
||||||
self._since_persist = 0
|
self._since_persist = 0
|
||||||
|
# Handelsdurchlauf und historisches Nachtraining schließen sich gegenseitig aus.
|
||||||
|
self._engine_lock = asyncio.Lock()
|
||||||
|
self._training_task: asyncio.Task[TrainingJob] | None = None
|
||||||
|
self.training = TrainingJob()
|
||||||
|
|
||||||
# ------------------------------------------------------------ Lebenszyklus
|
# ------------------------------------------------------------ Lebenszyklus
|
||||||
|
|
||||||
@@ -104,6 +188,11 @@ class TradingEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def shutdown(self, liquidate: bool = False) -> None:
|
async def shutdown(self, liquidate: bool = False) -> None:
|
||||||
|
if self._training_task is not None and not self._training_task.done():
|
||||||
|
log.info("Laufendes Nachtraining wird abgebrochen …")
|
||||||
|
self._training_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await self._training_task
|
||||||
if liquidate and self.portfolio.positions:
|
if liquidate and self.portfolio.positions:
|
||||||
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
|
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
|
||||||
for symbol in list(self.portfolio.positions):
|
for symbol in list(self.portfolio.positions):
|
||||||
@@ -120,45 +209,133 @@ class TradingEngine:
|
|||||||
self.running = False
|
self.running = False
|
||||||
|
|
||||||
async def bootstrap_learner(self) -> None:
|
async def bootstrap_learner(self) -> None:
|
||||||
"""Ein noch untrainiertes Modell aus der Kurshistorie vorlernen.
|
"""Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen.
|
||||||
|
|
||||||
Ohne diesen Schritt bräuchte ein frisch gestarteter Bot bei 5-Minuten-Kerzen
|
Ohne diesen Schritt bräuchte ein frisch gestarteter Bot bei 5-Minuten-Kerzen
|
||||||
mehrere Tage, bis das Modell genug Beobachtungen für die Aufwärmphase gesammelt hat.
|
mehrere Tage, bis das Modell genug Beobachtungen für die Aufwärmphase gesammelt hat.
|
||||||
"""
|
"""
|
||||||
learner = getattr(self.strategy, "learner", None)
|
learner = self.learner
|
||||||
warmup = getattr(self.strategy, "warmup_from_history", None)
|
|
||||||
bars = self.config.strategy.learner.bootstrap_bars
|
bars = self.config.strategy.learner.bootstrap_bars
|
||||||
if learner is None or warmup is None or not bars or learner.ready:
|
if learner is None or not bars or learner.ready:
|
||||||
return
|
return
|
||||||
|
|
||||||
log.info("Modell ist untrainiert – lerne aus bis zu %d historischen Kerzen vor …", bars)
|
log.info("Modell ist untrainiert – lerne aus bis zu %d historischen Kerzen vor …", bars)
|
||||||
|
await self._train_on_history(bars, adopt_timeline=True)
|
||||||
|
|
||||||
|
async def _train_on_history(self, bars: int, *, adopt_timeline: bool = False) -> TrainingJob:
|
||||||
|
"""Das Modell auf historischen Kerzen nachtrainieren – ohne zu handeln.
|
||||||
|
|
||||||
|
Läuft unter demselben Mutex wie der Handelsdurchlauf, damit sich beide nicht in
|
||||||
|
die Quere kommen. Die rechenintensive Schleife wandert in einen Worker-Thread,
|
||||||
|
sonst würde der Status-Server für Sekunden blockieren.
|
||||||
|
"""
|
||||||
|
learner = self.learner
|
||||||
|
warmup = getattr(self.strategy, "warmup_from_history", None)
|
||||||
|
job = self.training
|
||||||
|
if learner is None or warmup is None:
|
||||||
|
job.fail("Kein lernfähiges Modell konfiguriert (strategy.name/learner.enabled prüfen)")
|
||||||
|
return job
|
||||||
|
|
||||||
|
async with self._engine_lock:
|
||||||
|
job.start(bars, self.config.market.symbols)
|
||||||
before = learner.stats.samples_seen
|
before = learner.stats.samples_seen
|
||||||
|
try:
|
||||||
for symbol in self.config.market.symbols:
|
for symbol in self.config.market.symbols:
|
||||||
try:
|
try:
|
||||||
candles = await self._fetch_history(symbol, bars)
|
candles = await self._fetch_history(symbol, bars)
|
||||||
except Exception as exc: # noqa: BLE001 - Vorlernen darf den Start nie verhindern
|
except Exception as exc: # noqa: BLE001 - darf den Bot nie stoppen
|
||||||
log.warning("%s: Historie für das Vorlernen nicht abrufbar (%s)", symbol, exc)
|
log.warning("%s: Historie nicht abrufbar (%s)", symbol, exc)
|
||||||
|
job.skipped.append(f"{symbol}: {exc}")
|
||||||
continue
|
continue
|
||||||
matrix = build_feature_matrix(candles, self.config.strategy.rules)
|
matrix = build_feature_matrix(candles, self.config.strategy.rules)
|
||||||
if matrix is None:
|
if matrix is None:
|
||||||
log.warning("%s: zu wenig Historie zum Vorlernen (%d Kerzen)", symbol, len(candles))
|
log.warning("%s: zu wenig Historie (%d Kerzen)", symbol, len(candles))
|
||||||
|
job.skipped.append(f"{symbol}: nur {len(candles)} Kerzen")
|
||||||
continue
|
continue
|
||||||
last_index = warmup(symbol, matrix, candles)
|
|
||||||
# Zähler und Zeitstempel fortschreiben, damit der Live-Loop nahtlos anschließt
|
last_index = await asyncio.to_thread(warmup, symbol, matrix, candles)
|
||||||
# und die zuletzt genutzte Kerze nicht doppelt verarbeitet wird.
|
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.bar_counter[symbol] = last_index
|
||||||
self.last_bar_ts[symbol] = int(candles.timestamp[last_index])
|
self.last_bar_ts[symbol] = int(candles.timestamp[last_index])
|
||||||
|
|
||||||
gained = learner.stats.samples_seen - before
|
job.finish(before, learner.stats.samples_seen, learner.ready)
|
||||||
log.info(
|
log.info(
|
||||||
"Vorlernen abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
|
"Historisches Training abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
|
||||||
gained, learner.stats.samples_seen, "einsatzbereit" if learner.ready else "noch im Aufwärmen",
|
job.samples_gained, learner.stats.samples_seen,
|
||||||
|
"einsatzbereit" if learner.ready else "noch im Aufwärmen",
|
||||||
)
|
)
|
||||||
if gained:
|
if job.samples_gained:
|
||||||
try:
|
try:
|
||||||
learner.save()
|
learner.save()
|
||||||
except OSError as exc: # pragma: no cover
|
job.model_saved = True
|
||||||
log.error("Vorgelerntes Modell konnte nicht gespeichert werden: %s", exc)
|
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 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)
|
||||||
|
|
||||||
|
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 _fetch_history(self, symbol: str, bars: int) -> Candles:
|
async def _fetch_history(self, symbol: str, bars: int) -> Candles:
|
||||||
"""Längere Historie holen, wenn der Feed das kann – sonst das normale Fenster."""
|
"""Längere Historie holen, wenn der Feed das kann – sonst das normale Fenster."""
|
||||||
@@ -208,6 +385,11 @@ class TradingEngine:
|
|||||||
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
|
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
|
||||||
|
|
||||||
async def _tick(self, history: int) -> None:
|
async def _tick(self, history: int) -> None:
|
||||||
|
# Wartet, falls gerade ein historisches Nachtraining läuft.
|
||||||
|
async with self._engine_lock:
|
||||||
|
await self._tick_locked(history)
|
||||||
|
|
||||||
|
async def _tick_locked(self, history: int) -> None:
|
||||||
self._cash = await self.broker.cash()
|
self._cash = await self.broker.cash()
|
||||||
for symbol in self.config.market.symbols:
|
for symbol in self.config.market.symbols:
|
||||||
candles = await self.feed.fetch(symbol, self.config.market.timeframe, history)
|
candles = await self.feed.fetch(symbol, self.config.market.timeframe, history)
|
||||||
@@ -456,6 +638,7 @@ class TradingEngine:
|
|||||||
"recent_trades": self.portfolio.recent_trades(25),
|
"recent_trades": self.portfolio.recent_trades(25),
|
||||||
"strategy": self.strategy.snapshot(),
|
"strategy": self.strategy.snapshot(),
|
||||||
"risk": self.risk.snapshot(),
|
"risk": self.risk.snapshot(),
|
||||||
|
"training": self.training_status(),
|
||||||
"feature_weights": (
|
"feature_weights": (
|
||||||
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
|
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -235,7 +235,11 @@ class LearnerStats:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class PendingLabel:
|
class PendingLabel:
|
||||||
"""Ein Kandidatensignal, dessen Ausgang erst in der Zukunft feststeht."""
|
"""Ein Kandidatensignal, dessen Ausgang erst in der Zukunft feststeht.
|
||||||
|
|
||||||
|
``tag`` trennt Läufe voneinander: Ein historisches Nachtraining darf die Labels des
|
||||||
|
laufenden Live-Betriebs weder auflösen noch verwerfen.
|
||||||
|
"""
|
||||||
|
|
||||||
symbol: str
|
symbol: str
|
||||||
features: np.ndarray
|
features: np.ndarray
|
||||||
@@ -244,6 +248,7 @@ class PendingLabel:
|
|||||||
horizon_bars: int
|
horizon_bars: int
|
||||||
target_bps: float
|
target_bps: float
|
||||||
weight: float = 1.0
|
weight: float = 1.0
|
||||||
|
tag: str = "live"
|
||||||
|
|
||||||
def matured(self, current_bar: int) -> bool:
|
def matured(self, current_bar: int) -> bool:
|
||||||
return current_bar - self.created_bar >= self.horizon_bars
|
return current_bar - self.created_bar >= self.horizon_bars
|
||||||
@@ -275,7 +280,12 @@ class AdaptiveLearner:
|
|||||||
return self.stats.samples_seen >= self.config.warmup_samples
|
return self.stats.samples_seen >= self.config.warmup_samples
|
||||||
|
|
||||||
def score(self, features: np.ndarray) -> float:
|
def score(self, features: np.ndarray) -> float:
|
||||||
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1)."""
|
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1).
|
||||||
|
|
||||||
|
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))
|
x = self.scaler.transform(np.asarray(features, dtype=np.float64).reshape(1, -1))
|
||||||
return float(self.model.predict_proba(x)[0])
|
return float(self.model.predict_proba(x)[0])
|
||||||
|
|
||||||
@@ -345,7 +355,13 @@ class AdaptiveLearner:
|
|||||||
# -------------------------------------------------- Verzögerte Shadow-Labels
|
# -------------------------------------------------- Verzögerte Shadow-Labels
|
||||||
|
|
||||||
def register_candidate(
|
def register_candidate(
|
||||||
self, symbol: str, features: np.ndarray, price: float, bar_index: int, weight: float = 1.0
|
self,
|
||||||
|
symbol: str,
|
||||||
|
features: np.ndarray,
|
||||||
|
price: float,
|
||||||
|
bar_index: int,
|
||||||
|
weight: float = 1.0,
|
||||||
|
tag: str = "live",
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
|
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
|
||||||
self._pending.append(
|
self._pending.append(
|
||||||
@@ -357,21 +373,26 @@ class AdaptiveLearner:
|
|||||||
horizon_bars=self.config.label_horizon_bars,
|
horizon_bars=self.config.label_horizon_bars,
|
||||||
target_bps=self.config.label_target_bps,
|
target_bps=self.config.label_target_bps,
|
||||||
weight=weight,
|
weight=weight,
|
||||||
|
tag=tag,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def resolve_pending(self, symbol: str, bar_index: int, high: float, low: float, close: float) -> int:
|
def resolve_pending(
|
||||||
|
self, symbol: str, bar_index: int, high: float, low: float, close: float, tag: str | None = None
|
||||||
|
) -> int:
|
||||||
"""Fällige Shadow-Labels auswerten. Gibt die Anzahl neuer Beobachtungen zurück.
|
"""Fällige Shadow-Labels auswerten. Gibt die Anzahl neuer Beobachtungen zurück.
|
||||||
|
|
||||||
Label = 1, wenn der Kurs innerhalb des Horizonts das Ziel erreicht hat, ohne vorher
|
Label = 1, wenn der Kurs innerhalb des Horizonts das Ziel erreicht hat, ohne vorher
|
||||||
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
|
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
|
||||||
|
|
||||||
|
``tag`` grenzt auf einen Lauf ein; ``None`` wertet alle aus.
|
||||||
"""
|
"""
|
||||||
if not self._pending:
|
if not self._pending:
|
||||||
return 0
|
return 0
|
||||||
resolved = 0
|
resolved = 0
|
||||||
still_open: list[PendingLabel] = []
|
still_open: list[PendingLabel] = []
|
||||||
for item in self._pending:
|
for item in self._pending:
|
||||||
if item.symbol != symbol:
|
if item.symbol != symbol or (tag is not None and item.tag != tag):
|
||||||
still_open.append(item)
|
still_open.append(item)
|
||||||
continue
|
continue
|
||||||
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
|
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
|
||||||
@@ -393,8 +414,15 @@ class AdaptiveLearner:
|
|||||||
self._pending = still_open
|
self._pending = still_open
|
||||||
return resolved
|
return resolved
|
||||||
|
|
||||||
def drop_pending(self, symbol: str | None = None) -> None:
|
def drop_pending(self, symbol: str | None = None, tag: str | None = None) -> int:
|
||||||
self._pending = [p for p in self._pending if symbol is not None and p.symbol != symbol]
|
"""Vorgemerkte Labels verwerfen. Ohne Argumente alle, sonst nur die passenden."""
|
||||||
|
|
||||||
|
def matches(item: PendingLabel) -> bool:
|
||||||
|
return (symbol is None or item.symbol == symbol) and (tag is None or item.tag == tag)
|
||||||
|
|
||||||
|
before = len(self._pending)
|
||||||
|
self._pending = [p for p in self._pending if not matches(p)]
|
||||||
|
return before - len(self._pending)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def pending_count(self) -> int:
|
def pending_count(self) -> int:
|
||||||
@@ -537,8 +565,8 @@ class NullLearner:
|
|||||||
def resolve_pending(self, *args: object, **kwargs: object) -> int:
|
def resolve_pending(self, *args: object, **kwargs: object) -> int:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
def drop_pending(self, *args: object, **kwargs: object) -> None:
|
def drop_pending(self, *args: object, **kwargs: object) -> int:
|
||||||
return None
|
return 0
|
||||||
|
|
||||||
def learn_from_trade(self, *args: object, **kwargs: object) -> None:
|
def learn_from_trade(self, *args: object, **kwargs: object) -> None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+216
-2
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hmac
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
@@ -14,6 +15,18 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
StatusProvider = Callable[[], dict[str, Any]]
|
StatusProvider = Callable[[], dict[str, Any]]
|
||||||
|
|
||||||
|
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 training_status(self) -> dict[str, Any]: ...
|
||||||
|
|
||||||
_DASHBOARD = """<!doctype html>
|
_DASHBOARD = """<!doctype html>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>TradeMind</title>
|
<title>TradeMind</title>
|
||||||
@@ -35,10 +48,49 @@ _DASHBOARD = """<!doctype html>
|
|||||||
th:first-child, td:first-child { text-align: left; }
|
th:first-child, td:first-child { text-align: left; }
|
||||||
section { margin-top: 28px; }
|
section { margin-top: 28px; }
|
||||||
.overflow { overflow-x: auto; }
|
.overflow { overflow-x: auto; }
|
||||||
|
h2 { font-size: 15px; margin: 0 0 6px; }
|
||||||
|
.panel { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
||||||
|
.row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
|
||||||
|
button { font: inherit; padding: 7px 14px; border-radius: 7px; border: 1px solid var(--line);
|
||||||
|
background: var(--fg); color: var(--bg); cursor: pointer; }
|
||||||
|
button.ghost { background: transparent; color: var(--fg); }
|
||||||
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
input { font: inherit; padding: 6px 9px; border-radius: 7px; border: 1px solid var(--line);
|
||||||
|
background: var(--bg); color: var(--fg); }
|
||||||
|
input[type=number] { width: 8em; }
|
||||||
|
label { color: var(--muted); font-size: 13px; }
|
||||||
|
.hint { color: var(--muted); font-size: 12px; margin-top: 8px; }
|
||||||
|
.hint.err { color: #c62828; }
|
||||||
|
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; }
|
||||||
|
.dot.on { background: #1a8f3c; } .dot.off { background: #999; } .dot.busy { background: #e0a100; }
|
||||||
</style>
|
</style>
|
||||||
<h1>TradeMind</h1>
|
<h1>TradeMind</h1>
|
||||||
<div class="sub" id="sub">lädt …</div>
|
<div class="sub" id="sub">lädt …</div>
|
||||||
<div class="grid" id="cards"></div>
|
<div class="grid" id="cards"></div>
|
||||||
|
|
||||||
|
<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><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
|
<section><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
|
||||||
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
|
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
|
||||||
<script>
|
<script>
|
||||||
@@ -79,8 +131,88 @@ async function refresh() {
|
|||||||
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
|
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
|
||||||
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
|
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
|
||||||
(s.recent_trades || []).slice().reverse());
|
(s.recent_trades || []).slice().reverse());
|
||||||
|
renderTraining(s.training || {});
|
||||||
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
|
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Training
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
refresh(); setInterval(refresh, 5000);
|
refresh(); setInterval(refresh, 5000);
|
||||||
</script>
|
</script>
|
||||||
"""
|
"""
|
||||||
@@ -101,11 +233,21 @@ def _flatten_metrics(prefix: str, node: Any, out: list[tuple[str, float]]) -> No
|
|||||||
class StatusServer:
|
class StatusServer:
|
||||||
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
|
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
|
||||||
|
|
||||||
def __init__(self, config: ServerConfig, status_provider: StatusProvider) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: ServerConfig,
|
||||||
|
status_provider: StatusProvider,
|
||||||
|
controller: Controller | None = None,
|
||||||
|
) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
self._status = status_provider
|
self._status = status_provider
|
||||||
|
self._controller = controller
|
||||||
self._runner: web.AppRunner | None = None
|
self._runner: web.AppRunner | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def control_available(self) -> bool:
|
||||||
|
return self.config.enable_control and self._controller is not None
|
||||||
|
|
||||||
def _build_app(self) -> web.Application:
|
def _build_app(self) -> web.Application:
|
||||||
app = web.Application()
|
app = web.Application()
|
||||||
app.add_routes(
|
app.add_routes(
|
||||||
@@ -120,6 +262,14 @@ class StatusServer:
|
|||||||
)
|
)
|
||||||
if self.config.enable_metrics:
|
if self.config.enable_metrics:
|
||||||
app.router.add_get("/metrics", self._metrics)
|
app.router.add_get("/metrics", self._metrics)
|
||||||
|
if self.control_available:
|
||||||
|
app.add_routes(
|
||||||
|
[
|
||||||
|
web.get("/control/training", self._training_state),
|
||||||
|
web.post("/control/train/history", self._train_history),
|
||||||
|
web.post("/control/train/live", self._train_live),
|
||||||
|
]
|
||||||
|
)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
@@ -162,6 +312,70 @@ class StatusServer:
|
|||||||
trades = self._status().get("recent_trades", [])
|
trades = self._status().get("recent_trades", [])
|
||||||
return web.json_response(trades[-limit:], dumps=_dumps)
|
return web.json_response(trades[-limit:], dumps=_dumps)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Steuerung
|
||||||
|
|
||||||
|
def _authorized(self, request: web.Request) -> bool:
|
||||||
|
token = self.config.control_token
|
||||||
|
if not token:
|
||||||
|
return True
|
||||||
|
supplied = request.headers.get(TOKEN_HEADER, "")
|
||||||
|
return hmac.compare_digest(supplied, token)
|
||||||
|
|
||||||
|
async def _guard(self, request: web.Request) -> web.Response | None:
|
||||||
|
"""Gibt eine Fehlerantwort zurück, wenn der Aufruf nicht erlaubt ist."""
|
||||||
|
if self._controller is None or not self.config.enable_control:
|
||||||
|
return web.json_response({"error": "Steuerung ist deaktiviert"}, status=404)
|
||||||
|
if not self._authorized(request):
|
||||||
|
log.warning("Steuerbefehl ohne gültiges Token abgelehnt (%s)", request.remote)
|
||||||
|
return web.json_response(
|
||||||
|
{"error": f"Ungültiges oder fehlendes Token im Header {TOKEN_HEADER}"}, status=401
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _training_state(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
return web.json_response(self._controller.training_status(), dumps=_dumps)
|
||||||
|
|
||||||
|
async def _train_history(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
bars: int | None = None
|
||||||
|
if request.can_read_body:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if isinstance(payload, dict) and payload.get("bars") is not None:
|
||||||
|
try:
|
||||||
|
bars = int(payload["bars"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return web.json_response({"error": "'bars' muss eine Zahl sein"}, status=400)
|
||||||
|
|
||||||
|
result = self._controller.start_history_training(bars)
|
||||||
|
return web.json_response(result, status=202 if result.get("accepted") else 409, dumps=_dumps)
|
||||||
|
|
||||||
|
async def _train_live(self, request: web.Request) -> web.Response:
|
||||||
|
denied = await self._guard(request)
|
||||||
|
if denied is not None:
|
||||||
|
return denied
|
||||||
|
assert self._controller is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||||
|
if not isinstance(payload, dict) or not isinstance(payload.get("enabled"), bool):
|
||||||
|
return web.json_response({"error": "'enabled' (true/false) wird erwartet"}, status=400)
|
||||||
|
|
||||||
|
result = self._controller.set_online_learning(payload["enabled"])
|
||||||
|
return web.json_response(result, status=200 if result.get("accepted") else 409, dumps=_dumps)
|
||||||
|
|
||||||
async def _metrics(self, _: web.Request) -> web.Response:
|
async def _metrics(self, _: web.Request) -> web.Response:
|
||||||
state = self._status()
|
state = self._status()
|
||||||
samples: list[tuple[str, float]] = []
|
samples: list[tuple[str, float]] = []
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ class AdaptiveStrategy(Strategy):
|
|||||||
self.candidates_explored = 0
|
self.candidates_explored = 0
|
||||||
self.background_samples = 0
|
self.background_samples = 0
|
||||||
self._bar_index: dict[str, int] = {}
|
self._bar_index: dict[str, int] = {}
|
||||||
|
self._label_tag = "live"
|
||||||
|
|
||||||
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
|
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
|
||||||
if position is not None:
|
if position is not None:
|
||||||
@@ -163,12 +164,14 @@ class AdaptiveStrategy(Strategy):
|
|||||||
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
|
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
|
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
|
||||||
self.learner.register_candidate(symbol, snapshot.values, snapshot.price, bar_index, weight)
|
self.learner.register_candidate(
|
||||||
|
symbol, snapshot.values, snapshot.price, bar_index, weight, tag=self._label_tag
|
||||||
|
)
|
||||||
|
|
||||||
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
|
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
|
||||||
close: float) -> None:
|
close: float) -> None:
|
||||||
self._bar_index[symbol] = bar_index
|
self._bar_index[symbol] = bar_index
|
||||||
self.learner.resolve_pending(symbol, bar_index, high, low, close)
|
self.learner.resolve_pending(symbol, bar_index, high, low, close, tag=self._label_tag)
|
||||||
|
|
||||||
# Einstiegssignale sind selten – regelmäßige Stichproben des Marktzustands geben
|
# Einstiegssignale sind selten – regelmäßige Stichproben des Marktzustands geben
|
||||||
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
|
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
|
||||||
@@ -186,11 +189,19 @@ class AdaptiveStrategy(Strategy):
|
|||||||
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
|
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
|
||||||
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
|
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
|
||||||
|
|
||||||
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen.
|
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen,
|
||||||
Es werden ausschließlich vergangene Kerzen verwendet – dieselbe Logik wie im Backtest.
|
und ein laufender Bot lässt sich jederzeit nachtrainieren. Es werden ausschließlich
|
||||||
Gibt den Index der letzten verarbeiteten Kerze zurück.
|
vergangene Kerzen verwendet – dieselbe Logik wie im Backtest, nur ohne Handel.
|
||||||
|
|
||||||
|
Die dabei erzeugten Labels laufen unter dem Tag ``history`` und bleiben damit von
|
||||||
|
den offenen Labels des Live-Betriebs getrennt. Gibt den Index der letzten
|
||||||
|
verarbeiteten Kerze zurück.
|
||||||
"""
|
"""
|
||||||
|
previous_tag = self._label_tag
|
||||||
|
previous_bar_index = self._bar_index.get(symbol)
|
||||||
|
self._label_tag = "history"
|
||||||
last_index = matrix.first_valid
|
last_index = matrix.first_valid
|
||||||
|
try:
|
||||||
for index in range(matrix.first_valid, len(matrix)):
|
for index in range(matrix.first_valid, len(matrix)):
|
||||||
snapshot = matrix.snapshot(index)
|
snapshot = matrix.snapshot(index)
|
||||||
if snapshot is None:
|
if snapshot is None:
|
||||||
@@ -206,6 +217,15 @@ class AdaptiveStrategy(Strategy):
|
|||||||
if self.rules.entry_candidate(snapshot) is not None:
|
if self.rules.entry_candidate(snapshot) is not None:
|
||||||
self.register_candidate(symbol, snapshot, index)
|
self.register_candidate(symbol, snapshot, index)
|
||||||
last_index = index
|
last_index = index
|
||||||
|
finally:
|
||||||
|
self._label_tag = previous_tag
|
||||||
|
# Nicht aufgelöste Historien-Labels verwerfen; sie würden sonst gegen die
|
||||||
|
# nächste Live-Kerze ausgewertet und das Modell mit falschen Labels füttern.
|
||||||
|
self.learner.drop_pending(symbol=symbol, tag="history")
|
||||||
|
if previous_bar_index is None:
|
||||||
|
self._bar_index.pop(symbol, None)
|
||||||
|
else:
|
||||||
|
self._bar_index[symbol] = previous_bar_index
|
||||||
return last_index
|
return last_index
|
||||||
|
|
||||||
def snapshot(self) -> dict[str, object]:
|
def snapshot(self) -> dict[str, object]:
|
||||||
|
|||||||
@@ -0,0 +1,321 @@
|
|||||||
|
"""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.config import Config, ServerConfig
|
||||||
|
from trademind.features import N_FEATURES
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- 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
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user