d081edfbd3
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.
561 lines
21 KiB
Python
561 lines
21 KiB
Python
"""Steuerung über das Dashboard: historisches Nachtraining und Live-Lernschalter."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from aiohttp import web
|
|
|
|
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
|
|
from .test_engine import GrowingFeed, StaticFeed, build_engine, cyclical_series
|
|
|
|
|
|
def control_config(base_config: Config, **server: object) -> Config:
|
|
return Config.model_validate(
|
|
{**base_config.model_dump(), "mode": "paper", "server": {"enabled": True, **server}}
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------- Historisches Training
|
|
|
|
|
|
async def test_history_training_can_be_started_and_completes(base_config):
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
|
|
result = engine.start_history_training(bars=900)
|
|
assert result["accepted"] is True
|
|
assert engine.training.state == "queued"
|
|
|
|
await engine._training_task
|
|
job = engine.training
|
|
assert job.state == "done"
|
|
assert job.samples_gained > 0
|
|
assert job.symbols_done == ["BTC/USDT"]
|
|
assert job.bars_seen > 0
|
|
assert engine.strategy.learner.stats.samples_seen == job.as_dict()["samples_total"]
|
|
|
|
|
|
async def test_bars_are_clamped_to_sane_limits(base_config):
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
|
|
engine.start_history_training(bars=10)
|
|
assert engine.training.bars_requested == 500 # Untergrenze
|
|
await engine._training_task
|
|
|
|
engine.start_history_training(bars=10_000_000)
|
|
assert engine.training.bars_requested == 50_000 # Obergrenze
|
|
await engine._training_task
|
|
|
|
|
|
async def test_second_training_is_rejected_while_one_runs(base_config):
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
|
|
engine.start_history_training(bars=900)
|
|
second = engine.start_history_training(bars=900)
|
|
assert second["accepted"] is False
|
|
assert "läuft bereits" in second["reason"]
|
|
await engine._training_task
|
|
|
|
|
|
async def test_training_does_not_trade(base_config):
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
engine.start_history_training(bars=900)
|
|
await engine._training_task
|
|
|
|
assert engine.portfolio.trades == []
|
|
assert engine.portfolio.positions == {}
|
|
assert await engine.broker.cash() == pytest.approx(base_config.paper.starting_balance)
|
|
|
|
|
|
async def test_training_leaves_live_labels_untouched(base_config):
|
|
"""Historisches Training darf offene Live-Labels weder auflösen noch verwerfen."""
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
learner = engine.strategy.learner
|
|
|
|
learner.register_candidate("BTC/USDT", np.ones(N_FEATURES), 100.0, bar_index=5, tag="live")
|
|
assert learner.pending_count == 1
|
|
|
|
engine.start_history_training(bars=900)
|
|
await engine._training_task
|
|
|
|
remaining = [p for p in learner._pending if p.tag == "live"]
|
|
assert len(remaining) == 1
|
|
assert remaining[0].entry_price == 100.0
|
|
assert not [p for p in learner._pending if p.tag == "history"]
|
|
|
|
|
|
async def test_training_does_not_shift_the_live_timeline(base_config):
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
engine.bar_counter["BTC/USDT"] = 42
|
|
engine.last_bar_ts["BTC/USDT"] = 1_234_567
|
|
|
|
engine.start_history_training(bars=900)
|
|
await engine._training_task
|
|
|
|
assert engine.bar_counter["BTC/USDT"] == 42
|
|
assert engine.last_bar_ts["BTC/USDT"] == 1_234_567
|
|
|
|
|
|
async def test_bootstrap_still_adopts_the_timeline(base_config):
|
|
"""Beim Kaltstart soll der Live-Loop dagegen an die Historie anschließen."""
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
await engine.bootstrap_learner()
|
|
assert engine.bar_counter["BTC/USDT"] > 0
|
|
|
|
|
|
async def test_training_and_tick_do_not_overlap(base_config):
|
|
engine = build_engine(control_config(base_config), feed=GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
|
|
engine.start_history_training(bars=900)
|
|
tick = asyncio.create_task(engine._tick(300))
|
|
await asyncio.gather(engine._training_task, tick)
|
|
|
|
assert engine.training.state == "done"
|
|
assert engine.errors == 0
|
|
|
|
|
|
async def test_training_failure_is_reported_not_raised(base_config):
|
|
class BrokenFeed(StaticFeed):
|
|
async def fetch(self, symbol, timeframe, limit):
|
|
raise RuntimeError("Börse nicht erreichbar")
|
|
|
|
engine = build_engine(control_config(base_config), feed=BrokenFeed())
|
|
await engine.prepare()
|
|
engine.start_history_training(bars=900)
|
|
await engine._training_task
|
|
|
|
job = engine.training
|
|
assert job.state == "done" # der Lauf endet geordnet
|
|
assert job.symbols_done == []
|
|
assert any("nicht erreichbar" in s for s in job.skipped)
|
|
|
|
|
|
async def test_short_history_is_skipped(base_config):
|
|
engine = build_engine(control_config(base_config), feed=StaticFeed(make_candles(n=60)))
|
|
await engine.prepare()
|
|
engine.start_history_training(bars=900)
|
|
await engine._training_task
|
|
assert any("Kerzen" in s for s in engine.training.skipped)
|
|
|
|
|
|
async def test_rules_strategy_cannot_be_trained(base_config):
|
|
config = Config.model_validate(
|
|
{**control_config(base_config).model_dump(), "strategy": {"name": "rules"}}
|
|
)
|
|
engine = build_engine(config, feed=StaticFeed())
|
|
await engine.prepare()
|
|
result = engine.start_history_training()
|
|
assert result["accepted"] is False
|
|
assert "lernfähiges Modell" in result["reason"]
|
|
|
|
|
|
# ------------------------------------------------------- Live-Lernen umschalten
|
|
|
|
|
|
async def test_online_learning_can_be_toggled(base_config):
|
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
|
await engine.prepare()
|
|
assert engine.online_learning_enabled is True
|
|
|
|
assert engine.set_online_learning(False)["online_learning"] is False
|
|
assert engine.strategy.learner.frozen is True
|
|
assert engine.online_learning_enabled is False
|
|
|
|
assert engine.set_online_learning(True)["online_learning"] is True
|
|
assert engine.strategy.learner.frozen is False
|
|
|
|
|
|
async def test_frozen_learner_stops_updating_weights(base_config):
|
|
engine = build_engine(control_config(base_config), feed=StaticFeed())
|
|
await engine.prepare()
|
|
learner = engine.strategy.learner
|
|
for _ in range(60):
|
|
learner.observe(np.ones(N_FEATURES), 1.0)
|
|
|
|
engine.set_online_learning(False)
|
|
weights = learner.model.w.copy()
|
|
for _ in range(60):
|
|
learner.observe(np.ones(N_FEATURES), 0.0)
|
|
assert np.allclose(learner.model.w, weights)
|
|
|
|
|
|
async def test_control_disabled_rejects_commands(base_config):
|
|
engine = build_engine(control_config(base_config, enable_control=False), feed=StaticFeed())
|
|
await engine.prepare()
|
|
assert engine.start_history_training()["accepted"] is False
|
|
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
|
|
|
|
|
|
class SlowFeed(GrowingFeed):
|
|
"""Verzögert den Abruf, damit sich Anfragen zuverlässig überlappen."""
|
|
|
|
async def fetch(self, symbol: str, timeframe: str, limit: int):
|
|
await asyncio.sleep(0.3)
|
|
return await super().fetch(symbol, timeframe, limit)
|
|
|
|
|
|
@pytest.fixture
|
|
async def client(aiohttp_client, base_config):
|
|
"""Server mit angeschlossener Engine; gibt (client, engine) zurück."""
|
|
|
|
async def _make(feed=None, **server_options: object):
|
|
config = control_config(base_config, **server_options)
|
|
engine = build_engine(config, feed=feed or GrowingFeed(cyclical_series(n=900), start=900))
|
|
await engine.prepare()
|
|
srv = StatusServer(config.server, engine.status, controller=engine)
|
|
return await aiohttp_client(srv._build_app()), engine
|
|
|
|
return _make
|
|
|
|
|
|
async def test_dashboard_and_status_expose_training(client):
|
|
http, _ = await client()
|
|
page = await (await http.get("/")).text()
|
|
assert "Training starten" in page
|
|
assert "Kontinuierliches Lernen" in page
|
|
|
|
status = await (await http.get("/status")).json()
|
|
assert status["training"]["control_enabled"] is True
|
|
assert status["training"]["online_learning"] is True
|
|
|
|
|
|
async def test_post_history_training_returns_202(client):
|
|
http, engine = await client()
|
|
response = await http.post("/control/train/history", json={"bars": 900})
|
|
assert response.status == 202
|
|
assert (await response.json())["accepted"] is True
|
|
await engine._training_task
|
|
|
|
|
|
async def test_post_history_training_conflict_returns_409(client):
|
|
http, engine = await client(feed=SlowFeed(cyclical_series(n=900), start=900))
|
|
first = await http.post("/control/train/history", json={"bars": 900})
|
|
assert first.status == 202
|
|
|
|
second = await http.post("/control/train/history", json={"bars": 900})
|
|
assert second.status == 409
|
|
assert "läuft bereits" in (await second.json())["reason"]
|
|
|
|
await engine._training_task
|
|
assert engine.training.state == "done"
|
|
|
|
|
|
async def test_post_live_toggle(client):
|
|
http, engine = await client()
|
|
response = await http.post("/control/train/live", json={"enabled": False})
|
|
assert response.status == 200
|
|
assert (await response.json())["online_learning"] is False
|
|
assert engine.strategy.learner.frozen is True
|
|
|
|
|
|
async def test_live_toggle_rejects_bad_payload(client):
|
|
http, _ = await client()
|
|
assert (await http.post("/control/train/live", json={"enabled": "ja"})).status == 400
|
|
assert (await http.post("/control/train/live", data="kein json")).status == 400
|
|
|
|
|
|
async def test_token_is_required_when_configured(client):
|
|
http, engine = await client(control_token="geheim")
|
|
|
|
assert (await http.post("/control/train/live", json={"enabled": False})).status == 401
|
|
assert (await http.get("/control/training")).status == 401
|
|
assert engine.strategy.learner.frozen is False # nichts passiert
|
|
|
|
ok = await http.post(
|
|
"/control/train/live", json={"enabled": False}, headers={TOKEN_HEADER: "geheim"}
|
|
)
|
|
assert ok.status == 200
|
|
assert engine.strategy.learner.frozen is True
|
|
|
|
|
|
async def test_wrong_token_is_rejected(client):
|
|
http, _ = await client(control_token="geheim")
|
|
response = await http.post(
|
|
"/control/train/live", json={"enabled": False}, headers={TOKEN_HEADER: "falsch"}
|
|
)
|
|
assert response.status == 401
|
|
|
|
|
|
async def test_control_routes_absent_when_disabled(client):
|
|
http, _ = await client(enable_control=False)
|
|
assert (await http.post("/control/train/history", json={})).status == 404
|
|
assert (await http.get("/control/training")).status == 404
|
|
assert (await http.get("/status")).status == 200 # lesende Endpunkte bleiben
|
|
|
|
|
|
async def test_read_only_endpoints_need_no_token(client):
|
|
http, _ = await client(control_token="geheim")
|
|
for path in ("/health", "/status", "/metrics", "/positions", "/trades"):
|
|
assert (await http.get(path)).status == 200, path
|
|
|
|
|
|
async def test_training_state_endpoint(client):
|
|
http, _ = await client()
|
|
data = await (await http.get("/control/training")).json()
|
|
assert data["state"] == "idle"
|
|
assert data["learning_available"] is True
|
|
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()
|
|
assert isinstance(app, web.Application)
|
|
assert srv.control_available is False
|