本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):
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 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
333 lines
10 KiB
Python
333 lines
10 KiB
Python
"""配置加载与严格校验测试(对应 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")
|