"""配置加载与严格校验测试(对应 development-plan.md P3 验收:非法配置 100% 被拒)。""" from __future__ import annotations import copy import pytest import yaml from hdiv.core.config import ( config_hash, load_all, load_config, resolve_strategy_path, ) from hdiv.core.errors import ConfigError, ConfigNotFound from hdiv.core.paths import config_dir # --------------------------------------------------------------------------- # 正向:全部配置文件可加载 # --------------------------------------------------------------------------- @pytest.mark.parametrize( "name", ["datasource", "universe", "profile", "cost", "backtest", "report"] ) def test_all_configs_load(name: str) -> None: cfg = load_config(name) assert cfg is not None assert config_hash(cfg) def test_load_all() -> None: cfgs = load_all() assert set(cfgs) == {"datasource", "universe", "profile", "cost", "backtest", "report"} def test_strategy_config_loads() -> None: s = load_config("strategy:high_dividend_v1.yml") assert s.strategy.id == "HD_MR_V1" assert s.entry.yield_percentile == 75 assert s.exit.yield_percentile == 25 # 参数扁平化用于 hd_strategy_param pm = s.param_map() assert pm["strategy.id"] == "HD_MR_V1" assert pm["entry.yield_percentile"] == 75 assert len(pm) > 20 def test_strategy_path_resolution_variants() -> None: names = [ "high_dividend_v1.yml", "strategy/high_dividend_v1.yml", "config/strategy/high_dividend_v1.yml", ] paths = {resolve_strategy_path(n) for n in names} assert len(paths) == 1 assert paths.pop().is_file() def test_resolve_strategy_path_missing() -> None: with pytest.raises(ConfigNotFound): resolve_strategy_path("不存在.yml") # --------------------------------------------------------------------------- # 反向:拼写错误必须报错(不静默取默认值) # --------------------------------------------------------------------------- def _load_modified(name: str, mutate) -> None: """把配置改坏后写入临时文件并加载,断言抛错。""" raw = yaml.safe_load((config_dir() / f"{name}.yml").read_text(encoding="utf-8")) mutate(raw) import tempfile from pathlib import Path with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh: yaml.safe_dump(raw, fh, allow_unicode=True) tmp = Path(fh.name) try: with pytest.raises(ConfigError): load_config(name, path=tmp) finally: tmp.unlink(missing_ok=True) def test_unknown_field_rejected() -> None: _load_modified("universe", lambda r: r["market"].update({"min_market_capp": 1})) def test_wrong_type_rejected() -> None: _load_modified("universe", lambda r: r["market"].update({"min_listing_years": "十年"})) def test_unknown_top_level_section_rejected() -> None: _load_modified("universe", lambda r: r.update({"bogus_section": {"a": 1}})) def test_bad_enum_rejected() -> None: _load_modified("cost", lambda r: r["slippage"].update({"mode": "percentage"})) def test_dividend_years_consistency_rejected() -> None: def mutate(r: dict) -> None: r["dividend"]["min_continuous_years"] = 10 r["dividend"]["window_years"] = 6 _load_modified("universe", mutate) def test_negative_capital_rejected() -> None: _load_modified("backtest", lambda r: r["capital"].update({"initial": -1})) def test_period_order_rejected() -> None: _load_modified("backtest", lambda r: r["period"].update({"end": "2010-01-01"})) def test_freeze_params_disabled_rejected() -> None: """plan.md §25:测试阶段禁止重新调参 —— 关闭该开关必须被拒绝。""" _load_modified( "backtest", lambda r: r["walk_forward"].update({"freeze_params_in_test": False}) ) def test_duplicate_benchmark_rejected() -> None: def mutate(r: dict) -> None: r["benchmark"] = [ {"code": "000300.SH", "name": "沪深300"}, {"code": "000300.SH", "name": "重复"}, ] _load_modified("backtest", mutate) def test_profile_percentiles_must_be_sorted() -> None: _load_modified("profile", lambda r: r.update({"percentiles": [90, 10, 50]})) def test_profile_duplicate_metric_rejected() -> None: _load_modified( "profile", lambda r: r["metrics"].update({"valuation": ["pb", "pb"]}) ) def test_composite_weights_must_sum_to_one() -> None: def mutate(r: dict) -> None: r["safety_margin"]["mode"] = "composite" r["safety_margin"]["weights"] = {"dividend_yield": 0.5, "valuation": 0.2} _load_modified("profile", mutate) def test_datasource_readonly_writeoverlap_rejected() -> None: def mutate(r: dict) -> None: r["database"]["allow_write_tables"] = ["stock"] _load_modified("datasource", mutate) # --------------------------------------------------------------------------- # 策略语义校验 # --------------------------------------------------------------------------- def _load_strategy_mutated(mutate) -> None: raw = yaml.safe_load( (config_dir() / "strategy" / "high_dividend_v1.yml").read_text(encoding="utf-8") ) mutate(raw) import tempfile from pathlib import Path with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh: yaml.safe_dump(raw, fh, allow_unicode=True) tmp = Path(fh.name) try: with pytest.raises(ConfigError): load_config(f"strategy:{tmp}") finally: tmp.unlink(missing_ok=True) def test_strategy_entry_must_exceed_exit() -> None: _load_strategy_mutated(lambda r: r["entry"].update({"yield_percentile": 20})) def test_strategy_entry_percentile_range() -> None: _load_strategy_mutated(lambda r: r["entry"].update({"yield_percentile": 150})) def test_strategy_scale_in_must_be_ascending() -> None: def mutate(r: dict) -> None: r["entry"]["scale_in"] = [ {"percentile": 80, "weight": 0.25}, {"percentile": 75, "weight": 0.50}, ] _load_strategy_mutated(mutate) def test_strategy_scale_out_must_descend() -> None: def mutate(r: dict) -> None: r["exit"]["scale_out"] = [ {"percentile": 25, "weight": 0.50}, {"percentile": 50, "weight": 0.00}, ] _load_strategy_mutated(mutate) def test_strategy_scale_out_last_must_be_zero() -> None: def mutate(r: dict) -> None: r["exit"]["scale_out"] = [ {"percentile": 50, "weight": 0.50}, {"percentile": 25, "weight": 0.30}, ] _load_strategy_mutated(mutate) def test_strategy_position_bounds() -> None: _load_strategy_mutated(lambda r: r["position"].update({"max_position": 1.5})) def test_strategy_bad_status() -> None: _load_strategy_mutated(lambda r: r["strategy"].update({"status": "RUNNING"})) # --------------------------------------------------------------------------- # 实时画像闸门(profile_gate)配置校验 # # 这些必须挡在配置期:写错指标名若拖到运行时,只会表现为 # 「无法验证 → 保守不买」,即策略悄悄再也不交易,极难定位。 # --------------------------------------------------------------------------- def test_profile_gate_unknown_metric_rejected() -> None: def mutate(r: dict) -> None: r["entry"]["profile_gate"]["rules"] = [ {"metric": "not_a_metric", "op": ">=", "value": 1.0} ] _load_strategy_mutated(mutate) def test_profile_gate_percentile_on_scalar_metric_rejected() -> None: """标量指标没有历史分位,不能用 current_percentile。""" def mutate(r: dict) -> None: r["entry"]["profile_gate"]["rules"] = [ {"metric": "payout_ratio", "stat": "current_percentile", "op": "<=", "value": 1.0} ] _load_strategy_mutated(mutate) def test_profile_gate_enabled_without_rules_rejected() -> None: def mutate(r: dict) -> None: r["entry"]["profile_gate"] = {"enabled": True, "rules": []} _load_strategy_mutated(mutate) def test_profile_gate_bad_operator_rejected() -> None: def mutate(r: dict) -> None: r["entry"]["profile_gate"]["rules"] = [ {"metric": "dv_yield", "op": "~=", "value": 1.0} ] _load_strategy_mutated(mutate) def test_profile_gate_coverage_bounds() -> None: """min_window_coverage 必须落在 [0,1]:1.0 = 必须完整覆盖名义窗口。""" def mutate(r: dict) -> None: r["entry"]["profile_gate"]["min_window_coverage"] = 1.5 _load_strategy_mutated(mutate) def test_profile_gate_disabled_without_rules_is_allowed() -> None: """默认(未启用、无规则)必须能正常加载 —— 否则所有历史配置都会失效。""" raw = yaml.safe_load( (config_dir() / "strategy" / "high_dividend_v1.yml").read_text(encoding="utf-8") ) raw["entry"]["profile_gate"] = {"enabled": False, "rules": []} import tempfile from pathlib import Path with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh: yaml.safe_dump(raw, fh, allow_unicode=True) tmp = Path(fh.name) try: cfg = load_config(f"strategy:{tmp}") assert cfg.entry.profile_gate.enabled is False finally: tmp.unlink(missing_ok=True) # --------------------------------------------------------------------------- # 可复现性:config_hash 稳定性 # --------------------------------------------------------------------------- def test_config_hash_is_stable_and_sensitive() -> None: a = load_config("universe") b = load_config("universe") assert config_hash(a) == config_hash(b) raw = yaml.safe_load((config_dir() / "universe.yml").read_text(encoding="utf-8")) changed = copy.deepcopy(raw) changed["dividend"]["min_dividend_yield"] = 0.035 assert config_hash(raw) != config_hash(changed) def test_config_hash_ignores_key_order() -> None: raw = yaml.safe_load((config_dir() / "universe.yml").read_text(encoding="utf-8")) reordered = {k: raw[k] for k in reversed(list(raw))} assert config_hash(raw) == config_hash(reordered) def test_missing_config_raises() -> None: with pytest.raises(ConfigNotFound): load_config("universe", path="/nonexistent/nope.yml") def test_unknown_config_name_raises() -> None: with pytest.raises(ConfigError): load_config("no_such_config")