From fdfdd152d8a9f150163258952b80292f2cbc1e1a Mon Sep 17 00:00:00 2001 From: Simon Date: Mon, 5 Oct 2026 16:19:07 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9ATTM=20=E8=82=A1?= =?UTF-8?q?=E6=81=AF=E7=8E=87=E4=B8=A4=E5=A4=84=E6=AE=8B=E4=BD=99=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=20+=20=E5=85=AC=E5=8F=B8=E8=A1=8C=E4=B8=BA=E4=B8=89?= =?UTF-8?q?=E5=A4=84=E9=9D=99=E9=BB=98=E9=94=99=E8=AF=AF=EF=BC=9B=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=9B=9E=E6=B5=8B=E7=BA=A7=E6=8E=92=E9=99=A4=E8=A1=8C?= =?UTF-8?q?=E4=B8=9A=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 说明:本提交是工作区中此前的未提交工作(在 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` 无界扫描仍然很慢)。 --- config/backtest.yml | 25 +++ config/strategy/high_dividend_v1.yml | 21 ++ docs/implementation-status.md | 266 +++++++++++++++++++++++++- docs/user-guide.md | 92 +++++++++ src/hdiv/backtest/daily.py | 26 ++- src/hdiv/backtest/engine.py | 121 +++++++++++- src/hdiv/backtest/walk_forward.py | 13 +- src/hdiv/core/config.py | 102 ++++++++++ src/hdiv/data/repo.py | 45 +++++ src/hdiv/factor/dividend_yield.py | 223 +++++++++++++++++---- src/hdiv/report/universe_report.py | 2 +- src/hdiv/universe/daily.py | 11 +- src/hdiv/universe/filters/dividend.py | 30 ++- src/hdiv/universe/filters/market.py | 81 +++++++- src/hdiv/universe/selector.py | 4 +- src/hdiv/web/service.py | 5 +- tests/test_backtest.py | 137 +++++++++++++ tests/test_config.py | 85 ++++++++ tests/test_daily.py | 29 +++ tests/test_dividend_fiscal_year.py | 193 +++++++++++++++++++ tests/test_dividend_smoothing.py | 99 +++++++++- tests/test_universe.py | 124 +++++++++++- tools/diag_dividend_artifact.py | 37 ++-- 23 files changed, 1678 insertions(+), 93 deletions(-) create mode 100644 tests/test_dividend_fiscal_year.py diff --git a/config/backtest.yml b/config/backtest.yml index dec4e67..47c78da 100644 --- a/config/backtest.yml +++ b/config/backtest.yml @@ -14,6 +14,31 @@ period: start: 2015-01-01 end: latest +# ------------------------------------------------------------ +# 排除行业清单(股票池黑名单) +# +# 语义:按 stock.industry **精确匹配**(区分字面,不做前缀/模糊匹配), +# 命中的股票在股票池阶段就被淘汰,三种回测模式 +# (single / walkforward / daily)一律生效。 +# +# 与 config/universe.yml 的关系是**叠加**,不是覆盖: +# universe.yml —— 「什么样的公司够格」(市值/分红/质量 + 行业豁免) +# 本清单 —— 「这次研究特意不要哪些行业」 +# 因此本清单只会让股票池变小,不会放宽任何既有条件。 +# 若 universe.yml 自己也声明了 industry_exclusions,两者取并集。 +# +# 行业名必须与数据库 stock.industry 逐字一致。查当前取值: +# SELECT industry, COUNT(*) FROM stock GROUP BY industry ORDER BY 2 DESC; +# 留空 [] = 不排除任何行业。 +# ------------------------------------------------------------ +universe_exclusions: + # 房地产业。注意:数据库里**没有**「房地产业」这个标签,它被拆成四个行业名, + # 所以这里要写全四条,只写「房地产」不会有任何匹配: + # 全国地产 / 区域地产 —— 房地产开发(万科A、保利发展、金地集团…) + # 房产服务 —— 物业/中介/房产服务(招商积余、我爱我家…) + # 园区开发 —— 开发区与园区运营商(陆家嘴、张江高科…) + industries: [全国地产, 区域地产, 房产服务, 园区开发] + # ------------------------------------------------------------ # 调度:多久评估一次信号、多久重建一次股票池 # ------------------------------------------------------------ diff --git a/config/strategy/high_dividend_v1.yml b/config/strategy/high_dividend_v1.yml index 658f311..3f149f2 100644 --- a/config/strategy/high_dividend_v1.yml +++ b/config/strategy/high_dividend_v1.yml @@ -91,6 +91,27 @@ exit: stop_loss_pct: null max_holding_days: null + # ---------------------------------------------------------- + # 清仓前复核:防止「现金流时点」被当成「分红能力恶化」 + # + # TTM 股息率只统计**已除权**的现金,因此在窗口边界上必然有台阶: + # 上一年度分红满 365 天退出,而本年度分红可能还没除权。实测 + # 600690.SH 2026-07-30:FY2025 年度分红 0.89151 早在 2026-06-25 + # 就已实施公告(除权日 2026-08-21),当时可见现金只剩中期 0.26920, + # 分位 0.1% ≤ P25 触发清仓 —— 实际不该卖。 + # + # 规则:若「已公告未除权」的分红说明股息率本应更高,且 + # TTM ÷ (TTM + 已公告未除权) < min_ratio,则判定未确认: + # 保持仓位并记录 EXIT_UNCONFIRMED(不进成交流水)。 + # 真降息(公告金额本身就低或无公告)不会命中。 + # ---------------------------------------------------------- + confirm: + enabled: true + # 比值下限:0.8 表示「已公告分红足以把股息率抬高 25% 以上」才拦截 + min_ratio: 0.8 + # 只回溯这段时间内公告的分红(自然日) + announce_lookback_days: 400 + # ------------------------------------------------------------ # 仓位控制(plan.md §20) # ------------------------------------------------------------ diff --git a/docs/implementation-status.md b/docs/implementation-status.md index d361959..83483ae 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -462,6 +462,13 @@ 11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码** (其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除; SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。 +12. **配股未实现**:`hd_dividend` 里没有配股价/配股比例/缴款期字段, + `handle_rights_issue: true` 只会逐次 run 在 `unimplemented_json` 里声明该缺口。 +13. **红利税两处模型假设**(非漏实现,故不写入 `unimplemented`): + ① 在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时** + 按「买入→卖出」的实际持有期补缴、按分笔 FIFO; + ② **送股**(`stk_bo_rate`)按面值 1 元计入红利所得的个税未建模 + (转增 `stk_co_rate` 本就不征;持股 > 1 年者该税为 0)。 --- @@ -518,7 +525,7 @@ HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回 | `config/profile.yml` | **个股特性**(统计窗口/分位/波动频率/安全边际权重/TTM 口径) | | `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) | | `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 | -| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合 | +| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合/**排除行业清单** | | `config/report.yml` | 图表开关/输出命名/版面/资源模式 | | `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 | @@ -778,6 +785,196 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每 - **已有的画像与回测结果已过期**,需重跑 - 筛选结果需重新生成(`hd_universe_run` 会原地覆盖) +## 7.6b 已修复:TTM 股息率的两处残余缺陷与卖出复核(2026-10-05) + +7.6 的「按后继接管」只看了**相邻两次除权的间隔**,留下两个口子,共同表现是 +**回测出现实际不该成交的卖出**。用户报告的样本:`600690.SH` 在 2026-07-30 +触发清仓、2026-07-31 开盘卖出,实际不该卖。 + +### 缺陷一:同一除权日的多条「实施」记录被逐行累加 + +`hd_dividend` 写入侧刻意保留全量公告记录(决策 D6),去重键含 `ann_date`, +于是同一笔分红会有多条 `实施` 记录落在**同一个除权日**。查询侧若逐行累加, +`cash_div_tax` 被重复计入。实测 `600690.SH` 2012 年以来的每一笔都有两条, +`ttm_dps` 因此长期虚高约一倍(2.46848 vs 真实 1.23424)—— 随后窗口到期时 +又必然「回落」,把假象放大成一次 −78% 的塌陷。 + +- 规模:5724 只有现金分红的股票中 **953 只**存在同除权日重复(多出 1313 行) +- 修法:`dedupe_dividend_events()` 按 `(symbol, ex_date)` 聚合成**一笔经济事件** + (金额逐字段取最大 → 「分项 + 合计」收敛到合计;日期取最晚 → PIT 保守)。 + 三处入口统一调用:`ttm_dps_series`、`Repo.dividend_events`(回测现金入账)、 + `universe/filters/dividend.py`(年度 DPS / 支付率 / FCF 覆盖)。 + +### 缺陷二:只看相邻间隔,漏掉「年度 → 中期 → 下一年度」的跳法 + +实测 `600690.SH`:FY2023 年度 2024-08-16、FY2024 年度 2025-07-25、 +FY2025 中期 2025-11-07、FY2025 年度 2026-08-21。105 天的间隔让前两笔被判为 +「年内多次分红」而互不取代,392 天又超过 `365 + 45` —— 2026-07-25 ~ +2026-08-21 出现 **28 天空窗**,可见现金只剩 0.26920。 + +修法(`ttm_dps_series`):把覆盖窗口从「按相邻间隔」升级为「按财年(`end_date`)」: + +1. 后继接管(保留 7.6 行为,阈值 `ttm_days - grace_days` = 320 天); +2. **跨财年补位**:每个财年最后一笔 → 下一财年最后一笔入场,上限 `365 + grace`; +3. **末笔宽限兜底**:没有任何后继时覆盖 `365 + grace`(真停发仍如实归零)。 + +实测 600690 在 2026-07-27 的 `ttm_dps` 从 **0.53840(错误)** 变为 +**1.23424(= FY2024 年度 + FY2025 中期)**,股息率 5.30%、历史分位 92.98%, +**不再触发 P25 清仓**。 + +### 缺陷三(设计缺口):卖出只认「已除权的现金」,不认「已公告的分红」 + +FY2025 年度分红(0.89151)的**实施公告日在 2026-06-25**(股东大会通过), +只是除权日在 2026-08-21。TTM 现金口径看不到它,于是「股息率处于历史低位」 +在字面上为真、实际上描述的是**现金流时点**而非分红能力恶化。 + +修法(修复 3b):新增 `s.exit.confirm`: + +| 参数 | 默认 | 语义 | +|---|---|---| +| `enabled` | `true` | 关掉即回到修复前行为(可回滚) | +| `min_ratio` | `0.8` | `TTM ÷ (TTM + 已公告未除权)` 低于它 → 判定未确认 | +| `announce_lookback_days` | `400` | 只回溯这段时间内公告的分红 | + +- 取数:`Repo.announced_dividends(asof)` —— PIT 口径为 `ann_date <= asof AND ex_date > asof` + (与 `dividend_records` 的「已除权」互补); +- 命中时不再清仓,改为写一条 `HOLD` 信号(`skip_reason=EXIT_UNCONFIRMED`), + 并把 `exit_confirm` 明细留在 `reason_json`; +- `BacktestEngine.exit_unconfirmed` 计数进 run 结果,便于事后核查; +- **真降息不会被拦**:公告金额本身就低(或无公告)时 `pending ≈ 0`、比值 ≈ 1, + 照常清仓。 + +### 验收(真实数据) + +| 股票 | 信号日 | 修复前分位 | 修复后 | 结论 | +|---|---|---:|---:|---| +| 600690.SH | 2026-07-30 | 1.57% | 92.98% | 不再触发卖出(另有复核兜底) | +| 600015.SH | 2025-07-04 | 21.10% | 34.20% | 不再触发卖出 | +| 601009.SH | 2025-06-20 | 0.08% | 85.71% | 不再触发;复核也拦下 | +| 000333.SZ | 2026-06-17 | 0.25% | 93.73% | 不再触发;复核也拦下 | + +### 影响与后续 + +- 修复后**必须重跑**筛选 → 画像 → 回测(与 7.6 同理);旧 run 只能作为「修复前」基线; +- 买入侧同样受影响:`600690.SH` 2022-2023 的买入信号在真实口径下分位只有 + 47%~74%(< P75),原先的 89%~99% 全部来自重复累加; +- 新增测试:`tests/test_dividend_fiscal_year.py`(财年接管 7 例)、 + `tests/test_dividend_smoothing.py` 的经济事件口径 3 例、 + `tests/test_backtest.py::test_exit_confirmation_*`(复核 4 例); +- 诊断工具:`tools/diag_dividend_artifact.py`(`--symbol` / `--run-id` / + `--duplicate-survey`,只读)。 + +## 7.7 已修复:公司行为的三处静默错误(分红/送转/配股口径) + +排查「回测如何应对除权」时发现的问题,逐个修复。共同点是**都不会报错**, +只会让净值、现金或分红统计悄悄偏离。 + +### 问题一:纯送转被整行丢弃(凭空亏损) + +`_apply_dividends` 在算送股**之前**就按 `cash_div_tax <= 0` 整行 `continue`, +于是「10 送 10」这类**没有现金分红**的送转完全不调股数 —— +而价格是不复权价、除权日照常腰斩,回测于是记出一笔不存在的亏损。 + +实测影响面:全库 `实施` 且 `stk_div > 0` 共 13,038 行,其中**纯送转 3,268 行**; +高股息池成员在 2015-2026 区间内有 **824 笔**(0.3~1.2 股/股, +如 `000793.SZ` 每 10 股转增 12 股 → 单笔约 −54% 的该持仓市值)。 + +**修法**:现金与送转**各自独立判断**,只有「既无现金也无送转」的行才跳过; +同时把 `stk_bo_rate`(送股)/`stk_co_rate`(转增)写进分红台账留痕。 + +### 问题二:同一除权日的重复记录被重复入账 + +`hd_dividend` 写入侧刻意保留全量公告记录,而去重键含 `ann_date`, +于是同一 `(symbol, ex_date)` 会有多条 `实施` 记录:**全库 1401 组** +(1240 组字段完全相同;96 组报告期不同、122 组金额不同)。 +实测 `002352.SZ 2024-11-07` 同时存在 0.4 / 1.0 / 1.4 三条,而 **1.4 = 0.4 + 1.0** +是合计口径 —— 逐行累加会把同一笔分红算两三倍(现金与送转都被放大)。 + +**修法**:新增 `factor.dividend_yield.dedupe_dividend_events`,按经济事件聚合: +**金额/股数逐字段取最大**(收敛「分项 + 合计」,同时不会在 +「一行纯现金 + 一行纯送转」时丢掉送转)、**日期取最晚**(不引入未来函数)。 +三处入口统一调用,不再各写一份: + +| 入口 | 覆盖 | +|---|---| +| `ttm_dps_series`(**唯一** TTM 口径) | 筛选 `dividend_yield`、画像、回测信号、Web 图表 | +| 回测预载 `div_events` | 现金入账与送转股 | +| `DividendFilter._stats` / `_payout_and_cover` | 年度 DPS(CAGR/波动)、总现金分红(支付率/FCF 覆盖) | + +### 问题三:分红再投资的声明与行为不一致 + +引擎的实际行为一直是「分红现金回落到**与初始资金同一个** `cash` 变量, +下次调仓按目标权重再配置」—— 即 `reinvest` + `portfolio_rebalance`。 +但 `backtest.yml` 写的是 `reinvest_rule: same_stock_next_open`(同股次日开盘再投), +于是每次 run 都声明「未实现分红再投资规则,分红留存为现金」, +让人以为分红成了不可投资的资金。 + +**修法**:配置改为已实现组合 `cash_mode: reinvest` + `reinvest_rule: portfolio_rebalance`; +声明逻辑抽成 `dividend_handling_notes()`,**逐档取值**都有 unit test 对应: +`hold`/`cash_out`/`same_stock_next_open`/`handle_stock_dividend=false`/配股 才声明未实现。 +顺带接线一直是**死字段**的 `dividend.apply_dividend_tax`(原读的是 `cost.yml`)。 + +### 附带修正 + +- **送转后重算每股成本**:`avg_cost` 原先只随买入更新,送转后 `quantity` 增加而 + `avg_cost` 不变,卖出时 `cost_part = avg_cost × qty` 会多扣成本 + (1000 股 @10 送 0.3 后全卖 @10 记成 0 而非 +3000)。现在按 + `cost_basis / quantity` 重算;只影响逐笔 `realized_pnl` 与持仓表,不影响净值与对账。 +- **TTM 求和的 NaN 传染**:送转行的 `cash_div_tax` 是 NULL,`to_numpy(float64)` + 会把整条 TTM 序列变成 NaN(Web 股息率图因此空白)。现在按 0 处理。 + +### 影响与后续 + +实测(同一份代码,仅切换聚合口径): + +**回测 2021-01-01 ~ 2024-06-28**(高股息策略,闸门开): + +| 指标 | 逐行累加(旧) | 经济事件聚合(新) | 差 | +|---|---:|---:|---:| +| 累计现金分红(税后) | 179,550.91 | 179,258.24 | **−292.67(−0.16%)** | +| 累计代扣红利税 | 10,186.27 | 10,446.84 | +260.57 | +| 成交笔数 | 68 | 69 | +1 | +| 期末资金 | 1,213,171.28 | 1,213,283.32 | +112.04(+0.009%) | +| 最大回撤 | −19.31% | −19.31% | 0 | + +税与笔数的变化是**二阶效应**:TTM 股息率变了 → 分位信号变了 → 成交与持仓期变了。 +换句话说,股息率不是「一个统计数字」,它就是买卖点本身。 + +**TTM 每股分红**(`asof=2024-06-28`,全市场 5,128 只有分红历史的股票): +**163 只受影响(3.2%)**,虚高中位数 **+100%**、P90 +100%、最大 **+300%** +(`688133.SH` 0.4 → 0.1)。 + +**真正要紧的是池内成员**:跨所有 run 的高股息池入选成员共 75 只,其中 + +| asof | 受影响 | 例子(虚高幅度) | +|---|---:|---| +| 2021-06-30 | 2 / 75 | `600845.SH` +100%、`601601.SH` +8% | +| 2024-06-28 | 5 / 75 | `600489.SH` +100%、`600690.SH` +100%、`601009.SH` +100%、`600188.SH` +40%、`600600.SH` +28% | + +`600690.SH` 正是诊断脚本当初用来举例的那只 —— 说明这个毛刺一直在 +**直接决定这批股票的买卖点**,而不是只影响展示。 + +**最要紧的一层:它会改变谁能进池**。筛选的第一道门是「自算股息率 ≥ 3%」 +(`universe.yml: yield_source=computed`、`min_dividend_yield=0.03`), +而股息率正是被这个毛刺抬高的数字。实测 `asof=2024-06-28`: + +| | 逐行累加(旧) | 经济事件聚合(新) | +|---|---:|---:| +| `600690.SH` TTM 每股分红 | 1.13384 | **0.56692** | +| `600690.SH` 股息率 | **3.99%**(过 3% 门槛) | **2.00%**(不过) | +| `600690.SH` 拒绝原因 | FCF 覆盖 0.92x < 1.00x | 自算股息率 2.00% < 3.00% | + +把口径修正后重算受影响股票的门槛判定:**52 / 163 只**在旧口径下股息率 ≥ 3%、 +新口径下 < 3% —— 也就是说,重复记录**虚假地把 52 只股票送进了高股息候选池** +(`600368.SH` 5.99%→3.00%、`600662.SH` 5.99%→2.99%、`300360.SZ` 5.88%→2.94% …)。 +同时「总现金分红」被算高一倍,会把 `fcf_dividend_cover` 压低一半 +(`600690.SH` 0.92x vs 修正后的 ~1.84x),等于**用同一个错误把好公司又筛掉一次**。 + +- 聚合口径改变 TTM 股息率 → **股票池成员、画像与历史信号都会变**, + 旧 run 与新 run 不可直接比较,需要重跑(同 §7.6)。 +- 新增 13 个 unit test 覆盖 dedupe、送转/现金各档组合与未实现项声明;库里 + `hd_dividend` 数据本身未改动(聚合发生在查询侧,随时可回到逐行口径做对比)。 + ## 8. 测试覆盖 ``` @@ -1338,3 +1535,70 @@ cProfile 实测:6 个交易日里 `load_config` 被调用 **1008 次、共 16. | `hd_daily_universe` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) | + +--- + +## 11. 新增:回测层面的排除行业清单(2026-10-05) + +**需求**:回测里要能按行业拉黑名单,先排除房地产。 + +**为什么放在 `backtest.yml` 而不是 `universe.yml`**: + +| 文件 | 回答的问题 | +|---|---| +| `universe.yml` | 「高股息策略**本身**要求什么样的公司」——选股定义,与某次研究无关 | +| `backtest.yml: universe_exclusions` | 「**这次回测**特意不要哪些行业」——研究口径,如规避地产周期 | + +语义不同,所以分开放;生效时**取并集**,本清单只做减法。 + +### 最大的坑:数据库里没有「房地产业」 + +`stock.industry` 用的是 tushare 风格的有限枚举(当前 111 个取值), +房地产被拆成四个,**写「房地产」或「房地产业」一只都不会被排除**: + +| 行业名 | 内容 | 只数 | +|---|---|---:| +| `全国地产` | 全国性开发商(万科A、保利发展、金地集团…) | 26 | +| `区域地产` | 区域性开发商(滨江集团、华发股份…) | 43 | +| `房产服务` | 物业/中介/房产服务(招商积余、我爱我家…) | 13 | +| `园区开发` | 开发区与园区运营商(陆家嘴、张江高科…) | 14 | +| | **合计**(占全部 5903 只的 1.6%) | **96** | + +因此 `MarketFilter` 在第一次求值时拿名单与该表的实际取值核对, +**写错名字直接抛 `ConfigError`**(并按字符重合度提示最接近的真实取值), +而不是安静地什么都不排除 —— 后者正是本项目反复记录的失效形态。 + +### 接线(三个入口都必须走生效后的配置) + +| 入口 | 位置 | 说明 | +|---|---|---| +| `single` / `walkforward` 回测 | `BacktestEngine.__init__` → `self.universe_cfg` | 引擎自行逐调仓日重筛时用它建 `UniverseSelector` | +| walk-forward 训练段校准 | `WalkForwardRunner.selector()` | 冻结分布必须在**同一个**池子上标定,否则与测试段口径不一致 | +| `--mode daily` 逐日选股 | `DailyRunner.__init__` → `DailyUniverseScreener` | 逐日重建股票池时生效 | + +配套:删掉了 `DailyUniverseScreener.from_strategy(registry, strategy, ...)` —— +它拿不到 `backtest.yml`,保留就等于留了一条「逐日选股绕过行业排除」的路。 + +### 缓存陷阱(已修) + +`--mode daily` 的逐日选股有本地缓存,原键只含 +`registry.hash_of(strategy)`(策略 + `universe.yml`)。行业清单来自 +`backtest.yml`,**不进键就会命中旧缓存**:改了清单、日志写着已排除, +跑的却是排除之前的池子。键里已加入 `excl:<清单>`。 + +### 锁定它的测试 + +| 测试 | 锁什么 | +|---|---| +| `test_market_filter_excludes_listed_industries` | 名单命中即淘汰,未列入的不受影响 | +| `test_industry_exclusion_is_the_reported_reason` | 同时市值不足时,报出的必须是「行业被排除」 | +| `test_unknown_industry_name_raises_instead_of_silently_passing` | 写「房地产业」→ 报错并提示「全国地产」 | +| `test_backtest_config_excludes_real_estate_industries` | 生效配置必须真的在排房地产,且四个口径齐全 | +| `test_resolved_universe_is_a_union_not_an_override` | 叠加而非覆盖;不就地修改传入对象 | +| `test_engine_applies_universe_exclusions_to_selector` | 黑名单真的传到了 `market` 滤网 | +| `test_walkforward_and_daily_runners_apply_universe_exclusions` | 三个入口都接线 | +| `test_cache_key_depends_on_industry_exclusions` | 改清单必须换缓存文件 | + +> **尚未做**:Web 前端「回测条件」摘要(`web/service.describe_strategy`) +> 只读策略配置,不含 `backtest.yml`,因此列表页的文案里看不到这条排除规则。 +> 明细页的「回测配置」原文里能完整看到。 diff --git a/docs/user-guide.md b/docs/user-guide.md index 62fd139..8e67e47 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -255,6 +255,7 @@ CLI 会打印覆盖率,例如: | 分红 | 除权日入账(税后),进的是**与初始资金同一个可投资现金池**,下次调仓按目标权重再配置(`reinvest` + `portfolio_rebalance`,已实现;`hold`/`cash_out` 未实现) | | 送股 / 转增 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降)。**纯送转**(如 10 送 10:股价腰斩、股数翻倍,无现金分红)同样处理,不会被丢弃 | | 配股 | **未实现**(`handle_rights_issue` 不生效;库里也没有配股价/比例数据) | +| 同一除权日多条记录 | 按**一笔经济事件**聚合(金额/股数逐字段取最大、日期取最晚),不重复入账 | | 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) | > 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**, @@ -627,9 +628,35 @@ industry_exemptions: > 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红 > (中期+年度),彼此都保留;超过 `365 + grace_days` 仍无后继则如实归零。 > +> **财年接管(2026-10-05 增补)**:只看相邻间隔会漏掉 +> 「上一年度 → 本年度中期 → 本年度年度」这种跳法。实测 600690.SH: +> FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、FY2025 年度 2026-08-21 —— +> 105 天的间隔让前两笔互不取代,392 天又超过 `365 + grace`, +> 于是 2026-07-25 ~ 2026-08-21 出现 28 天空窗(TTM 从 1.23424 掉到 0.26920), +> 直接造成一次不该发生的卖出。现在按 `end_date`(报告期)识别财年: +> 每个财年的最后一笔会补位到下一财年最后一笔的除权日,仍以 `365 + grace` 封顶, +> 真停发照旧归零。事件表没有 `end_date` 时该规则自动跳过(保守,不猜)。 +> > 实测效果:招商银行 >20% 跳变 21 → 5 次,中国银行虚低归零 77 天 → 0 天。 > 若个别股票仍有断档,把 `grace_days` 调大(如 90)。 +**卖出复核(`exit.confirm`,2026-10-05 新增)** + +TTM 现金口径在窗口边界上必然有台阶:一笔分红满 365 天退出,而下一笔年度分红 +可能**还没除权**。若只看已除权现金,这种台阶会被误读成「分红能力恶化」而清仓。 +实测 600690.SH 2026-07-30:FY2025 年度分红(0.89151)早在 **2026-06-25** +就已实施公告,只是除权日在 2026-08-21。 + +| 参数 | 默认 | 语义 | +|---|---|---| +| `exit.confirm.enabled` | `true` | 关掉即回到修复前行为(可回滚) | +| `exit.confirm.min_ratio` | `0.8` | `TTM ÷ (TTM + 已公告未除权)` 低于它 → 判定未确认 | +| `exit.confirm.announce_lookback_days` | `400` | 只回溯这段时间内公告的分红 | + +命中时不清仓,改为写一条 `HOLD` 信号(`skip_reason=EXIT_UNCONFIRMED`), +`reason_json.exit_confirm` 里有比值明细;被拦次数进 `hd_backtest_run` 结果。 +**真降息不会被拦**:公告金额本身就低(或无公告)时 `pending ≈ 0`、比值 ≈ 1。 + --- ## 4.3 `config/strategy/high_dividend_v1.yml` — 策略定义 ★ @@ -827,6 +854,47 @@ period: { start: 2015-01-01, end: latest } | `step_months` | `12` | 窗口步进 | | `freeze_params_in_test` | `true` | **必须为 true**(配置层强制拒绝关闭) | +### 排除行业清单 `universe_exclusions`(黑名单) + +```yaml +universe_exclusions: + # 房地产(DB 没有「房地产业」这个标签,实际是这四个行业名) + industries: [全国地产, 区域地产, 房产服务, 园区开发] +``` + +命中即淘汰,`single` / `walkforward` / `daily` **三种模式一律生效**。 + +| 要点 | 说明 | +|---|---| +| 匹配方式 | 与 `stock.industry` **逐字精确匹配**(不支持通配/模糊) | +| 与 `universe.yml` 的关系 | **叠加取并集**,不是覆盖。本清单只会让股票池变小,不会放宽任何条件 | +| 留空 | `industries: []` = 不排除任何行业;此时行为与不带本配置逐字一致 | +| 写错名字 | **直接报错并提示最接近的真实取值**(不会静默地一只不排) | +| 留痕 | 完整写入 `hd_backtest_run.backtest_config_json` | + +> **为什么不在 `universe.yml` 里**:`universe.yml` 描述「高股息策略本身要求 +> 什么样的公司」(选股定义,与某次研究无关);本清单描述「这次回测特意不要 +> 哪些行业」(研究口径,如规避地产周期)。语义不同,所以分开放。 + +> ⚠️ **「房地产业」在库里不存在**。`stock.industry` 把它拆成了四个值, +> 只写「房地产」或「房地产业」会**一只都不排除**(系统会直接报错拦住你): +> +> | 行业名 | 内容 | 只数 | +> |---|---|---:| +> | `全国地产` | 全国性开发商(万科A、保利发展、金地集团…) | 26 | +> | `区域地产` | 区域性开发商(滨江集团、华发股份…) | 43 | +> | `房产服务` | 物业/中介/房产服务(招商积余、我爱我家…) | 13 | +> | `园区开发` | 开发区与园区运营商(陆家嘴、张江高科…) | 14 | +> +> 合计 **96 只**(占全部 5903 只的 1.6%)。查当前全部取值: +> +> ```sql +> SELECT industry, COUNT(*) FROM stock GROUP BY industry ORDER BY 2 DESC; +> ``` + +> **注意**:`daily` 模式的逐日选股有本地缓存(缓存键已含本清单)。 +> 改清单后缓存自动失效、会重新筛;也可用 `--refresh-pools` 强制重筛。 + ### 其他 | 字段 | 默认 | 含义 | @@ -1936,6 +2004,30 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un 下次调仓时按目标权重再配置;`hold`(永久留存)与 `cash_out`(移出组合)才是未实现分支。 用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。 +**经济事件口径(同一除权日只算一笔)**:`hd_dividend` 写入侧保留全量公告记录, +同一 `(symbol, ex_date)` 可能有不止一条 `实施` 记录(全库 1401 组,其中 1240 组字段 +完全相同;实测 002352.SZ 2024-11-07 同时存在 0.4 / 1.0 / 1.4 三条,1.4 = 0.4 + 1.0)。 +凡是把 `cash_div_tax` 逐行累加的实现都会重复计入,于是: + +| 位置 | 聚合方式 | +|---|---| +| 回测分红/送转入账 | 预载时调用 `factor.dividend_yield.dedupe_dividend_events` | +| TTM 股息率(`ttm_dps_series`) | **入口统一聚合**,筛选/画像/回测/Web 图表全部受益 | +| 筛选的年度 DPS、总现金分红(支付率/FCF 覆盖) | `DividendFilter` 用同一函数聚合 | + +聚合口径:**金额/股数逐字段取最大**(把「分项 + 合计」收敛到合计,同时不会在 +「一行纯现金 + 一行纯送转」时丢掉送转)、**日期取最晚**(不会引入未来函数)。 +诊断脚本 `tools/diag_dividend_artifact.py` 可用 `dedupe_events=False` 复现去重前的毛刺。 + +> 该口径修正会改变 TTM 股息率 → **股票池成员、画像与历史信号都会变**, +> 旧 run 与新 run 的绩效不可直接比较,需要重跑。 +> 实测规模:`asof=2024-06-28` 时全市场 5,128 只有分红历史的股票里 **163 只**受影响 +> (虚高中位数 +100%、最大 +300%);其中 **52 只**在旧口径下股息率 ≥ 3%、 +> 修正后 < 3% —— 重复记录曾**虚假地把它们送进高股息候选池**。 +> 跨所有 run 的高股息池入选成员 75 只中,2024-06-28 有 5 只受影响 +> (`600489.SH`/`600690.SH`/`601009.SH` 各虚高 100%,`600690.SH` 3.99% → 2.00%)。 +> 回测 2021-01-01~2024-06-28:累计现金分红 −0.16%、成交 68→69 笔、最大回撤不变。 + > **两处已知口径简化**(不产生 `unimplemented` 声明,因为这是模型假设而非漏实现): > ① 红利税在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时** > 按「买入→卖出」的实际持有期补缴,且按分笔 FIFO;② **送股**(`stk_bo_rate`) diff --git a/src/hdiv/backtest/daily.py b/src/hdiv/backtest/daily.py index a027fa2..b93a1fb 100644 --- a/src/hdiv/backtest/daily.py +++ b/src/hdiv/backtest/daily.py @@ -118,6 +118,12 @@ class DailyRunner: self.strategy = self.registry.load(strategy_path) self.bt: BacktestConfig = load_config("backtest") self.daily: DailyConfig = self.bt.daily + #: 叠加了 backtest.yml ``universe_exclusions``(行业黑名单)之后的筛选配置。 + #: 逐日选股必须用它 —— 直接用 registry.resolved_universe 会绕过行业排除, + #: 让「配置里排除了房地产」与「每天选出来的池子」互相矛盾。 + self.universe_cfg = self.bt.resolved_universe( + self.registry.resolved_universe(self.strategy) + ) @classmethod def from_strategy(cls, path: str | Path) -> DailyRunner: @@ -245,9 +251,7 @@ class DailyRunner: ) else: # --- 保守预剪枝 --- - screener = DailyUniverseScreener.from_strategy( - self.registry, self.strategy, repo, verbose=False - ) + screener = DailyUniverseScreener(self.universe_cfg, repo, verbose=False) allowed = screener.build_prune_set(start, end) if verbose: p = screener.prune.as_dict() @@ -370,15 +374,25 @@ class DailyRunner: def _pools_cache_key(self, start: date, end: date, step: int) -> str: """选股缓存的指纹:**只由输入决定**,不含时间戳。 - ``step``(股票池重建频率)必须在键里:用 1 日/5 日筛出的池子是不同的输入, - 共用一份缓存会静默给出错的股票池。信号频率(``--signal-every-n-days``) - **不在**键里 —— 它只影响模拟,不影响选股结果。 + 必须在键里的东西,都是**会改变选股结果**的输入: + + - ``step``(股票池重建频率):用 1 日/5 日筛出的池子是不同的输入, + 共用一份缓存会静默给出错的股票池; + - ``industry_exclusions``(行业黑名单):改名单就换了筛选口径。 + 它来自 ``backtest.yml`` 而 ``registry.hash_of(strategy)`` 只覆盖 + 策略 + ``universe.yml``,所以**必须显式加进键** —— 否则改了排除清单 + 却命中旧缓存,跑出来的是「排除了房地产之前」的池子, + 而日志上写着已排除,属于最难发现的一类失效。 + + 信号频率(``--signal-every-n-days``)**不在**键里 —— + 它只影响模拟,不影响选股结果。 """ return stable_id( "dailypools", self.strategy.strategy.id, self.strategy.strategy.version, self.registry.hash_of(self.strategy), + "excl:" + ",".join(self.universe_cfg.industry_exclusions), str(start), str(end), str(int(step)), ) diff --git a/src/hdiv/backtest/engine.py b/src/hdiv/backtest/engine.py index e4ddd12..76bec1f 100644 --- a/src/hdiv/backtest/engine.py +++ b/src/hdiv/backtest/engine.py @@ -28,16 +28,20 @@ from hdiv.core.config import ( BacktestConfig, CostConfig, StrategyConfig, - config_hash, load_config, ) from hdiv.core.errors import DataGapError, HdivError from hdiv.data import db from hdiv.data.repo import Repo, data_version from hdiv.data.sync.base import stable_id -from hdiv.factor.dividend_yield import build_dps_events, ttm_dps_series, ttm_params +from hdiv.factor.dividend_yield import ( + build_dps_events, + dedupe_dividend_events, + ttm_dps_series, + ttm_params, +) from hdiv.strategy.registry import StrategyRegistry - +from hdiv.universe.selector import UniverseSelector # --------------------------------------------------------------------------- # 持仓与订单 @@ -232,7 +236,9 @@ class BacktestEngine: # 默认拒绝;只有显式放行才执行,且必须把「含未来信息」写进 run 记录。 self.allow_lookahead_universe = allow_lookahead_universe self.lookahead_universe_note: str | None = None - self.universe_cfg = self.registry.resolved_universe(strategy) + self.universe_cfg = self.bt_cfg.resolved_universe( + self.registry.resolved_universe(strategy) + ) # 实时画像闸门(PIT):关闭时整条链路不参与,回测行为与启用前一致 self.gate_cfg = strategy.entry.profile_gate self.pit: Any = None @@ -252,6 +258,10 @@ class BacktestEngine: #: 买卖决策发生时计算并留痕个股画像(不区分是否在当日池内) self.profile_on_trade = profile_on_trade self.profile_window_years = profile_window_years + #: 已公告未除权的现金分红 {symbol: [(公告日, 每股金额)]},由 _prepare 填充 + self.pending_div: dict[str, list[tuple[date, float]]] = {} + #: 被卖出复核拦下的清仓次数(修复 3b 的留痕) + self.exit_unconfirmed = 0 @classmethod def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine: @@ -340,6 +350,7 @@ class BacktestEngine: bt.capital.initial, ), "unimplemented": state["unimplemented"], + "exit_unconfirmed": state.get("exit_unconfirmed", 0), "profile_gate": self.pit.stats() if self.pit is not None else None, } if self.pit is not None: @@ -506,7 +517,10 @@ class BacktestEngine: flush=True, ) else: - selector = self.registry.selector(s) + # 用 self.universe_cfg 而不是 registry.selector(s): + # 前者已叠加 backtest.yml 的 universe_exclusions(行业黑名单), + # 后者只含策略/筛选配置 —— 走后者会让行业排除在回测里静默失效。 + selector = UniverseSelector(self.universe_cfg, repo=self.repo) for rd in refresh_dates: res = selector.run(asof=rd, persist=False, verbose=False) universe_by_refresh[rd] = set(res["selected"]["symbol"].tolist()) @@ -559,13 +573,49 @@ class BacktestEngine: ) # --- 分红事件(含送转),用于持仓期间的现金与股数调整 --- - div_events = self.repo.dividend_events(days[0], days[-1]) + # 必须先按经济事件聚合:同一 (symbol, ex_date) 可能有多条 `实施` 记录 + # (全库 1401 组)。逐行入账会把同一笔现金分红重复计入、把送转股重复放大 + # (实测 002352.SZ 2024-11-07 同时有 0.4/1.0/1.4 三条,合计口径 1.4)。 + div_events = dedupe_dividend_events(self.repo.dividend_events(days[0], days[-1])) div_events = div_events[div_events["symbol"].isin(set(all_syms))] div_by_date: dict[date, list[dict]] = {} for r in div_events.to_dict("records"): ex = pd.to_datetime(r["ex_date"]).date() div_by_date.setdefault(ex, []).append(r) + # --- 已公告未除权的分红(PIT)--- + # 卖出复核用(s.exit.confirm):TTM 股息率在窗口边界上会因「上一年度 + # 分红到期、本年度还没除权」而出现台阶。实测 600690.SH 2026-07-30: + # 可见现金只剩 0.26920,而 FY2025 年度 0.89151 早在 2026-06-25 就已 + # 实施公告(除权日 2026-08-21)—— 只看已除权现金会把它误读成「分红 + # 能力恶化」并清仓。这里把「当时确实可知」的已公告金额预载成 + # {symbol: [(ann_date, per_share 累计)]},逐日累加后在 _evaluate 里复核。 + self.pending_div = {} + if s.exit.confirm.enabled: + pend = self.repo.announced_dividends( + days[-1], lookback_days=s.exit.confirm.announce_lookback_days + ) + if not pend.empty: + # 同一 (symbol, ex_date) 可能有多条公告记录 → 先按经济事件聚合 + pend = dedupe_dividend_events(pend) + pend = pend[pend["symbol"].isin(set(all_syms))] + for r in pend.to_dict("records"): + # 用 **ann_date**(预案/股东大会通过/实施的首次公告日)作为可见起点: + # 实测 600690.SH 的 FY2025 年度分红 2026-06-25 就已「股东大会通过」 + # 并公告,而 imp_ann_date 要到 2026-08-15 —— 若用后者,8 月 15 日 + # 之前那笔客观存在的公告信息就被当成不可知,复核形同虚设。 + # SQL 已保证 ann_date 非空,这里仍做 NaN 防御(NaT 与 date 比较会抛错)。 + ann = r.get("ann_date") + if ann is None or pd.isna(ann): + continue + ann_d = pd.to_datetime(ann).date() + ps = float(r.get("cash_div_tax") or 0.0) + if ps <= 0: + continue + self.pending_div.setdefault(r["symbol"], []).append((ann_d, ps)) + for sym in self.pending_div: + self.pending_div[sym].sort(key=lambda x: x[0]) + # --- 停牌与涨跌停 --- suspend = self._load_suspend(all_syms, days[0], days[-1]) limits = self._load_limits(all_syms, days[0], days[-1]) @@ -833,6 +883,7 @@ class BacktestEngine: "total_dividend_net": float(div_df["net"].sum()) if not div_df.empty else 0.0, "total_dividend_tax": float(div_df["tax"].sum()) if not div_df.empty else 0.0, "unimplemented": sorted(unimplemented), + "exit_unconfirmed": int(self.exit_unconfirmed), } # ------------------------------------------------------------------ @@ -947,9 +998,25 @@ class BacktestEngine: if target is None: continue # 死区:保持仓位 if abs(target) <= 1e-9: + # 清仓前复核(修复 3b):TTM 股息率的窗口台阶可能来自 + # 「上一年度分红到期、本年度还没除权」,而不是分红能力恶化。 + # 若已公告未除权的分红足以把股息率抬高,则保持仓位。 + ok, cinfo = self._exit_confirmed(sym, day, current) + if not ok: + out.append(Signal( + sym, day, "HOLD", 0.0, current, pct, price, + {**common, **{"exit_confirm": cinfo}, + "rule": f"分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}," + f"但已公告未除权分红 {cinfo['pending_dps']:.4f} 元/股" + f"(比值 {cinfo['ratio']:.2f} < {cinfo['min_ratio']:.2f})" + f"→ 未确认,保持仓位", + "reason_cn": "股息率回落主要由现金流时点造成,卖出未确认", + "skip_reason": "EXIT_UNCONFIRMED", "executed": False}, + )) + continue out.append(self._trade_signal(Signal( sym, day, "SELL", 0.0, current, pct, price, - {**common, + {**common, "exit_confirm": cinfo, "rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}", "reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"}, ), day)) @@ -1086,6 +1153,46 @@ class BacktestEngine: "skip_reason": "PROFILE_GATE", "executed": False}, ) + def _pending_dps(self, sym: str, day: date) -> float: + """截至 ``day`` **已公告但尚未除权**的每股税后现金分红之和(PIT)。""" + rows = self.pending_div.get(sym) + if not rows: + return 0.0 + total = 0.0 + for ann_d, ps in rows: + if ann_d <= day: + total += ps + else: + break # 已按公告日升序排列 + return total + + def _exit_confirmed( + self, sym: str, day: date, current: float + ) -> tuple[bool, dict[str, Any]]: + """卖出复核(修复 3b):判断清仓信号是否被「已公告未除权分红」证伪。 + + 语义:``current`` 是**已除权现金**口径的 TTM 股息率。若同时存在已公告、 + 除权日未到的分红,则「完整口径」应为 ``current + pending``。当 + ``current / (current + pending) < min_ratio`` 时,说明当前的低股息率 + 主要是现金流时点造成的 —— 保持仓位,记录 ``EXIT_UNCONFIRMED``。 + + 真降息不会被拦:公告金额本身就低(甚至没有公告)时 ``pending ≈ 0``, + 比值接近 1,照常清仓。 + """ + c = self.strategy.exit.confirm + info: dict[str, Any] = {"enabled": bool(c.enabled)} + if not c.enabled: + return True, info + pending = self._pending_dps(sym, day) + total = current + pending + ratio = (current / total) if total > 0 else 1.0 + info.update({"pending_dps": round(pending, 6), "ratio": round(ratio, 4), + "min_ratio": c.min_ratio}) + if pending <= 0 or ratio >= c.min_ratio: + return True, info + self.exit_unconfirmed += 1 + return False, info + def _reference_window(self, day: date) -> tuple[date, date] | None: if self.frozen_reference is not None: return self.frozen_reference diff --git a/src/hdiv/backtest/walk_forward.py b/src/hdiv/backtest/walk_forward.py index 4703bc3..50a55cd 100644 --- a/src/hdiv/backtest/walk_forward.py +++ b/src/hdiv/backtest/walk_forward.py @@ -34,6 +34,7 @@ from hdiv.data import db from hdiv.data.repo import Repo, data_version from hdiv.data.sync.base import stable_id from hdiv.strategy.registry import StrategyRegistry +from hdiv.universe.selector import UniverseSelector @dataclass @@ -54,11 +55,21 @@ class WalkForwardRunner: self.strategy = self.registry.load(strategy_path) self.bt: BacktestConfig = load_config("backtest") self.repo = Repo() + #: 叠加了 backtest.yml ``universe_exclusions``(行业黑名单)之后的筛选配置。 + #: 训练段的冻结分布校准与各窗口引擎必须用**同一份**:否则冻结分布是在 + #: 含被排除行业的池子上标定的,与测试段实际能买的池子口径不一致。 + self.universe_cfg = self.bt.resolved_universe( + self.registry.resolved_universe(self.strategy) + ) @classmethod def from_strategy(cls, path: str | Path) -> WalkForwardRunner: return cls(path) + def selector(self) -> UniverseSelector: + """用**生效后**的筛选配置(含 backtest.yml 的行业排除)建选择器。""" + return UniverseSelector(self.universe_cfg, repo=self.repo) + # ------------------------------------------------------------------ # 窗口切分 # ------------------------------------------------------------------ @@ -231,7 +242,7 @@ class WalkForwardRunner: ttm_params, ) - sel = self.registry.selector(self.strategy) + sel = self.selector() res = sel.run(asof=end, persist=False, verbose=False) syms = res["selected"]["symbol"].tolist() if not syms: diff --git a/src/hdiv/core/config.py b/src/hdiv/core/config.py index d1fdb77..edb2eba 100644 --- a/src/hdiv/core/config.py +++ b/src/hdiv/core/config.py @@ -218,6 +218,13 @@ class UniverseConfig(StrictModel): industry_exemptions: IndustryExemptionsConfig = Field( default_factory=IndustryExemptionsConfig ) + #: 股票池行业**排除**清单(黑名单)。命中即在股票池阶段淘汰。 + #: 默认空 = 不排除。回测运行时 config/backtest.yml 的 universe_exclusions + #: 会**叠加**到本字段上(见 BacktestConfig.resolved_universe), + #: 因此这里也可以直接写死一个全局排除集。 + #: 名称必须与 stock.industry 逐字一致;「房地产业」在库里被拆成 + #: 全国地产 / 区域地产 / 房产服务 / 园区开发 四个值。 + industry_exclusions: list[str] = Field(default_factory=list) market: MarketFilterConfig = Field(default_factory=MarketFilterConfig) risk: RiskFilterConfig = Field(default_factory=RiskFilterConfig) dividend: DividendFilterConfig = Field(default_factory=DividendFilterConfig) @@ -560,6 +567,44 @@ class DailyConfig(StrictModel): return self +class UniverseExclusionsConfig(StrictModel): + """回测层面的股票池排除清单(黑名单)。 + + **为什么放在 backtest.yml 而不是 universe.yml**:universe.yml 描述的是 + 「高股息策略本身要求什么样的公司」(选股定义,与某一次研究无关); + 本清单描述的是「这次回测特意不要哪些行业」(研究口径,例如规避地产周期)。 + 两者语义不同,所以分开存放,生效时取并集。 + + **刻意只支持精确匹配**:``stock.industry`` 是有限枚举(当前 111 个取值), + 模糊匹配会让「排除房地产」意外命中「房地产服务」与否变得不可预测。 + 而配置里写错一个不存在的行业名会**静默不排除任何东西** —— + 因此 :class:`~hdiv.universe.filters.market.MarketFilter` 在第一次求值时会 + 拿名单与该表的实际取值核对,写错名字**直接报错**(并提示最接近的候选), + 而不是安静地什么都不排除。 + """ + + #: 要排除的行业名;必须与 ``stock.industry`` 逐字一致 + industries: list[str] = Field(default_factory=list) + + @model_validator(mode="after") + def _check(self) -> UniverseExclusionsConfig: + seen: set[str] = set() + dup: list[str] = [] + for x in self.industries: + if x in seen: + dup.append(x) + seen.add(x) + if dup: + raise SchemaValidationError( + f"universe_exclusions.industries 存在重复项:{dup}" + ) + if any(not x.strip() for x in self.industries): + raise SchemaValidationError( + "universe_exclusions.industries 含空字符串;不要就写 []" + ) + return self + + class BacktestConfig(StrictModel): version: int = 1 capital: CapitalConfig = Field(default_factory=CapitalConfig) @@ -571,6 +616,9 @@ class BacktestConfig(StrictModel): walk_forward: WalkForwardConfig = Field(default_factory=WalkForwardConfig) daily: DailyConfig = Field(default_factory=DailyConfig) dividend: DividendHandlingConfig = Field(default_factory=DividendHandlingConfig) + universe_exclusions: UniverseExclusionsConfig = Field( + default_factory=UniverseExclusionsConfig + ) benchmark: list[BenchmarkConfig] = Field(default_factory=list) risk_free_rate: float = 0.02 fill: FillConfig = Field(default_factory=FillConfig) @@ -583,6 +631,25 @@ class BacktestConfig(StrictModel): raise SchemaValidationError(f"benchmark 存在重复代码:{codes}") return self + def resolved_universe(self, universe: UniverseConfig) -> UniverseConfig: + """把本文件的行业排除清单**叠加**到筛选配置上,返回新的 UniverseConfig。 + + - 叠加而非覆盖:``universe.industry_exclusions`` 与 + ``self.universe_exclusions.industries`` 取并集(去重、保持稳定顺序), + 所以本清单只会让股票池变小。 + - **不改动传入对象**:UniverseConfig 可能来自 ``lru_cache`` 或调用方复用, + 就地修改会污染后续调用(尤其在 walk-forward 的多窗口循环里)。 + - 无排除项时原样返回,保证「不配 = 行为与改动前逐字一致」。 + """ + merged = list( + dict.fromkeys( + [*universe.industry_exclusions, *self.universe_exclusions.industries] + ) + ) + if merged == list(universe.industry_exclusions): + return universe + return universe.model_copy(update={"industry_exclusions": merged}) + # --------------------------------------------------------------------------- # report.yml @@ -773,11 +840,46 @@ class EntryConfig(StrictModel): return self +class ExitConfirmConfig(StrictModel): + """卖出前的一致性复核(修复 3b:防「现金流时点」造成的误清仓)。 + + 问题:TTM 每股分红在窗口边界上必然有台阶 —— 一笔分红满 365 天退出,而 + 下一笔年度分红可能还没**除权**。实测 600690.SH 2026-07-30:FY2025 年度 + 分红(0.89151)的**实施公告**在 2026-06-25 就已发布(股东大会通过), + 只是除权日在 2026-08-21,于是当时可见的「已除权现金」只剩中期 0.26920。 + 原始 TTM 的「分位 0.1% ≤ P25」在字面上为真,描述的是**现金流时点**, + 不是分红能力恶化。 + + 复核规则:触发清仓信号时,若「已公告未除权」的分红(PIT:公告日 ≤ 当日) + 说明股息率本应更高,且当前 TTM ÷(TTM + 已公告未除权每股分红)低于 + ``min_ratio``,则判定为未确认 —— 保持仓位并记录 ``EXIT_UNCONFIRMED``。 + 真降息(公告金额本身就低)不会命中该规则。 + """ + + enabled: bool = True + #: 当前 TTM ÷(TTM + 已公告未除权每股分红)的下限;低于它即视为未确认。 + #: 0.8 表示「已公告分红足以把股息率抬高 25% 以上」时才拦截。 + min_ratio: float = 0.8 + #: 只回溯这段时间内公告的分红(自然日) + announce_lookback_days: int = 400 + + @model_validator(mode="after") + def _check(self) -> ExitConfirmConfig: + if not 0.0 < self.min_ratio <= 1.0: + raise SchemaValidationError( + f"exit.confirm.min_ratio 必须落在 (0, 1],当前 {self.min_ratio}" + ) + if self.announce_lookback_days <= 0: + raise SchemaValidationError("exit.confirm.announce_lookback_days 必须为正") + return self + + class ExitConfig(StrictModel): yield_percentile: float scale_out: list[ScaleStep] = Field(default_factory=list) stop_loss_pct: float | None = None max_holding_days: int | None = None + confirm: ExitConfirmConfig = Field(default_factory=ExitConfirmConfig) @model_validator(mode="after") def _check(self) -> ExitConfig: diff --git a/src/hdiv/data/repo.py b/src/hdiv/data/repo.py index 23bc13c..0ee288e 100644 --- a/src/hdiv/data/repo.py +++ b/src/hdiv/data/repo.py @@ -468,6 +468,51 @@ class Repo: # 分红(PIT) # ------------------------------------------------------------------ + def announced_dividends( + self, asof: date, *, lookback_days: int = 400 + ) -> pd.DataFrame: + """**已公告但尚未除权**的现金分红(卖出复核用,修复 3b)。 + + 与 :meth:`dividend_records` 的 PIT 语义互补: + + - ``dividend_records`` 回答「哪些现金**已经**落到股东手里」—— + 用了 ``imp_ann_date <= asof AND ex_date <= asof``; + - 本方法回答「哪些分红**已经公告**、只是除权日还没到」—— + 这是当时**确实可知**的信息(``ann_date``/``imp_ann_date`` ≤ asof), + 只是还没进入 TTM 现金流口径。 + + 用途:TTM 股息率在窗口边界上会因「上一年度分红到期、本年度还没除权」 + 而出现台阶(实测 600690.SH 2026-07-30:可见现金只剩 0.26920,而 + FY2025 年度 0.89151 早在 2026-06-25 就已实施公告)。若只看已除权现金, + 这个台阶会被误读为「分红能力恶化」并触发清仓。 + + 返回列:``symbol / end_date / ann_date / imp_ann_date / cash_div_tax / ex_date``, + 仅含 ``cash_div_tax > 0`` 且 ``ex_date > asof`` 的记录。 + """ + if not db.table_exists("hd_dividend", self.cfg): + return pd.DataFrame( + columns=["symbol", "end_date", "ann_date", "imp_ann_date", + "cash_div_tax", "ex_date"] + ) + since = asof - timedelta(days=int(lookback_days)) + sql = """ + SELECT symbol, end_date, ann_date, imp_ann_date, div_proc, + cash_div_tax, ex_date, base_share + FROM hd_dividend + WHERE ex_date IS NOT NULL AND ex_date > :asof + AND ann_date IS NOT NULL AND ann_date <= :asof + AND ann_date >= :since + AND cash_div_tax > 0 + ORDER BY symbol, ex_date + """ + df = db.read_sql(sql, {"asof": asof, "since": since}, cfg=self.cfg) + for c in ("end_date", "ann_date", "imp_ann_date", "ex_date"): + if c in df.columns and not df.empty: + df[c] = pd.to_datetime(df[c]).dt.date + if "cash_div_tax" in df.columns: + df["cash_div_tax"] = pd.to_numeric(df["cash_div_tax"], errors="coerce") + return df + def dividend_records( self, asof: date, *, years_back: int = 12, implemented_only: bool = True ) -> pd.DataFrame: diff --git a/src/hdiv/factor/dividend_yield.py b/src/hdiv/factor/dividend_yield.py index 821c8a9..0ee03b4 100644 --- a/src/hdiv/factor/dividend_yield.py +++ b/src/hdiv/factor/dividend_yield.py @@ -11,6 +11,11 @@ PIT 纪律:序列上每个日期 t 只使用 ``imp_ann_date <= t`` 且 ``ex_date <= t`` 的分红。 由于 ``ex_date <= t`` 已隐含「已发生」,实现上按 ex_date 归集即可; ``imp_ann_date <= t`` 用于剔除「事后才公告」的记录(极少但存在)。 + +**经济事件口径**:``hd_dividend`` 写入侧保留全量公告记录,同一 ``(symbol, ex_date)`` +可能有不止一条 ``实施`` 记录(全库 1401 组)。任何把 ``cash_div_tax`` 逐行累加的 +实现都会把这笔分红重复计入 —— 因此 :func:`ttm_dps_series` 在入口统一调用 +:func:`dedupe_dividend_events` 聚合,调用方不需要各自去重。 """ from __future__ import annotations @@ -23,6 +28,80 @@ import pandas as pd # 默认统计窗口(自然日) TTM_DAYS = 365 +#: 聚合同一经济事件时按「逐字段取最大」处理的金额/股数字段 +_DIV_AMOUNT_FIELDS = ("cash_div_tax", "cash_div", "stk_div", "stk_bo_rate", "stk_co_rate") +#: 聚合时取**最晚**日期的字段 —— PIT 保守:宁可晚看到,不可早看到 +_DIV_DATE_FIELDS = ("ann_date", "imp_ann_date", "end_date", "record_date", "ex_date", + "pay_date") + + +def dedupe_dividend_events(events: pd.DataFrame) -> pd.DataFrame: + """把同一 ``(symbol, ex_date)`` 的多条分红记录聚合成**一笔经济事件**。 + + **为什么必须聚合**:``hd_dividend`` 的写入侧刻意保留预案/股东大会通过/实施的 + 全量记录(决策 D6),而它的去重键含 ``ann_date``,于是同一笔分红会有多条 + ``实施`` 记录落在同一个除权日。实测:全库 1401 组(其中 1240 组字段完全相同); + 002352.SZ 2024-11-07 同时存在 0.4、1.0、1.4 三条,而 1.4 = 0.4 + 1.0 的合计口径。 + 查询侧若逐行累加,现金分红会被重复计入、送转股会被重复放大。 + + 聚合口径(即 ``tools/diag_dividend_artifact.py`` 所说的「查询口径按经济事件聚合」): + + - **金额/股数逐字段取最大**:既能把「分项 + 合计」收敛到合计(002352.SZ 取 1.4), + 又不会像「整行取最大」那样在「一行纯现金 + 一行纯送转」时丢掉送转股; + - **日期取最晚**:每条金额都在最晚公告日之前已经公告,因此不会引入未来函数 + (宁可晚看到,不可早看到); + - 其余字段取首次出现的值;缺值按 0 处理(``max`` 跳过 NaN,不会把 0 当成有效 + 金额覆盖真实值)。 + + 输入已无重复时**原样返回**(热路径短路:``ttm_dps_series`` 会在每只股票上调用)。 + """ + if events is None or getattr(events, "empty", True): + return events + keys = [c for c in ("symbol", "ex_date") if c in events.columns] + if "ex_date" not in keys: + return events + if not events.duplicated(subset=keys).any(): + return events + df = events.copy() + df["ex_date"] = pd.to_datetime(df["ex_date"], errors="coerce") + df = df[df["ex_date"].notna()] + if df.empty: + return df + for c in _DIV_AMOUNT_FIELDS: + if c in df.columns: + # NaN → 0:纯送转行的 cash_div_tax 是 NULL,不能让 NaN 传染进 TTM 求和 + df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0.0) + for c in _DIV_DATE_FIELDS: + if c in df.columns and c != "ex_date": + df[c] = pd.to_datetime(df[c], errors="coerce") + agg = { + c: ("max" if (c in _DIV_AMOUNT_FIELDS or c in _DIV_DATE_FIELDS) else "first") + for c in df.columns + if c not in keys + } + return df.groupby(keys, as_index=False, sort=False).agg(agg) + + +def _fiscal_year_codes(events: pd.DataFrame, ex: np.ndarray) -> tuple[np.ndarray, bool]: + """给每笔分红一个可比较的**财年序号**,供同财年接管判定使用。 + + 返回 ``(codes, has_fy)``。``has_fy=False`` 表示事件表没有可用的财年信息, + 调用方应退回旧的「按相邻间隔接管」规则(缺信息时不猜)。 + + 有 ``end_date``(分红所属报告期)时用它 —— 这才是「哪个财年的利润分了红」。 + 但 ``has_fy`` 还要求 ``end_date`` 覆盖 **至少两个不同年份**:只有一个年份时 + 「跨财年」无从谈起,而单笔事件的短序列(测试夹具、刚上市的股票)会因此 + 被误加一个 365 天以上的延长窗口。没有该列时退化为「除权日所在年」,且 + ``has_fy=False``(掉进退化路径)。 + """ + if "end_date" in events.columns: + ed = pd.to_datetime(events["end_date"], errors="coerce").dt.year + if ed.notna().any() and ed.dropna().nunique() >= 2: + return ed.fillna(0).to_numpy(dtype="int64"), True + years = pd.to_datetime(events["ex_date"], errors="coerce").dt.year + return years.fillna(0).to_numpy(dtype="int64"), False + + def ttm_params() -> tuple[int, int, bool]: """读取 TTM 股息率的统一参数 ``(window_days, grace_days, smooth_spikes)``。 @@ -64,6 +143,9 @@ def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]: """按股票整理分红事件(只保留现金分红 > 0)。 返回 ``{symbol: DataFrame[ex_date, imp_ann_date, cash_div_tax]}``,按 ex_date 升序。 + 这里**不做** ``(symbol, ex_date)`` 聚合 —— 它由 :func:`ttm_dps_series` 在入口 + 统一处理(同一份口径只需实现一次),诊断脚本可用 + ``ttm_dps_series(..., dedupe_events=False)`` 复现去重前的毛刺。 """ if dividends.empty: return {} @@ -75,7 +157,6 @@ def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]: df = df.dropna(subset=["ex_date"]).sort_values(["symbol", "ex_date"]) return {sym: g.reset_index(drop=True) for sym, g in df.groupby("symbol")} - def ttm_dps_series( dates: pd.DatetimeIndex, events: pd.DataFrame, @@ -83,76 +164,141 @@ def ttm_dps_series( ttm_days: int = TTM_DAYS, grace_days: int = 45, smooth_spikes: bool = True, + dedupe_events: bool = True, ) -> np.ndarray: """给定日期序列,向量化计算每一天的 TTM 每股分红。 - **毛刺从哪来**:A 股相邻两次除权的间隔经常不是 365 天(实测招商银行 - 14 次分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近 - 制造出两种假象: + 口径:**过去 12 个月内已除权的税前现金分红之和**,按 ``(symbol, ex_date)`` + 聚合成一笔经济事件(见 :func:`dedupe_dividend_events`),并按 ``imp_ann_date`` + 做 PIT 约束。 + + **毛刺从哪来**:A 股相邻两次除权的间隔通常不是 365 天(实测招商银行 14 次 + 分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近制造两种 + 日历假象: - **重叠虚高**:间隔 < 365 天时,新分红入场而旧的尚未到期,两者同时在窗口内。 实测招商银行 2015-07-03:0.620 → 1.290(+108%),10 天后回落到 0.670。 - **断档虚低**:间隔 > 365 天时,旧的已到期而新的尚未入场。 实测中国神华 2016-07-04:0.740 → 0.320(−57%)。 - 两者都是日历假象而非分红能力变化,却会直接污染「历史分位」这一核心信号 - (虚高点拉高分位、虚低点压低 min 与低分位)。 + 两者都是日历假象而非分红能力变化,却会直接污染「历史分位」这一核心信号。 - **修法**:把「硬窗口」换成「按后继接管」。对每次分红 i: + **本轮修复(第二类假象的残余)**:只看**相邻**间隔会漏掉 + 「上一年度 → 本年度中期 → 本年度年度」这种三年两跳的节奏。实测 600690.SH: + FY2023 年度 2024-08-16、FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、 + FY2025 年度 2026-08-21。105 天的间隔让前两笔互不取代,而 392 天又超过 + ``365 + 45`` —— 2026-07-25 ~ 2026-08-21 出现 28 天空窗,TTM 从 1.23424 + 掉到 0.26920(−78%),期间公司没有任何真实现金事件。该 run 于是在 + 2026-07-30 用 2.31% 的假股息率(历史分位 1.57% ≤ P25)误发卖出信号, + 次日开盘清仓 —— 实际上不该卖。 - - 若与下一次分红的间隔 ``gap >= ttm_days - grace_days``,视为**同一档年度分红**, - 计入区间延到 ``min(下一次除权日, 除权日 + ttm_days + grace_days)``: - 间隔略小于一年 → 由后继提前接管,**消除重叠虚高**; - 间隔略大于一年 → 旧的一直计到新的入场,**填补断档虚低**; - 超过 ``ttm_days + grace_days`` 仍无后继(真停发)→ 封顶,如实归零。 - - 若 ``gap < ttm_days - grace_days``,视为**年内多次分红**(中期+年度), - 彼此不取代,各自保留标准 ``ttm_days`` 窗口 —— 否则会把中期分红误删, - 人为制造出新的低点。 - - 最后一次分红没有后继:沿用宽限期兜底(与旧行为一致)。 + **覆盖窗口算法**(每笔分红 i 的「计入终点」``ends[i]``): - ``grace_days`` 现在同时承担两件事:判定「同一档」的容差,以及真停发时的兜底宽度。 + 1. 基准 = ``ex_date + ttm_days``(关闭 ``smooth_spikes`` 时就是这个值)。 + 2. **后继接管**(旧行为,阈值 ``ttm_days - grace_days`` = 320 天):相邻间隔 + ≥ 320 天视为同一档分红 → 旧的计到新的入场(提前交接消除重叠虚高, + 或顺延填补一年内的断档虚低),上限 ``ttm_days + grace_days``; + 间隔 < 320 天视为年内多次分红 → 各自保留标准窗口。 + 3. **跨财年补位**(本轮修复):对「本财年最后一笔」,把终点补位到 + **下一财年第一笔**的除权日(仍以 ``ttm_days + grace_days`` 封顶)。 + 这正是消除上面那 28 天空窗的规则;超过宽限期(真断档/停发)则不予补位, + 如实归零。 + + 没有 ``end_date`` 时无法识别财年,跳过第 3 步(保守,不猜),第 2 步照常。 + + ``grace_days`` 同时承担两件事:判定「同一档」的容差,与真停发时的兜底宽度。 """ n = len(dates) if n == 0: return np.zeros(0, dtype="float64") - if events.empty: + if dedupe_events: + events = dedupe_dividend_events(events) + if events is None or events.empty: return np.zeros(n, dtype="float64") ex = events["ex_date"].to_numpy(dtype="datetime64[ns]") imp = events["imp_ann_date"].to_numpy(dtype="datetime64[ns]") - dps = events["cash_div_tax"].to_numpy(dtype="float64") + # NaN 感知:送转行的 cash_div_tax 是 NULL,NaN 一旦进入累加会污染整条序列 + dps = pd.to_numeric(events["cash_div_tax"], errors="coerce").fillna(0.0).to_numpy( + dtype="float64" + ) d = dates.to_numpy(dtype="datetime64[ns]") span_strict = np.timedelta64(ttm_days, "D") span_ext = np.timedelta64(ttm_days + max(0, grace_days), "D") - # 「同一档年度分红」的判定阈值:间隔小于它即视为年内多次分红 + # 「同一档年度分红」的判定阈值:相邻间隔小于它即视为年内多次分红 same_slot_min = np.timedelta64(max(0, ttm_days - max(0, grace_days)), "D") + fy, has_fy = _fiscal_year_codes(events, ex) + + m = len(ex) + ends = np.array([ex[i] + span_strict for i in range(m)], dtype="datetime64[ns]") + + if smooth_spikes: + # (2) 后继接管:同一档分红的时间错位 + for i in range(m): + if i + 1 >= m: + continue + if np.isnat(ex[i]) or np.isnat(ex[i + 1]): + continue + if ex[i + 1] - ex[i] >= same_slot_min: + cand = ex[i + 1] if ex[i + 1] < ex[i] + span_ext else ex[i] + span_ext + ends[i] = cand + + # (3) 跨财年补位:只看相邻间隔会漏掉「上一年度 → 本年度中期 → 本年度年度」 + # 这种跳法(见 docstring 的 600690.SH 实测)。规则:对**每个财年的 + # 最后一笔**,把终点补位到**下一财年的最后一笔**入场 —— 空窗正是 + # 从本财年末到下一财年最后一笔入场之间的那段。上限仍是 + # ``ttm_days + grace_days``:超过宽限期说明是真断档/停发,如实归零。 + if has_fy: + for i in range(m): + if np.isnat(ex[i]): + continue + # 本财年最后一笔? + is_last_of_fy = True + for j in range(i + 1, m): + if not np.isnat(ex[j]) and fy[j] == fy[i]: + is_last_of_fy = False + break + if not is_last_of_fy: + continue + # 下一财年的第一笔 + nxt = -1 + for j in range(i + 1, m): + if not np.isnat(ex[j]) and fy[j] > fy[i]: + nxt = j + break + if nxt < 0: + continue + cap = ex[i] + span_ext + cand = -1 + k = nxt + while k < m and not np.isnat(ex[k]) and fy[k] == fy[nxt]: + if ex[k] <= cap and k > cand: + cand = k + k += 1 + if cand < 0: + continue # 下一财年整段都在宽限期之外 → 真断档,不补位 + if ex[cand] > ends[i]: + ends[i] = ex[cand] + if smooth_spikes: + # (4) 已知的最后一笔分红:宽限期兜底。必须放在 ``if has_fy`` **之外** —— + # 否则单笔事件(has_fy=False)或无下一财年时,上面的 `continue` + # 会把这段整个跳过,窗口在第 365 天就归零。而「单笔事件 + 股价下跌」 + # 正是买入闸门测试与刚上市股票的真实形态,不能靠它来兜底。 + # 年度分红宣布到除权常隔 12~13 个月,硬 365 天会在下一次除权前 + # 制造空窗(正是本 bug)。超过宽限期仍无后继 → 如实归零。 + if not np.isnat(ex[m - 1]) and ends[m - 1] < ex[m - 1] + span_ext: + ends[m - 1] = ex[m - 1] + span_ext acc = np.zeros(n, dtype="float64") - m = len(ex) for i in range(m): if np.isnat(ex[i]): continue - - if not smooth_spikes: - end_ts = ex[i] + span_strict - elif i + 1 < m and not np.isnat(ex[i + 1]): - gap = ex[i + 1] - ex[i] - if gap >= same_slot_min: - # 同一档:由后继接管,但不超过宽限期封顶 - end_ts = min(ex[i + 1], ex[i] + span_ext) - else: - # 年内多次分红:保留标准窗口,互不取代 - end_ts = ex[i] + span_strict - else: - # 最后一次分红:宽限期兜底 - end_ts = ex[i] + span_ext - start = int(np.searchsorted(d, ex[i], side="left")) if not np.isnat(imp[i]): # PIT:公告日之前不可见 start = max(start, int(np.searchsorted(d, imp[i], side="left"))) - end = int(np.searchsorted(d, end_ts, side="left")) + end = int(np.searchsorted(d, ends[i], side="left")) if end > start: acc[start:end] += dps[i] return acc @@ -165,6 +311,7 @@ def dividend_yield_series( ttm_days: int = TTM_DAYS, grace_days: int = 45, smooth_spikes: bool = True, + dedupe_events: bool = True, ) -> pd.DataFrame: """构造单只股票的股息率日序列。 @@ -182,7 +329,7 @@ def dividend_yield_series( return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"]) idx = pd.DatetimeIndex(pd.to_datetime(s.index)) dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days, - smooth_spikes=smooth_spikes) + smooth_spikes=smooth_spikes, dedupe_events=dedupe_events) out = pd.DataFrame({"trade_date": idx, "close": s.to_numpy(dtype="float64"), "ttm_dps": dps}) out["dividend_yield"] = out["ttm_dps"] / out["close"] return out.reset_index(drop=True) diff --git a/src/hdiv/report/universe_report.py b/src/hdiv/report/universe_report.py index 875db96..82b629e 100644 --- a/src/hdiv/report/universe_report.py +++ b/src/hdiv/report/universe_report.py @@ -16,7 +16,7 @@ from hdiv.report.renderer import Provenance, Renderer, query # 滤网中文说明(呈现用,非业务逻辑) _FILTER_DESC = { - "market": "交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易", + "market": "行业排除清单 / 交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易", "risk": "ST / 退市 / 停牌 / 净资产为负 / 资产负债率(金融豁免)", "dividend": "股息率 / 连续分红年数 / 窗口内分红次数 / 支付率 / FCF 覆盖", "quality": "年均 ROE / ROIC / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)", diff --git a/src/hdiv/universe/daily.py b/src/hdiv/universe/daily.py index d3254c8..ea4e6e2 100644 --- a/src/hdiv/universe/daily.py +++ b/src/hdiv/universe/daily.py @@ -110,11 +110,12 @@ class DailyUniverseScreener: self.prune = PruneReport() self._allowed: set[str] | None = None - @classmethod - def from_strategy(cls, registry: Any, strategy: Any, repo: PitRepo, - *, verbose: bool = True) -> DailyUniverseScreener: - cfg = registry.resolved_universe(strategy) - return cls(cfg, repo, verbose=verbose) + # 刻意**没有** ``from_strategy(registry, strategy, ...)``: + # ``registry.resolved_universe`` 只含策略 + universe.yml,拿不到 + # backtest.yml 的 ``universe_exclusions``(行业黑名单)。保留这样一个 + # 便捷构造器,等于给「逐日选股绕过行业排除」留了一条谁都看不出来的路。 + # 调用方应先用 ``BacktestConfig.resolved_universe`` 合并出**生效后**的 + # UniverseConfig(见 DailyRunner.__init__),再用普通构造函数传入。 # ------------------------------------------------------------------ # 预剪枝 diff --git a/src/hdiv/universe/filters/dividend.py b/src/hdiv/universe/filters/dividend.py index cc64191..037e980 100644 --- a/src/hdiv/universe/filters/dividend.py +++ b/src/hdiv/universe/filters/dividend.py @@ -6,7 +6,10 @@ - 「分红年度」以**报告期年份**(``end_date.year``)归属 —— 这符合「哪个财年的利润分了红」的通常理解; - 连续性只要求到「最近一个**年报已公告**的财年」为止, - 避免在年报尚未披露时误判为中断。 + 避免在年报尚未披露时误判为中断; +- 同一 ``(symbol, ex_date)`` 的多条记录按**一笔经济事件**聚合 + (与 ``ttm_dps_series`` 共用 ``dedupe_dividend_events``), + 否则年度 DPS 与总现金分红会被重复累加。 PIT 纪律:只使用 ``imp_ann_date <= asof`` 且 ``ex_date <= asof`` 的记录。 """ @@ -19,13 +22,28 @@ from typing import Any import pandas as pd from hdiv.core.config import DividendFilterConfig -from hdiv.factor.dividend_yield import ttm_dps_at +from hdiv.factor.dividend_yield import dedupe_dividend_events, ttm_dps_at from hdiv.universe.filters.base import Filter, FilterOutcome # 年报到次年 4 月 30 日前披露完毕(法定上限) ANNUAL_REPORT_DEADLINE_MONTH_DAY = (4, 30) +def _dedupe_cash_records(cash: list[dict[str, Any]]) -> list[dict[str, Any]]: + """按 ``(symbol, ex_date)`` 把同一笔分红的多条记录收敛成一条。 + + 年度 DPS(→ CAGR / 波动率)与「总现金分红」(→ 支付率 / FCF 覆盖)都是 + **逐行累加** ``cash_div_tax``,重复记录会让分子虚高、让 FCF 覆盖虚低。 + 聚合口径与 ``ttm_dps_series`` 完全一致(共用 + :func:`~hdiv.factor.dividend_yield.dedupe_dividend_events`),避免同一份 + 分红在「股息率」与「支付率」两个指标上口径漂移。 + """ + if len(cash) < 2: + return cash + df = dedupe_dividend_events(pd.DataFrame(cash)) + return df.to_dict("records") + + class DividendFilter(Filter): name = "dividend" @@ -146,7 +164,9 @@ class DividendFilter(Filter): recs: list[dict[str, Any]], target_year: int, asof: date, cfg: DividendFilterConfig ) -> dict[str, Any]: """计算分红连续性与 TTM 股息。""" - cash = [r for r in recs if (r.get("cash_div_tax") or 0) > 0] + cash = _dedupe_cash_records( + [r for r in recs if (r.get("cash_div_tax") or 0) > 0] + ) years: set[int] = set() for r in cash: ed = r.get("end_date") @@ -246,7 +266,9 @@ class DividendFilter(Filter): 分子(去年分红)与分母(今年一季度利润)不同期,结果无意义。 """ out: dict[str, Any] = {"payout_ratio": None, "fcf_dividend_cover": None} - cash = [r for r in recs if (r.get("cash_div_tax") or 0) > 0] + cash = _dedupe_cash_records( + [r for r in recs if (r.get("cash_div_tax") or 0) > 0] + ) if not cash: return out years = sorted( diff --git a/src/hdiv/universe/filters/market.py b/src/hdiv/universe/filters/market.py index c7db493..d611be5 100644 --- a/src/hdiv/universe/filters/market.py +++ b/src/hdiv/universe/filters/market.py @@ -1,6 +1,6 @@ """MarketFilter —— 市场属性过滤(plan.md §5.1)。 -条件:交易所 / 板块 / 上市年限 / 总市值 / 流通市值 / 流动性 / 当日可交易。 +条件:行业排除 / 交易所 / 板块 / 上市年限 / 总市值 / 流通市值 / 流动性 / 当日可交易。 """ from __future__ import annotations @@ -11,15 +11,78 @@ from typing import Any import pandas as pd from hdiv.core.config import MarketFilterConfig +from hdiv.core.errors import ConfigError from hdiv.universe.filters.base import Filter, FilterOutcome class MarketFilter(Filter): name = "market" - def __init__(self, config: MarketFilterConfig) -> None: + def __init__( + self, + config: MarketFilterConfig, + *, + exclude_industries: list[str] | None = None, + ) -> None: super().__init__(config) self.cfg = config + #: 行业黑名单(来自 ``UniverseConfig.industry_exclusions``, + #: 回测时由 backtest.yml 的 universe_exclusions 叠加进来)。 + #: 空列表 = 不排除任何行业。 + self.exclude_industries: list[str] = list(exclude_industries or ()) + #: 名单是否已与 stock 表核对过(每个实例只查一次) + self._industries_checked = False + + # ------------------------------------------------------------------ + # 名单自检 + # ------------------------------------------------------------------ + + def _check_known_industries(self, repo: Any) -> None: + """确认黑名单里的每个行业名都真实存在。 + + **为什么必须查一次**:``stock.industry`` 是有限枚举,而「房地产业」 + 这种听起来正确、库里却**根本不存在**的名字不会有任何一行命中 —— + 股票池看起来「排除了」,实际一只没少,属于最难发现的静默失效 + (本项目已有同类记录:配置写了但没有任何代码路径会读)。 + 核实后写错名字会**直接报错**并提示最接近的候选。 + """ + if self._industries_checked or not self.exclude_industries: + return + self._industries_checked = True + master = repo.stock_master() + if master is None or "industry" not in getattr(master, "columns", []): + return + known = {str(x) for x in master["industry"].dropna().unique()} + unknown = [x for x in self.exclude_industries if x not in known] + if not unknown: + return + # 提示最接近的真实取值。刻意用**字符重合度**而不是前缀匹配: + # 用户最可能的错法(写「房地产业」,库里只有「全国地产/区域地产」) + # 与任何真实取值都没有共同前缀,前缀匹配给不出任何提示。 + hints: list[str] = [] + for name in unknown: + chars = set(name) + scored = sorted( + ((len(chars & set(k)), len(k), k) for k in known), + key=lambda t: (-t[0], t[1]), + ) + near = [k for sc, _n, k in scored if sc >= 2][:5] + if near: + hints.append(f" {name} → 是否想写:{'、'.join(near)}") + detail = ("\n" + "\n".join(hints)) if hints else "" + raise ConfigError( + f"排除行业清单含数据库里不存在的行业名:{unknown}。\n" + f" stock.industry 是精确枚举,写错名字**不会排除任何股票**," + f"因此这里直接报错。{detail}\n" + f" 查当前全部取值:\n" + f" SELECT industry, COUNT(*) FROM stock GROUP BY industry ORDER BY 2 DESC;\n" + f" 注意「房地产业」并不在库里,它被拆成 全国地产 / 区域地产 / " + f"房产服务 / 园区开发 四个值。" + ) + + # ------------------------------------------------------------------ + # 求值 + # ------------------------------------------------------------------ def compute(self, candidates: pd.DataFrame, repo: Any, asof: date) -> FilterOutcome: cfg = self.cfg @@ -32,10 +95,24 @@ class MarketFilter(Filter): mask.at[idx] = False reasons[df.at[idx, "symbol"]] = why + # --- 行业排除(黑名单)--- + # 放在最前:它是纯名称匹配,不需要任何数值解析;而且「这个行业不做」 + # 是最强的排除理由 —— 若排在市值/流动性之后,被排除的股票会先以 + # 「市值不足」等理由落选,事后无法分辨「行业被排除了」还是「真的不达标」。 + if self.exclude_industries: + self._check_known_industries(repo) + banned = set(self.exclude_industries) + for idx in df.index: + ind = df.at[idx, "industry"] if "industry" in df.columns else None + if isinstance(ind, str) and ind in banned: + fail(idx, f"行业「{ind}」在排除清单中") + # --- 交易所 --- if cfg.exchanges: allowed = set(cfg.exchanges) for idx in df.index: + if not mask.at[idx]: + continue if df.at[idx, "exchange"] not in allowed: fail(idx, f"交易所 {df.at[idx, 'exchange']} 不在 {sorted(allowed)}") diff --git a/src/hdiv/universe/selector.py b/src/hdiv/universe/selector.py index 59bfdbf..ae9278d 100644 --- a/src/hdiv/universe/selector.py +++ b/src/hdiv/universe/selector.py @@ -79,7 +79,9 @@ class UniverseSelector: c = self.config exempt_leverage = list(c.industry_exemptions.leverage) self._filters = { - "market": MarketFilter(c.market), + "market": MarketFilter( + c.market, exclude_industries=list(c.industry_exclusions) + ), "risk": RiskFilter(c.risk, exempt_leverage=exempt_leverage), "dividend": DividendFilter(c.dividend), "quality": FinancialQualityFilter( diff --git a/src/hdiv/web/service.py b/src/hdiv/web/service.py index 1921e58..35a776f 100644 --- a/src/hdiv/web/service.py +++ b/src/hdiv/web/service.py @@ -299,7 +299,10 @@ def _funnel(run_id: str, candidate_count: int, member_count: int, 因此这里把「候选 → market → risk → dividend → quality → 入选」 的存活曲线算好返回,前端只渲染不算数。 """ - labels = ["候选", "市场属性", "风险", "分红", "财务质量"] + # 「market」滤网含**行业排除清单**(backtest.yml 的 universe_exclusions + # 会叠加进筛选配置),所以标签里带上「行业」,否则被排除的地产股会 + # 无声地算进「市场属性」这一段,读者看不出是行业原因。 + labels = ["候选", "行业/市场属性", "风险", "分红", "财务质量"] order = ["market", "risk", "dividend", "quality"] values = [candidate_count] cur = candidate_count diff --git a/tests/test_backtest.py b/tests/test_backtest.py index e90ab4b..2b1067d 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py index ab19ae8..c6b6ef9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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") diff --git a/tests/test_daily.py b/tests/test_daily.py index 20c8fa4..1ad5a2d 100644 --- a/tests/test_daily.py +++ b/tests/test_daily.py @@ -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 diff --git a/tests/test_dividend_fiscal_year.py b/tests/test_dividend_fiscal_year.py new file mode 100644 index 0000000..d8522f9 --- /dev/null +++ b/tests/test_dividend_fiscal_year.py @@ -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}" diff --git a/tests/test_dividend_smoothing.py b/tests/test_dividend_smoothing.py index aa72699..a64c5ab 100644 --- a/tests/test_dividend_smoothing.py +++ b/tests/test_dividend_smoothing.py @@ -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 必须用同一份参数 # --------------------------------------------------------------------------- diff --git a/tests/test_universe.py b/tests/test_universe.py index cf2fa23..b66432c 100644 --- a/tests/test_universe.py +++ b/tests/test_universe.py @@ -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 diff --git a/tools/diag_dividend_artifact.py b/tools/diag_dividend_artifact.py index 8f7cac3..b72f815 100644 --- a/tools/diag_dividend_artifact.py +++ b/tools/diag_dividend_artifact.py @@ -12,8 +12,11 @@ 诊断三件事: 1. **同一除权日的重复分红记录**:``hd_dividend`` 写入侧刻意保留 - 预案/股东大会通过/实施 全量记录(决策 D6),但查询侧把它们当成 - **多笔独立分红**,于是 ``cash_div_tax`` 被重复累加。 + 预案/股东大会通过/实施 全量记录(决策 D6),同一 ``(symbol, ex_date)`` + 因此可能有多条 ``实施`` 记录。业务路径已由 + ``factor.dividend_yield.dedupe_dividend_events`` 按经济事件聚合 + (``ttm_dps_series`` 入口统一调用),本脚本用 ``dedupe_events=False`` + 复现「逐行累加」的旧口径,用来量化这个毛刺曾经有多大。 2. **TTM 每股分红的时间线**:逐交易日打印去重前 / 去重后的取值, 毛刺会表现为「无任何真实现金事件的一天突然跳变」。 3. **异常成交反查**:给定回测 run_id,列出每一笔卖出当日 TTM 值的 @@ -34,25 +37,12 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) from hdiv.data import db # noqa: E402 from hdiv.factor.dividend_yield import ( # noqa: E402 build_dps_events, + dedupe_dividend_events, ttm_dps_series, ttm_params, ) -def _dedup(events: pd.DataFrame) -> pd.DataFrame: - """同一除权日只保留一笔(取金额最大者)。 - - 这是「写入口径全量保留、查询口径按经济事件聚合」的最小实现。 - """ - if events.empty: - return events - return ( - events.sort_values("cash_div_tax") - .drop_duplicates("ex_date", keep="last") - .reset_index(drop=True) - ) - - def diagnose_symbol(symbol: str, start: date, end: date) -> None: div = db.read_sql( "SELECT symbol, end_date, ann_date, imp_ann_date, div_proc, " @@ -90,8 +80,10 @@ def diagnose_symbol(symbol: str, start: date, end: date) -> None: px["trade_date"] = pd.to_datetime(px["trade_date"]) idx = pd.DatetimeIndex(px["trade_date"]) w, g, sm = ttm_params() - raw = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm) - ded = ttm_dps_series(idx, _dedup(events), ttm_days=w, grace_days=g, smooth_spikes=sm) + # 「去重前」必须显式关掉入口聚合,否则业务路径(默认去重)永远看不到毛刺 + raw = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm, + dedupe_events=False) + ded = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm) out = pd.DataFrame( { "trade_date": px["trade_date"].dt.date, @@ -142,9 +134,10 @@ def diagnose_run(run_id: str) -> None: i = pd.DatetimeIndex(s.index) ref = (d0 - timedelta(days=int(365.25 * years)), d0) - def decision(events: pd.DataFrame) -> tuple[float, float, float]: + def decision(events: pd.DataFrame, *, dedupe: bool) -> tuple[float, float, float]: dps = pd.Series( - ttm_dps_series(i, events, ttm_days=w, grace_days=g, smooth_spikes=sm), + ttm_dps_series(i, events, ttm_days=w, grace_days=g, smooth_spikes=sm, + dedupe_events=dedupe), index=i, ) y = (dps / s).loc[: pd.Timestamp(d0)] @@ -153,8 +146,8 @@ def diagnose_run(run_id: str) -> None: pct = float((rs <= cur).sum() / rs.size * 100) if rs.size else float("nan") return cur, pct, float(rs.quantile(0.25)) if rs.size else float("nan") - for label, evx in (("去重前", ev), ("去重后", _dedup(ev))): - cur, pct, p25 = decision(evx) + for label, dedupe in (("去重前", False), ("去重后", True)): + cur, pct, p25 = decision(ev, dedupe=dedupe) rows.append( { "symbol": sym,