Files
ggx/tests/test_config.py
T
simon fdfdd152d8 修复:TTM 股息率两处残余缺陷 + 公司行为三处静默错误;新增回测级排除行业清单
说明:本提交是工作区中此前的未提交工作(在 cf6d4d2 之后产生),**非本次会话所写**,
按用户要求**不跑测试、直接记录变更并推送**。
已完成推送前的基础安全检查:无明文凭据、无大文件、`.env`/`logs/`/`output/` 仍被忽略。
测试状态:**本次未执行测试套件**。

## 一、TTM 股息率的两处残余缺陷 + 卖出复核

起因:用户报告 `600690.SH` 在 2026-07-30 触发清仓、07-31 开盘卖出,实际不该卖。

### 缺陷一:同一除权日的多条「实施」记录被逐行累加
- 成因:`hd_dividend` 写入侧刻意保留全量公告记录,去重键含 `ann_date`,
  同一笔分红会有多条「实施」记录落在**同一除权日**;查询侧逐行累加即重复计入。
- 规模:5724 只有现金分红的股票中 **953 只**存在同除权日重复(多出 1313 行)。
- 效果:`600690.SH` 的 `ttm_dps` 长期虚高约一倍(2.46848 vs 真实 1.23424),
  窗口到期时又必然回落,把假象放大成一次 −78% 的塌陷。
- 修法:新增 `factor.dividend_yield.dedupe_dividend_events()`,按 `(symbol, ex_date)`
  聚合成**一笔经济事件**(金额/股数逐字段取最大 → 收敛「分项 + 合计」;
  日期取最晚 → PIT 保守)。三处入口统一调用:`ttm_dps_series`、
  `Repo.dividend_events`、`universe/filters/dividend.py`。

### 缺陷二:只看相邻间隔,漏掉「年度 → 中期 → 下一年度」的跳法
- 成因:7.6 的「按后继接管」只看相邻两次除权的间隔。实测 `600690.SH`:
  FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、FY2025 年度 2026-08-21。
  105 天的间隔使前两笔被判为「年内多次分红」而互不取代,392 天又超过 `365+45`
  → **2026-07-25~08-21 出现 28 天空窗**,可见现金只剩 0.26920。
- 修法:`ttm_dps_series` 的覆盖窗口由「按相邻间隔」升级为「**按财年 `end_date`**」:
  ① 后继接管(保留 7.6 行为,阈值 `ttm_days - grace_days` = 320 天);
  ② **跨财年补位**:每个财年最后一笔 → 下一财年最后一笔入场,上限 `365 + grace`;
  ③ **末笔宽限兜底**:无后继时覆盖 `365 + grace`(真停发仍如实归零)。
- 验收(作者实测):600690 在 2026-07-27 的 `ttm_dps` 由 0.53840 变为 **1.23424**,
  股息率 5.30%、历史分位 92.98%,**不再触发 P25 清仓**。

### 缺陷三(设计缺口):卖出只认「已除权的现金」,不认「已公告的分红」
- 成因:FY2025 年度分红 0.89151 的**实施公告日是 2026-06-25**,除权日 2026-08-21。
  TTM 现金口径看不到它 → 「股息率处于历史低位」在字面上为真,
  实际描述的是**现金流时点**而非分红能力恶化。
- 修法:新增 `entry/exit.confirm`(`enabled` / `min_ratio` / `announce_lookback_days`):
  若「已公告未除权」的分红说明股息率本应更高,且
  `TTM ÷ (TTM + 已公告未除权) < min_ratio`,则判定**未确认**:
  保持仓位并记录 `EXIT_UNCONFIRMED`(不进成交流水)。真降息不会命中。

## 二、公司行为的三处静默错误(分红/送转/配股口径)

### 问题一:纯送转被整行丢弃(凭空亏损)
- `_apply_dividends` 在算送股**之前**就按 `cash_div_tax <= 0` 整行 `continue`,
  于是「10 送 10」这类**无现金分红**的送转完全不调股数 ——
  而价格是不复权价、除权日照常腰斩 → 记出一笔不存在的亏损。
- 规模:全库「实施且 `stk_div > 0`」13,038 行,其中**纯送转 3,268 行**;
  高股息池成员在 2015-2026 区间内 **824 笔**(如 `000793.SZ` 每 10 股转增 12 股,
  单笔约 −54% 的该持仓市值)。
- 修法:现金与送转**各自独立判断**,只有「既无现金也无送转」才跳过;
  并把 `stk_bo_rate`/`stk_co_rate` 写入分红台账留痕。

### 问题二:同一除权日的重复记录被重复入账
- 全库 **1401 组**同 `(symbol, ex_date)` 的多条实施记录(1240 组字段相同;
  96 组报告期不同、122 组金额不同)。实测 `002352.SZ 2024-11-07` 同时有
  0.4 / 1.0 / 1.4 三条,而 1.4 = 0.4 + 1.0 是合计口径 → 逐行累加会放大两三倍。
- 修法:复用 `dedupe_dividend_events`(与缺陷一同一个函数)。

### 问题三:分红再投资的声明与行为不一致
- 引擎实际行为一直是「分红现金回到与初始资金同一个 `cash` 变量,
  下次调仓按目标权重再配置」= `reinvest` + `portfolio_rebalance`;
  但 `backtest.yml` 写的是 `same_stock_next_open`,于是每次 run 都声明
  「未实现分红再投资规则,分红留存为现金」,让人误以为分红不可再投资。
- 修法:配置改为已实现组合 `cash_mode: reinvest` + `reinvest_rule: portfolio_rebalance`;
  声明逻辑抽成 `dividend_handling_notes()`,**逐档取值都有单测**对应
  (`hold`/`cash_out`/`same_stock_next_open`/`handle_stock_dividend=false`/配股
  才声明未实现)。顺带接线一直是**死字段**的 `dividend.apply_dividend_tax`。

## 三、新增:回测层面的排除行业清单(黑名单)

- 位置与语义:`config/backtest.yml: universe_exclusions.industries` ——
  「**这次回测**特意不要哪些行业」(研究口径),
  与 `config/universe.yml`(策略选股定义)**叠加取并集**,只做减法。
  三种回测模式(single / walkforward / daily)一律生效。
- 最大的坑:数据库 `stock.industry` 里**没有「房地产业」**,它被拆成四个名字,
  写「房地产」或「房地产业」**一只都排除不掉**:
  `全国地产` 26 只 + `区域地产` 43 只 + `房产服务` 13 只 + `园区开发` 14 只 = **96 只**(1.6%)。
  因此 `MarketFilter` 首次求值时拿名单与表内实际取值核对,
  **写错名字直接抛 `ConfigError`**(并按字符重合度提示最接近的真实取值)。
- 接线:三个入口都走生效后的配置;并修掉一处缓存陷阱(配置变更后缓存未失效)。
- 新增测试锁定它。

## 四、其它

- `src/hdiv/core/config.py`:新增配置模型(排除行业、卖出复核等,+102 行)
- `src/hdiv/data/repo.py`(+45)、`src/hdiv/backtest/engine.py`(+121)、
  `backtest/daily.py`、`backtest/walk_forward.py`、`web/service.py`、
  `report/universe_report.py` 相应接线
- 测试:新增 `tests/test_dividend_fiscal_year.py`;扩充
  `test_backtest.py` / `test_config.py` / `test_daily.py` /
  `test_dividend_smoothing.py` / `test_universe.py`
- `tools/diag_dividend_artifact.py`:诊断脚本与上述修复对齐
- 文档:`docs/implementation-status.md` 新增 §7.6b / §7.7 / §11;
  `docs/user-guide.md` 新增排除行业清单说明

## 待验证

本次按要求**未执行测试**。上述「实测/验收」数字均引自文档中作者自己的记录,
非本次会话验证结果。建议合入后跑一次全量测试(注意:daily 的 DB 标记测试
因 `hd_cashflow` 无界扫描仍然很慢)。
2026-10-05 16:19:07 +08:00

418 lines
14 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")
# ---------------------------------------------------------------------------
# 回测层面的行业排除清单(backtest.yml: universe_exclusions)
# ---------------------------------------------------------------------------
def test_backtest_config_excludes_real_estate_industries() -> None:
"""当前生效配置必须真的在排除房地产,且四个口径齐全。
数据库里没有「房地产业」这个取值,它被拆成四个行业名;
少写一条就少排一类(例如只写「全国地产」会漏掉全部区域地产公司)。
"""
bt = load_config("backtest")
excl = set(bt.universe_exclusions.industries)
assert {"全国地产", "区域地产", "房产服务", "园区开发"} <= excl
def test_resolved_universe_applies_backtest_exclusions() -> None:
from hdiv.core.config import BacktestConfig, UniverseConfig
bt = BacktestConfig.model_validate(
{
"period": {"start": "2015-01-01", "end": "latest"},
"universe_exclusions": {"industries": ["全国地产", "区域地产"]},
}
)
uni = UniverseConfig.model_validate({"name": "t"})
merged = bt.resolved_universe(uni)
assert merged.industry_exclusions == ["全国地产", "区域地产"]
assert uni.industry_exclusions == [], "不得就地修改传入的筛选配置"
def test_resolved_universe_is_a_union_not_an_override() -> None:
"""universe.yml 自带的排除项不能被 backtest.yml 顶掉(只做减法)。"""
from hdiv.core.config import BacktestConfig, UniverseConfig
bt = BacktestConfig.model_validate(
{
"period": {"start": "2015-01-01", "end": "latest"},
"universe_exclusions": {"industries": ["房产服务"]},
}
)
uni = UniverseConfig.model_validate(
{"name": "t", "industry_exclusions": ["园区开发"]}
)
merged = bt.resolved_universe(uni)
assert merged.industry_exclusions == ["园区开发", "房产服务"]
def test_resolved_universe_without_exclusions_returns_same_object() -> None:
"""不配排除清单 → 原样返回,保证「不配 = 行为与改动前一致」。"""
from hdiv.core.config import BacktestConfig, UniverseConfig
bt = BacktestConfig.model_validate({"period": {"start": "2015-01-01"}})
uni = UniverseConfig.model_validate({"name": "t"})
assert bt.resolved_universe(uni) is uni
def test_duplicate_exclusion_industry_rejected() -> None:
raw = yaml.safe_load((config_dir() / "backtest.yml").read_text(encoding="utf-8"))
raw["universe_exclusions"] = {"industries": ["全国地产", "全国地产"]}
with pytest.raises(ConfigError):
_validate_tmp("backtest", raw)
def test_unknown_exclusion_field_rejected() -> None:
"""字段名写错必须报错,不能静默忽略(本项目的一贯纪律)。"""
raw = yaml.safe_load((config_dir() / "backtest.yml").read_text(encoding="utf-8"))
raw["universe_exclusions"] = {"industry": ["全国地产"]}
with pytest.raises(ConfigError):
_validate_tmp("backtest", raw)
def _validate_tmp(name: str, raw: dict) -> object:
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:
return load_config(name, path=tmp)
finally:
tmp.unlink(missing_ok=True)
def test_unknown_config_name_raises() -> None:
with pytest.raises(ConfigError):
load_config("no_such_config")