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; }