Files
ggx/tests/test_config.py
T
simon 14ec0c6c86 修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 2005;手册补全流程
本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):

1) 修复 stock_daily 量价单位前后不一致
   - 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
     而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
     把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
   - 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
     按行判定并幂等换算(price_history / avg_amount 都走它);
     审计新增 UNIT-OHLCV 防回归。
   - 效果:2016/2017/2018 的股票池变为 7/11/13 只。

2) 未来函数守卫(单次回测)
   - 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
     与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
     偏差写入 unimplemented_json。

3) 新增实时(PIT)个股画像闸门
   - profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
     惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
     规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
   - 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
   - profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
     策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
   - core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。

4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
   - stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
     hd_suspend / hd_limit 补到 2010-01-04。
   - 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
     残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
   - 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
     会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
   - 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。

5) 自我声明如实化
   - 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
     误报成「hd_suspend 无数据」;改为按表级判定。
   - 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
     cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
     max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。

6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
   - 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
     含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
   - 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
     修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。

测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。

注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
2026-10-04 12:47:17 +08:00

333 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""配置加载与严格校验测试(对应 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")