Files
Trademind/tests/test_configstore.py
T
Tobias Zimmermann 9f10f9000e 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.
2026-08-23 13:25:33 +02:00

296 lines
11 KiB
Python

"""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)}"