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