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
+41 -2
View File
@@ -349,9 +349,46 @@ Slack als auch Discord.
--- ---
## Training aus dem Dashboard anstoßen ## Handel und Training aus dem Dashboard steuern
Im Abschnitt **Training** des Dashboards stehen zwei Bedienelemente: ### Automatisierter Handel
Der Abschnitt **Automatisierter Handel** zeigt den Zustand und schaltet ihn um — in beiden
Modi, simuliert wie echt. Standardmäßig startet der Bot handelnd (`trading.autostart: true`);
mit `autostart: false` sammelt er zunächst nur Daten und wartet auf die Freigabe.
Pausiert bedeutet **nur: keine neuen Einstiege**. Alles andere läuft weiter:
| Auch pausiert | Pausiert ausgesetzt |
|---|---|
| Marktdaten werden abgerufen | Neue Positionen eröffnen |
| Signale werden ausgewertet und gelabelt | |
| Das Modell trainiert weiter | |
| Offene Positionen bleiben unter Stop-/Ziel-Überwachung | |
Der Bot lernt also durchgehend weiter, auch wenn er nicht handelt. Auf identischen Daten:
```
Handel aktiv Trades=9 Beobachtungen=99 Shadow=90 Trade-Labels=9 nur-gelernte Signale=0
Handel pausiert Trades=0 Beobachtungen=90 Shadow=90 Trade-Labels=0 nur-gelernte Signale=9
```
Der Unterschied sind genau die neun Trade-Labels, die ohne Handel nicht entstehen können —
die Shadow-Labels aus dem Marktgeschehen laufen unverändert weiter.
> **Live-Modus.** Das Starten verlangt zusätzlich die Bestätigung `START_LIVE_TRADING`
> (das Dashboard fragt sie ab, per HTTP als `"confirm"` im Body). **Pausieren** geht immer
> ohne Bestätigung — im Zweifel muss man schnell anhalten können. Abschalten lässt sich die
> Rückfrage mit `trading.require_confirmation_for_live: false`.
>
> Die Kombination *Live-Modus + Port nicht nur lokal + kein `control_token`* wird **nicht
> bedient**: Der Bot schaltet die Steuerung dann beim Start ab und sagt das im Log. Sonst
> könnte jeder im Netz echten Handel starten. Mit Token oder auf `127.0.0.1` steht sie
> wieder zur Verfügung.
### Training
Im Abschnitt **Training** stehen zwei Bedienelemente:
**Historisch nachtrainieren.** Anzahl Kerzen wählen (500 50 000 je Symbol), Knopf drücken. **Historisch nachtrainieren.** Anzahl Kerzen wählen (500 50 000 je Symbol), Knopf drücken.
Der Bot lädt die Historie, läuft sie mit derselben Logik wie ein Backtest durch — **ohne zu Der Bot lädt die Historie, läuft sie mit derselben Logik wie ein Backtest durch — **ohne zu
@@ -377,6 +414,8 @@ curl -X POST localhost:8080/control/train/history -H 'Content-Type: application/
| Endpunkt | Methode | Wirkung | | Endpunkt | Methode | Wirkung |
|--------------------------------|---------|--------------------------------------------| |--------------------------------|---------|--------------------------------------------|
| `/control/trading` | GET | Zustand des automatisierten Handels |
| `/control/trading` | POST | `{"enabled": true}` — Handel starten/pausieren (live zusätzlich `"confirm"`) |
| `/control/training` | GET | Zustand des letzten/laufenden Trainings | | `/control/training` | GET | Zustand des letzten/laufenden Trainings |
| `/control/train/history` | POST | `{"bars": 5000}` — Nachtraining anstoßen | | `/control/train/history` | POST | `{"bars": 5000}` — Nachtraining anstoßen |
| `/control/train/live` | POST | `{"enabled": false}` — Lernen ein/aus | | `/control/train/live` | POST | `{"enabled": false}` — Lernen ein/aus |
+10
View File
@@ -55,6 +55,16 @@ paper:
slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil
max_volume_participation: 0.1 # max. 10 % des Kerzenvolumens pro Order max_volume_participation: 0.1 # max. 10 % des Kerzenvolumens pro Order
# ────────────────────────── Automatisierter Handel ──────────────────────────
trading:
# true = der Bot handelt ab dem Start automatisch (bisheriges Verhalten)
# false = er sammelt Daten und lernt, eröffnet aber erst nach Freigabe im Dashboard
# Positionen. Für den ersten Live-Einsatz die sicherere Wahl.
autostart: true
# Nur mode: live Start des Handels über das Dashboard verlangt die ausdrückliche
# Bestätigung START_LIVE_TRADING. Pausieren geht immer ohne Bestätigung.
require_confirmation_for_live: true
# ──────────────────────────────── Risikoregeln ────────────────────────────── # ──────────────────────────────── Risikoregeln ──────────────────────────────
risk: risk:
max_position_pct: 0.20 # je Position, gemessen an der Equity max_position_pct: 0.20 # je Position, gemessen an der Equity
+56 -12
View File
@@ -80,22 +80,57 @@ class Runtime:
self.storage.close() self.storage.close()
def _warn_about_exposure(config: Config) -> None: def control_is_unsafe(config: Config) -> bool:
"""Warnen, wenn Steuerbefehle ungeschützt aus dem Netz erreichbar sind.""" """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: 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: if config.server.control_token:
log.info("Steuerbefehle sind aktiv und durch ein Token geschützt") log.info("Steuerbefehle sind aktiv und durch ein Token geschützt")
return elif unprotected:
if config.server.publicly_reachable:
log.warning( 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 " "erreichbar. Jeder, der den Port erreicht, kann sie auslösen bitte "
"server.control_token setzen oder den Zugriff per Firewall/Reverse Proxy begrenzen.", "server.control_token setzen oder den Zugriff per Firewall/Reverse Proxy begrenzen.",
config.server.host, config.server.port, config.server.host, config.server.port,
) )
else: else:
log.info("Steuerbefehle sind aktiv (nur lokal erreichbar, kein Token gesetzt)") 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: 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, seed: int | None = None,
) -> Runtime: ) -> Runtime:
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste.""" """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: Storage | NullStorage = (
Storage(config.storage.database_path) if with_storage else NullStorage() Storage(config.storage.database_path) if with_storage else NullStorage()
) )
@@ -165,10 +205,7 @@ async def build_runtime(
notifier=notifier, notifier=notifier,
) )
server = None server = StatusServer(config.server, engine.status, controller=engine) if serving else None
if with_server and config.server.enabled:
server = StatusServer(config.server, engine.status, controller=engine)
_warn_about_exposure(config)
storage.start_run( storage.start_run(
mode=config.mode.value, 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"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"Notbremsen Tagesverlust {config.risk.max_daily_loss_pct:.0%}, "
f"Drawdown {config.risk.max_drawdown_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"Datenbank {config.storage.database_path}",
f"Modelldatei {config.strategy.learner.model_path}", f"Modelldatei {config.strategy.learner.model_path}",
] ]
@@ -224,9 +264,13 @@ def describe_config(config: Config) -> str:
) )
if config.server.enabled: if config.server.enabled:
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/") 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" 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: else:
lines.append("Steuerung deaktiviert (nur lesende Endpunkte)") lines.append("Steuerung deaktiviert (nur lesende Endpunkte)")
return "\n".join(" " + line for line in lines) return "\n".join(" " + line for line in lines)
+12
View File
@@ -147,6 +147,17 @@ class StrategyConfig(_Base):
return v return v
class TradingConfig(_Base):
"""Steuerung des automatisierten Handels."""
# true = der Bot handelt ab dem Start automatisch (bisheriges Verhalten).
# false = er sammelt Daten und lernt, eröffnet aber erst nach Freigabe Positionen.
autostart: bool = True
# Nur für mode: live verlangt beim Einschalten über das Dashboard eine
# ausdrückliche Bestätigung im Request-Body.
require_confirmation_for_live: bool = True
class StorageConfig(_Base): class StorageConfig(_Base):
database_path: str = "/data/trademind.sqlite3" database_path: str = "/data/trademind.sqlite3"
@@ -187,6 +198,7 @@ class Config(_Base):
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig) exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
market: MarketConfig = Field(default_factory=MarketConfig) market: MarketConfig = Field(default_factory=MarketConfig)
paper: PaperConfig = Field(default_factory=PaperConfig) paper: PaperConfig = Field(default_factory=PaperConfig)
trading: TradingConfig = Field(default_factory=TradingConfig)
risk: RiskConfig = Field(default_factory=RiskConfig) risk: RiskConfig = Field(default_factory=RiskConfig)
strategy: StrategyConfig = Field(default_factory=StrategyConfig) strategy: StrategyConfig = Field(default_factory=StrategyConfig)
storage: StorageConfig = Field(default_factory=StorageConfig) storage: StorageConfig = Field(default_factory=StorageConfig)
+81 -1
View File
@@ -35,6 +35,9 @@ STATE_KEY = "engine_state"
MIN_TRAINING_BARS = 500 MIN_TRAINING_BARS = 500
MAX_TRAINING_BARS = 50_000 MAX_TRAINING_BARS = 50_000
# Muss mitgesendet werden, um den Handel im Live-Modus über das Dashboard zu starten.
LIVE_TRADING_CONFIRMATION = "START_LIVE_TRADING"
@dataclass @dataclass
class TrainingJob: class TrainingJob:
@@ -171,6 +174,12 @@ class TradingEngine:
self._engine_lock = asyncio.Lock() self._engine_lock = asyncio.Lock()
self._training_task: asyncio.Task[TrainingJob] | None = None self._training_task: asyncio.Task[TrainingJob] | None = None
self.training = TrainingJob() self.training = TrainingJob()
# Automatisierter Handel. Pausiert unterbindet nur neue Einstiege Marktdaten,
# Signalauswertung und Lernen laufen weiter, offene Positionen werden weiter
# überwacht (Stop-Loss und Take-Profit greifen also auch im Pausenzustand).
self.trading_active = config.trading.autostart
self.trading_changed_at: float | None = None
self.paused_signals = 0
# ------------------------------------------------------------ Lebenszyklus # ------------------------------------------------------------ Lebenszyklus
@@ -307,6 +316,64 @@ class TradingEngine:
log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested) log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested)
return {"accepted": True, "reason": "", **self.training.as_dict()} return {"accepted": True, "reason": "", **self.training.as_dict()}
def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]:
"""Automatisierten Handel starten oder pausieren.
Pausiert werden ausschließlich neue Einstiege. Marktdaten, Signalauswertung und
Lernen laufen weiter, offene Positionen bleiben unter Stop-Loss-Überwachung ein
pausierter Bot lässt also niemanden ungeschützt im Markt stehen.
"""
if not self.config.server.enable_control:
return {"accepted": False, "reason": "Steuerung ist deaktiviert",
**self.trading_control_status()}
# Echtgeld zusätzlich absichern: Ein versehentlicher Klick soll nicht reichen.
if enabled and self.requires_trade_confirmation and confirm != LIVE_TRADING_CONFIRMATION:
return {
"accepted": False,
"reason": (
"Im Live-Modus wird echtes Geld eingesetzt. Zum Starten "
f"'confirm': '{LIVE_TRADING_CONFIRMATION}' mitsenden."
),
**self.trading_control_status(),
}
if self.trading_active == enabled:
return {"accepted": True, "reason": "Zustand war bereits gesetzt",
**self.trading_control_status()}
self.trading_active = enabled
self.trading_changed_at = time.time()
level = log.warning if (enabled and not self.config.is_simulated) else log.info
level(
"Automatisierter Handel %s (Modus %s)",
"GESTARTET" if enabled else "pausiert", self.config.mode.value,
)
if self.notifier is not None:
state = "gestartet" if enabled else "pausiert"
self.notifier.send_soon(f"⚙️ Automatisierter Handel {state} (Modus {self.config.mode.value})")
return {"accepted": True, "reason": "", **self.trading_control_status()}
@property
def requires_trade_confirmation(self) -> bool:
return not self.config.is_simulated and self.config.trading.require_confirmation_for_live
def trading_control_status(self) -> dict[str, Any]:
return {
"control_enabled": self.config.server.enable_control,
"mode": self.config.mode.value,
"simulated": self.config.is_simulated,
"active": self.trading_active,
"autostart": self.config.trading.autostart,
"requires_confirmation": self.requires_trade_confirmation,
"confirmation_phrase": LIVE_TRADING_CONFIRMATION if self.requires_trade_confirmation else None,
"paused_signals": self.paused_signals,
"open_positions": len(self.portfolio.positions),
"halted": self.risk.trading_halted,
"halt_reason": self.risk.halt.reason,
"changed_at": self.trading_changed_at,
}
def set_online_learning(self, enabled: bool) -> dict[str, Any]: def set_online_learning(self, enabled: bool) -> dict[str, Any]:
"""Kontinuierliches Lernen im laufenden Betrieb ein- oder ausschalten.""" """Kontinuierliches Lernen im laufenden Betrieb ein- oder ausschalten."""
if not self.config.server.enable_control: if not self.config.server.enable_control:
@@ -434,7 +501,10 @@ class TradingEngine:
if position is not None: if position is not None:
if await self._manage_open_position(symbol, position, snapshot, bar): if await self._manage_open_position(symbol, position, snapshot, bar):
return return
elif not self.risk.trading_halted: else:
# Auch bei pausiertem Handel und bei aktiver Notbremse ausgewertet: Nur so
# bekommt das Modell weiter Kandidaten zum Labeln. Die Entscheidung, ob
# tatsächlich gekauft wird, fällt in _maybe_enter.
await self._maybe_enter(symbol, snapshot, bar) await self._maybe_enter(symbol, snapshot, bar)
async def _manage_open_position( async def _manage_open_position(
@@ -460,12 +530,21 @@ class TradingEngine:
return False return False
async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None: async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
# Die Auswertung läuft immer sie meldet den Kandidaten zum verzögerten Labeln an.
signal = self.strategy.evaluate(symbol, snapshot, None) signal = self.strategy.evaluate(symbol, snapshot, None)
if signal.action is not Action.ENTER_LONG: if signal.action is not Action.ENTER_LONG:
if signal.confidence and log.isEnabledFor(logging.DEBUG): if signal.confidence and log.isEnabledFor(logging.DEBUG):
log.debug("%s: kein Einstieg %s", symbol, signal.reason) log.debug("%s: kein Einstieg %s", symbol, signal.reason)
return return
if not self.trading_active:
self.paused_signals += 1
log.info(
"%s: Einstiegssignal (%s) Handel ist pausiert, es wird nur gelernt",
symbol, signal.reason,
)
return
decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close) decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close)
if not decision: if not decision:
log.debug("%s: Einstieg durch Risikoprüfung verhindert %s", symbol, decision.reason) log.debug("%s: Einstieg durch Risikoprüfung verhindert %s", symbol, decision.reason)
@@ -638,6 +717,7 @@ class TradingEngine:
"recent_trades": self.portfolio.recent_trades(25), "recent_trades": self.portfolio.recent_trades(25),
"strategy": self.strategy.snapshot(), "strategy": self.strategy.snapshot(),
"risk": self.risk.snapshot(), "risk": self.risk.snapshot(),
"trading": self.trading_control_status(),
"training": self.training_status(), "training": self.training_status(),
"feature_weights": ( "feature_weights": (
learner.feature_importance(FEATURE_NAMES) if learner is not None else {} learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
+101 -1
View File
@@ -25,8 +25,12 @@ class Controller(Protocol):
def set_online_learning(self, enabled: bool) -> dict[str, Any]: ... def set_online_learning(self, enabled: bool) -> dict[str, Any]: ...
def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]: ...
def training_status(self) -> dict[str, Any]: ... def training_status(self) -> dict[str, Any]: ...
def trading_control_status(self) -> dict[str, Any]: ...
_DASHBOARD = """<!doctype html> _DASHBOARD = """<!doctype html>
<meta charset="utf-8"> <meta charset="utf-8">
<title>TradeMind</title> <title>TradeMind</title>
@@ -63,11 +67,28 @@ _DASHBOARD = """<!doctype html>
.hint.err { color: #c62828; } .hint.err { color: #c62828; }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; } .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; } .dot.on { background: #1a8f3c; } .dot.off { background: #999; } .dot.busy { background: #e0a100; }
.banner { background: #c62828; color: #fff; padding: 10px 14px; border-radius: 8px;
margin-bottom: 16px; font-weight: 600; }
button.danger { background: #c62828; color: #fff; border-color: #c62828; }
</style> </style>
<h1>TradeMind</h1> <h1>TradeMind</h1>
<div class="sub" id="sub">lädt …</div> <div class="sub" id="sub">lädt …</div>
<div id="live-banner" class="banner" hidden>⚠ LIVE-MODUS — Orders werden mit echtem Geld ausgeführt.</div>
<div class="grid" id="cards"></div> <div class="grid" id="cards"></div>
<section id="trading-section" hidden>
<h2>Automatisierter Handel</h2>
<div class="panel">
<div class="row">
<span id="trade-dot" class="dot off"></span>
<strong id="trade-state"></strong>
<button id="trade-btn"></button>
<span class="hint" id="trade-hint" style="margin:0"></span>
</div>
<div class="hint" id="trade-detail"></div>
</div>
</section>
<section id="training-section" hidden> <section id="training-section" hidden>
<h2>Training</h2> <h2>Training</h2>
<div class="panel"> <div class="panel">
@@ -131,11 +152,12 @@ async function refresh() {
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`], ["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]], ["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
(s.recent_trades || []).slice().reverse()); (s.recent_trades || []).slice().reverse());
renderTrading(s.trading || {});
renderTraining(s.training || {}); renderTraining(s.training || {});
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; } } catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
} }
// ---------------------------------------------------------------- Training // ---------------------------------------------------------------- Steuerung
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
let barsTouched = false; let barsTouched = false;
$("bars").addEventListener("input", () => { barsTouched = true; }); $("bars").addEventListener("input", () => { barsTouched = true; });
@@ -158,6 +180,51 @@ async function post(path, body) {
return data; return data;
} }
let lastTrading = {};
function renderTrading(t) {
lastTrading = t;
if (!t.control_enabled) return;
$("trading-section").hidden = false;
$("live-banner").hidden = t.simulated !== false;
const halted = !!t.halted;
$("trade-dot").className = "dot " + (halted ? "busy" : t.active ? "on" : "off");
$("trade-state").textContent = halted
? "durch Risikoregel gestoppt"
: (t.active ? "läuft" : "pausiert") + (t.simulated ? " (simuliert)" : " (ECHTES GELD)");
const btn = $("trade-btn");
btn.textContent = t.active ? "Handel pausieren" : "Handel starten";
btn.className = (!t.active && !t.simulated) ? "danger" : (t.active ? "ghost" : "");
btn.disabled = false;
setHint($("trade-hint"), halted ? t.halt_reason : "", halted);
const bits = [];
if (!t.active) bits.push("Es werden keine neuen Positionen eröffnet. Marktdaten, Signalauswertung "
+ "und Lernen laufen weiter, offene Positionen bleiben unter Stop-Überwachung.");
if (t.paused_signals) bits.push(`${t.paused_signals} Signal(e) während der Pause nur gelernt.`);
if (t.open_positions) bits.push(`${t.open_positions} Position(en) offen.`);
if (!t.autostart) bits.push("Autostart ist aus nach einem Neustart muss neu gestartet werden.");
$("trade-detail").textContent = bits.join(" ");
}
$("trade-btn").addEventListener("click", async () => {
const turnOn = !lastTrading.active;
const body = { enabled: turnOn };
if (turnOn && lastTrading.requires_confirmation) {
const phrase = lastTrading.confirmation_phrase || "";
const answer = prompt(
"LIVE-MODUS: Es werden Orders mit echtem Geld ausgeführt.\n\n"
+ "Zum Bestätigen exakt eingeben:\n" + phrase);
if (answer === null) return;
body.confirm = answer;
}
$("trade-btn").disabled = true;
try { await post("control/trading", body); setHint($("trade-hint"), ""); }
catch (e) { setHint($("trade-hint"), e.message, true); }
refresh();
});
function renderTraining(t) { function renderTraining(t) {
if (!t.control_enabled) return; if (!t.control_enabled) return;
$("training-section").hidden = false; $("training-section").hidden = false;
@@ -268,6 +335,8 @@ class StatusServer:
web.get("/control/training", self._training_state), web.get("/control/training", self._training_state),
web.post("/control/train/history", self._train_history), web.post("/control/train/history", self._train_history),
web.post("/control/train/live", self._train_live), web.post("/control/train/live", self._train_live),
web.get("/control/trading", self._trading_state),
web.post("/control/trading", self._set_trading),
] ]
) )
return app return app
@@ -376,6 +445,37 @@ class StatusServer:
result = self._controller.set_online_learning(payload["enabled"]) result = self._controller.set_online_learning(payload["enabled"])
return web.json_response(result, status=200 if result.get("accepted") else 409, dumps=_dumps) return web.json_response(result, status=200 if result.get("accepted") else 409, dumps=_dumps)
async def _trading_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.trading_control_status(), dumps=_dumps)
async def _set_trading(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)
confirm = payload.get("confirm")
if confirm is not None and not isinstance(confirm, str):
return web.json_response({"error": "'confirm' muss eine Zeichenkette sein"}, status=400)
result = self._controller.set_trading(payload["enabled"], confirm)
if result.get("accepted"):
return web.json_response(result, status=200, dumps=_dumps)
# Fehlende Live-Bestätigung ist kein Konflikt, sondern eine unvollständige Anfrage.
status = 428 if result.get("requires_confirmation") and payload["enabled"] else 409
return web.json_response(result, status=status, dumps=_dumps)
async def _metrics(self, _: web.Request) -> web.Response: async def _metrics(self, _: web.Request) -> web.Response:
state = self._status() state = self._status()
samples: list[tuple[str, float]] = [] samples: list[tuple[str, float]] = []
+241 -2
View File
@@ -8,8 +8,12 @@ import numpy as np
import pytest import pytest
from aiohttp import web from aiohttp import web
from trademind.config import Config, ServerConfig from trademind.app import _guard_control_exposure
from trademind.features import N_FEATURES 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 trademind.server import TOKEN_HEADER, StatusServer
from .conftest import make_candles 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 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 # --------------------------------------------------------------- HTTP-Schicht
@@ -314,6 +448,111 @@ async def test_training_state_endpoint(client):
assert data["min_bars"] == 500 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): def test_server_app_builds_without_controller(base_config):
srv = StatusServer(ServerConfig(), lambda: {}, controller=None) srv = StatusServer(ServerConfig(), lambda: {}, controller=None)
app = srv._build_app() app = srv._build_app()