Files
ggx/tests/test_dividend_smoothing.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

346 lines
15 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.
"""TTM 股息率毛刺消除测试。
**背景**:A 股相邻两次除权的间隔经常不是 365 天。硬 365 天窗口于是在每年
除权日附近制造两种日历假象:
- **重叠虚高**:间隔 < 365 时新旧分红同时在窗口内。实测招商银行 2015-07-03
股息率 0.620 → 1.290(+108%),10 天后回落到 0.670。
- **断档虚低**:间隔 > 365 时旧的已到期而新的未入场。实测中国神华
2016-07-04:0.740 → 0.320(−57%)。
两者都会污染「历史分位」这一核心信号,且筛选器用的也是同一个数
(直接决定选股),因此必须消除。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from hdiv.factor.dividend_yield import (
build_dps_events,
dedupe_dividend_events,
ttm_dps_at,
ttm_dps_series,
ttm_params,
)
TTM = 365
GRACE = 45
def _events(pairs: list[tuple[str, float]]) -> pd.DataFrame:
"""构造分红事件表:(除权日, 金额)。"""
return pd.DataFrame({
"ex_date": pd.to_datetime([d for d, _ in pairs]),
"imp_ann_date": pd.to_datetime([d for d, _ in pairs]),
"cash_div_tax": [a for _, a in pairs],
})
def _daily(start: str, end: str) -> pd.DatetimeIndex:
return pd.date_range(start, end, freq="D")
# ---------------------------------------------------------------------------
# 核心:两种毛刺都要消除
# ---------------------------------------------------------------------------
def test_overlap_spike_is_removed() -> None:
"""间隔 360 天:新分红入场时旧的不应再计入(消除 +100% 虚高)。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2), ("2023-05-22", 1.4)])
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=False), index=d)
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 未平滑时:2022-05-27 当刻涨到 1.0+1.2=2.2,5 天后旧的到期回落
assert raw.max() > 2.1, f"未平滑应出现重叠虚高,实际 max={raw.max()}"
# 平滑后:不应出现两笔相加
assert sm.max() <= 1.45, f"平滑后不应双算,实际 max={sm.max()}"
# 且切换当天不跳变
after = sm.loc[pd.Timestamp("2022-05-27"):].iloc[:5]
assert after.max() / after.min() - 1 < 0.05, "接管当天不应有跳变"
def test_gap_dip_is_filled() -> None:
"""间隔 370 天:旧的到期后应继续计到新的入场(消除断档虚低)。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2)]) # 间隔 370 天
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=False), index=d)
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 只看「第一笔到第二笔」这段(尾部无后继本就该归零,属正确行为)
win = slice(pd.Timestamp("2021-06-01"), pd.Timestamp("2022-06-06"))
raw_a, sm_a = raw.loc[win], sm.loc[win]
# 未平滑:2022-06-01 旧的到期、新的还没来 → 归零 5 天
assert raw_a.min() == 0.0, "未平滑应出现断档归零"
# 平滑后:同区间不应归零
assert sm_a.min() > 0.9, f"平滑后不应断档,实际 min={sm_a.min()}"
def test_intra_year_multiple_payments_are_not_merged() -> None:
"""年内多次分红(间隔 180 天)必须都保留 —— 否则会把中期分红误删。"""
d = _daily("2021-01-01", "2023-12-31")
ev = _events([
("2022-06-01", 0.3), ("2022-11-28", 0.7),
("2023-05-29", 0.3), ("2023-11-25", 0.7),
])
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)。
# 重叠期 = 第二笔入场(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:
"""真停发必须如实归零,不能因为平滑就永远挂着旧分红。"""
d = _daily("2020-01-01", "2025-12-31")
ev = _events([("2021-06-01", 1.0), ("2023-06-01", 1.0)]) # 中间空了两年
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 2021 那笔在 2022-06-01 + 45 天宽限后必须归零
gap = sm.loc[pd.Timestamp("2022-09-01"):pd.Timestamp("2023-05-31")]
assert gap.max() == 0.0, f"停发期间应归零,实际 max={gap.max()}"
def test_smoothing_can_be_disabled() -> None:
"""smooth_spikes=False 应精确复现旧的「硬窗口 + 归零才兜底」行为。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
off = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False)
# 旧行为:重叠期双算
assert off.max() >= 2.1
on = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert on.max() < off.max()
def test_build_dps_events_matches_series_expectations() -> None:
"""事件表经 build_dps_events 规范化后仍可用。"""
raw = pd.DataFrame({
"symbol": ["X"] * 3,
"ex_date": ["2021-06-01", "2022-05-27", "2023-05-22"],
"imp_ann_date": ["2021-05-25", "2022-05-20", "2023-05-15"],
"cash_div_tax": [1.0, 1.2, 1.4],
})
e = build_dps_events(raw)["X"]
d = _daily("2021-01-01", "2023-12-31")
out = ttm_dps_series(d, e, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
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 必须用同一份参数
# ---------------------------------------------------------------------------
def test_ttm_params_is_single_source_of_truth() -> None:
"""四处调用点必须都从 ttm_params() 取参,不得各自硬编码。"""
import inspect
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
# 回测引擎曾硬编码 ttm_days=365, grace_days=45
eng = (root / "backtest" / "engine.py").read_text(encoding="utf-8")
assert "ttm_days=365, grace_days=45" not in eng, "引擎仍在硬编码 TTM 参数"
assert "ttm_params()" in eng
# walk-forward 与 web 曾用函数默认值
for rel in ("backtest/walk_forward.py", "web/analysis.py"):
text = (root / rel).read_text(encoding="utf-8")
assert "ttm_params()" in text, f"{rel} 未使用统一参数"
# 因子层自身
f = inspect.getsource(__import__(
"hdiv.factor.dividend_yield", fromlist=["x"]))
assert "def ttm_params" in f
def test_ttm_dps_at_matches_series_right_endpoint() -> None:
"""单点求值(筛选器用)必须与序列右端点一致。"""
d = _daily("2020-01-01", "2022-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
asof = pd.Timestamp("2021-12-31").date()
one = ttm_dps_at(asof, ev)
ser = ttm_dps_series(pd.DatetimeIndex([pd.Timestamp(asof)]), ev,
ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert one is not None
assert abs(one - float(ser[0])) < 1e-9
def test_ttm_params_reads_config() -> None:
from hdiv.core.config import load_config
w, g, sm = ttm_params()
c = load_config("profile").ttm_dividend
assert (w, g, sm) == (c.window_days, c.grace_days, c.smooth_spikes)
def test_config_exposes_smooth_spikes_switch() -> None:
"""开关必须暴露在 YAML 里,用户可自行关闭。"""
from hdiv.core.config import load_config
assert hasattr(load_config("profile").ttm_dividend, "smooth_spikes")
@pytest.mark.parametrize("grace", [0, 10, 45, 90])
def test_smoothing_never_produces_negative_or_nan(grace: int) -> None:
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2), ("2023-06-01", 1.4)])
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=grace, smooth_spikes=True)
assert np.isfinite(out).all()
assert (out >= 0).all()
# ---------------------------------------------------------------------------
# 透传一致性:包装函数必须接收并转发所有参数
# ---------------------------------------------------------------------------
def test_wrapper_signature_forwards_all_params() -> None:
"""回归:`dividend_yield_series` 是 `ttm_dps_series` 的包装。
曾经只给**调用方**加了 `smooth_spikes`,却忘了在包装函数签名里声明,
于是 walk-forward 直接 `TypeError` 崩在第一个窗口 —— 而测试全绿,
因为测试没走 walk-forward 那条路径。
"""
import inspect
from hdiv.factor import dividend_yield as dy
inner = set(inspect.signature(dy.ttm_dps_series).parameters) - {"dates", "events"}
outer = set(inspect.signature(dy.dividend_yield_series).parameters) - {"close", "events"}
missing = inner - outer
assert not missing, (
f"dividend_yield_series 未转发参数 {sorted(missing)};"
"调用方传了就会 TypeError"
)
# 且必须真的往下传
src = inspect.getsource(dy.dividend_yield_series)
for name in inner:
assert f"{name}={name}" in src, f"包装函数未把 {name} 传给 ttm_dps_series"
def test_wrapper_accepts_smooth_spikes() -> None:
"""直接以关键字调用,确保签名真的可用(不只是字符串包含)。"""
from hdiv.factor.dividend_yield import dividend_yield_series
d = _daily("2021-01-01", "2022-12-31")
close = pd.Series(10.0, index=d)
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
for flag in (True, False):
out = dividend_yield_series(close, ev, ttm_days=365, grace_days=45,
smooth_spikes=flag)
assert not out.empty
assert "dividend_yield" in out.columns
def test_all_ttm_callers_pass_the_unified_params() -> None:
"""五处调用点都必须显式传 smooth_spikes,不能靠默认值(否则与配置脱钩)。"""
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
for rel in ("profile/builder.py", "backtest/engine.py",
"backtest/walk_forward.py", "web/analysis.py"):
src = (root / rel).read_text(encoding="utf-8")
assert "smooth_spikes" in src, f"{rel} 未传 smooth_spikes(会与配置脱钩)"