修复:TTM 股息率两处残余缺陷 + 公司行为三处静默错误;新增回测级排除行业清单

说明:本提交是工作区中此前的未提交工作(在 cf6d4d2 之后产生),**非本次会话所写**,
按用户要求**不跑测试、直接记录变更并推送**。
已完成推送前的基础安全检查:无明文凭据、无大文件、`.env`/`logs/`/`output/` 仍被忽略。
测试状态:**本次未执行测试套件**。

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

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

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

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

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

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

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

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

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

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

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

## 四、其它

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

## 待验证

本次按要求**未执行测试**。上述「实测/验收」数字均引自文档中作者自己的记录,
非本次会话验证结果。建议合入后跑一次全量测试(注意:daily 的 DB 标记测试
因 `hd_cashflow` 无界扫描仍然很慢)。
This commit is contained in:
2026-10-05 16:19:07 +08:00
parent cf6d4d2c56
commit fdfdd152d8
23 changed files with 1678 additions and 93 deletions
+25
View File
@@ -14,6 +14,31 @@ period:
start: 2015-01-01 start: 2015-01-01
end: latest 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: [全国地产, 区域地产, 房产服务, 园区开发]
# ------------------------------------------------------------ # ------------------------------------------------------------
# 调度:多久评估一次信号、多久重建一次股票池 # 调度:多久评估一次信号、多久重建一次股票池
# ------------------------------------------------------------ # ------------------------------------------------------------
+21
View File
@@ -91,6 +91,27 @@ exit:
stop_loss_pct: null stop_loss_pct: null
max_holding_days: 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) # 仓位控制(plan.md §20)
# ------------------------------------------------------------ # ------------------------------------------------------------
+265 -1
View File
@@ -462,6 +462,13 @@
11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码** 11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码**
(其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除; (其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除;
SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。 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/profile.yml` | **个股特性**(统计窗口/分位/波动频率/安全边际权重/TTM 口径) |
| `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) | | `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) |
| `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 | | `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 |
| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合 | | `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合/**排除行业清单** |
| `config/report.yml` | 图表开关/输出命名/版面/资源模式 | | `config/report.yml` | 图表开关/输出命名/版面/资源模式 |
| `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 | | `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 |
@@ -778,6 +785,196 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每
- **已有的画像与回测结果已过期**,需重跑 - **已有的画像与回测结果已过期**,需重跑
- 筛选结果需重新生成(`hd_universe_run` 会原地覆盖) - 筛选结果需重新生成(`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. 测试覆盖 ## 8. 测试覆盖
``` ```
@@ -1338,3 +1535,70 @@ cProfile 实测:6 个交易日里 `load_config` 被调用 **1008 次、共 16.
| `hd_daily_universe` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) | | `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`,因此列表页的文案里看不到这条排除规则。
> 明细页的「回测配置」原文里能完整看到。
+92
View File
@@ -255,6 +255,7 @@ CLI 会打印覆盖率,例如:
| 分红 | 除权日入账(税后),进的是**与初始资金同一个可投资现金池**,下次调仓按目标权重再配置(`reinvest` + `portfolio_rebalance`,已实现;`hold`/`cash_out` 未实现) | | 分红 | 除权日入账(税后),进的是**与初始资金同一个可投资现金池**,下次调仓按目标权重再配置(`reinvest` + `portfolio_rebalance`,已实现;`hold`/`cash_out` 未实现) |
| 送股 / 转增 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降)。**纯送转**(如 10 送 10:股价腰斩、股数翻倍,无现金分红)同样处理,不会被丢弃 | | 送股 / 转增 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降)。**纯送转**(如 10 送 10:股价腰斩、股数翻倍,无现金分红)同样处理,不会被丢弃 |
| 配股 | **未实现**(`handle_rights_issue` 不生效;库里也没有配股价/比例数据) | | 配股 | **未实现**(`handle_rights_issue` 不生效;库里也没有配股价/比例数据) |
| 同一除权日多条记录 | 按**一笔经济事件**聚合(金额/股数逐字段取最大、日期取最晚),不重复入账 |
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) | | 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**, > 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
@@ -627,9 +628,35 @@ industry_exemptions:
> 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红 > 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红
> (中期+年度),彼此都保留;超过 `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 天。 > 实测效果:招商银行 >20% 跳变 21 → 5 次,中国银行虚低归零 77 天 → 0 天。
> 若个别股票仍有断档,把 `grace_days` 调大(如 90)。 > 若个别股票仍有断档,把 `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` — 策略定义 ★ ## 4.3 `config/strategy/high_dividend_v1.yml` — 策略定义 ★
@@ -827,6 +854,47 @@ period: { start: 2015-01-01, end: latest }
| `step_months` | `12` | 窗口步进 | | `step_months` | `12` | 窗口步进 |
| `freeze_params_in_test` | `true` | **必须为 true**(配置层强制拒绝关闭) | | `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`(移出组合)才是未实现分支。 下次调仓时按目标权重再配置;`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` 声明,因为这是模型假设而非漏实现): > **两处已知口径简化**(不产生 `unimplemented` 声明,因为这是模型假设而非漏实现):
> ① 红利税在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时** > ① 红利税在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时**
> 按「买入→卖出」的实际持有期补缴,且按分笔 FIFO;② **送股**(`stk_bo_rate`) > 按「买入→卖出」的实际持有期补缴,且按分笔 FIFO;② **送股**(`stk_bo_rate`)
+20 -6
View File
@@ -118,6 +118,12 @@ class DailyRunner:
self.strategy = self.registry.load(strategy_path) self.strategy = self.registry.load(strategy_path)
self.bt: BacktestConfig = load_config("backtest") self.bt: BacktestConfig = load_config("backtest")
self.daily: DailyConfig = self.bt.daily 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 @classmethod
def from_strategy(cls, path: str | Path) -> DailyRunner: def from_strategy(cls, path: str | Path) -> DailyRunner:
@@ -245,9 +251,7 @@ class DailyRunner:
) )
else: else:
# --- 保守预剪枝 --- # --- 保守预剪枝 ---
screener = DailyUniverseScreener.from_strategy( screener = DailyUniverseScreener(self.universe_cfg, repo, verbose=False)
self.registry, self.strategy, repo, verbose=False
)
allowed = screener.build_prune_set(start, end) allowed = screener.build_prune_set(start, end)
if verbose: if verbose:
p = screener.prune.as_dict() p = screener.prune.as_dict()
@@ -370,15 +374,25 @@ class DailyRunner:
def _pools_cache_key(self, start: date, end: date, step: int) -> str: 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( return stable_id(
"dailypools", "dailypools",
self.strategy.strategy.id, self.strategy.strategy.id,
self.strategy.strategy.version, self.strategy.strategy.version,
self.registry.hash_of(self.strategy), self.registry.hash_of(self.strategy),
"excl:" + ",".join(self.universe_cfg.industry_exclusions),
str(start), str(end), str(int(step)), str(start), str(end), str(int(step)),
) )
+114 -7
View File
@@ -28,16 +28,20 @@ from hdiv.core.config import (
BacktestConfig, BacktestConfig,
CostConfig, CostConfig,
StrategyConfig, StrategyConfig,
config_hash,
load_config, load_config,
) )
from hdiv.core.errors import DataGapError, HdivError from hdiv.core.errors import DataGapError, HdivError
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo, data_version from hdiv.data.repo import Repo, data_version
from hdiv.data.sync.base import stable_id 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.strategy.registry import StrategyRegistry
from hdiv.universe.selector import UniverseSelector
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 持仓与订单 # 持仓与订单
@@ -232,7 +236,9 @@ class BacktestEngine:
# 默认拒绝;只有显式放行才执行,且必须把「含未来信息」写进 run 记录。 # 默认拒绝;只有显式放行才执行,且必须把「含未来信息」写进 run 记录。
self.allow_lookahead_universe = allow_lookahead_universe self.allow_lookahead_universe = allow_lookahead_universe
self.lookahead_universe_note: str | None = None 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):关闭时整条链路不参与,回测行为与启用前一致 # 实时画像闸门(PIT):关闭时整条链路不参与,回测行为与启用前一致
self.gate_cfg = strategy.entry.profile_gate self.gate_cfg = strategy.entry.profile_gate
self.pit: Any = None self.pit: Any = None
@@ -252,6 +258,10 @@ class BacktestEngine:
#: 买卖决策发生时计算并留痕个股画像(不区分是否在当日池内) #: 买卖决策发生时计算并留痕个股画像(不区分是否在当日池内)
self.profile_on_trade = profile_on_trade self.profile_on_trade = profile_on_trade
self.profile_window_years = profile_window_years self.profile_window_years = profile_window_years
#: 已公告未除权的现金分红 {symbol: [(公告日, 每股金额)]},由 _prepare 填充
self.pending_div: dict[str, list[tuple[date, float]]] = {}
#: 被卖出复核拦下的清仓次数(修复 3b 的留痕)
self.exit_unconfirmed = 0
@classmethod @classmethod
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine: def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
@@ -340,6 +350,7 @@ class BacktestEngine:
bt.capital.initial, bt.capital.initial,
), ),
"unimplemented": state["unimplemented"], "unimplemented": state["unimplemented"],
"exit_unconfirmed": state.get("exit_unconfirmed", 0),
"profile_gate": self.pit.stats() if self.pit is not None else None, "profile_gate": self.pit.stats() if self.pit is not None else None,
} }
if self.pit is not None: if self.pit is not None:
@@ -506,7 +517,10 @@ class BacktestEngine:
flush=True, flush=True,
) )
else: 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: for rd in refresh_dates:
res = selector.run(asof=rd, persist=False, verbose=False) res = selector.run(asof=rd, persist=False, verbose=False)
universe_by_refresh[rd] = set(res["selected"]["symbol"].tolist()) 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_events = div_events[div_events["symbol"].isin(set(all_syms))]
div_by_date: dict[date, list[dict]] = {} div_by_date: dict[date, list[dict]] = {}
for r in div_events.to_dict("records"): for r in div_events.to_dict("records"):
ex = pd.to_datetime(r["ex_date"]).date() ex = pd.to_datetime(r["ex_date"]).date()
div_by_date.setdefault(ex, []).append(r) 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]) suspend = self._load_suspend(all_syms, days[0], days[-1])
limits = self._load_limits(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_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, "total_dividend_tax": float(div_df["tax"].sum()) if not div_df.empty else 0.0,
"unimplemented": sorted(unimplemented), "unimplemented": sorted(unimplemented),
"exit_unconfirmed": int(self.exit_unconfirmed),
} }
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -947,9 +998,25 @@ class BacktestEngine:
if target is None: if target is None:
continue # 死区:保持仓位 continue # 死区:保持仓位
if abs(target) <= 1e-9: 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( out.append(self._trade_signal(Signal(
sym, day, "SELL", 0.0, current, pct, price, sym, day, "SELL", 0.0, current, pct, price,
{**common, {**common, "exit_confirm": cinfo,
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}", "rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"}, "reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
), day)) ), day))
@@ -1086,6 +1153,46 @@ class BacktestEngine:
"skip_reason": "PROFILE_GATE", "executed": False}, "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: def _reference_window(self, day: date) -> tuple[date, date] | None:
if self.frozen_reference is not None: if self.frozen_reference is not None:
return self.frozen_reference return self.frozen_reference
+12 -1
View File
@@ -34,6 +34,7 @@ from hdiv.data import db
from hdiv.data.repo import Repo, data_version from hdiv.data.repo import Repo, data_version
from hdiv.data.sync.base import stable_id from hdiv.data.sync.base import stable_id
from hdiv.strategy.registry import StrategyRegistry from hdiv.strategy.registry import StrategyRegistry
from hdiv.universe.selector import UniverseSelector
@dataclass @dataclass
@@ -54,11 +55,21 @@ class WalkForwardRunner:
self.strategy = self.registry.load(strategy_path) self.strategy = self.registry.load(strategy_path)
self.bt: BacktestConfig = load_config("backtest") self.bt: BacktestConfig = load_config("backtest")
self.repo = Repo() self.repo = Repo()
#: 叠加了 backtest.yml ``universe_exclusions``(行业黑名单)之后的筛选配置。
#: 训练段的冻结分布校准与各窗口引擎必须用**同一份**:否则冻结分布是在
#: 含被排除行业的池子上标定的,与测试段实际能买的池子口径不一致。
self.universe_cfg = self.bt.resolved_universe(
self.registry.resolved_universe(self.strategy)
)
@classmethod @classmethod
def from_strategy(cls, path: str | Path) -> WalkForwardRunner: def from_strategy(cls, path: str | Path) -> WalkForwardRunner:
return cls(path) 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, ttm_params,
) )
sel = self.registry.selector(self.strategy) sel = self.selector()
res = sel.run(asof=end, persist=False, verbose=False) res = sel.run(asof=end, persist=False, verbose=False)
syms = res["selected"]["symbol"].tolist() syms = res["selected"]["symbol"].tolist()
if not syms: if not syms:
+102
View File
@@ -218,6 +218,13 @@ class UniverseConfig(StrictModel):
industry_exemptions: IndustryExemptionsConfig = Field( industry_exemptions: IndustryExemptionsConfig = Field(
default_factory=IndustryExemptionsConfig 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) market: MarketFilterConfig = Field(default_factory=MarketFilterConfig)
risk: RiskFilterConfig = Field(default_factory=RiskFilterConfig) risk: RiskFilterConfig = Field(default_factory=RiskFilterConfig)
dividend: DividendFilterConfig = Field(default_factory=DividendFilterConfig) dividend: DividendFilterConfig = Field(default_factory=DividendFilterConfig)
@@ -560,6 +567,44 @@ class DailyConfig(StrictModel):
return self 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): class BacktestConfig(StrictModel):
version: int = 1 version: int = 1
capital: CapitalConfig = Field(default_factory=CapitalConfig) capital: CapitalConfig = Field(default_factory=CapitalConfig)
@@ -571,6 +616,9 @@ class BacktestConfig(StrictModel):
walk_forward: WalkForwardConfig = Field(default_factory=WalkForwardConfig) walk_forward: WalkForwardConfig = Field(default_factory=WalkForwardConfig)
daily: DailyConfig = Field(default_factory=DailyConfig) daily: DailyConfig = Field(default_factory=DailyConfig)
dividend: DividendHandlingConfig = Field(default_factory=DividendHandlingConfig) dividend: DividendHandlingConfig = Field(default_factory=DividendHandlingConfig)
universe_exclusions: UniverseExclusionsConfig = Field(
default_factory=UniverseExclusionsConfig
)
benchmark: list[BenchmarkConfig] = Field(default_factory=list) benchmark: list[BenchmarkConfig] = Field(default_factory=list)
risk_free_rate: float = 0.02 risk_free_rate: float = 0.02
fill: FillConfig = Field(default_factory=FillConfig) fill: FillConfig = Field(default_factory=FillConfig)
@@ -583,6 +631,25 @@ class BacktestConfig(StrictModel):
raise SchemaValidationError(f"benchmark 存在重复代码:{codes}") raise SchemaValidationError(f"benchmark 存在重复代码:{codes}")
return self 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 # report.yml
@@ -773,11 +840,46 @@ class EntryConfig(StrictModel):
return self 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): class ExitConfig(StrictModel):
yield_percentile: float yield_percentile: float
scale_out: list[ScaleStep] = Field(default_factory=list) scale_out: list[ScaleStep] = Field(default_factory=list)
stop_loss_pct: float | None = None stop_loss_pct: float | None = None
max_holding_days: int | None = None max_holding_days: int | None = None
confirm: ExitConfirmConfig = Field(default_factory=ExitConfirmConfig)
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> ExitConfig: def _check(self) -> ExitConfig:
+45
View File
@@ -468,6 +468,51 @@ class Repo:
# 分红(PIT) # 分红(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( def dividend_records(
self, asof: date, *, years_back: int = 12, implemented_only: bool = True self, asof: date, *, years_back: int = 12, implemented_only: bool = True
) -> pd.DataFrame: ) -> pd.DataFrame:
+185 -38
View File
@@ -11,6 +11,11 @@
PIT 纪律:序列上每个日期 t 只使用 ``imp_ann_date <= t`` 且 ``ex_date <= t`` 的分红。 PIT 纪律:序列上每个日期 t 只使用 ``imp_ann_date <= t`` 且 ``ex_date <= t`` 的分红。
由于 ``ex_date <= t`` 已隐含「已发生」,实现上按 ex_date 归集即可; 由于 ``ex_date <= t`` 已隐含「已发生」,实现上按 ex_date 归集即可;
``imp_ann_date <= t`` 用于剔除「事后才公告」的记录(极少但存在)。 ``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 from __future__ import annotations
@@ -23,6 +28,80 @@ import pandas as pd
# 默认统计窗口(自然日) # 默认统计窗口(自然日)
TTM_DAYS = 365 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]: def ttm_params() -> tuple[int, int, bool]:
"""读取 TTM 股息率的统一参数 ``(window_days, grace_days, smooth_spikes)``。 """读取 TTM 股息率的统一参数 ``(window_days, grace_days, smooth_spikes)``。
@@ -64,6 +143,9 @@ def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""按股票整理分红事件(只保留现金分红 > 0)。 """按股票整理分红事件(只保留现金分红 > 0)。
返回 ``{symbol: DataFrame[ex_date, imp_ann_date, cash_div_tax]}``,按 ex_date 升序。 返回 ``{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: if dividends.empty:
return {} 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"]) 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")} return {sym: g.reset_index(drop=True) for sym, g in df.groupby("symbol")}
def ttm_dps_series( def ttm_dps_series(
dates: pd.DatetimeIndex, dates: pd.DatetimeIndex,
events: pd.DataFrame, events: pd.DataFrame,
@@ -83,76 +164,141 @@ def ttm_dps_series(
ttm_days: int = TTM_DAYS, ttm_days: int = TTM_DAYS,
grace_days: int = 45, grace_days: int = 45,
smooth_spikes: bool = True, smooth_spikes: bool = True,
dedupe_events: bool = True,
) -> np.ndarray: ) -> np.ndarray:
"""给定日期序列,向量化计算每一天的 TTM 每股分红。 """给定日期序列,向量化计算每一天的 TTM 每股分红。
**毛刺从哪来**:A 股相邻两次除权的间隔经常不是 365 天(实测招商银行 口径:**过去 12 个月内已除权的税前现金分红之和**,按 ``(symbol, ex_date)``
14 次分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近 聚合成一笔经济事件(见 :func:`dedupe_dividend_events`),并按 ``imp_ann_date``
制造出两种假象: 做 PIT 约束。
**毛刺从哪来**:A 股相邻两次除权的间隔通常不是 365 天(实测招商银行 14 次
分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近制造两种
日历假象:
- **重叠虚高**:间隔 < 365 天时,新分红入场而旧的尚未到期,两者同时在窗口内。 - **重叠虚高**:间隔 < 365 天时,新分红入场而旧的尚未到期,两者同时在窗口内。
实测招商银行 2015-07-03:0.620 → 1.290(+108%),10 天后回落到 0.670。 实测招商银行 2015-07-03:0.620 → 1.290(+108%),10 天后回落到 0.670。
- **断档虚低**:间隔 > 365 天时,旧的已到期而新的尚未入场。 - **断档虚低**:间隔 > 365 天时,旧的已到期而新的尚未入场。
实测中国神华 2016-07-04:0.740 → 0.320(−57%)。 实测中国神华 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``,视为**同一档年度分红**, **覆盖窗口算法**(每笔分红 i 的「计入终点」``ends[i]``):
计入区间延到 ``min(下一次除权日, 除权日 + ttm_days + grace_days)``:
间隔略小于一年 → 由后继提前接管,**消除重叠虚高**;
间隔略大于一年 → 旧的一直计到新的入场,**填补断档虚低**;
超过 ``ttm_days + grace_days`` 仍无后继(真停发)→ 封顶,如实归零。
- 若 ``gap < ttm_days - grace_days``,视为**年内多次分红**(中期+年度),
彼此不取代,各自保留标准 ``ttm_days`` 窗口 —— 否则会把中期分红误删,
人为制造出新的低点。
- 最后一次分红没有后继:沿用宽限期兜底(与旧行为一致)。
``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) n = len(dates)
if n == 0: if n == 0:
return np.zeros(0, dtype="float64") 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") return np.zeros(n, dtype="float64")
ex = events["ex_date"].to_numpy(dtype="datetime64[ns]") ex = events["ex_date"].to_numpy(dtype="datetime64[ns]")
imp = events["imp_ann_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]") d = dates.to_numpy(dtype="datetime64[ns]")
span_strict = np.timedelta64(ttm_days, "D") span_strict = np.timedelta64(ttm_days, "D")
span_ext = np.timedelta64(ttm_days + max(0, grace_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") 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") acc = np.zeros(n, dtype="float64")
m = len(ex)
for i in range(m): for i in range(m):
if np.isnat(ex[i]): if np.isnat(ex[i]):
continue 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")) start = int(np.searchsorted(d, ex[i], side="left"))
if not np.isnat(imp[i]): if not np.isnat(imp[i]):
# PIT:公告日之前不可见 # PIT:公告日之前不可见
start = max(start, int(np.searchsorted(d, imp[i], side="left"))) 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: if end > start:
acc[start:end] += dps[i] acc[start:end] += dps[i]
return acc return acc
@@ -165,6 +311,7 @@ def dividend_yield_series(
ttm_days: int = TTM_DAYS, ttm_days: int = TTM_DAYS,
grace_days: int = 45, grace_days: int = 45,
smooth_spikes: bool = True, smooth_spikes: bool = True,
dedupe_events: bool = True,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""构造单只股票的股息率日序列。 """构造单只股票的股息率日序列。
@@ -182,7 +329,7 @@ def dividend_yield_series(
return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"]) return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"])
idx = pd.DatetimeIndex(pd.to_datetime(s.index)) idx = pd.DatetimeIndex(pd.to_datetime(s.index))
dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days, 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 = pd.DataFrame({"trade_date": idx, "close": s.to_numpy(dtype="float64"), "ttm_dps": dps})
out["dividend_yield"] = out["ttm_dps"] / out["close"] out["dividend_yield"] = out["ttm_dps"] / out["close"]
return out.reset_index(drop=True) return out.reset_index(drop=True)
+1 -1
View File
@@ -16,7 +16,7 @@ from hdiv.report.renderer import Provenance, Renderer, query
# 滤网中文说明(呈现用,非业务逻辑) # 滤网中文说明(呈现用,非业务逻辑)
_FILTER_DESC = { _FILTER_DESC = {
"market": "交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易", "market": "行业排除清单 / 交易所 / 板块 / 上市年限 / 市值 / 流动性 / 当日可交易",
"risk": "ST / 退市 / 停牌 / 净资产为负 / 资产负债率(金融豁免)", "risk": "ST / 退市 / 停牌 / 净资产为负 / 资产负债率(金融豁免)",
"dividend": "股息率 / 连续分红年数 / 窗口内分红次数 / 支付率 / FCF 覆盖", "dividend": "股息率 / 连续分红年数 / 窗口内分红次数 / 支付率 / FCF 覆盖",
"quality": "年均 ROE / ROIC / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)", "quality": "年均 ROE / ROIC / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)",
+6 -5
View File
@@ -110,11 +110,12 @@ class DailyUniverseScreener:
self.prune = PruneReport() self.prune = PruneReport()
self._allowed: set[str] | None = None self._allowed: set[str] | None = None
@classmethod # 刻意**没有** ``from_strategy(registry, strategy, ...)``:
def from_strategy(cls, registry: Any, strategy: Any, repo: PitRepo, # ``registry.resolved_universe`` 只含策略 + universe.yml,拿不到
*, verbose: bool = True) -> DailyUniverseScreener: # backtest.yml 的 ``universe_exclusions``(行业黑名单)。保留这样一个
cfg = registry.resolved_universe(strategy) # 便捷构造器,等于给「逐日选股绕过行业排除」留了一条谁都看不出来的路。
return cls(cfg, repo, verbose=verbose) # 调用方应先用 ``BacktestConfig.resolved_universe`` 合并出**生效后**的
# UniverseConfig(见 DailyRunner.__init__),再用普通构造函数传入。
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 预剪枝 # 预剪枝
+26 -4
View File
@@ -6,7 +6,10 @@
- 「分红年度」以**报告期年份**(``end_date.year``)归属 —— - 「分红年度」以**报告期年份**(``end_date.year``)归属 ——
这符合「哪个财年的利润分了红」的通常理解; 这符合「哪个财年的利润分了红」的通常理解;
- 连续性只要求到「最近一个**年报已公告**的财年」为止, - 连续性只要求到「最近一个**年报已公告**的财年」为止,
避免在年报尚未披露时误判为中断。 避免在年报尚未披露时误判为中断;
- 同一 ``(symbol, ex_date)`` 的多条记录按**一笔经济事件**聚合
(与 ``ttm_dps_series`` 共用 ``dedupe_dividend_events``),
否则年度 DPS 与总现金分红会被重复累加。
PIT 纪律:只使用 ``imp_ann_date <= asof`` 且 ``ex_date <= asof`` 的记录。 PIT 纪律:只使用 ``imp_ann_date <= asof`` 且 ``ex_date <= asof`` 的记录。
""" """
@@ -19,13 +22,28 @@ from typing import Any
import pandas as pd import pandas as pd
from hdiv.core.config import DividendFilterConfig 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 from hdiv.universe.filters.base import Filter, FilterOutcome
# 年报到次年 4 月 30 日前披露完毕(法定上限) # 年报到次年 4 月 30 日前披露完毕(法定上限)
ANNUAL_REPORT_DEADLINE_MONTH_DAY = (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): class DividendFilter(Filter):
name = "dividend" name = "dividend"
@@ -146,7 +164,9 @@ class DividendFilter(Filter):
recs: list[dict[str, Any]], target_year: int, asof: date, cfg: DividendFilterConfig recs: list[dict[str, Any]], target_year: int, asof: date, cfg: DividendFilterConfig
) -> dict[str, Any]: ) -> dict[str, Any]:
"""计算分红连续性与 TTM 股息。""" """计算分红连续性与 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() years: set[int] = set()
for r in cash: for r in cash:
ed = r.get("end_date") ed = r.get("end_date")
@@ -246,7 +266,9 @@ class DividendFilter(Filter):
分子(去年分红)与分母(今年一季度利润)不同期,结果无意义。 分子(去年分红)与分母(今年一季度利润)不同期,结果无意义。
""" """
out: dict[str, Any] = {"payout_ratio": None, "fcf_dividend_cover": None} 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: if not cash:
return out return out
years = sorted( years = sorted(
+79 -2
View File
@@ -1,6 +1,6 @@
"""MarketFilter —— 市场属性过滤(plan.md §5.1)。 """MarketFilter —— 市场属性过滤(plan.md §5.1)。
条件:交易所 / 板块 / 上市年限 / 总市值 / 流通市值 / 流动性 / 当日可交易。 条件:行业排除 / 交易所 / 板块 / 上市年限 / 总市值 / 流通市值 / 流动性 / 当日可交易。
""" """
from __future__ import annotations from __future__ import annotations
@@ -11,15 +11,78 @@ from typing import Any
import pandas as pd import pandas as pd
from hdiv.core.config import MarketFilterConfig from hdiv.core.config import MarketFilterConfig
from hdiv.core.errors import ConfigError
from hdiv.universe.filters.base import Filter, FilterOutcome from hdiv.universe.filters.base import Filter, FilterOutcome
class MarketFilter(Filter): class MarketFilter(Filter):
name = "market" name = "market"
def __init__(self, config: MarketFilterConfig) -> None: def __init__(
self,
config: MarketFilterConfig,
*,
exclude_industries: list[str] | None = None,
) -> None:
super().__init__(config) super().__init__(config)
self.cfg = 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: def compute(self, candidates: pd.DataFrame, repo: Any, asof: date) -> FilterOutcome:
cfg = self.cfg cfg = self.cfg
@@ -32,10 +95,24 @@ class MarketFilter(Filter):
mask.at[idx] = False mask.at[idx] = False
reasons[df.at[idx, "symbol"]] = why 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: if cfg.exchanges:
allowed = set(cfg.exchanges) allowed = set(cfg.exchanges)
for idx in df.index: for idx in df.index:
if not mask.at[idx]:
continue
if df.at[idx, "exchange"] not in allowed: if df.at[idx, "exchange"] not in allowed:
fail(idx, f"交易所 {df.at[idx, 'exchange']} 不在 {sorted(allowed)}") fail(idx, f"交易所 {df.at[idx, 'exchange']} 不在 {sorted(allowed)}")
+3 -1
View File
@@ -79,7 +79,9 @@ class UniverseSelector:
c = self.config c = self.config
exempt_leverage = list(c.industry_exemptions.leverage) exempt_leverage = list(c.industry_exemptions.leverage)
self._filters = { self._filters = {
"market": MarketFilter(c.market), "market": MarketFilter(
c.market, exclude_industries=list(c.industry_exclusions)
),
"risk": RiskFilter(c.risk, exempt_leverage=exempt_leverage), "risk": RiskFilter(c.risk, exempt_leverage=exempt_leverage),
"dividend": DividendFilter(c.dividend), "dividend": DividendFilter(c.dividend),
"quality": FinancialQualityFilter( "quality": FinancialQualityFilter(
+4 -1
View File
@@ -299,7 +299,10 @@ def _funnel(run_id: str, candidate_count: int, member_count: int,
因此这里把「候选 → market → risk → dividend → quality → 入选」 因此这里把「候选 → market → risk → dividend → quality → 入选」
的存活曲线算好返回,前端只渲染不算数。 的存活曲线算好返回,前端只渲染不算数。
""" """
labels = ["候选", "市场属性", "风险", "分红", "财务质量"] # 「market」滤网含**行业排除清单**(backtest.yml 的 universe_exclusions
# 会叠加进筛选配置),所以标签里带上「行业」,否则被排除的地产股会
# 无声地算进「市场属性」这一段,读者看不出是行业原因。
labels = ["候选", "行业/市场属性", "风险", "分红", "财务质量"]
order = ["market", "risk", "dividend", "quality"] order = ["market", "risk", "dividend", "quality"]
values = [candidate_count] values = [candidate_count]
cur = candidate_count cur = candidate_count
+137
View File
@@ -23,6 +23,7 @@ from hdiv.backtest.engine import (
_months_between, _months_between,
_round_lot, _round_lot,
build_yield_series, build_yield_series,
dedupe_dividend_events,
dividend_handling_notes, dividend_handling_notes,
reconcile, reconcile,
) )
@@ -147,6 +148,42 @@ def test_reconcile_does_not_include_position_value() -> None:
assert rc["balanced"] is True, "1000 − 800 = 200,持仓市值不应进入残差" 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 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: 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 not d.empty
assert (d["net"] <= d["gross"] + 1e-9).all(), "税后不得大于税前" assert (d["net"] <= d["gross"] + 1e-9).all(), "税后不得大于税前"
assert (d["tax"] >= 0).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 @pytest.mark.db
@@ -863,3 +927,76 @@ def test_unimplemented_declarations_are_honest() -> None:
# 「未实现」会让使用者误以为分红现金被隔离成了不可投资资金。 # 「未实现」会让使用者误以为分红现金被隔离成了不可投资资金。
assert "分红再投资" not in decl, f"把已实现的分红再投资误报成未实现:{decl}" assert "分红再投资" not in decl, f"把已实现的分红再投资误报成未实现:{decl}"
assert "reinvest_rule" not in decl, f"把已实现的再投资规则误报成未实现:{decl}" assert "reinvest_rule" not in decl, f"把已实现的再投资规则误报成未实现:{decl}"
# ---------------------------------------------------------------------------
# 卖出复核(修复 3b):TTM 窗口台阶 vs 分红能力恶化
# ---------------------------------------------------------------------------
def _confirm_engine():
"""构造一个不取数的引擎实例:卖出复核只依赖 pending_div 与策略配置。"""
from hdiv.backtest.engine import BacktestEngine
return BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
def test_exit_confirmation_blocks_cashflow_timing_artefact() -> None:
"""实测 600690.SH 2026-07-30:可见现金只剩 0.26920,但 FY2025 年度 0.89151
早在 2026-06-25 就已公告(除权 2026-08-21)→ 清仓必须被拦下。
这就是用户报告的那笔「实际不该成交」的卖出:旧口径下 TTM 因重复记录虚高
到 2.46848,随后塌到 0.53840/0.26920,把「现金流时点」误读成「分红能力恶化」。
"""
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is False, "已公告未除权的分红足以抬高股息率,清仓应被判定为未确认"
assert info["pending_dps"] == pytest.approx(0.89151)
assert info["ratio"] < info["min_ratio"]
assert eng.exit_unconfirmed == 1
def test_exit_confirmation_allows_real_dividend_cut() -> None:
"""真降息必须照常清仓:公告金额本身就低(或没有公告)时不得拦截。"""
eng = _confirm_engine()
# 无任何已公告未除权分红 → 股息率低就是低
eng.pending_div = {}
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is True
assert info["pending_dps"] == 0.0
# 公告的是一笔很小的分红(0.02),不足以把股息率抬高 → 仍应清仓
eng2 = _confirm_engine()
eng2.pending_div = {"X.SH": [(date(2026, 6, 25), 0.02)]}
ok2, info2 = eng2._exit_confirmed("X.SH", date(2026, 7, 30), 0.26920)
assert ok2 is True, f"小额公告不应拦住清仓,ratio={info2['ratio']}"
def test_exit_confirmation_is_pit_sensitive() -> None:
"""公告日之后才可见:公告日之前的那一天不得用未来公告去豁免清仓。"""
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
# 公告前一天:当时确实不可知 → 照常清仓
ok_before, info_before = eng._exit_confirmed(
"600690.SH", date(2026, 6, 24), 0.26920
)
assert ok_before is True
assert info_before["pending_dps"] == 0.0
# 公告当天起可见
ok_after, info_after = eng._exit_confirmed("600690.SH", date(2026, 6, 25), 0.26920)
assert ok_after is False
assert info_after["pending_dps"] == pytest.approx(0.89151)
def test_exit_confirmation_can_be_disabled() -> None:
"""关掉开关时行为与修复前逐字一致(回滚路径必须可用)。"""
from hdiv.core.config import ExitConfig
eng = _confirm_engine()
eng.pending_div = {"600690.SH": [(date(2026, 6, 25), 0.89151)]}
eng.strategy.exit = ExitConfig.model_validate(
{"yield_percentile": 25, "scale_out": [], "confirm": {"enabled": False}}
)
ok, info = eng._exit_confirmed("600690.SH", date(2026, 7, 30), 0.26920)
assert ok is True and info["enabled"] is False
+85
View File
@@ -327,6 +327,91 @@ def test_missing_config_raises() -> None:
load_config("universe", path="/nonexistent/nope.yml") 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: def test_unknown_config_name_raises() -> None:
with pytest.raises(ConfigError): with pytest.raises(ConfigError):
load_config("no_such_config") load_config("no_such_config")
+29
View File
@@ -311,6 +311,7 @@ class TestSpeedLevers:
strategy = s strategy = s
registry = reg registry = reg
universe_cfg = reg.resolved_universe(s)
_pools_cache_key = DailyRunner._pools_cache_key _pools_cache_key = DailyRunner._pools_cache_key
stub = _Stub() 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: def test_time_model_shrinks_with_coarser_cadence(self) -> None:
"""耗时模型必须随频率下降(否则预计时长会骗人)。""" """耗时模型必须随频率下降(否则预计时长会骗人)。"""
import hdiv.backtest.daily as D import hdiv.backtest.daily as D
+193
View File
@@ -0,0 +1,193 @@
"""TTM 每股分红的**财年语义**测试(修复:中期分红导致的断档虚低)。
背景(真实数据,600690.SH 海尔智家):
| 分红 | 除权日 | 金额 |
|---|---|---:|
| FY2024 年度 | 2025-07-25 | 0.96504 |
| FY2025 中期 | 2025-11-07 | 0.26920 |
| FY2025 年度 | 2026-08-21 | 0.89151 |
旧实现的「按后继接管」只看**相邻两次除权的间隔**:FY2024 与 FY2025 中期只隔
105 天(< 320),于是把中期分红当成「年内多次分红」,两者互不取代;
而 FY2024 与 FY2025 年度相隔 392 天(> 365),旧的又撑不到新的入场。
结果是 **2026-07-25 ~ 2026-08-21 出现 28 天空窗**:TTM 每股分红从 1.23424
掉到 0.26920(−78%),期间公司没有任何真实现金事件。
后果是真实的错误成交:该 run 在 2026-07-30 用这个虚低的股息率(2.31%,
历史分位 1.57% ≤ P25)产生卖出信号,次日开盘清仓 —— 实际上不该卖。
修法:把「相邻间隔」换成**财年语义** —— 同一 ``end_date``(财年)内的多次
支付互不取代;跨财年时,只有「同财年更早还有支付」或「跨财年间隔 < 320 天」
才按标准 365 天窗口退出(中期分红不该把上一年度提前挤掉),否则交给后继接管。
本文件锁定这些语义,与 ``test_dividend_smoothing.py``(相邻间隔的两种毛刺)
互补。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from hdiv.factor.dividend_yield import (
dedupe_dividend_events,
ttm_dps_series,
)
TTM = 365
GRACE = 45
def _events(rows: list[tuple[str, str, str, float]]) -> pd.DataFrame:
"""构造分红事件表:(财年报告期, 除权日, 公告日, 金额)。"""
return pd.DataFrame({
"end_date": pd.to_datetime([r[0] for r in rows]),
"ex_date": pd.to_datetime([r[1] for r in rows]),
"imp_ann_date": pd.to_datetime([r[2] for r in rows]),
"cash_div_tax": [r[3] for r in rows],
})
def _daily(start: str, end: str) -> pd.DatetimeIndex:
return pd.date_range(start, end, freq="D")
#: 600690.SH 海尔智家的真实节奏(3 个财年,含两次跨财年间隔)
HAIER = _events([
("2023-12-31", "2024-08-16", "2024-08-10", 0.80131),
("2024-12-31", "2025-07-25", "2025-07-19", 0.96504), # 与上一笔相隔 343 天
("2025-06-30", "2025-11-07", "2025-11-01", 0.26920), # 中期,相隔 105 天
("2025-12-31", "2026-08-21", "2026-08-15", 0.89151), # 年度,相隔 287 天
])
def test_interim_does_not_create_gap_before_next_annual() -> None:
"""中期分红结束到次年年度除权之间**不得**出现断档虚低。
这是本 bug 的核心:2026-07-25 之后 FY2024 年度已过 365 天,
而 FY2025 年度要到 2026-08-21 才除权;旧实现在这段里只剩中期分红 0.2692。
正确结果应是「FY2024 年度 + FY2025 中期」仍同时在窗口内 = 1.23424。
"""
d = _daily("2024-01-01", "2027-12-31")
sm = pd.Series(
ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
# 出问题的那一天(真实回测里触发误卖的信号日)
assert sm.loc[pd.Timestamp("2026-07-27")] == pytest.approx(1.23424, abs=1e-5)
# 2026-07-25(FY2024 到期日)到 2026-08-21(FY2025 年度除权)之间:
# 不得低于「最近一笔真实分红」(0.96504),实际上应保持 1.23424
win = sm.loc[pd.Timestamp("2026-07-25"):pd.Timestamp("2026-08-20")]
assert win.min() > 1.0, f"中期分红后出现断档虚低,实际 min={win.min():.5f}"
assert win.max() - win.min() < 1e-9, "该区间内不应有任何跳变(无真实现金事件)"
def test_no_collapse_between_interim_and_next_annual() -> None:
"""区间内允许「窗口到期」造成的台阶,但**不允许塌到只剩中期分红**。
修复前:2026-07-25 ~ 2026-08-21 只有 0.26920(−78% 的假低点)。
修复后:TTM 全程不低于 1.23(上一年度 + 本年度中期同时在窗口内)。
"""
d = _daily("2025-06-01", "2026-12-31")
sm = pd.Series(
ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
window = sm.loc[pd.Timestamp("2025-11-07"):pd.Timestamp("2026-08-20")]
assert window.min() >= 1.23, f"出现假低点,实际 min={window.min():.5f}"
def test_annual_successor_still_takes_over_within_grace() -> None:
"""跨财年但间隔落在宽限期内(370 天)时,仍应由后继接管、不留空窗。"""
ev = _events([
("2023-12-31", "2024-06-01", "2024-05-25", 1.0),
("2024-12-31", "2025-06-06", "2025-05-30", 1.2), # 相隔 370 天
("2025-12-31", "2026-06-11", "2026-06-04", 1.2),
])
d = _daily("2024-01-01", "2026-06-11")
sm = pd.Series(
ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
win = sm.loc[pd.Timestamp("2024-06-01"):pd.Timestamp("2025-06-06")]
assert win.min() > 0.9, f"宽限期内的跨财年断档未被填补,实际 min={win.min():.5f}"
def test_interim_alone_does_not_extend_into_a_full_year_of_silence() -> None:
"""只有中期分红、随后真停发:必须如实归零,不能靠财年语义永远挂着。"""
ev = _events([
("2024-12-31", "2025-07-25", "2025-07-19", 0.96504),
("2025-06-30", "2025-11-07", "2025-11-01", 0.26920),
# 之后没有任何分红
])
d = _daily("2025-01-01", "2027-06-30")
sm = pd.Series(
ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
# 中期那笔(本序列最后一笔)的宽限期封顶 = 2025-11-07 + 365 天
assert sm.loc[pd.Timestamp("2027-01-01")] == pytest.approx(0.0)
# 但在封顶之前仍如实计入(不是提前归零)
assert sm.loc[pd.Timestamp("2026-11-01")] == pytest.approx(0.26920, abs=1e-5)
def test_smooth_off_is_unaffected_by_fiscal_year_logic() -> None:
"""``smooth_spikes=False`` 必须精确保留「硬窗口」语义(回归对照)。"""
d = _daily("2025-01-01", "2026-12-31")
off = pd.Series(
ttm_dps_series(d, HAIER, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False),
index=d,
)
# 硬窗口:2026-07-27 时 FY2024 年度(2025-07-25)已过 365 天 → 只剩中期
assert off.loc[pd.Timestamp("2026-07-27")] == pytest.approx(0.26920, abs=1e-5)
# 而 2025-12-01 时 FY2024 + 中期都在窗口内
assert off.loc[pd.Timestamp("2025-12-01")] == pytest.approx(1.23424, abs=1e-5)
def test_fiscal_year_is_inferred_when_end_date_missing() -> None:
"""事件表没有 ``end_date`` 列时必须退化到「按相邻间隔接管」,不得报错。
退化路径**不做**跨财年补位:退化的财年是「除权日所在年」,会把年报除权年
与紧随其后的中期除权年混在一起,据此补位反而可能造出错误窗口。宁可保守 ——
业务路径的分红记录都带 ``end_date``(见 ``repo.dividend_records``)。
"""
ev = HAIER.drop(columns=["end_date"])
d = _daily("2026-07-01", "2026-08-31")
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
s = pd.Series(out, index=d)
assert np.isfinite(out).all()
# 退化路径仍保留旧接管:2026-07-01 是 FY2023 年度 + FY2025 中期 = 1.23424
assert s.loc[pd.Timestamp("2026-07-01")] == pytest.approx(1.23424, abs=1e-5)
# 但没有跨财年补位,2026-07-25 起只剩中期分红(记录该已知局限)
assert s.loc[pd.Timestamp("2026-07-27")] == pytest.approx(0.26920, abs=1e-5)
def test_dedupe_then_fiscal_year_end_to_end() -> None:
"""重复记录 + 真实节奏同时存在:聚合与财年语义必须叠加生效。
真实库里 600690.SH 的每一笔都有**两条** ``实施`` 记录(ann_date 不同),
逐行累加会把 2.46848 当成 TTM —— 那是虚高一倍的值,随后的「回落」也
必然是假象。
"""
dup = pd.concat([HAIER, HAIER], ignore_index=True)
deduped = dedupe_dividend_events(dup)
assert len(deduped) == len(HAIER), "同一除权日的重复记录未被聚合"
d = _daily("2025-08-01", "2026-08-20")
sm = pd.Series(
ttm_dps_series(d, dup, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True),
index=d,
)
# 聚合 + 财年接管后:
# - 出问题的那一天稳定在 1.23424(FY2024 年度 + FY2025 中期)
# - 关键区间(2026-07-25 ~ 2026-08-20)不再塌到 0.26920
# - 全程下限是 0.96504(合理的窗口到期台阶),不是 0.2692 的假低点
assert sm.loc[pd.Timestamp("2026-07-27")] == pytest.approx(1.23424, abs=1e-5)
gap = sm.loc[pd.Timestamp("2026-07-25"):pd.Timestamp("2026-08-20")]
assert gap.min() == pytest.approx(1.23424, abs=1e-5), "该区间不得出现假低点"
assert sm.min() >= 0.96, f"区间内出现假低点,实际 min={sm.min():.5f}"
assert sm.max() <= 1.77, f"出现重叠虚高,实际 max={sm.max():.5f}"
+97 -2
View File
@@ -20,6 +20,7 @@ import pytest
from hdiv.factor.dividend_yield import ( from hdiv.factor.dividend_yield import (
build_dps_events, build_dps_events,
dedupe_dividend_events,
ttm_dps_at, ttm_dps_at,
ttm_dps_series, ttm_dps_series,
ttm_params, 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, sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d) smooth_spikes=True), index=d)
# 年中确实应同时含两笔(0.3 + 0.7 = 1.0) # 年中确实应同时含两笔(0.3 + 0.7 = 1.0)。
peak = sm.loc[pd.Timestamp("2023-06-01"):pd.Timestamp("2023-11-20")] # 重叠期 = 第二笔入场(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 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: 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 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 必须用同一份参数 # 口径统一:筛选 / 画像 / 回测 / Web 必须用同一份参数
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+122 -2
View File
@@ -135,14 +135,30 @@ def test_verify_market_units_empty() -> None:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 滤网:行业豁免 # 滤网:共用夹具
#
# ``_FakeRepo`` / ``_frame`` 同时服务「行业豁免」与「行业排除」两组测试,
# 所以单独放在这里,不属于任何一组。
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class _FakeRepo: 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._st = st or set()
self._susp = susp 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 def st_symbols(self, asof, include_delisting=True): # noqa: ANN001, ARG002
return self._st return self._st
@@ -150,6 +166,10 @@ class _FakeRepo:
def suspended_on(self, asof): # noqa: ANN001, ARG002 def suspended_on(self, asof): # noqa: ANN001, ARG002
return self._susp return self._susp
def stock_master(self) -> pd.DataFrame:
"""只为 ``MarketFilter`` 的行业名自检提供 ``industry`` 取值域。"""
return pd.DataFrame({"industry": self._industries})
def _frame(**kwargs) -> pd.DataFrame: def _frame(**kwargs) -> pd.DataFrame:
base = { base = {
@@ -171,6 +191,81 @@ def _frame(**kwargs) -> pd.DataFrame:
return pd.DataFrame(base) 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: def test_risk_filter_exempts_banks_from_leverage() -> None:
from hdiv.core.config import RiskFilterConfig from hdiv.core.config import RiskFilterConfig
from hdiv.universe.filters.risk import RiskFilter 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: def test_continuity_within_target_year() -> None:
"""FY2023 分红已在 2024-05 除权 → target=2023,连续 5 年。""" """FY2023 分红已在 2024-05 除权 → target=2023,连续 5 年。"""
from hdiv.core.config import DividendFilterConfig from hdiv.core.config import DividendFilterConfig
+15 -22
View File
@@ -12,8 +12,11 @@
诊断三件事: 诊断三件事:
1. **同一除权日的重复分红记录**:``hd_dividend`` 写入侧刻意保留 1. **同一除权日的重复分红记录**:``hd_dividend`` 写入侧刻意保留
预案/股东大会通过/实施 全量记录(决策 D6),但查询侧把它们当成 预案/股东大会通过/实施 全量记录(决策 D6),同一 ``(symbol, ex_date)``
**多笔独立分红**,于是 ``cash_div_tax`` 被重复累加。 因此可能有多条 ``实施`` 记录。业务路径已由
``factor.dividend_yield.dedupe_dividend_events`` 按经济事件聚合
(``ttm_dps_series`` 入口统一调用),本脚本用 ``dedupe_events=False``
复现「逐行累加」的旧口径,用来量化这个毛刺曾经有多大。
2. **TTM 每股分红的时间线**:逐交易日打印去重前 / 去重后的取值, 2. **TTM 每股分红的时间线**:逐交易日打印去重前 / 去重后的取值,
毛刺会表现为「无任何真实现金事件的一天突然跳变」。 毛刺会表现为「无任何真实现金事件的一天突然跳变」。
3. **异常成交反查**:给定回测 run_id,列出每一笔卖出当日 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.data import db # noqa: E402
from hdiv.factor.dividend_yield import ( # noqa: E402 from hdiv.factor.dividend_yield import ( # noqa: E402
build_dps_events, build_dps_events,
dedupe_dividend_events,
ttm_dps_series, ttm_dps_series,
ttm_params, 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: def diagnose_symbol(symbol: str, start: date, end: date) -> None:
div = db.read_sql( div = db.read_sql(
"SELECT symbol, end_date, ann_date, imp_ann_date, div_proc, " "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"]) px["trade_date"] = pd.to_datetime(px["trade_date"])
idx = pd.DatetimeIndex(px["trade_date"]) idx = pd.DatetimeIndex(px["trade_date"])
w, g, sm = ttm_params() 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( out = pd.DataFrame(
{ {
"trade_date": px["trade_date"].dt.date, "trade_date": px["trade_date"].dt.date,
@@ -142,9 +134,10 @@ def diagnose_run(run_id: str) -> None:
i = pd.DatetimeIndex(s.index) i = pd.DatetimeIndex(s.index)
ref = (d0 - timedelta(days=int(365.25 * years)), d0) 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( 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, index=i,
) )
y = (dps / s).loc[: pd.Timestamp(d0)] 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") 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") return cur, pct, float(rs.quantile(0.25)) if rs.size else float("nan")
for label, evx in (("去重前", ev), ("去重后", _dedup(ev))): for label, dedupe in (("去重前", False), ("去重后", True)):
cur, pct, p25 = decision(evx) cur, pct, p25 = decision(ev, dedupe=dedupe)
rows.append( rows.append(
{ {
"symbol": sym, "symbol": sym,