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:
Tobias Zimmermann
2026-08-23 13:25:33 +02:00
parent 4551339632
commit 9f10f9000e
9 changed files with 1133 additions and 4 deletions
+3
View File
@@ -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
View File
@@ -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()
+46
View File
@@ -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):
+458
View File
@@ -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
View File
@@ -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
+188
View File
@@ -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]] = []