From 9f10f9000e64abfde7a632a95f62a807bdef7a7f Mon Sep 17 00:00:00 2001 From: Tobias Zimmermann Date: Sun, 23 Aug 2026 13:25:33 +0200 Subject: [PATCH] =?UTF-8?q?Konfiguration=20im=20Dashboard=20=C3=A4nderbar?= =?UTF-8?q?=20machen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 52 ++++ config/config.example.yaml | 3 + src/trademind/app.py | 3 + src/trademind/cli.py | 13 +- src/trademind/config.py | 46 ++++ src/trademind/configstore.py | 458 +++++++++++++++++++++++++++++++++++ src/trademind/engine.py | 79 +++++- src/trademind/server.py | 188 ++++++++++++++ tests/test_configstore.py | 295 ++++++++++++++++++++++ 9 files changed, 1133 insertions(+), 4 deletions(-) create mode 100644 src/trademind/configstore.py create mode 100644 tests/test_configstore.py diff --git a/README.md b/README.md index ab82775..4bd159a 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/config/config.example.yaml b/config/config.example.yaml index 3822985..f7095ee 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -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: diff --git a/src/trademind/app.py b/src/trademind/app.py index 2758e85..9d98fcc 100644 --- a/src/trademind/app.py +++ b/src/trademind/app.py @@ -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 diff --git a/src/trademind/cli.py b/src/trademind/cli.py index 0a6b947..c749786 100644 --- a/src/trademind/cli.py +++ b/src/trademind/cli.py @@ -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() diff --git a/src/trademind/config.py b/src/trademind/config.py index c6e6948..d2c363a 100644 --- a/src/trademind/config.py +++ b/src/trademind/config.py @@ -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): diff --git a/src/trademind/configstore.py b/src/trademind/configstore.py new file mode 100644 index 0000000..79f1dec --- /dev/null +++ b/src/trademind/configstore.py @@ -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) diff --git a/src/trademind/engine.py b/src/trademind/engine.py index d04b287..940bb26 100644 --- a/src/trademind/engine.py +++ b/src/trademind/engine.py @@ -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 diff --git a/src/trademind/server.py b/src/trademind/server.py index e0cbcb5..855aa67 100644 --- a/src/trademind/server.py +++ b/src/trademind/server.py @@ -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""" .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; }

TradeMind

lädt …
@@ -115,6 +130,20 @@ _DASHBOARD = r""" + +

Offene Positionen

Letzte Trades

""" @@ -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]] = [] diff --git a/tests/test_configstore.py b/tests/test_configstore.py new file mode 100644 index 0000000..57fd649 --- /dev/null +++ b/tests/test_configstore.py @@ -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)}"