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:
Tobias Zimmermann
2026-08-23 12:15:28 +02:00
parent 35d5c64e5b
commit 3cd12b67aa
11 changed files with 933 additions and 69 deletions
+27 -1
View File
@@ -80,6 +80,24 @@ class Runtime:
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:
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
quotes.discard(None)
@@ -147,7 +165,10 @@ async def build_runtime(
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(
mode=config.mode.value,
@@ -203,4 +224,9 @@ def describe_config(config: Config) -> str:
)
if config.server.enabled:
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)
+9
View File
@@ -156,6 +156,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):
+214 -31
View File
@@ -7,10 +7,11 @@ 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
@@ -30,6 +31,85 @@ 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
@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:
@@ -87,6 +167,10 @@ 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()
# ------------------------------------------------------------ Lebenszyklus
@@ -104,6 +188,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 +209,133 @@ 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 = build_feature_matrix(candles, self.config.strategy.rules)
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 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:
"""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)
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)
@@ -456,6 +638,7 @@ class TradingEngine:
"recent_trades": self.portfolio.recent_trades(25),
"strategy": self.strategy.snapshot(),
"risk": self.risk.snapshot(),
"training": self.training_status(),
"feature_weights": (
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
),
+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
+216 -2
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,6 +15,18 @@ log = logging.getLogger(__name__)
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>
<meta charset="utf-8">
<title>TradeMind</title>
@@ -35,10 +48,49 @@ _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; }
</style>
<h1>TradeMind</h1>
<div class="sub" id="sub">lädt …</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">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
<script>
@@ -79,8 +131,88 @@ 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());
renderTraining(s.training || {});
} 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);
</script>
"""
@@ -101,11 +233,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 +262,14 @@ 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),
]
)
return app
async def start(self) -> None:
@@ -162,6 +312,70 @@ 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 _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]: