Konfiguration im Dashboard änderbar machen
Neuer Abschnitt "Konfiguration" im Dashboard, aus dem pydantic-Schema erzeugt:
76 Felder in 13 Bereichen mit Kurzbeschreibung, typgerechtem Eingabefeld und den
Grenzen aus dem Modell. 70 davon sind änderbar, 45 greifen sofort, 31 sind als
neustartpflichtig markiert. Dazu GET und POST /control/config sowie
/control/config/reset.
Overlay statt Direktschreiben
- config.yaml ist im Container read-only eingehängt. Änderungen landen deshalb in
/data/config.overrides.yaml und werden beim Start über die Basiskonfiguration
gelegt. Rangfolge: YAML, dann ${ENV}, dann TRADEMIND__-Variablen, dann Overlay.
- Gespeichert wird nur, was vom Basiswert abweicht. Ein auf den Ausgangswert
zurückgestelltes Feld verschwindet wieder aus dem Overlay, damit spätere
Änderungen an config.yaml dort erneut durchschlagen.
- Beschädigte oder ungültige Overlays werden protokolliert und ignoriert, statt
den Start zu blockieren.
Übernehmen zur Laufzeit
- apply_config hängt die Laufzeitobjekte auf die neuen Teilkonfigurationen um
(Risiko, Strategie, Regelwerk, Lernmodell, Paper-Broker, Notifier, Log-Level).
- Was nur beim Aufbau ausgewertet wird - Börsenclient, Symbole, Timeframe,
Startkapital, Datenbank, Socket - meldet der Store als neustartpflichtig.
Nebenbei behoben: Der Handels-Loop las Abfrageintervall und Historienlänge nur
einmal vor der Schleife. Eine Änderung an poll_interval_seconds hätte nie
gegriffen; beide werden jetzt je Durchlauf frisch gelesen.
Drei Ausnahmen von "komplett", bewusst gesetzt
- exchange.api_key/api_secret/password/uid werden maskiert angezeigt und nicht
entgegengenommen. Sonst könnte jeder mit Zugriff auf den Port die
Börsenschlüssel auslesen oder austauschen.
- mode lässt sich zwischen paper und backtest umstellen, nicht auf live.
- live_confirmation ist nicht schreibbar.
Zusammen verhindern sie, dass sich der Bot über das Netz schrittweise auf
Echtgeldhandel umstellen lässt.
222 Tests (36 neue), ruff sauber. Darunter Prüfungen, dass Geheimnisse in keiner
Antwort auftauchen und dass Tippfehler in RESTART_REQUIRED oder NON_WRITABLE
auffallen. Im Browser durchgeklickt: Änderung sofort wirksam, neustartpflichtige
Felder korrekt gemeldet und nach Neustart aktiv, Zurücksetzen einzeln und
komplett, alle Schutzregeln mit HTTP 400 samt lesbarer Begründung.
This commit is contained in:
@@ -9,6 +9,7 @@ from typing import Any
|
||||
|
||||
from .broker import Broker, LiveBroker, PaperBroker
|
||||
from .config import Config, Mode
|
||||
from .configstore import ConfigStore
|
||||
from .data import CcxtDataFeed, DataFeed
|
||||
from .engine import TradingEngine
|
||||
from .exchange import build_exchange, load_market_info
|
||||
@@ -152,6 +153,7 @@ async def build_runtime(
|
||||
with_storage: bool = True,
|
||||
load_model: bool = True,
|
||||
seed: int | None = None,
|
||||
config_store: ConfigStore | None = None,
|
||||
) -> Runtime:
|
||||
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
|
||||
serving = with_server and config.server.enabled
|
||||
@@ -203,6 +205,7 @@ async def build_runtime(
|
||||
risk=risk,
|
||||
storage=storage,
|
||||
notifier=notifier,
|
||||
config_store=config_store,
|
||||
)
|
||||
|
||||
server = StatusServer(config.server, engine.status, controller=engine) if serving else None
|
||||
|
||||
+12
-1
@@ -14,6 +14,7 @@ from . import __version__
|
||||
from .app import build_runtime, describe_config, setup_logging
|
||||
from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv
|
||||
from .config import Config, Mode, load_config
|
||||
from .configstore import ConfigStore
|
||||
from .data import align_series, load_csv, parse_iso8601
|
||||
from .engine import install_signal_handlers
|
||||
from .exchange import available_exchanges
|
||||
@@ -164,11 +165,21 @@ async def cmd_run(args: argparse.Namespace) -> int:
|
||||
return 2
|
||||
setup_logging(config.log_level)
|
||||
|
||||
# Im Dauerbetrieb kommen gespeicherte Dashboard-Änderungen über den Store dazu.
|
||||
store: ConfigStore | None = None
|
||||
if not args.mode:
|
||||
try:
|
||||
store = ConfigStore.load(args.config)
|
||||
config = store.config
|
||||
except Exception as exc: # noqa: BLE001 - Overlay darf den Start nie verhindern
|
||||
log.error("Gespeicherte Konfigurationsänderungen nicht nutzbar (%s)", exc)
|
||||
setup_logging(config.log_level)
|
||||
|
||||
print("\nTradeMind startet:\n" + describe_config(config) + "\n")
|
||||
if config.mode is Mode.LIVE:
|
||||
log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.")
|
||||
|
||||
runtime = await build_runtime(config, with_server=not args.no_server)
|
||||
runtime = await build_runtime(config, with_server=not args.no_server, config_store=store)
|
||||
try:
|
||||
await runtime.start_services()
|
||||
await runtime.engine.prepare()
|
||||
|
||||
@@ -15,6 +15,49 @@ _ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
|
||||
|
||||
LIVE_CONFIRMATION_PHRASE = "I_UNDERSTAND_THE_RISK"
|
||||
|
||||
# Felder, die niemals über das Netz ausgeliefert oder entgegengenommen werden. Sie werden
|
||||
# im Dashboard maskiert angezeigt und bleiben ausschließlich über die Umgebung setzbar.
|
||||
SECRET_FIELDS: frozenset[str] = frozenset(
|
||||
{"exchange.api_key", "exchange.api_secret", "exchange.password", "exchange.uid"}
|
||||
)
|
||||
|
||||
# Felder, deren Änderung erst nach einem Neustart greift: Sie werden beim Aufbau der
|
||||
# Laufzeitobjekte einmalig ausgewertet (Börsenclient, Broker, Datenbank, Socket …).
|
||||
RESTART_REQUIRED: frozenset[str] = frozenset(
|
||||
{
|
||||
"mode",
|
||||
"live_confirmation",
|
||||
"market.symbols",
|
||||
"market.timeframe",
|
||||
"market.history_bars",
|
||||
"paper.starting_balance",
|
||||
"paper.quote_currency",
|
||||
# Wirkt erst beim nächsten Start – der aktuelle Handelszustand bleibt, wie er ist.
|
||||
"trading.autostart",
|
||||
"strategy.name",
|
||||
"strategy.learner.enabled",
|
||||
"strategy.learner.model_path",
|
||||
"strategy.learner.replay_size",
|
||||
"storage.database_path",
|
||||
"storage.overrides_path",
|
||||
"server.enabled",
|
||||
"server.host",
|
||||
"server.port",
|
||||
"server.enable_metrics",
|
||||
}
|
||||
)
|
||||
|
||||
# Ganze Abschnitte, die nur beim Start ausgewertet werden.
|
||||
RESTART_REQUIRED_PREFIXES: tuple[str, ...] = ("exchange.", "backtest.")
|
||||
|
||||
|
||||
def requires_restart(path: str) -> bool:
|
||||
return path in RESTART_REQUIRED or path.startswith(RESTART_REQUIRED_PREFIXES)
|
||||
|
||||
|
||||
def is_secret(path: str) -> bool:
|
||||
return path in SECRET_FIELDS
|
||||
|
||||
|
||||
class Mode(str, Enum):
|
||||
PAPER = "paper"
|
||||
@@ -160,6 +203,9 @@ class TradingConfig(_Base):
|
||||
|
||||
class StorageConfig(_Base):
|
||||
database_path: str = "/data/trademind.sqlite3"
|
||||
# Im Dashboard geänderte Werte. Liegt bewusst im beschreibbaren Datenvolume, weil
|
||||
# die Konfigurationsdatei üblicherweise read-only eingehängt ist.
|
||||
overrides_path: str = "/data/config.overrides.yaml"
|
||||
|
||||
|
||||
class ServerConfig(_Base):
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
"""Konfiguration zur Laufzeit lesen, ändern und persistieren.
|
||||
|
||||
Die Konfigurationsdatei ist im Container üblicherweise read-only eingehängt. Änderungen
|
||||
aus dem Dashboard landen deshalb als Overlay in einer eigenen Datei im Datenvolume und
|
||||
werden beim Start über die Basiskonfiguration gelegt.
|
||||
|
||||
Rangfolge, von schwach nach stark:
|
||||
|
||||
1. ``config.yaml``
|
||||
2. ``${ENV}``-Platzhalter darin
|
||||
3. ``TRADEMIND__ABSCHNITT__SCHLUESSEL``-Umgebungsvariablen
|
||||
4. Overlay aus dem Dashboard
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from types import UnionType
|
||||
from typing import Any, Union, get_args, get_origin
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from .config import (
|
||||
Config,
|
||||
Mode,
|
||||
_apply_env_overrides,
|
||||
_substitute_env,
|
||||
is_secret,
|
||||
requires_restart,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
SECRET_PLACEHOLDER = "••••••••"
|
||||
|
||||
# Über das Dashboard nicht schreibbar – siehe Begründung je Eintrag.
|
||||
NON_WRITABLE: frozenset[str] = frozenset(
|
||||
{
|
||||
# Zugangsdaten gehören nicht über HTTP: weder ausgeliefert noch entgegengenommen.
|
||||
"exchange.api_key",
|
||||
"exchange.api_secret",
|
||||
"exchange.password",
|
||||
"exchange.uid",
|
||||
# Wer live_confirmation setzen könnte, könnte den Bot aus dem Netz auf Echtgeld
|
||||
# umstellen. Bleibt der Konfigurationsdatei vorbehalten.
|
||||
"live_confirmation",
|
||||
# Zirkulär: der Pfad bestimmt, wo die Overlays selbst liegen.
|
||||
"storage.overrides_path",
|
||||
}
|
||||
)
|
||||
|
||||
# Modi, die sich aus dem Dashboard heraus setzen lassen. "live" fehlt bewusst.
|
||||
DASHBOARD_MODES: frozenset[str] = frozenset({Mode.PAPER.value, Mode.BACKTEST.value})
|
||||
|
||||
DESCRIPTIONS: dict[str, str] = {
|
||||
"mode": "paper = simuliert auf Live-Kursen, backtest = historisch. "
|
||||
"Umstellung auf live nur in der Konfigurationsdatei.",
|
||||
"log_level": "Ausführlichkeit der Protokollierung.",
|
||||
"live_confirmation": "Sicherheitsnetz für den Live-Modus.",
|
||||
"exchange.id": "ccxt-Kennung der Börse, z. B. binance, kraken, okx.",
|
||||
"exchange.sandbox": "Testnet der Börse verwenden, soweit unterstützt.",
|
||||
"exchange.enable_rate_limit": "Anfragen drosseln, um Sperren zu vermeiden.",
|
||||
"exchange.timeout_ms": "Zeitlimit einzelner Börsenanfragen.",
|
||||
"market.symbols": "Handelspaare, kommagetrennt. Alle mit derselben Quote-Währung.",
|
||||
"market.timeframe": "Kerzenlänge, z. B. 5m, 1h, 1d.",
|
||||
"market.history_bars": "Kerzen je Abruf; die Indikatoren brauchen mindestens 140.",
|
||||
"market.poll_interval_seconds": "Abstand zwischen zwei Abfragen nach neuen Kerzen.",
|
||||
"trading.autostart": "Handelt der Bot direkt nach dem Start, oder wartet er auf Freigabe?",
|
||||
"trading.require_confirmation_for_live": "Rückfrage, bevor Echtgeldhandel startet.",
|
||||
"paper.starting_balance": "Startkapital der Simulation.",
|
||||
"paper.fee_rate": "Gebühr je Seite, 0.001 = 0,1 %.",
|
||||
"paper.slippage_bps": "Ausführungsnachteil in Basispunkten.",
|
||||
"paper.max_volume_participation": "Höchstanteil am Kerzenvolumen je Order.",
|
||||
"risk.max_position_pct": "Anteil der Equity je Position.",
|
||||
"risk.max_total_exposure_pct": "Anteil der Equity über alle Positionen.",
|
||||
"risk.max_open_positions": "Wie viele Positionen gleichzeitig offen sein dürfen.",
|
||||
"risk.stop_loss_atr_mult": "Stop-Abstand als Vielfaches der ATR. 0 = kein Stop.",
|
||||
"risk.take_profit_atr_mult": "Zielabstand als Vielfaches der ATR. 0 = kein Ziel.",
|
||||
"risk.trailing_stop_atr_mult": "Nachziehender Stop. 0 = aus.",
|
||||
"risk.max_holding_bars": "Zwangsausstieg nach so vielen Kerzen. 0 = unbegrenzt.",
|
||||
"risk.max_daily_loss_pct": "Notbremse bis zum nächsten UTC-Tag.",
|
||||
"risk.max_drawdown_pct": "Notbremse bis zum Neustart, schließt offene Positionen.",
|
||||
"risk.min_notional": "Kleinste sinnvolle Ordergröße in Quote-Währung.",
|
||||
"risk.cooldown_bars_after_exit": "Pause je Symbol nach einem Ausstieg.",
|
||||
"strategy.name": "adaptive = Regelwerk plus Lernmodell, rules = nur Regelwerk.",
|
||||
"strategy.rules.fast_ema": "Schnelle EMA-Periode.",
|
||||
"strategy.rules.slow_ema": "Langsame EMA-Periode, muss größer als die schnelle sein.",
|
||||
"strategy.rules.rsi_period": "RSI-Periode.",
|
||||
"strategy.rules.rsi_oversold": "Schwelle für den Rücksetzer-Einstieg.",
|
||||
"strategy.rules.rsi_overbought": "Schwelle für nachlassendes Momentum.",
|
||||
"strategy.rules.atr_period": "ATR-Periode für Stops und Ziele.",
|
||||
"strategy.rules.trend_filter_period": "Trendfilter-EMA. 0 = aus.",
|
||||
"strategy.rules.min_holding_bars": "Signalausstiege erst danach; Stop und Ziel gelten immer.",
|
||||
"strategy.learner.enabled": "Lernmodul überhaupt verwenden.",
|
||||
"strategy.learner.entry_threshold": "Ab welcher Gewinnwahrscheinlichkeit gehandelt wird.",
|
||||
"strategy.learner.exploration_rate": "Anteil bewusst gegen das Modell gehandelter Signale.",
|
||||
"strategy.learner.learning_rate": "Schrittweite des Optimierers.",
|
||||
"strategy.learner.l2": "Regularisierung gegen Überanpassung.",
|
||||
"strategy.learner.batch_size": "Beobachtungen je Trainingsschritt.",
|
||||
"strategy.learner.train_every_n_samples": "Wie oft trainiert wird.",
|
||||
"strategy.learner.warmup_samples": "Bis dahin entscheidet allein das Regelwerk.",
|
||||
"strategy.learner.label_horizon_bars": "Bewertungsfenster eines Signals.",
|
||||
"strategy.learner.label_target_bps": "Kursziel, das als Treffer zählt (30 = 0,3 %).",
|
||||
"strategy.learner.trade_sample_weight": "Gewicht echter Trades gegenüber Shadow-Labels.",
|
||||
"strategy.learner.background_sample_every_n_bars": "Zusätzliche Stichproben. 0 = aus.",
|
||||
"strategy.learner.background_sample_weight": "Gewicht dieser Stichproben.",
|
||||
"strategy.learner.bootstrap_bars": "Vorlernen beim Kaltstart. 0 = aus.",
|
||||
"strategy.learner.freeze_in_live": "Im Live-Modus nicht weiterlernen.",
|
||||
"strategy.learner.save_every_n_updates": "Speicherintervall des Modells.",
|
||||
"strategy.learner.model_path": "Ablageort der Modellgewichte.",
|
||||
"strategy.learner.replay_size": "Größe des Erfahrungsspeichers.",
|
||||
"storage.database_path": "SQLite-Datei für Trades und Equity.",
|
||||
"server.enabled": "Status-Server und Dashboard bereitstellen.",
|
||||
"server.host": "Adresse, auf der gelauscht wird.",
|
||||
"server.port": "Port des Dashboards.",
|
||||
"server.enable_metrics": "Prometheus-Endpunkt /metrics.",
|
||||
"server.enable_control": "Steuerbefehle im Dashboard zulassen.",
|
||||
"server.control_token": "Schützt alle Steuerbefehle. Leer = ungeschützt.",
|
||||
"notifications.webhook_url": "Slack- oder Discord-Webhook. Leer = aus.",
|
||||
"notifications.notify_on_trade": "Meldung bei Ein- und Ausstiegen.",
|
||||
"notifications.notify_on_risk_halt": "Meldung, wenn eine Notbremse greift.",
|
||||
"backtest.bars": "Kerzen je Backtest-Lauf.",
|
||||
"backtest.start": "Startzeit ISO-8601, überschreibt bars.",
|
||||
"backtest.end": "Endzeit ISO-8601.",
|
||||
"backtest.csv_dir": "OHLCV aus CSV statt von der Börse.",
|
||||
}
|
||||
|
||||
SECTION_TITLES: dict[str, str] = {
|
||||
"": "Allgemein",
|
||||
"exchange": "Börsenanbindung",
|
||||
"market": "Marktauswahl",
|
||||
"trading": "Automatisierter Handel",
|
||||
"paper": "Simulation",
|
||||
"risk": "Risikoregeln",
|
||||
"strategy": "Strategie",
|
||||
"strategy.rules": "Strategie – Regelwerk",
|
||||
"strategy.learner": "Strategie – Lernmodell",
|
||||
"storage": "Persistenz",
|
||||
"server": "Status-Server",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"backtest": "Backtest",
|
||||
}
|
||||
|
||||
|
||||
class ConfigError(RuntimeError):
|
||||
"""Die gewünschte Änderung ist nicht zulässig."""
|
||||
|
||||
|
||||
def deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Nicht-destruktives Zusammenführen; ``patch`` gewinnt."""
|
||||
out = dict(base)
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, dict) and isinstance(out.get(key), dict):
|
||||
out[key] = deep_merge(out[key], value)
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def flatten(data: dict[str, Any], prefix: str = "") -> dict[str, Any]:
|
||||
"""``{"risk": {"a": 1}}`` → ``{"risk.a": 1}``."""
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
path = f"{prefix}{key}"
|
||||
if isinstance(value, dict):
|
||||
out.update(flatten(value, f"{path}."))
|
||||
else:
|
||||
out[path] = value
|
||||
return out
|
||||
|
||||
|
||||
def unflatten(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Umkehrung von :func:`flatten`."""
|
||||
out: dict[str, Any] = {}
|
||||
for path, value in data.items():
|
||||
parts = path.split(".")
|
||||
cursor = out
|
||||
for part in parts[:-1]:
|
||||
cursor = cursor.setdefault(part, {})
|
||||
cursor[parts[-1]] = value
|
||||
return out
|
||||
|
||||
|
||||
def _unwrap_optional(annotation: Any) -> tuple[Any, bool]:
|
||||
"""``str | None`` → ``(str, True)``."""
|
||||
if get_origin(annotation) in (Union, UnionType):
|
||||
args = [a for a in get_args(annotation) if a is not type(None)]
|
||||
if len(args) == 1:
|
||||
return args[0], True
|
||||
return annotation, False
|
||||
|
||||
|
||||
def _constraints(field_info: Any) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for item in getattr(field_info, "metadata", ()): # annotated_types
|
||||
for attr, key in (("ge", "min"), ("gt", "exclusive_min"), ("le", "max"), ("lt", "exclusive_max")):
|
||||
value = getattr(item, attr, None)
|
||||
if value is not None:
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldSpec:
|
||||
"""Beschreibt ein einzelnes Konfigurationsfeld für die Oberfläche."""
|
||||
|
||||
path: str
|
||||
section: str
|
||||
name: str
|
||||
type: str # bool | int | float | str | list | enum
|
||||
value: Any
|
||||
writable: bool
|
||||
restart: bool
|
||||
secret: bool
|
||||
overridden: bool
|
||||
description: str = ""
|
||||
choices: list[str] = field(default_factory=list)
|
||||
constraints: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"path": self.path,
|
||||
"section": self.section,
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"value": self.value,
|
||||
"writable": self.writable,
|
||||
"restart": self.restart,
|
||||
"secret": self.secret,
|
||||
"overridden": self.overridden,
|
||||
"description": self.description,
|
||||
"choices": self.choices,
|
||||
"constraints": self.constraints,
|
||||
}
|
||||
|
||||
|
||||
def describe_model(model: BaseModel, prefix: str = "", overridden: set[str] | None = None) -> list[FieldSpec]:
|
||||
"""Läuft das Konfigurationsmodell ab und beschreibt jedes Blattfeld."""
|
||||
overridden = overridden or set()
|
||||
specs: list[FieldSpec] = []
|
||||
for name, info in type(model).model_fields.items():
|
||||
path = f"{prefix}{name}"
|
||||
value = getattr(model, name)
|
||||
if isinstance(value, BaseModel):
|
||||
specs.extend(describe_model(value, f"{path}.", overridden))
|
||||
continue
|
||||
|
||||
annotation, _ = _unwrap_optional(info.annotation)
|
||||
choices: list[str] = []
|
||||
if isinstance(annotation, type) and issubclass(annotation, enum.Enum):
|
||||
kind = "enum"
|
||||
choices = [m.value for m in annotation]
|
||||
value = value.value if isinstance(value, enum.Enum) else value
|
||||
elif annotation is bool:
|
||||
kind = "bool"
|
||||
elif annotation is int:
|
||||
kind = "int"
|
||||
elif annotation is float:
|
||||
kind = "float"
|
||||
elif get_origin(annotation) in (list, set, tuple):
|
||||
kind = "list"
|
||||
value = list(value) if value is not None else []
|
||||
elif annotation is dict or get_origin(annotation) is dict:
|
||||
kind = "json"
|
||||
else:
|
||||
kind = "str"
|
||||
|
||||
secret = is_secret(path)
|
||||
specs.append(
|
||||
FieldSpec(
|
||||
path=path,
|
||||
section=prefix.rstrip("."),
|
||||
name=name,
|
||||
type=kind,
|
||||
value=SECRET_PLACEHOLDER if (secret and value) else (None if secret else value),
|
||||
writable=path not in NON_WRITABLE,
|
||||
restart=requires_restart(path),
|
||||
secret=secret,
|
||||
overridden=path in overridden,
|
||||
description=DESCRIPTIONS.get(path, ""),
|
||||
choices=[m for m in choices if path != "mode" or m in DASHBOARD_MODES] if choices else [],
|
||||
constraints=_constraints(info),
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
class ConfigStore:
|
||||
"""Hält Basiskonfiguration, Overlay und die daraus gebaute wirksame Konfiguration."""
|
||||
|
||||
def __init__(self, config_path: str | Path, base_raw: dict[str, Any], config: Config) -> None:
|
||||
self.config_path = Path(config_path)
|
||||
self._base_raw = base_raw
|
||||
self.config = config
|
||||
self.overrides: dict[str, Any] = {}
|
||||
self.pending_restart: set[str] = set()
|
||||
|
||||
# ------------------------------------------------------------------ Laden
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path: str | Path) -> ConfigStore:
|
||||
path = Path(config_path)
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {path}")
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{path}: erwartet wurde ein YAML-Mapping auf oberster Ebene")
|
||||
base_raw = _apply_env_overrides(_substitute_env(raw))
|
||||
base_config = Config.model_validate(base_raw)
|
||||
|
||||
store = cls(path, base_raw, base_config)
|
||||
store.overrides = store._read_overrides(base_config.storage.overrides_path)
|
||||
if store.overrides:
|
||||
try:
|
||||
store.config = Config.model_validate(deep_merge(base_raw, store.overrides))
|
||||
log.info(
|
||||
"%d gespeicherte Dashboard-Änderung(en) übernommen aus %s",
|
||||
len(flatten(store.overrides)), base_config.storage.overrides_path,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
log.error(
|
||||
"Gespeicherte Dashboard-Änderungen sind ungültig und werden ignoriert: %s", exc
|
||||
)
|
||||
store.overrides = {}
|
||||
return store
|
||||
|
||||
@staticmethod
|
||||
def _read_overrides(path: str | Path) -> dict[str, Any]:
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, yaml.YAMLError) as exc:
|
||||
log.error("Overlay %s nicht lesbar (%s) – wird ignoriert", p, exc)
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
log.error("Overlay %s hat kein Mapping auf oberster Ebene – wird ignoriert", p)
|
||||
return {}
|
||||
return data
|
||||
|
||||
def _write_overrides(self) -> None:
|
||||
path = Path(self.config.storage.overrides_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
header = (
|
||||
"# Über das TradeMind-Dashboard geänderte Werte.\n"
|
||||
"# Wird beim Start über config.yaml gelegt. Datei löschen = alles zurücksetzen.\n"
|
||||
)
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
tmp.write_text(
|
||||
header + yaml.safe_dump(self.overrides, allow_unicode=True, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
tmp.replace(path)
|
||||
|
||||
# ------------------------------------------------------------- Beschreiben
|
||||
|
||||
def describe(self) -> dict[str, Any]:
|
||||
overridden = set(flatten(self.overrides))
|
||||
specs = describe_model(self.config, overridden=overridden)
|
||||
sections: dict[str, list[dict[str, Any]]] = {}
|
||||
for spec in specs:
|
||||
sections.setdefault(spec.section, []).append(spec.as_dict())
|
||||
return {
|
||||
"config_path": str(self.config_path),
|
||||
"overrides_path": self.config.storage.overrides_path,
|
||||
"override_count": len(overridden),
|
||||
"pending_restart": sorted(self.pending_restart),
|
||||
"sections": [
|
||||
{"key": key, "title": SECTION_TITLES.get(key, key), "fields": fields}
|
||||
for key, fields in sections.items()
|
||||
],
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------- Ändern
|
||||
|
||||
def _reject_unwritable(self, flat_patch: dict[str, Any]) -> None:
|
||||
blocked = sorted(p for p in flat_patch if p in NON_WRITABLE)
|
||||
if blocked:
|
||||
raise ConfigError(
|
||||
"Diese Felder lassen sich nicht über das Dashboard ändern: "
|
||||
+ ", ".join(blocked)
|
||||
+ ". Zugangsdaten und der Live-Schalter bleiben der Konfigurationsdatei "
|
||||
"beziehungsweise der Umgebung vorbehalten."
|
||||
)
|
||||
mode = flat_patch.get("mode")
|
||||
if mode is not None and str(mode) not in DASHBOARD_MODES:
|
||||
raise ConfigError(
|
||||
f"Der Modus '{mode}' lässt sich nicht über das Dashboard setzen. "
|
||||
"Echtgeldhandel wird ausschließlich in der Konfigurationsdatei freigeschaltet."
|
||||
)
|
||||
|
||||
def apply(self, patch: dict[str, Any]) -> tuple[Config, list[str]]:
|
||||
"""Änderungen prüfen, persistieren und die neue Konfiguration liefern.
|
||||
|
||||
Gibt ``(neue_konfiguration, felder_die_einen_neustart_brauchen)`` zurück.
|
||||
"""
|
||||
flat_patch = flatten(patch)
|
||||
if not flat_patch:
|
||||
raise ConfigError("Keine Änderungen übermittelt")
|
||||
self._reject_unwritable(flat_patch)
|
||||
|
||||
candidate_overrides = deep_merge(self.overrides, unflatten(flat_patch))
|
||||
try:
|
||||
new_config = Config.model_validate(deep_merge(self._base_raw, candidate_overrides))
|
||||
except ValidationError as exc:
|
||||
raise ConfigError(_readable_errors(exc)) from None
|
||||
|
||||
# Nur echte Abweichungen als Overlay behalten – so bleibt die Datei schlank und
|
||||
# spätere Änderungen an config.yaml schlagen wieder durch.
|
||||
current_flat = flatten(self.config.model_dump(mode="json"))
|
||||
base_flat = flatten(Config.model_validate(self._base_raw).model_dump(mode="json"))
|
||||
new_flat = flatten(new_config.model_dump(mode="json"))
|
||||
cleaned = {
|
||||
p: v
|
||||
for p, v in flatten(candidate_overrides).items()
|
||||
if new_flat.get(p) != base_flat.get(p)
|
||||
}
|
||||
|
||||
changed = [p for p, v in new_flat.items() if current_flat.get(p) != v]
|
||||
restart_needed = sorted(p for p in changed if requires_restart(p))
|
||||
|
||||
self.overrides = unflatten(cleaned)
|
||||
self.config = new_config
|
||||
self.pending_restart.update(restart_needed)
|
||||
self._write_overrides()
|
||||
log.info(
|
||||
"Konfiguration geändert: %s%s",
|
||||
", ".join(changed) or "(keine Abweichung)",
|
||||
f" – Neustart nötig für: {', '.join(restart_needed)}" if restart_needed else "",
|
||||
)
|
||||
return new_config, restart_needed
|
||||
|
||||
def reset(self, paths: list[str] | None = None) -> Config:
|
||||
"""Overlay ganz oder für einzelne Felder verwerfen."""
|
||||
if paths:
|
||||
flat = flatten(self.overrides)
|
||||
for path in paths:
|
||||
flat.pop(path, None)
|
||||
self.overrides = unflatten(flat)
|
||||
else:
|
||||
self.overrides = {}
|
||||
self.config = Config.model_validate(deep_merge(self._base_raw, self.overrides))
|
||||
self._write_overrides()
|
||||
log.info("Konfiguration zurückgesetzt (%s)", ", ".join(paths) if paths else "alle Felder")
|
||||
return self.config
|
||||
|
||||
|
||||
def _readable_errors(exc: ValidationError) -> str:
|
||||
parts = []
|
||||
for error in exc.errors():
|
||||
location = ".".join(str(p) for p in error["loc"]) or "(Wurzel)"
|
||||
parts.append(f"{location}: {error['msg']}")
|
||||
return "Ungültige Werte – " + "; ".join(parts)
|
||||
+76
-3
@@ -18,6 +18,7 @@ import numpy as np
|
||||
|
||||
from .broker import Broker, InsufficientFunds, OrderRejected, PaperBroker
|
||||
from .config import Config
|
||||
from .configstore import ConfigError, ConfigStore
|
||||
from .data import DataFeed
|
||||
from .features import FEATURE_NAMES, FeatureSnapshot, build_feature_matrix, required_bars
|
||||
from .models import Action, Candles, ExitReason, Position, Side, Signal
|
||||
@@ -25,7 +26,7 @@ from .notify import Notifier
|
||||
from .portfolio import Portfolio
|
||||
from .risk import RiskManager
|
||||
from .storage import NullStorage, Storage
|
||||
from .strategy import AdaptiveStrategy, Strategy
|
||||
from .strategy import AdaptiveStrategy, RuleStrategy, Strategy
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -149,8 +150,10 @@ class TradingEngine:
|
||||
risk: RiskManager,
|
||||
storage: Storage | NullStorage,
|
||||
notifier: Notifier | None = None,
|
||||
config_store: ConfigStore | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.config_store = config_store
|
||||
self.broker = broker
|
||||
self.feed = feed
|
||||
self.strategy = strategy
|
||||
@@ -316,6 +319,42 @@ class TradingEngine:
|
||||
log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested)
|
||||
return {"accepted": True, "reason": "", **self.training.as_dict()}
|
||||
|
||||
def apply_config(self, new_config: Config) -> list[str]:
|
||||
"""Geänderte Konfiguration im laufenden Betrieb übernehmen.
|
||||
|
||||
Die Laufzeitobjekte halten Referenzen auf die Teilkonfigurationen; sie werden hier
|
||||
umgehängt. Was nur beim Aufbau ausgewertet wird (Börsenclient, Broker-Startkapital,
|
||||
Datenbank, Socket), lässt sich so nicht ändern – das meldet der ConfigStore als
|
||||
neustartpflichtig.
|
||||
"""
|
||||
previous_level = self.config.log_level
|
||||
self.config = new_config
|
||||
self.risk.config = new_config.risk
|
||||
if isinstance(self.strategy, AdaptiveStrategy):
|
||||
self.strategy.config = new_config.strategy
|
||||
self.strategy.rules.config = new_config.strategy.rules
|
||||
elif isinstance(self.strategy, RuleStrategy):
|
||||
self.strategy.config = new_config.strategy.rules
|
||||
learner = self.learner
|
||||
if learner is not None:
|
||||
learner.config = new_config.strategy.learner
|
||||
if isinstance(self.broker, PaperBroker):
|
||||
# Gebühren, Slippage und Volumengrenze werden je Order gelesen.
|
||||
self.broker.config = new_config.paper.model_copy(
|
||||
update={"quote_currency": self.broker.quote_currency,
|
||||
"starting_balance": self.broker.starting_balance}
|
||||
)
|
||||
if self.notifier is not None:
|
||||
self.notifier.config = new_config.notifications
|
||||
|
||||
if new_config.log_level != previous_level:
|
||||
logging.getLogger().setLevel(new_config.log_level)
|
||||
log.info("Log-Level auf %s gesetzt", new_config.log_level)
|
||||
|
||||
applied = ["risk", "strategy", "trading", "notifications", "market.poll_interval_seconds"]
|
||||
log.info("Konfigurationsänderung übernommen (%s)", ", ".join(applied))
|
||||
return applied
|
||||
|
||||
def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]:
|
||||
"""Automatisierten Handel starten oder pausieren.
|
||||
|
||||
@@ -392,6 +431,37 @@ class TradingEngine:
|
||||
learner = self.learner
|
||||
return bool(learner is not None and not learner.frozen)
|
||||
|
||||
# ------------------------------------------------- Konfiguration (Dashboard)
|
||||
|
||||
def config_state(self) -> dict[str, Any]:
|
||||
if self.config_store is None:
|
||||
return {"available": False, "sections": [], "pending_restart": []}
|
||||
return {"available": self.config.server.enable_control, **self.config_store.describe()}
|
||||
|
||||
def update_config(self, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Konfigurationsänderung prüfen, sichern und – soweit möglich – sofort übernehmen."""
|
||||
if not self.config.server.enable_control or self.config_store is None:
|
||||
return {"accepted": False, "reason": "Steuerung ist deaktiviert"}
|
||||
try:
|
||||
new_config, restart_needed = self.config_store.apply(patch)
|
||||
except ConfigError as exc:
|
||||
return {"accepted": False, "reason": str(exc)}
|
||||
applied = self.apply_config(new_config)
|
||||
return {
|
||||
"accepted": True,
|
||||
"reason": "",
|
||||
"applied": applied,
|
||||
"restart_required": restart_needed,
|
||||
**self.config_state(),
|
||||
}
|
||||
|
||||
def reset_config(self, paths: list[str] | None = None) -> dict[str, Any]:
|
||||
if not self.config.server.enable_control or self.config_store is None:
|
||||
return {"accepted": False, "reason": "Steuerung ist deaktiviert"}
|
||||
new_config = self.config_store.reset(paths)
|
||||
self.apply_config(new_config)
|
||||
return {"accepted": True, "reason": "", **self.config_state()}
|
||||
|
||||
def training_status(self) -> dict[str, Any]:
|
||||
learner = self.learner
|
||||
return {
|
||||
@@ -423,11 +493,14 @@ class TradingEngine:
|
||||
"""Endlosschleife für Paper- und Live-Betrieb."""
|
||||
self._stop_event = asyncio.Event()
|
||||
self.running = True
|
||||
interval = self.config.market.poll_interval_seconds
|
||||
history = max(self.config.market.history_bars, required_bars(self.config.strategy.rules) + 10)
|
||||
|
||||
while self.running:
|
||||
cycle_start = time.monotonic()
|
||||
# Je Durchlauf frisch gelesen, damit Änderungen aus dem Dashboard sofort greifen.
|
||||
interval = self.config.market.poll_interval_seconds
|
||||
history = max(
|
||||
self.config.market.history_bars, required_bars(self.config.strategy.rules) + 10
|
||||
)
|
||||
try:
|
||||
await self._tick(history)
|
||||
self.iterations += 1
|
||||
|
||||
@@ -31,6 +31,12 @@ class Controller(Protocol):
|
||||
|
||||
def trading_control_status(self) -> dict[str, Any]: ...
|
||||
|
||||
def config_state(self) -> dict[str, Any]: ...
|
||||
|
||||
def update_config(self, patch: dict[str, Any]) -> dict[str, Any]: ...
|
||||
|
||||
def reset_config(self, paths: list[str] | None = None) -> dict[str, Any]: ...
|
||||
|
||||
# Roh-String: Escape-Sequenzen wie \n gehören dem eingebetteten JavaScript, nicht Python.
|
||||
# Ohne das r wird aus \n ein echter Zeilenumbruch mitten im JS-String-Literal – das Skript
|
||||
# lässt sich dann nicht mehr parsen und das Dashboard bleibt bei "lädt …" stehen.
|
||||
@@ -73,6 +79,15 @@ _DASHBOARD = r"""<!doctype html>
|
||||
.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; }
|
||||
select { font: inherit; padding: 6px 9px; border-radius: 7px; border: 1px solid var(--line);
|
||||
background: var(--bg); color: var(--fg); }
|
||||
table.cfg td { text-align: left; vertical-align: top; padding: 5px 8px; }
|
||||
table.cfg td:last-child { text-align: right; white-space: nowrap; }
|
||||
table.cfg code { font-size: 12px; }
|
||||
table.cfg input, table.cfg select { min-width: 12em; }
|
||||
.tag { font-size: 11px; padding: 1px 6px; border-radius: 999px; border: 1px solid var(--line);
|
||||
color: var(--muted); margin-left: 6px; white-space: nowrap; }
|
||||
.tag.warn { border-color: #e0a100; color: #b07d00; }
|
||||
</style>
|
||||
<h1>TradeMind</h1>
|
||||
<div class="sub" id="sub">lädt …</div>
|
||||
@@ -115,6 +130,20 @@ _DASHBOARD = r"""<!doctype html>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="config-section" hidden>
|
||||
<h2>Konfiguration <button id="cfg-toggle" class="ghost" style="font-size:12px;padding:3px 9px">anzeigen</button></h2>
|
||||
<div class="panel" id="cfg-panel" hidden>
|
||||
<div class="hint" id="cfg-meta"></div>
|
||||
<div id="cfg-sections"></div>
|
||||
<div class="row" style="margin-top:14px; border-top:1px solid var(--line); padding-top:14px">
|
||||
<button id="cfg-save">Änderungen speichern</button>
|
||||
<button id="cfg-revert" class="ghost">Eingaben verwerfen</button>
|
||||
<button id="cfg-reset" class="ghost">Alle Overlays zurücksetzen</button>
|
||||
<span class="hint" id="cfg-hint" style="margin:0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
|
||||
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
|
||||
<script>
|
||||
@@ -283,6 +312,115 @@ $("token-btn").addEventListener("click", () => {
|
||||
refresh();
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------- Konfiguration
|
||||
let cfgOpen = false, cfgLoaded = null;
|
||||
|
||||
function fieldId(path) { return "cfg__" + path.replace(/\./g, "__"); }
|
||||
|
||||
function fieldRow(f) {
|
||||
const id = fieldId(f.path);
|
||||
const badges = [];
|
||||
if (f.restart) badges.push('<span class="tag warn">Neustart nötig</span>');
|
||||
if (f.overridden) badges.push('<span class="tag">geändert</span>');
|
||||
if (!f.writable) badges.push('<span class="tag">nur Datei</span>');
|
||||
|
||||
let input;
|
||||
const off = f.writable ? "" : " disabled";
|
||||
if (f.secret) {
|
||||
input = `<input type="text" value="${f.value ? "gesetzt" : "nicht gesetzt"}" disabled>`;
|
||||
} else if (f.type === "bool") {
|
||||
input = `<select id="${id}"${off}><option value="true"${f.value ? " selected" : ""}>ja</option>`
|
||||
+ `<option value="false"${f.value ? "" : " selected"}>nein</option></select>`;
|
||||
} else if (f.type === "enum") {
|
||||
input = `<select id="${id}"${off}>`
|
||||
+ f.choices.map(c => `<option${c === f.value ? " selected" : ""}>${c}</option>`).join("")
|
||||
+ "</select>";
|
||||
} else if (f.type === "int" || f.type === "float") {
|
||||
const c = f.constraints || {};
|
||||
const step = f.type === "int" ? "1" : "any";
|
||||
const min = c.min !== undefined ? ` min="${c.min}"` : "";
|
||||
const max = c.max !== undefined ? ` max="${c.max}"` : "";
|
||||
input = `<input type="number" id="${id}" step="${step}"${min}${max} value="${f.value ?? ""}"${off}>`;
|
||||
} else if (f.type === "list") {
|
||||
input = `<input type="text" id="${id}" value="${(f.value || []).join(", ")}"${off}>`;
|
||||
} else if (f.type === "json") {
|
||||
input = `<input type="text" id="${id}" value="${JSON.stringify(f.value ?? {})}"${off}>`;
|
||||
} else {
|
||||
input = `<input type="text" id="${id}" value="${f.value ?? ""}"${off}>`;
|
||||
}
|
||||
return `<tr><td><code>${f.path}</code>${badges.join(" ")}<div class="hint" style="margin:0">`
|
||||
+ `${f.description || ""}</div></td><td>${input}</td></tr>`;
|
||||
}
|
||||
|
||||
function renderConfig(cfg) {
|
||||
if (!cfg.available) return;
|
||||
$("config-section").hidden = false;
|
||||
cfgLoaded = cfg;
|
||||
$("cfg-meta").textContent =
|
||||
`${cfg.config_path} · ${cfg.override_count} geänderte(s) Feld(er) in ${cfg.overrides_path}`
|
||||
+ (cfg.pending_restart.length ? ` · Neustart ausstehend für: ${cfg.pending_restart.join(", ")}` : "");
|
||||
$("cfg-sections").innerHTML = cfg.sections.map(s =>
|
||||
`<h3 style="font-size:13px;margin:16px 0 4px;color:var(--muted)">${s.title}</h3>`
|
||||
+ `<div class="overflow"><table class="cfg">${s.fields.map(fieldRow).join("")}</table></div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
function collectChanges() {
|
||||
const out = {};
|
||||
for (const s of cfgLoaded.sections) for (const f of s.fields) {
|
||||
if (!f.writable || f.secret) continue;
|
||||
const el = $(fieldId(f.path));
|
||||
if (!el) continue;
|
||||
let value = el.value;
|
||||
if (f.type === "bool") value = value === "true";
|
||||
else if (f.type === "int") value = value === "" ? null : parseInt(value, 10);
|
||||
else if (f.type === "float") value = value === "" ? null : parseFloat(value);
|
||||
else if (f.type === "list") value = value.split(",").map(v => v.trim()).filter(Boolean);
|
||||
else if (f.type === "json") { try { value = JSON.parse(value); } catch { continue; } }
|
||||
else if (value === "") value = null;
|
||||
if (JSON.stringify(value) !== JSON.stringify(f.value)) out[f.path] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function loadConfig() {
|
||||
try { renderConfig(await (await fetch("control/config", { headers: headers() })).json()); }
|
||||
catch (e) { setHint($("cfg-hint"), "Konfiguration nicht lesbar: " + e.message, true); }
|
||||
}
|
||||
|
||||
$("cfg-toggle").addEventListener("click", async () => {
|
||||
cfgOpen = !cfgOpen;
|
||||
$("cfg-panel").hidden = !cfgOpen;
|
||||
$("cfg-toggle").textContent = cfgOpen ? "ausblenden" : "anzeigen";
|
||||
if (cfgOpen) await loadConfig();
|
||||
});
|
||||
|
||||
$("cfg-save").addEventListener("click", async () => {
|
||||
const changes = collectChanges();
|
||||
if (!Object.keys(changes).length) { setHint($("cfg-hint"), "Nichts geändert."); return; }
|
||||
$("cfg-save").disabled = true;
|
||||
try {
|
||||
const r = await post("control/config", changes);
|
||||
const n = Object.keys(changes).length;
|
||||
setHint($("cfg-hint"), `${n} Feld(er) gespeichert.`
|
||||
+ (r.restart_required && r.restart_required.length
|
||||
? ` Neustart nötig für: ${r.restart_required.join(", ")}` : " Sofort wirksam."));
|
||||
renderConfig(r);
|
||||
} catch (e) { setHint($("cfg-hint"), e.message, true); }
|
||||
$("cfg-save").disabled = false;
|
||||
});
|
||||
|
||||
$("cfg-revert").addEventListener("click", () => { renderConfig(cfgLoaded); setHint($("cfg-hint"), ""); });
|
||||
|
||||
$("cfg-reset").addEventListener("click", async () => {
|
||||
if (!confirm("Alle im Dashboard geänderten Werte verwerfen und zur Konfigurationsdatei "
|
||||
+ "zurückkehren?")) return;
|
||||
try {
|
||||
renderConfig(await post("control/config/reset", {}));
|
||||
setHint($("cfg-hint"), "Zurückgesetzt.");
|
||||
} catch (e) { setHint($("cfg-hint"), e.message, true); }
|
||||
});
|
||||
|
||||
refresh(); setInterval(refresh, 5000);
|
||||
</script>
|
||||
"""
|
||||
@@ -340,6 +478,9 @@ class StatusServer:
|
||||
web.post("/control/train/live", self._train_live),
|
||||
web.get("/control/trading", self._trading_state),
|
||||
web.post("/control/trading", self._set_trading),
|
||||
web.get("/control/config", self._config_state),
|
||||
web.post("/control/config", self._update_config),
|
||||
web.post("/control/config/reset", self._reset_config),
|
||||
]
|
||||
)
|
||||
return app
|
||||
@@ -479,6 +620,53 @@ class StatusServer:
|
||||
status = 428 if result.get("requires_confirmation") and payload["enabled"] else 409
|
||||
return web.json_response(result, status=status, dumps=_dumps)
|
||||
|
||||
async def _config_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.config_state(), dumps=_dumps)
|
||||
|
||||
async def _update_config(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):
|
||||
return web.json_response({"error": "Objekt mit Feldpfaden erwartet"}, status=400)
|
||||
|
||||
# Sowohl {"risk.max_open_positions": 5} als auch {"risk": {"max_open_positions": 5}}.
|
||||
patch = payload.get("values") if isinstance(payload.get("values"), dict) else payload
|
||||
result = self._controller.update_config(patch)
|
||||
return web.json_response(result, status=200 if result.get("accepted") else 400, dumps=_dumps)
|
||||
|
||||
async def _reset_config(self, request: web.Request) -> web.Response:
|
||||
denied = await self._guard(request)
|
||||
if denied is not None:
|
||||
return denied
|
||||
assert self._controller is not None
|
||||
|
||||
paths: list[str] | None = None
|
||||
if request.can_read_body:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Ungültiges JSON"}, status=400)
|
||||
if isinstance(payload, dict) and payload.get("paths") is not None:
|
||||
raw = payload["paths"]
|
||||
if not isinstance(raw, list) or not all(isinstance(p, str) for p in raw):
|
||||
return web.json_response({"error": "'paths' muss eine Liste von Texten sein"},
|
||||
status=400)
|
||||
paths = raw
|
||||
|
||||
result = self._controller.reset_config(paths)
|
||||
return web.json_response(result, status=200 if result.get("accepted") else 409, 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