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
+52
View File
@@ -386,6 +386,55 @@ die Shadow-Labels aus dem Marktgeschehen laufen unverändert weiter.
> könnte jeder im Netz echten Handel starten. Mit Token oder auf `127.0.0.1` steht sie
> wieder zur Verfügung.
### Konfiguration
Der Abschnitt **Konfiguration** zeigt alle 76 Felder, nach Bereichen gruppiert, mit
Kurzbeschreibung und den Grenzen aus dem Schema. 70 davon sind direkt änderbar.
Weil `config.yaml` im Container read-only eingehängt ist, landen Änderungen als Overlay in
`/data/config.overrides.yaml` und werden beim Start über die Basiskonfiguration gelegt.
Die Rangfolge, von schwach nach stark:
1. `config.yaml`
2. `${ENV}`-Platzhalter darin
3. `TRADEMIND__ABSCHNITT__SCHLUESSEL`-Umgebungsvariablen
4. Overlay aus dem Dashboard
Gespeichert wird nur, was vom Basiswert abweicht — stellst du ein Feld auf seinen
Ausgangswert zurück, verschwindet es wieder aus dem Overlay und spätere Änderungen an
`config.yaml` schlagen dort wieder durch. Einzelne Felder oder alles auf einmal lassen sich
über die Knöpfe zurücksetzen; die Overlay-Datei zu löschen hat denselben Effekt.
Jedes Feld ist markiert, wenn es **einen Neustart braucht** (31 Felder — Börsenclient,
Symbole, Timeframe, Datenbank, Socket und alles andere, was nur beim Aufbau ausgewertet
wird). Der Rest greift sofort: Risikoregeln, Strategie-Parameter, Lernmodell, Gebühren und
Slippage der Simulation, Abfrageintervall, Benachrichtigungen, Log-Level.
Ungültige Eingaben werden abgelehnt, bevor irgendetwas übernommen wird — inklusive
feldübergreifender Regeln:
```
HTTP 400 — Ungültige Werte strategy.rules: strategy.rules.fast_ema muss kleiner als slow_ema sein
```
> **Drei Felder sind bewusst ausgenommen** und bleiben der Konfigurationsdatei
> beziehungsweise der Umgebung vorbehalten:
>
> | Feld | Grund |
> |---|---|
> | `exchange.api_key` / `api_secret` / `password` / `uid` | Werden nie ausgeliefert (maskiert als „gesetzt"/„nicht gesetzt") und nicht entgegengenommen. Sonst könnte jeder mit Zugriff auf den Port die Börsenschlüssel auslesen oder austauschen. |
> | `mode: live` | Wechsel zwischen `paper` und `backtest` geht; auf Echtgeld umstellen nicht. |
> | `live_confirmation` | Sonst wäre die Live-Freigabe aus dem Netz setzbar. |
>
> Zusammen verhindert das, dass jemand über das Dashboard schrittweise auf Echtgeldhandel
> umstellt.
Per HTTP:
```bash
curl -X POST localhost:8080/control/config -H 'Content-Type: application/json' -H "X-TradeMind-Token: $TOKEN" -d '{"risk.max_open_positions": 5, "strategy.learner.entry_threshold": 0.6}'
```
### Training
Im Abschnitt **Training** stehen zwei Bedienelemente:
@@ -414,6 +463,9 @@ curl -X POST localhost:8080/control/train/history -H 'Content-Type: application/
| Endpunkt | Methode | Wirkung |
|--------------------------------|---------|--------------------------------------------|
| `/control/config` | GET | Alle Felder mit Wert, Typ, Grenzen und Markierungen |
| `/control/config` | POST | `{"risk.max_open_positions": 5}` — ändern und sichern |
| `/control/config/reset` | POST | `{}` oder `{"paths": [...]}` — Overlay verwerfen |
| `/control/trading` | GET | Zustand des automatisierten Handels |
| `/control/trading` | POST | `{"enabled": true}` — Handel starten/pausieren (live zusätzlich `"confirm"`) |
| `/control/training` | GET | Zustand des letzten/laufenden Trainings |
+3
View File
@@ -122,6 +122,9 @@ strategy:
# ───────────────────────────────── Persistenz ───────────────────────────────
storage:
database_path: /data/trademind.sqlite3
# Im Dashboard geänderte Werte. Liegt im beschreibbaren Datenvolume, weil diese Datei
# hier üblicherweise read-only eingehängt ist. Löschen = alle Änderungen zurücksetzen.
overrides_path: /data/config.overrides.yaml
# ─────────────────────────── Status-Server / Monitoring ─────────────────────
server:
+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]] = []
+295
View File
@@ -0,0 +1,295 @@
"""Konfiguration zur Laufzeit ändern: Overlay, Validierung, Schema, Schutzregeln."""
from __future__ import annotations
import pytest
import yaml
from trademind.config import Config, Mode, is_secret, requires_restart
from trademind.configstore import (
NON_WRITABLE,
SECRET_PLACEHOLDER,
ConfigError,
ConfigStore,
deep_merge,
describe_model,
flatten,
unflatten,
)
BASE_YAML = """
mode: paper
market:
symbols: [BTC/USDT]
risk:
max_open_positions: 3
"""
@pytest.fixture
def store(tmp_path) -> ConfigStore:
config_file = tmp_path / "config.yaml"
config_file.write_text(
BASE_YAML + f"storage:\n overrides_path: {tmp_path / 'overrides.yaml'}\n", encoding="utf-8"
)
return ConfigStore.load(config_file)
# ------------------------------------------------------------------ Hilfsteile
def test_flatten_round_trip():
nested = {"risk": {"max_open_positions": 3, "inner": {"a": 1}}, "mode": "paper"}
flat = flatten(nested)
assert flat == {"risk.max_open_positions": 3, "risk.inner.a": 1, "mode": "paper"}
assert unflatten(flat) == nested
def test_deep_merge_keeps_untouched_branches():
base = {"risk": {"a": 1, "b": 2}, "market": {"symbols": ["X"]}}
merged = deep_merge(base, {"risk": {"b": 99}})
assert merged == {"risk": {"a": 1, "b": 99}, "market": {"symbols": ["X"]}}
assert base["risk"]["b"] == 2, "Original darf nicht verändert werden"
# ------------------------------------------------------------------- Schema
def test_describe_covers_every_leaf_field():
specs = describe_model(Config())
paths = {s.path for s in specs}
for expected in (
"mode", "log_level", "exchange.id", "market.timeframe", "trading.autostart",
"paper.fee_rate", "risk.max_open_positions", "strategy.name",
"strategy.rules.fast_ema", "strategy.learner.entry_threshold",
"storage.database_path", "server.port", "notifications.notify_on_trade",
"backtest.bars",
):
assert expected in paths, f"{expected} fehlt in der Beschreibung"
# Verschachtelte Modelle müssen aufgelöst sein; ein echtes Dict-Feld (exchange.options)
# ist dagegen ein Blatt und wird als Typ "json" ausgeliefert.
dict_valued = [s.path for s in specs if isinstance(s.value, dict) and s.type != "json"]
assert not dict_valued, f"Unaufgelöste Teilmodelle: {dict_valued}"
def test_field_types_are_detected():
by_path = {s.path: s for s in describe_model(Config())}
assert by_path["risk.max_open_positions"].type == "int"
assert by_path["paper.fee_rate"].type == "float"
assert by_path["exchange.sandbox"].type == "bool"
assert by_path["market.symbols"].type == "list"
assert by_path["mode"].type == "enum"
assert by_path["exchange.id"].type == "str"
assert by_path["exchange.options"].type == "json"
def test_constraints_are_exposed():
by_path = {s.path: s for s in describe_model(Config())}
assert by_path["risk.max_position_pct"].constraints["max"] == 1.0
assert by_path["server.port"].constraints["min"] == 1
assert by_path["risk.max_open_positions"].constraints["min"] == 1
def test_mode_choices_exclude_live():
by_path = {s.path: s for s in describe_model(Config())}
assert set(by_path["mode"].choices) == {"paper", "backtest"}
def test_restart_and_secret_flags():
by_path = {s.path: s for s in describe_model(Config())}
assert by_path["exchange.id"].restart is True
assert by_path["market.symbols"].restart is True
assert by_path["risk.max_open_positions"].restart is False
assert by_path["strategy.rules.fast_ema"].restart is False
assert by_path["exchange.api_key"].secret is True
assert by_path["exchange.api_key"].writable is False
def test_secret_values_never_leave_the_process():
config = Config.model_validate(
{"exchange": {"api_key": "geheim-123", "api_secret": "auch-geheim"}}
)
for spec in describe_model(config):
if is_secret(spec.path):
assert spec.value in (SECRET_PLACEHOLDER, None)
assert "geheim" not in str(spec.value), f"{spec.path} verrät ein Geheimnis"
def test_describe_marks_overridden_fields(store):
store.apply({"risk.max_open_positions": 5})
by_path = {f["path"]: f for s in store.describe()["sections"] for f in s["fields"]}
assert by_path["risk.max_open_positions"]["overridden"] is True
assert by_path["risk.min_notional"]["overridden"] is False
# ------------------------------------------------------------------- Ändern
def test_change_is_applied_and_persisted(store, tmp_path):
config, restart = store.apply({"risk.max_open_positions": 7})
assert config.risk.max_open_positions == 7
assert restart == []
saved = yaml.safe_load((tmp_path / "overrides.yaml").read_text(encoding="utf-8"))
assert saved == {"risk": {"max_open_positions": 7}}
def test_change_survives_a_reload(store, tmp_path):
store.apply({"risk.max_open_positions": 9, "strategy.learner.entry_threshold": 0.8})
revived = ConfigStore.load(tmp_path / "config.yaml")
assert revived.config.risk.max_open_positions == 9
assert revived.config.strategy.learner.entry_threshold == 0.8
def test_nested_patch_form_is_accepted(store):
config, _ = store.apply({"risk": {"max_open_positions": 4}})
assert config.risk.max_open_positions == 4
def test_restart_required_fields_are_reported(store):
_, restart = store.apply({"market.timeframe": "15m", "risk.max_open_positions": 2})
assert restart == ["market.timeframe"]
assert "market.timeframe" in store.pending_restart
def test_only_real_deviations_are_stored(store, tmp_path):
"""Ein auf den Ausgangswert zurückgesetztes Feld darf kein Overlay hinterlassen."""
store.apply({"risk.max_open_positions": 7})
store.apply({"risk.max_open_positions": 3}) # 3 steht so in der Basisdatei
saved = yaml.safe_load((tmp_path / "overrides.yaml").read_text(encoding="utf-8")) or {}
assert flatten(saved) == {}
def test_invalid_value_is_rejected_with_a_readable_message(store):
with pytest.raises(ConfigError, match="risk.max_position_pct"):
store.apply({"risk.max_position_pct": 5.0})
assert store.config.risk.max_position_pct == 0.2, "Alter Wert muss erhalten bleiben"
def test_cross_field_rule_is_enforced(store):
with pytest.raises(ConfigError, match="fast_ema"):
store.apply({"strategy.rules.fast_ema": 50})
assert store.config.strategy.rules.fast_ema == 12
def test_unknown_field_is_rejected(store):
with pytest.raises(ConfigError):
store.apply({"risk.gibtsnicht": 1})
def test_empty_patch_is_rejected(store):
with pytest.raises(ConfigError, match="Keine Änderungen"):
store.apply({})
# --------------------------------------------------------------- Schutzregeln
@pytest.mark.parametrize("path", sorted(NON_WRITABLE))
def test_protected_fields_cannot_be_written(store, path):
with pytest.raises(ConfigError, match="lassen sich nicht"):
store.apply({path: "beliebig"})
def test_credentials_cannot_be_set_through_the_store(store):
with pytest.raises(ConfigError):
store.apply({"exchange.api_secret": "gestohlen"})
assert store.config.exchange.api_secret is None
def test_switching_to_live_is_refused(store):
with pytest.raises(ConfigError, match="Echtgeldhandel"):
store.apply({"mode": "live"})
assert store.config.mode is Mode.PAPER
def test_switching_between_safe_modes_is_allowed(store):
config, restart = store.apply({"mode": "backtest"})
assert config.mode is Mode.BACKTEST
assert restart == ["mode"]
# ------------------------------------------------------------- Zurücksetzen
def test_reset_all(store):
store.apply({"risk.max_open_positions": 8, "risk.min_notional": 50.0})
config = store.reset()
assert config.risk.max_open_positions == 3
assert config.risk.min_notional == 10.0
assert store.overrides == {}
def test_reset_single_field(store):
store.apply({"risk.max_open_positions": 8, "risk.min_notional": 50.0})
config = store.reset(["risk.max_open_positions"])
assert config.risk.max_open_positions == 3
assert config.risk.min_notional == 50.0
# --------------------------------------------------------------- Robustheit
def test_broken_overlay_is_ignored(tmp_path):
config_file = tmp_path / "config.yaml"
overrides = tmp_path / "overrides.yaml"
config_file.write_text(BASE_YAML + f"storage:\n overrides_path: {overrides}\n", encoding="utf-8")
overrides.write_text("das ist: [kein gueltiges: yaml", encoding="utf-8")
store = ConfigStore.load(config_file)
assert store.config.risk.max_open_positions == 3
assert store.overrides == {}
def test_overlay_with_invalid_values_is_ignored(tmp_path):
config_file = tmp_path / "config.yaml"
overrides = tmp_path / "overrides.yaml"
config_file.write_text(BASE_YAML + f"storage:\n overrides_path: {overrides}\n", encoding="utf-8")
overrides.write_text("risk:\n max_open_positions: -5\n", encoding="utf-8")
store = ConfigStore.load(config_file)
assert store.config.risk.max_open_positions == 3
assert store.overrides == {}
def test_missing_config_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
ConfigStore.load(tmp_path / "gibtsnicht.yaml")
def test_env_override_is_beaten_by_the_dashboard(tmp_path, monkeypatch):
monkeypatch.setenv("TRADEMIND__RISK__MAX_OPEN_POSITIONS", "6")
config_file = tmp_path / "config.yaml"
config_file.write_text(
BASE_YAML + f"storage:\n overrides_path: {tmp_path / 'o.yaml'}\n", encoding="utf-8"
)
store = ConfigStore.load(config_file)
assert store.config.risk.max_open_positions == 6
store.apply({"risk.max_open_positions": 2})
assert ConfigStore.load(config_file).config.risk.max_open_positions == 2
def test_every_restart_path_exists_in_the_model():
"""Schutz vor Tippfehlern in RESTART_REQUIRED."""
paths = {s.path for s in describe_model(Config())}
for path in paths:
requires_restart(path) # darf nicht werfen
from trademind.config import RESTART_REQUIRED
unknown = RESTART_REQUIRED - paths
assert not unknown, f"Unbekannte Pfade in RESTART_REQUIRED: {sorted(unknown)}"
def test_every_protected_path_exists_in_the_model():
paths = {s.path for s in describe_model(Config())}
unknown = NON_WRITABLE - paths
assert not unknown, f"Unbekannte Pfade in NON_WRITABLE: {sorted(unknown)}"
def test_every_description_matches_a_real_field():
from trademind.configstore import DESCRIPTIONS
paths = {s.path for s in describe_model(Config())}
unknown = set(DESCRIPTIONS) - paths
assert not unknown, f"Beschreibungen ohne Feld: {sorted(unknown)}"