Automatisierten Handel über das Dashboard starten und pausieren

Neuer Abschnitt "Automatisierter Handel" im Dashboard mit Zustandsanzeige und
Start/Pause-Knopf, dazu GET und POST /control/trading. Funktioniert in beiden
Modi, simuliert wie echt.

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

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

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

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

174 Tests (21 neue), ruff sauber. Im gebauten Container geprüft: 401 ohne Token,
200 mit, idempotentes Pausieren, 400 bei kaputtem Payload, und im Live-Start mit
ungültigen Dummy-Schlüsseln greift die Abschaltung der Steuerung.
This commit is contained in:
Tobias Zimmermann
2026-08-23 12:40:56 +02:00
parent 3cd12b67aa
commit d081edfbd3
7 changed files with 542 additions and 18 deletions
+56 -12
View File
@@ -80,22 +80,57 @@ class Runtime:
self.storage.close()
def _warn_about_exposure(config: Config) -> None:
"""Warnen, wenn Steuerbefehle ungeschützt aus dem Netz erreichbar sind."""
def control_is_unsafe(config: Config) -> bool:
"""Live-Handel, der ungeschützt aus dem Netz steuerbar wäre wird nicht bedient."""
return (
config.server.enable_control
and config.mode is Mode.LIVE
and not config.server.control_token
and config.server.publicly_reachable
)
def control_effective(config: Config) -> bool:
"""Ob die Steuerbefehle tatsächlich bereitgestellt werden."""
return config.server.enable_control and not control_is_unsafe(config)
def _guard_control_exposure(config: Config) -> Config:
"""Steuerbefehle absichern, bevor der Server startet.
Im Live-Modus könnte ein ungeschützter Endpunkt aus dem Netz echten Handel starten.
Diese Kombination wird deshalb nicht bedient: Die Steuerung wird abgeschaltet, der Bot
läuft aber normal weiter.
"""
if not config.server.enable_control:
return
log.info("Steuerbefehle sind deaktiviert nur lesende Endpunkte")
return config
unprotected = not config.server.control_token and config.server.publicly_reachable
if control_is_unsafe(config):
log.error(
"Steuerung abgeschaltet: Im Live-Modus dürfen Handel und Training nicht ohne "
"Token aus dem Netz steuerbar sein (server.host=%s, kein server.control_token). "
"Token setzen oder server.host auf 127.0.0.1 begrenzen, dann steht die "
"Dashboard-Steuerung wieder zur Verfügung.",
config.server.host,
)
return config.model_copy(
update={"server": config.server.model_copy(update={"enable_control": False})}
)
if config.server.control_token:
log.info("Steuerbefehle sind aktiv und durch ein Token geschützt")
return
if config.server.publicly_reachable:
elif unprotected:
log.warning(
"Steuerbefehle (Training anstoßen, Lernen umschalten) sind ohne Token auf %s:%d "
"Steuerbefehle (Handel starten, Training anstoßen) sind ohne Token auf %s:%d "
"erreichbar. Jeder, der den Port erreicht, kann sie auslösen bitte "
"server.control_token setzen oder den Zugriff per Firewall/Reverse Proxy begrenzen.",
config.server.host, config.server.port,
)
else:
log.info("Steuerbefehle sind aktiv (nur lokal erreichbar, kein Token gesetzt)")
return config
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
@@ -119,6 +154,11 @@ async def build_runtime(
seed: int | None = None,
) -> Runtime:
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
serving = with_server and config.server.enabled
if serving:
# Muss vor dem Bau der Engine laufen sie liest enable_control für ihre Steuerbefehle.
config = _guard_control_exposure(config)
storage: Storage | NullStorage = (
Storage(config.storage.database_path) if with_storage else NullStorage()
)
@@ -165,10 +205,7 @@ async def build_runtime(
notifier=notifier,
)
server = None
if with_server and config.server.enabled:
server = StatusServer(config.server, engine.status, controller=engine)
_warn_about_exposure(config)
server = StatusServer(config.server, engine.status, controller=engine) if serving else None
storage.start_run(
mode=config.mode.value,
@@ -213,6 +250,9 @@ def describe_config(config: Config) -> str:
f"Stop {config.risk.stop_loss_atr_mult}×ATR, Ziel {config.risk.take_profit_atr_mult}×ATR",
f"Notbremsen Tagesverlust {config.risk.max_daily_loss_pct:.0%}, "
f"Drawdown {config.risk.max_drawdown_pct:.0%}",
"Handel "
+ ("startet automatisch" if config.trading.autostart
else "startet pausiert Freigabe über das Dashboard"),
f"Datenbank {config.storage.database_path}",
f"Modelldatei {config.strategy.learner.model_path}",
]
@@ -224,9 +264,13 @@ 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:
if control_effective(config):
guard = "Token gesetzt" if config.server.control_token else "OHNE Token"
lines.append(f"Steuerung Training über das Dashboard möglich ({guard})")
lines.append(f"Steuerung Handel und Training über das Dashboard ({guard})")
elif control_is_unsafe(config):
lines.append(
"Steuerung ABGESCHALTET Live-Modus ohne Token auf offenem Port"
)
else:
lines.append("Steuerung deaktiviert (nur lesende Endpunkte)")
return "\n".join(" " + line for line in lines)