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

194 lines
9.0 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 每股分红的**财年语义**测试(修复:中期分红导致的断档虚低)。
背景(真实数据,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}"