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:
+56
-12
@@ -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)
|
||||
|
||||
@@ -147,6 +147,17 @@ class StrategyConfig(_Base):
|
||||
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):
|
||||
database_path: str = "/data/trademind.sqlite3"
|
||||
|
||||
@@ -187,6 +198,7 @@ class Config(_Base):
|
||||
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
|
||||
market: MarketConfig = Field(default_factory=MarketConfig)
|
||||
paper: PaperConfig = Field(default_factory=PaperConfig)
|
||||
trading: TradingConfig = Field(default_factory=TradingConfig)
|
||||
risk: RiskConfig = Field(default_factory=RiskConfig)
|
||||
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
|
||||
storage: StorageConfig = Field(default_factory=StorageConfig)
|
||||
|
||||
+81
-1
@@ -35,6 +35,9 @@ STATE_KEY = "engine_state"
|
||||
MIN_TRAINING_BARS = 500
|
||||
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
|
||||
class TrainingJob:
|
||||
@@ -171,6 +174,12 @@ class TradingEngine:
|
||||
self._engine_lock = asyncio.Lock()
|
||||
self._training_task: asyncio.Task[TrainingJob] | None = None
|
||||
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
|
||||
|
||||
@@ -307,6 +316,64 @@ class TradingEngine:
|
||||
log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested)
|
||||
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]:
|
||||
"""Kontinuierliches Lernen im laufenden Betrieb ein- oder ausschalten."""
|
||||
if not self.config.server.enable_control:
|
||||
@@ -434,7 +501,10 @@ class TradingEngine:
|
||||
if position is not None:
|
||||
if await self._manage_open_position(symbol, position, snapshot, bar):
|
||||
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)
|
||||
|
||||
async def _manage_open_position(
|
||||
@@ -460,12 +530,21 @@ class TradingEngine:
|
||||
return False
|
||||
|
||||
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)
|
||||
if signal.action is not Action.ENTER_LONG:
|
||||
if signal.confidence and log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("%s: kein Einstieg – %s", symbol, signal.reason)
|
||||
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)
|
||||
if not decision:
|
||||
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),
|
||||
"strategy": self.strategy.snapshot(),
|
||||
"risk": self.risk.snapshot(),
|
||||
"trading": self.trading_control_status(),
|
||||
"training": self.training_status(),
|
||||
"feature_weights": (
|
||||
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
|
||||
|
||||
+101
-1
@@ -25,8 +25,12 @@ class Controller(Protocol):
|
||||
|
||||
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 trading_control_status(self) -> dict[str, Any]: ...
|
||||
|
||||
_DASHBOARD = """<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>TradeMind</title>
|
||||
@@ -63,11 +67,28 @@ _DASHBOARD = """<!doctype html>
|
||||
.hint.err { color: #c62828; }
|
||||
.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; }
|
||||
.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>
|
||||
<h1>TradeMind</h1>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<h2>Training</h2>
|
||||
<div class="panel">
|
||||
@@ -131,11 +152,12 @@ async function refresh() {
|
||||
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
|
||||
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
|
||||
(s.recent_trades || []).slice().reverse());
|
||||
renderTrading(s.trading || {});
|
||||
renderTraining(s.training || {});
|
||||
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Training
|
||||
// ---------------------------------------------------------------- Steuerung
|
||||
const $ = id => document.getElementById(id);
|
||||
let barsTouched = false;
|
||||
$("bars").addEventListener("input", () => { barsTouched = true; });
|
||||
@@ -158,6 +180,51 @@ async function post(path, body) {
|
||||
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) {
|
||||
if (!t.control_enabled) return;
|
||||
$("training-section").hidden = false;
|
||||
@@ -268,6 +335,8 @@ class StatusServer:
|
||||
web.get("/control/training", self._training_state),
|
||||
web.post("/control/train/history", self._train_history),
|
||||
web.post("/control/train/live", self._train_live),
|
||||
web.get("/control/trading", self._trading_state),
|
||||
web.post("/control/trading", self._set_trading),
|
||||
]
|
||||
)
|
||||
return app
|
||||
@@ -376,6 +445,37 @@ class StatusServer:
|
||||
result = self._controller.set_online_learning(payload["enabled"])
|
||||
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:
|
||||
state = self._status()
|
||||
samples: list[tuple[str, float]] = []
|
||||
|
||||
Reference in New Issue
Block a user