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:
+241
-2
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user