9f10f9000e
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.
296 lines
11 KiB
Python
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)}"
|