修复: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` 无界扫描仍然很慢)。
This commit is contained in:
2026-10-05 16:19:07 +08:00
parent cf6d4d2c56
commit fdfdd152d8
23 changed files with 1678 additions and 93 deletions
+137
View File
@@ -23,6 +23,7 @@ from hdiv.backtest.engine import (
_months_between,
_round_lot,
build_yield_series,
dedupe_dividend_events,
dividend_handling_notes,
reconcile,
)
@@ -147,6 +148,42 @@ def test_reconcile_does_not_include_position_value() -> None:
assert rc["balanced"] is True, "1000 − 800 = 200,持仓市值不应进入残差"
# ---------------------------------------------------------------------------
# 行业排除清单:从 backtest.yml 一路走到选股滤网
#
# 本项目最怕的失效形态是「配置写了,但没有任何代码路径会读它」——
# 那样回测照跑、日志照打,只是排除从未生效。下面三个入口都必须接线。
# ---------------------------------------------------------------------------
def test_engine_applies_universe_exclusions_to_selector() -> None:
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.config import load_config
from hdiv.universe.selector import UniverseSelector
expected = list(load_config("backtest").universe_exclusions.industries)
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
assert eng.universe_cfg.industry_exclusions == expected
market = UniverseSelector(eng.universe_cfg)._build_filters()["market"]
assert market.exclude_industries == expected, "黑名单没有传到 market 滤网"
def test_walkforward_and_daily_runners_apply_universe_exclusions() -> None:
"""训练段校准 / 逐日选股各自都持有生效后的筛选配置。
walk-forward 的训练段也要排除:否则冻结分布是在**含被排除行业**的池子上
标定的,与测试段实际能买的池子口径不一致。
"""
from hdiv.backtest.daily import DailyRunner
from hdiv.backtest.walk_forward import WalkForwardRunner
from hdiv.core.config import load_config
expected = list(load_config("backtest").universe_exclusions.industries)
path = "config/strategy/high_dividend_v1.yml"
assert DailyRunner.from_strategy(path).universe_cfg.industry_exclusions == expected
assert WalkForwardRunner.from_strategy(path).universe_cfg.industry_exclusions == expected
# ---------------------------------------------------------------------------
# 目标仓位阶梯(防「分批建仓/减仓互相冲突」)
# ---------------------------------------------------------------------------
@@ -498,6 +535,28 @@ def test_dividend_cash_joins_the_investable_pool(engine) -> None:
assert cash_after >= 0.0
def test_duplicate_dividend_rows_are_credited_once(engine) -> None:
"""同一除权日的多条记录只入账一次(引擎在预载阶段按经济事件聚合)。
实测 002352.SZ 2024-11-07 同时有 0.4 / 1.0 / 1.4 三条记录,逐行入账会把同一笔
分红算两三次(现金与送转都会被放大)。
"""
day = date(2024, 6, 20)
raw = pd.DataFrame([
{"symbol": "X.SH", "ex_date": day, "imp_ann_date": date(2024, 5, 20),
"cash_div_tax": 0.5, "stk_div": None, "stk_bo_rate": None, "stk_co_rate": None},
{"symbol": "X.SH", "ex_date": day, "imp_ann_date": date(2024, 6, 1),
"cash_div_tax": 0.5, "stk_div": 0.4, "stk_bo_rate": None, "stk_co_rate": 0.4},
])
ev = dedupe_dividend_events(raw)
assert len(ev) == 1, "同一 (symbol, ex_date) 必须收敛成一笔"
ctx = {"div_by_date": {day: ev.to_dict("records")}}
pos = _held(first_buy=day - timedelta(days=800)) # 持股 > 1 年 → 免税
cash = engine._apply_dividends(day, {"X.SH": pos}, ctx, 0.0, [])
assert cash == pytest.approx(500.0), "同一笔现金分红只入账一次"
assert pos.quantity == pytest.approx(1400.0), "送转只放大一次"
def test_dividend_handling_notes_match_each_mode() -> None:
"""声明口径必须与实现逐档对应:已实现的组合不得留声明,未实现的必须声明。
@@ -544,6 +603,11 @@ def test_engine_dividends_are_creditable() -> None:
assert not d.empty
assert (d["net"] <= d["gross"] + 1e-9).all(), "税后不得大于税前"
assert (d["tax"] >= 0).all()
# 同一 (symbol, ex_date) 不得出现两笔入账 —— 库里同一除权日有多条 `实施`
# 记录(全库 1401 组),引擎必须在预载阶段按经济事件聚合
assert not d.duplicated(["ex_date", "symbol"]).any(), (
f"同一除权日重复入账:{d[d.duplicated(['ex_date', 'symbol'], keep=False)]}"
)
@pytest.mark.db
@@ -863,3 +927,76 @@ def test_unimplemented_declarations_are_honest() -> None:
# 「未实现」会让使用者误以为分红现金被隔离成了不可投资资金。
assert "分红再投资" not in decl, f"把已实现的分红再投资误报成未实现:{decl}"
assert "reinvest_rule" not in decl, f"把已实现的再投资规则误报成未实现:{decl}"
# ---------------------------------------------------------------------------
# 卖出复核(修复 3b):TTM 窗口台阶 vs 分红能力恶化
# ---------------------------------------------------------------------------
def _confirm_engine():
"""构造一个不取数的引擎实例:卖出复核只依赖 pending_div 与策略配置。"""
from hdiv.backtest.engine import BacktestEngine
return BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
def test_exit_confirmation_blocks_cashflow_timing_artefact() -> None:
"""实测 600690.SH 2026-07-30:可见现金只剩 0.26920,但 FY2025 年度 0.89151
早在 2026-06-25 就已公告(除权 2026-08-21)→ 清仓必须被拦下。
这就是用户报告的那笔「实际不该成交」的卖出:旧口径下 TTM 因重复记录虚高
到 2.46848,随后塌到 0.53840/0.26920,把「现金流时点」误读成「分红能力恶化」。
"""
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is False, "已公告未除权的分红足以抬高股息率,清仓应被判定为未确认"
assert info["pending_dps"] == pytest.approx(0.89151)
assert info["ratio"] < info["min_ratio"]
assert eng.exit_unconfirmed == 1
def test_exit_confirmation_allows_real_dividend_cut() -> None:
"""真降息必须照常清仓:公告金额本身就低(或没有公告)时不得拦截。"""
eng = _confirm_engine()
# 无任何已公告未除权分红 → 股息率低就是低
eng.pending_div = {}
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is True
assert info["pending_dps"] == 0.0
# 公告的是一笔很小的分红(0.02),不足以把股息率抬高 → 仍应清仓
eng2 = _confirm_engine()
eng2.pending_div = {"X.SH": [(date(2026, 6, 25), 0.02)]}
ok2, info2 = eng2._exit_confirmed("X.SH", date(2026, 7, 30), 0.26920)
assert ok2 is True, f"小额公告不应拦住清仓,ratio={info2['ratio']}"
def test_exit_confirmation_is_pit_sensitive() -> None:
"""公告日之后才可见:公告日之前的那一天不得用未来公告去豁免清仓。"""
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
# 公告前一天:当时确实不可知 → 照常清仓
ok_before, info_before = eng._exit_confirmed(
"600690.SH", date(2026, 6, 24), 0.26920
)
assert ok_before is True
assert info_before["pending_dps"] == 0.0
# 公告当天起可见
ok_after, info_after = eng._exit_confirmed("600690.SH", date(2026, 6, 25), 0.26920)
assert ok_after is False
assert info_after["pending_dps"] == pytest.approx(0.89151)
def test_exit_confirmation_can_be_disabled() -> None:
"""关掉开关时行为与修复前逐字一致(回滚路径必须可用)。"""
from hdiv.core.config import ExitConfig
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
eng.strategy.exit = ExitConfig.model_validate(
{"yield_percentile": 25, "scale_out": [], "confirm": {"enabled": False}}
)
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is True and info["enabled"] is False
+85
View File
@@ -327,6 +327,91 @@ def test_missing_config_raises() -> None:
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")
+29
View File
@@ -311,6 +311,7 @@ class TestSpeedLevers:
strategy = s
registry = reg
universe_cfg = reg.resolved_universe(s)
_pools_cache_key = DailyRunner._pools_cache_key
stub = _Stub()
@@ -321,6 +322,34 @@ class TestSpeedLevers:
"同样的输入必须得到同样的键(缓存要可复用)"
)
def test_cache_key_depends_on_industry_exclusions(self) -> None:
"""行业排除清单必须进缓存键。
回归:清单写在 ``backtest.yml``(``universe_exclusions``),而
``registry.hash_of(strategy)`` 只覆盖策略 + ``universe.yml``。
键里不含它就意味着「改了清单却复用旧缓存」—— 拿着排除了房地产之前
的池子做回测,日志上却写着已排除,属静默失效。
"""
from hdiv.backtest.daily import DailyRunner
from hdiv.strategy.registry import StrategyRegistry
reg = StrategyRegistry()
s = reg.load("config/strategy/high_dividend_v1.yml")
base = reg.resolved_universe(s)
with_excl = base.model_copy(
update={"industry_exclusions": ["全国地产", "区域地产"]}
)
class _Stub:
strategy = s
registry = reg
_pools_cache_key = DailyRunner._pools_cache_key
a, b = _Stub(), _Stub()
a.universe_cfg, b.universe_cfg = base, with_excl
k = lambda st: st._pools_cache_key(date(2020, 1, 5), date(2020, 12, 31), 1) # noqa: E731
assert k(a) != k(b), "换了行业排除清单就必须换缓存文件"
def test_time_model_shrinks_with_coarser_cadence(self) -> None:
"""耗时模型必须随频率下降(否则预计时长会骗人)。"""
import hdiv.backtest.daily as D
+193
View File
@@ -0,0 +1,193 @@
"""TTM 每股分红的**财年语义**测试(修复:中期分红导致的断档虚低)。
背景(真实数据,600690.SH 海尔智家):
| 分红 | 除权日 | 金额 |
|---|---|---:|
| FY2024 年度 | 2025-07-25 | 0.96504 |
| FY2025 中期 | 2025-11-07 | 0.26920 |
| FY2025 年度 | 2026-08-21 | 0.89151 |
旧实现的「按后继接管」只看**相邻两次除权的间隔**:FY2024 与 FY2025 中期只隔
105 天(< 320),于是把中期分红当成「年内多次分红」,两者互不取代;
而 FY2024 与 FY2025 年度相隔 392 天(> 365),旧的又撑不到新的入场。
结果是 **2026-07-25 ~ 2026-08-21 出现 28 天空窗**:TTM 每股分红从 1.23424
掉到 0.26920(−78%),期间公司没有任何真实现金事件。
后果是真实的错误成交:该 run 在 2026-07-30 用这个虚低的股息率(2.31%,
历史分位 1.57% ≤ P25)产生卖出信号,次日开盘清仓 —— 实际上不该卖。
修法:把「相邻间隔」换成**财年语义** —— 同一 ``end_date``(财年)内的多次
支付互不取代;跨财年时,只有「同财年更早还有支付」或「跨财年间隔 < 320 天」
才按标准 365 天窗口退出(中期分红不该把上一年度提前挤掉),否则交给后继接管。
本文件锁定这些语义,与 ``test_dividend_smoothing.py``(相邻间隔的两种毛刺)
互补。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from hdiv.factor.dividend_yield import (
dedupe_dividend_events,
ttm_dps_series,
)
TTM = 365
GRACE = 45
def _events(rows: list[tuple[str, str, str, float]]) -> pd.DataFrame:
"""构造分红事件表:(财年报告期, 除权日, 公告日, 金额)。"""
return pd.DataFrame({
"end_date": pd.to_datetime([r[0] for r in rows]),
"ex_date": pd.to_datetime([r[1] for r in rows]),
"imp_ann_date": pd.to_datetime([r[2] for r in rows]),
"cash_div_tax": [r[3] for r in rows],
})
def _daily(start: str, end: str) -> pd.DatetimeIndex:
return pd.date_range(start, end, freq="D")
#: 600690.SH 海尔智家的真实节奏(3 个财年,含两次跨财年间隔)
HAIER = _events([
("2023-12-31", "2024-08-16", "2024-08-10", 0.80131),
("2024-12-31", "2025-07-25", "2025-07-19", 0.96504), # 与上一笔相隔 343 天
("2025-06-30", "2025-11-07", "2025-11-01", 0.26920), # 中期,相隔 105 天
("2025-12-31", "2026-08-21", "2026-08-15", 0.89151), # 年度,相隔 287 天
])
def test_interim_does_not_create_gap_before_next_annual() -> None:
"""中期分红结束到次年年度除权之间**不得**出现断档虚低。
这是本 bug 的核心:2026-07-25 之后 FY2024 年度已过 365 天,
而 FY2025 年度要到 2026-08-21 才除权;旧实现在这段里只剩中期分红 0.2692。
正确结果应是「FY2024 年度 + FY2025 中期」仍同时在窗口内 = 1.23424。
"""
d = _daily("2024-01-01", "2027-12-31")
sm = pd.Series(
ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
# 出问题的那一天(真实回测里触发误卖的信号日)
assert sm.loc[pd.Timestamp("2026-07-27")] == pytest.approx(1.23424, abs=1e-5)
# 2026-07-25(FY2024 到期日)到 2026-08-21(FY2025 年度除权)之间:
# 不得低于「最近一笔真实分红」(0.96504),实际上应保持 1.23424
win = sm.loc[pd.Timestamp("2026-07-25"):pd.Timestamp("2026-08-20")]
assert win.min() > 1.0, f"中期分红后出现断档虚低,实际 min={win.min():.5f}"
assert win.max() - win.min() < 1e-9, "该区间内不应有任何跳变(无真实现金事件)"
def test_no_collapse_between_interim_and_next_annual() -> None:
"""区间内允许「窗口到期」造成的台阶,但**不允许塌到只剩中期分红**。
修复前:2026-07-25 ~ 2026-08-21 只有 0.26920(−78% 的假低点)。
修复后:TTM 全程不低于 1.23(上一年度 + 本年度中期同时在窗口内)。
"""
d = _daily("2025-06-01", "2026-12-31")
sm = pd.Series(
ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
window = sm.loc[pd.Timestamp("2025-11-07"):pd.Timestamp("2026-08-20")]
assert window.min() >= 1.23, f"出现假低点,实际 min={window.min():.5f}"
def test_annual_successor_still_takes_over_within_grace() -> None:
"""跨财年但间隔落在宽限期内(370 天)时,仍应由后继接管、不留空窗。"""
ev = _events([
("2023-12-31", "2024-06-01", "2024-05-25", 1.0),
("2024-12-31", "2025-06-06", "2025-05-30", 1.2), # 相隔 370 天
("2025-12-31", "2026-06-11", "2026-06-04", 1.2),
])
d = _daily("2024-01-01", "2026-06-11")
sm = pd.Series(
ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
win = sm.loc[pd.Timestamp("2024-06-01"):pd.Timestamp("2025-06-06")]
assert win.min() > 0.9, f"宽限期内的跨财年断档未被填补,实际 min={win.min():.5f}"
def test_interim_alone_does_not_extend_into_a_full_year_of_silence() -> None:
"""只有中期分红、随后真停发:必须如实归零,不能靠财年语义永远挂着。"""
ev = _events([
("2024-12-31", "2025-07-25", "2025-07-19", 0.96504),
("2025-06-30", "2025-11-07", "2025-11-01", 0.26920),
# 之后没有任何分红
])
d = _daily("2025-01-01", "2027-06-30")
sm = pd.Series(
ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
# 中期那笔(本序列最后一笔)的宽限期封顶 = 2025-11-07 + 365 天
assert sm.loc[pd.Timestamp("2027-01-01")] == pytest.approx(0.0)
# 但在封顶之前仍如实计入(不是提前归零)
assert sm.loc[pd.Timestamp("2026-11-01")] == pytest.approx(0.26920, abs=1e-5)
def test_smooth_off_is_unaffected_by_fiscal_year_logic() -> None:
"""``smooth_spikes=False`` 必须精确保留「硬窗口」语义(回归对照)。"""
d = _daily("2025-01-01", "2026-12-31")
off = pd.Series(
ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False),
index=d,
)
# 硬窗口:2026-07-27 时 FY2024 年度(2025-07-25)已过 365 天 → 只剩中期
assert off.loc[pd.Timestamp("2026-07-27")] == pytest.approx(0.26920, abs=1e-5)
# 而 2025-12-01 时 FY2024 + 中期都在窗口内
assert off.loc[pd.Timestamp("2025-12-01")] == pytest.approx(1.23424, abs=1e-5)
def test_fiscal_year_is_inferred_when_end_date_missing() -> None:
"""事件表没有 ``end_date`` 列时必须退化到「按相邻间隔接管」,不得报错。
退化路径**不做**跨财年补位:退化的财年是「除权日所在年」,会把年报除权年
与紧随其后的中期除权年混在一起,据此补位反而可能造出错误窗口。宁可保守 ——
业务路径的分红记录都带 ``end_date``(见 ``repo.dividend_records``)。
"""
ev = HAIER.drop(columns=["end_date"])
d = _daily("2026-07-01", "2026-08-31")
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
s = pd.Series(out, index=d)
assert np.isfinite(out).all()
# 退化路径仍保留旧接管:2026-07-01 是 FY2023 年度 + FY2025 中期 = 1.23424
assert s.loc[pd.Timestamp("2026-07-01")] == pytest.approx(1.23424, abs=1e-5)
# 但没有跨财年补位,2026-07-25 起只剩中期分红(记录该已知局限)
assert s.loc[pd.Timestamp("2026-07-27")] == pytest.approx(0.26920, abs=1e-5)
def test_dedupe_then_fiscal_year_end_to_end() -> None:
"""重复记录 + 真实节奏同时存在:聚合与财年语义必须叠加生效。
真实库里 600690.SH 的每一笔都有**两条** ``实施`` 记录(ann_date 不同),
逐行累加会把 2.46848 当成 TTM —— 那是虚高一倍的值,随后的「回落」也
必然是假象。
"""
dup = pd.concat([HAIER, HAIER], ignore_index=True)
deduped = dedupe_dividend_events(dup)
assert len(deduped) == len(HAIER), "同一除权日的重复记录未被聚合"
d = _daily("2025-08-01", "2026-08-20")
sm = pd.Series(
ttm_dps_series(d, dup, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
# 聚合 + 财年接管后:
# - 出问题的那一天稳定在 1.23424(FY2024 年度 + FY2025 中期)
# - 关键区间(2026-07-25 ~ 2026-08-20)不再塌到 0.26920
# - 全程下限是 0.96504(合理的窗口到期台阶),不是 0.2692 的假低点
assert sm.loc[pd.Timestamp("2026-07-27")] == pytest.approx(1.23424, abs=1e-5)
gap = sm.loc[pd.Timestamp("2026-07-25"):pd.Timestamp("2026-08-20")]
assert gap.min() == pytest.approx(1.23424, abs=1e-5), "该区间不得出现假低点"
assert sm.min() >= 0.96, f"区间内出现假低点,实际 min={sm.min():.5f}"
assert sm.max() <= 1.77, f"出现重叠虚高,实际 max={sm.max():.5f}"
+97 -2
View File
@@ -20,6 +20,7 @@ import pytest
from hdiv.factor.dividend_yield import (
build_dps_events,
dedupe_dividend_events,
ttm_dps_at,
ttm_dps_series,
ttm_params,
@@ -94,9 +95,12 @@ def test_intra_year_multiple_payments_are_not_merged() -> None:
])
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 年中确实应同时含两笔(0.3 + 0.7 = 1.0)
peak = sm.loc[pd.Timestamp("2023-06-01"):pd.Timestamp("2023-11-20")]
# 年中确实应同时含两笔(0.3 + 0.7 = 1.0)。
# 重叠期 = 第二笔入场(2022-11-28)到第一笔满 365 天(2023-05-28),
# 区间右端取半开语义:2023-05-29 当天第一笔已经到期,只剩 0.3。
peak = sm.loc[pd.Timestamp("2022-11-28"):pd.Timestamp("2023-05-28")]
assert peak.max() > 0.95, f"年内两笔分红应同时计入,实际 max={peak.max()}"
assert sm.loc[pd.Timestamp("2022-11-28")] == pytest.approx(1.0)
def test_true_cessation_still_goes_to_zero() -> None:
@@ -135,6 +139,97 @@ def test_build_dps_events_matches_series_expectations() -> None:
assert len(out) == len(d) and out.max() <= 1.45
# ---------------------------------------------------------------------------
# 经济事件口径:同一 (symbol, ex_date) 的多条记录只能算一笔
# ---------------------------------------------------------------------------
def test_dedupe_collapses_duplicate_announcements() -> None:
"""同一除权日的重复记录(含「分项 + 合计」)必须收敛成一笔。"""
raw = pd.DataFrame({
"symbol": ["X"] * 3 + ["Y"],
"ex_date": ["2024-11-07"] * 3 + ["2024-05-10"],
"imp_ann_date": ["2024-08-29", "2024-10-11", "2024-10-30", "2024-05-06"],
"cash_div_tax": [0.4, 1.0, 1.4, 0.5],
})
out = dedupe_dividend_events(raw)
assert len(out) == 2
x = out[out["symbol"] == "X"].iloc[0]
# 实测 002352.SZ 2024-11-07:0.4 + 1.0 = 1.4,取合计口径
assert float(x["cash_div_tax"]) == pytest.approx(1.4)
# PIT:取**最晚**公告日,绝不早于该金额真正公告的时间
assert pd.Timestamp(x["imp_ann_date"]) == pd.Timestamp("2024-10-30")
assert len(out[out["symbol"] == "Y"]) == 1
def test_dedupe_keeps_stock_dividend_alongside_cash() -> None:
"""一行纯现金 + 一行纯送转时不得丢掉送转(逐字段取最大,而非整行取最大)。"""
raw = pd.DataFrame({
"symbol": ["X", "X"],
"ex_date": ["2024-07-01", "2024-07-01"],
"imp_ann_date": ["2024-06-01", "2024-06-20"],
"cash_div_tax": [0.5, None],
"stk_div": [None, 0.3],
"stk_bo_rate": [None, 0.3],
})
out = dedupe_dividend_events(raw)
assert len(out) == 1
assert float(out.iloc[0]["cash_div_tax"]) == pytest.approx(0.5)
assert float(out.iloc[0]["stk_div"]) == pytest.approx(0.3)
def test_dedupe_is_identity_without_duplicates() -> None:
"""没有重复时原样返回(热路径短路,不得改变 dtype 或行序)。"""
raw = pd.DataFrame({
"symbol": ["X", "X"],
"ex_date": ["2021-06-01", "2022-05-27"],
"cash_div_tax": [1.0, 1.2],
})
out = dedupe_dividend_events(raw)
assert out is raw
def test_ttm_series_dedupes_by_default() -> None:
"""TTM 入口默认按经济事件聚合;重复记录不得把股息率抬高。"""
dup = pd.DataFrame({
"ex_date": pd.to_datetime(["2022-06-01", "2022-06-01"]),
"imp_ann_date": pd.to_datetime(["2022-05-20", "2022-05-25"]),
"cash_div_tax": [0.5, 0.5],
})
d = _daily("2022-01-01", "2023-12-31")
on = ttm_dps_series(d, dup, ttm_days=TTM, grace_days=GRACE)
assert on.max() == pytest.approx(0.5)
# 关掉聚合应复现**旧行为**:同一天两条被当成两笔独立分红累加(1.0)。
# 这正是本 bug 的来源 —— 业务路径必须保持默认聚合。
off = ttm_dps_series(d, dup, ttm_days=TTM, grace_days=GRACE, dedupe_events=False)
assert off.max() == pytest.approx(1.0), "关掉聚合应复现重复累加"
def test_partial_and_total_records_are_merged_not_summed() -> None:
"""「分项 + 合计」同一天多条:必须取合计(1.4),而不是累加成 2.8。"""
raw = pd.DataFrame({
"ex_date": pd.to_datetime(["2024-11-07"] * 3),
"imp_ann_date": pd.to_datetime(["2024-08-29", "2024-10-11", "2024-10-30"]),
"cash_div_tax": [0.4, 1.0, 1.4],
})
d = _daily("2024-01-01", "2025-12-31")
on = ttm_dps_series(d, raw, ttm_days=TTM, grace_days=GRACE)
assert on.max() == pytest.approx(1.4)
def test_ttm_series_survives_null_cash_on_stock_dividend_rows() -> None:
"""送转行的 cash_div_tax 是 NULL:不得让 NaN 传染整条 TTM 序列。"""
ev = pd.DataFrame({
"ex_date": pd.to_datetime(["2022-06-01", "2023-06-05"]),
"imp_ann_date": pd.to_datetime(["2022-05-20", "2023-05-25"]),
"cash_div_tax": [None, 0.8],
})
d = _daily("2023-01-01", "2023-12-31")
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE)
assert np.isfinite(out).all(), "NULL 现金分红把 TTM 序列变成了 NaN"
assert out.max() == pytest.approx(0.8)
# ---------------------------------------------------------------------------
# 口径统一:筛选 / 画像 / 回测 / Web 必须用同一份参数
# ---------------------------------------------------------------------------
+122 -2
View File
@@ -135,14 +135,30 @@ def test_verify_market_units_empty() -> None:
# ---------------------------------------------------------------------------
# 滤网:行业豁免
# 滤网:共用夹具
#
# ``_FakeRepo`` / ``_frame`` 同时服务「行业豁免」与「行业排除」两组测试,
# 所以单独放在这里,不属于任何一组。
# ---------------------------------------------------------------------------
class _FakeRepo:
def __init__(self, st: set[str] | None = None, susp: set[str] | None = None) -> None:
#: 行业黑名单自检要用的取值域(与 _frame 里的 industry 列保持同源)
_DEFAULT_INDUSTRIES = [
"银行", "煤炭开采", "白酒", "全国地产", "区域地产", "房产服务", "园区开发",
]
def __init__(
self,
st: set[str] | None = None,
susp: set[str] | None = None,
industries: list[str] | None = None,
) -> None:
self._st = st or set()
self._susp = susp or set()
self._industries = list(
self._DEFAULT_INDUSTRIES if industries is None else industries
)
def st_symbols(self, asof, include_delisting=True): # noqa: ANN001, ARG002
return self._st
@@ -150,6 +166,10 @@ class _FakeRepo:
def suspended_on(self, asof): # noqa: ANN001, ARG002
return self._susp
def stock_master(self) -> pd.DataFrame:
"""只为 ``MarketFilter`` 的行业名自检提供 ``industry`` 取值域。"""
return pd.DataFrame({"industry": self._industries})
def _frame(**kwargs) -> pd.DataFrame:
base = {
@@ -171,6 +191,81 @@ def _frame(**kwargs) -> pd.DataFrame:
return pd.DataFrame(base)
# ---------------------------------------------------------------------------
# 滤网:行业排除清单(黑名单)
#
# 回归背景:``backtest.yml: universe_exclusions.industries`` 写在库里,
# 但数据库**没有**「房地产业」这个取值 —— 它被拆成 全国地产 / 区域地产 /
# 房产服务 / 园区开发。写错名字不会报任何错,只是「一只都没排除」。
# ---------------------------------------------------------------------------
def _market_filter(exclude: list[str]):
from hdiv.core.config import MarketFilterConfig
from hdiv.universe.filters.market import MarketFilter
return MarketFilter(MarketFilterConfig(), exclude_industries=exclude)
def test_market_filter_excludes_listed_industries() -> None:
f = _market_filter(["全国地产", "区域地产"])
out = f.compute(
_frame(industry=["区域地产", "煤炭开采"]), _FakeRepo(), date(2024, 6, 28)
)
assert bool(out.passed.iloc[0]) is False, "区域地产必须被排除"
assert bool(out.passed.iloc[1]) is True, "未列入黑名单的行业不受影响"
assert "排除清单" in out.reasons["600036.SH"]
def test_industry_exclusion_is_the_reported_reason() -> None:
"""同时市值不足时,报出的必须是「行业被排除」。
若行业判定排在市值之后,被排除的股票会先以「市值不足」落选,
事后无法分辨「这个行业不做了」还是「这只真的不达标」。
"""
f = _market_filter(["全国地产"])
df = _frame(industry=["全国地产", "煤炭开采"], total_mv=[1e10, 8.8e11])
out = f.compute(df, _FakeRepo(), date(2024, 6, 28))
assert bool(out.passed.iloc[0]) is False
assert "排除清单" in out.reasons["600036.SH"]
assert "市值" not in out.reasons["600036.SH"]
def test_empty_exclusion_list_changes_nothing() -> None:
"""不配 = 与改动前逐字一致(本清单只做减法)。"""
out = _market_filter([]).compute(
_frame(industry=["全国地产", "煤炭开采"]), _FakeRepo(), date(2024, 6, 28)
)
assert out.passed.all()
def test_industry_exclusion_allows_all_four_real_estate_labels() -> None:
"""四个地产口径都要能被单独命中(配置里缺一个就少排一类)。"""
labels = ["全国地产", "区域地产", "房产服务", "园区开发"]
f = _market_filter(labels)
out = f.compute(
_frame(industry=["房产服务", "园区开发"]), _FakeRepo(), date(2024, 6, 28)
)
assert not out.passed.any()
def test_unknown_industry_name_raises_instead_of_silently_passing() -> None:
"""写错行业名(库里不存在的「房地产业」)必须报错。
这是本功能最容易踩的坑:名单写错时股票池看起来「排除了」,
实际一只没少 —— 静默失效。宁可直接失败。
"""
from hdiv.core.errors import ConfigError
f = _market_filter(["房地产业"])
with pytest.raises(ConfigError) as ei:
f.compute(_frame(), _FakeRepo(), date(2024, 6, 28))
msg = str(ei.value)
assert "房地产业" in msg
# 必须给出最接近的真实取值,否则用户只能自己去翻库
assert "全国地产" in msg, f"错误信息没给出候选:{msg}"
def test_risk_filter_exempts_banks_from_leverage() -> None:
from hdiv.core.config import RiskFilterConfig
from hdiv.universe.filters.risk import RiskFilter
@@ -252,6 +347,31 @@ def _div(symbol: str, end_year: int, ex_year: int, dps: float = 1.0, month: int
}
def test_duplicate_dividend_records_count_once() -> None:
"""同一 (symbol, ex_date) 的重复记录不得把年度 DPS / 总分红重复累加。
年度 DPS 决定 ``dps_cagr_5y`` 与 ``dps_volatility``,总现金分红决定
``payout_ratio`` 与 ``fcf_dividend_cover``(并直接决定选股),
因此重复记录必须与 ``ttm_dps_series`` 用同一份聚合口径。
"""
from hdiv.core.config import DividendFilterConfig
from hdiv.universe.filters.dividend import DividendFilter
cfg = DividendFilterConfig()
recs = [
_div("600036.SH", 2023, 2024, dps=1.0, month=7),
_div("600036.SH", 2023, 2024, dps=1.0, month=7), # 重复公告
]
s = DividendFilter._stats(recs, target_year=2023, asof=date(2024, 12, 31), cfg=cfg)
assert s["dps_by_year"][2023] == pytest.approx(1.0), "年度 DPS 被重复累加"
row = pd.Series({"n_income_attr_p": 5.0e9, "free_cashflow": 8.0e9})
p = DividendFilter._payout_and_cover(recs, row, date(2024, 12, 31), cfg=cfg)
# 总现金分红 = 每股 1.0 元 × 基准股本 10000 万股 = 1.0e8
assert p["total_cash_dividend"] == pytest.approx(1.0 * 10000.0 * 1e4)
assert p["payout_ratio"] == pytest.approx(1.0 * 10000.0 * 1e4 / 5.0e9)
def test_continuity_within_target_year() -> None:
"""FY2023 分红已在 2024-05 除权 → target=2023,连续 5 年。"""
from hdiv.core.config import DividendFilterConfig