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
+241 -2
View File
@@ -8,8 +8,12 @@ import numpy as np
import pytest
from aiohttp import web
from trademind.config import Config, ServerConfig
from trademind.features import N_FEATURES
from trademind.app import _guard_control_exposure
from trademind.backtest import BacktestRunner
from trademind.config import LIVE_CONFIRMATION_PHRASE, Config, ServerConfig
from trademind.engine import LIVE_TRADING_CONFIRMATION, Bar
from trademind.features import N_FEATURES, compute_features
from trademind.models import ExitReason, Side
from trademind.server import TOKEN_HEADER, StatusServer
from .conftest import make_candles
@@ -200,6 +204,136 @@ async def test_control_disabled_rejects_commands(base_config):
assert engine.set_online_learning(False)["accepted"] is False
# ------------------------------------------------------ Automatisierter Handel
def trading_config(base_config: Config, **trading: object) -> Config:
return Config.model_validate(
{**control_config(base_config).model_dump(), "trading": trading}
)
async def test_trading_is_active_by_default(base_config):
engine = build_engine(control_config(base_config), feed=StaticFeed())
await engine.prepare()
assert engine.trading_active is True
assert engine.trading_control_status()["simulated"] is True
async def test_autostart_false_starts_paused(base_config):
engine = build_engine(trading_config(base_config, autostart=False), feed=StaticFeed())
await engine.prepare()
assert engine.trading_active is False
async def test_trading_can_be_paused_and_resumed(base_config):
engine = build_engine(control_config(base_config), feed=StaticFeed())
await engine.prepare()
assert engine.set_trading(False)["active"] is False
assert engine.trading_active is False
assert engine.set_trading(True)["active"] is True
assert engine.trading_active is True
async def test_repeated_state_is_accepted_without_change(base_config):
engine = build_engine(control_config(base_config), feed=StaticFeed())
await engine.prepare()
result = engine.set_trading(True)
assert result["accepted"] is True
assert "bereits" in result["reason"]
async def test_paused_bot_opens_no_positions(base_config):
engine = build_engine(trading_config(base_config, autostart=False))
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert engine.portfolio.trades == []
assert engine.portfolio.positions == {}
assert engine.paused_signals > 0, "es hätte Signale geben müssen, die nur gelernt wurden"
async def test_paused_bot_keeps_learning(base_config):
"""Kern der Anforderung: Pausiert fließen weiter Daten ins Modell."""
paused = build_engine(trading_config(base_config, autostart=False))
await paused.prepare()
await BacktestRunner(paused, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert paused.strategy.learner.stats.samples_seen > 0
assert paused.strategy.learner.stats.shadow_samples > 0
assert paused.strategy.learner.stats.updates > 0
assert paused.strategy.candidates_seen > 0
# Ohne Trades gibt es logischerweise keine Trade-Labels, nur Shadow-Labels.
assert paused.strategy.learner.stats.trade_samples == 0
async def test_active_bot_trades_on_the_same_data(base_config):
active = build_engine(control_config(base_config))
await active.prepare()
await BacktestRunner(active, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert active.portfolio.stats.trades > 0
async def test_pausing_keeps_stop_loss_active(base_config):
"""Eine offene Position darf beim Pausieren nicht ungeschützt stehenbleiben."""
engine = build_engine(control_config(base_config))
await engine.prepare()
fill = await engine.broker.execute("BTC/USDT", Side.BUY, 0.05, 30_000.0)
engine.portfolio.open_position(
fill, stop_loss=29_500.0, take_profit=None,
features=np.ones(N_FEATURES), confidence=0.6, exploratory=False,
)
engine.set_trading(False)
snapshot = compute_features(cyclical_series(), base_config.strategy.rules)
bar = Bar(timestamp=1, open=29_600.0, high=29_700.0, low=29_000.0, close=29_400.0, volume=5.0)
await engine.process_bar("BTC/USDT", snapshot, bar)
assert engine.portfolio.positions == {}
assert engine.portfolio.trades[-1].exit_reason is ExitReason.STOP_LOSS
async def test_live_mode_requires_confirmation(base_config, monkeypatch):
engine = build_engine(control_config(base_config), feed=StaticFeed())
await engine.prepare()
monkeypatch.setattr(type(engine.config), "is_simulated", property(lambda self: False))
engine.trading_active = False
denied = engine.set_trading(True)
assert denied["accepted"] is False
assert LIVE_TRADING_CONFIRMATION in denied["reason"]
assert engine.trading_active is False
assert engine.set_trading(True, "falsch")["accepted"] is False
assert engine.trading_active is False
ok = engine.set_trading(True, LIVE_TRADING_CONFIRMATION)
assert ok["accepted"] is True
assert engine.trading_active is True
async def test_pausing_live_never_needs_confirmation(base_config, monkeypatch):
engine = build_engine(control_config(base_config), feed=StaticFeed())
await engine.prepare()
monkeypatch.setattr(type(engine.config), "is_simulated", property(lambda self: False))
assert engine.set_trading(False)["accepted"] is True
async def test_paper_mode_needs_no_confirmation(base_config):
engine = build_engine(control_config(base_config), feed=StaticFeed())
await engine.prepare()
engine.set_trading(False)
assert engine.set_trading(True)["accepted"] is True
async def test_trading_control_rejected_when_disabled(base_config):
engine = build_engine(control_config(base_config, enable_control=False), feed=StaticFeed())
await engine.prepare()
assert engine.set_trading(False)["accepted"] is False
assert engine.trading_active is True
# --------------------------------------------------------------- HTTP-Schicht
@@ -314,6 +448,111 @@ async def test_training_state_endpoint(client):
assert data["min_bars"] == 500
async def test_http_trading_toggle(client):
http, engine = await client()
response = await http.post("/control/trading", json={"enabled": False})
assert response.status == 200
assert (await response.json())["active"] is False
assert engine.trading_active is False
state = await (await http.get("/control/trading")).json()
assert state["active"] is False
assert state["mode"] == "paper"
async def test_http_trading_rejects_bad_payload(client):
http, _ = await client()
assert (await http.post("/control/trading", json={})).status == 400
assert (await http.post("/control/trading", json={"enabled": 1})).status == 400
assert (await http.post("/control/trading", json={"enabled": True, "confirm": 5})).status == 400
async def test_http_trading_needs_token(client):
http, engine = await client(control_token="geheim")
assert (await http.post("/control/trading", json={"enabled": False})).status == 401
assert engine.trading_active is True
ok = await http.post(
"/control/trading", json={"enabled": False}, headers={TOKEN_HEADER: "geheim"}
)
assert ok.status == 200
assert engine.trading_active is False
async def test_http_live_start_without_confirmation_returns_428(client, monkeypatch):
http, engine = await client()
monkeypatch.setattr(type(engine.config), "is_simulated", property(lambda self: False))
engine.trading_active = False
response = await http.post("/control/trading", json={"enabled": True})
assert response.status == 428
assert engine.trading_active is False
ok = await http.post(
"/control/trading", json={"enabled": True, "confirm": LIVE_TRADING_CONFIRMATION}
)
assert ok.status == 200
assert engine.trading_active is True
async def test_status_and_dashboard_expose_trading(client):
http, _ = await client()
status = await (await http.get("/status")).json()
assert status["trading"]["active"] is True
assert status["trading"]["simulated"] is True
page = await (await http.get("/")).text()
assert "Automatisierter Handel" in page
assert "control/trading" in page
assert "LIVE-MODUS" in page
# ------------------------------------------- Absicherung beim Zusammenbau
def test_live_without_token_on_open_port_disables_control():
config = Config.model_validate(
{
"mode": "live",
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
"exchange": {"api_key": "k", "api_secret": "s"},
"server": {"enabled": True, "host": "0.0.0.0", "enable_control": True},
}
)
guarded = _guard_control_exposure(config)
assert guarded.server.enable_control is False
def test_live_with_token_keeps_control():
config = Config.model_validate(
{
"mode": "live",
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
"exchange": {"api_key": "k", "api_secret": "s"},
"server": {"enabled": True, "host": "0.0.0.0", "enable_control": True,
"control_token": "geheim"},
}
)
assert _guard_control_exposure(config).server.enable_control is True
def test_live_on_loopback_keeps_control():
config = Config.model_validate(
{
"mode": "live",
"live_confirmation": LIVE_CONFIRMATION_PHRASE,
"exchange": {"api_key": "k", "api_secret": "s"},
"server": {"enabled": True, "host": "127.0.0.1", "enable_control": True},
}
)
assert _guard_control_exposure(config).server.enable_control is True
def test_paper_on_open_port_keeps_control(base_config):
config = control_config(base_config, host="0.0.0.0", enable_control=True)
assert _guard_control_exposure(config).server.enable_control is True
def test_server_app_builds_without_controller(base_config):
srv = StatusServer(ServerConfig(), lambda: {}, controller=None)
app = srv._build_app()