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:
+27
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user