修复: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:
@@ -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: [全国地产, 区域地产, 房产服务, 园区开发]
|
||||||
|
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
# 调度:多久评估一次信号、多久重建一次股票池
|
# 调度:多久评估一次信号、多久重建一次股票池
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
# ------------------------------------------------------------
|
# ------------------------------------------------------------
|
||||||
|
|||||||
@@ -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`,因此列表页的文案里看不到这条排除规则。
|
||||||
|
> 明细页的「回测配置」原文里能完整看到。
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
acc = np.zeros(n, dtype="float64")
|
|
||||||
m = len(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):
|
for i in range(m):
|
||||||
if np.isnat(ex[i]):
|
if np.isnat(ex[i]):
|
||||||
continue
|
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
|
||||||
|
|
||||||
if not smooth_spikes:
|
acc = np.zeros(n, dtype="float64")
|
||||||
end_ts = ex[i] + span_strict
|
for i in range(m):
|
||||||
elif i + 1 < m and not np.isnat(ex[i + 1]):
|
if np.isnat(ex[i]):
|
||||||
gap = ex[i + 1] - ex[i]
|
continue
|
||||||
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)
|
||||||
|
|||||||
@@ -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 / 毛利率 / 净利率 / 经营现金流对利润(金融豁免)",
|
||||||
|
|||||||
@@ -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__),再用普通构造函数传入。
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# 预剪枝
|
# 预剪枝
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)}")
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user