修复: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:
@@ -462,6 +462,13 @@
|
||||
11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码**
|
||||
(其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除;
|
||||
SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。
|
||||
12. **配股未实现**:`hd_dividend` 里没有配股价/配股比例/缴款期字段,
|
||||
`handle_rights_issue: true` 只会逐次 run 在 `unimplemented_json` 里声明该缺口。
|
||||
13. **红利税两处模型假设**(非漏实现,故不写入 `unimplemented`):
|
||||
① 在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时**
|
||||
按「买入→卖出」的实际持有期补缴、按分笔 FIFO;
|
||||
② **送股**(`stk_bo_rate`)按面值 1 元计入红利所得的个税未建模
|
||||
(转增 `stk_co_rate` 本就不征;持股 > 1 年者该税为 0)。
|
||||
|
||||
---
|
||||
|
||||
@@ -518,7 +525,7 @@ HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回
|
||||
| `config/profile.yml` | **个股特性**(统计窗口/分位/波动频率/安全边际权重/TTM 口径) |
|
||||
| `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) |
|
||||
| `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 |
|
||||
| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合 |
|
||||
| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合/**排除行业清单** |
|
||||
| `config/report.yml` | 图表开关/输出命名/版面/资源模式 |
|
||||
| `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 |
|
||||
|
||||
@@ -778,6 +785,196 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每
|
||||
- **已有的画像与回测结果已过期**,需重跑
|
||||
- 筛选结果需重新生成(`hd_universe_run` 会原地覆盖)
|
||||
|
||||
## 7.6b 已修复:TTM 股息率的两处残余缺陷与卖出复核(2026-10-05)
|
||||
|
||||
7.6 的「按后继接管」只看了**相邻两次除权的间隔**,留下两个口子,共同表现是
|
||||
**回测出现实际不该成交的卖出**。用户报告的样本:`600690.SH` 在 2026-07-30
|
||||
触发清仓、2026-07-31 开盘卖出,实际不该卖。
|
||||
|
||||
### 缺陷一:同一除权日的多条「实施」记录被逐行累加
|
||||
|
||||
`hd_dividend` 写入侧刻意保留全量公告记录(决策 D6),去重键含 `ann_date`,
|
||||
于是同一笔分红会有多条 `实施` 记录落在**同一个除权日**。查询侧若逐行累加,
|
||||
`cash_div_tax` 被重复计入。实测 `600690.SH` 2012 年以来的每一笔都有两条,
|
||||
`ttm_dps` 因此长期虚高约一倍(2.46848 vs 真实 1.23424)—— 随后窗口到期时
|
||||
又必然「回落」,把假象放大成一次 −78% 的塌陷。
|
||||
|
||||
- 规模:5724 只有现金分红的股票中 **953 只**存在同除权日重复(多出 1313 行)
|
||||
- 修法:`dedupe_dividend_events()` 按 `(symbol, ex_date)` 聚合成**一笔经济事件**
|
||||
(金额逐字段取最大 → 「分项 + 合计」收敛到合计;日期取最晚 → PIT 保守)。
|
||||
三处入口统一调用:`ttm_dps_series`、`Repo.dividend_events`(回测现金入账)、
|
||||
`universe/filters/dividend.py`(年度 DPS / 支付率 / FCF 覆盖)。
|
||||
|
||||
### 缺陷二:只看相邻间隔,漏掉「年度 → 中期 → 下一年度」的跳法
|
||||
|
||||
实测 `600690.SH`:FY2023 年度 2024-08-16、FY2024 年度 2025-07-25、
|
||||
FY2025 中期 2025-11-07、FY2025 年度 2026-08-21。105 天的间隔让前两笔被判为
|
||||
「年内多次分红」而互不取代,392 天又超过 `365 + 45` —— 2026-07-25 ~
|
||||
2026-08-21 出现 **28 天空窗**,可见现金只剩 0.26920。
|
||||
|
||||
修法(`ttm_dps_series`):把覆盖窗口从「按相邻间隔」升级为「按财年(`end_date`)」:
|
||||
|
||||
1. 后继接管(保留 7.6 行为,阈值 `ttm_days - grace_days` = 320 天);
|
||||
2. **跨财年补位**:每个财年最后一笔 → 下一财年最后一笔入场,上限 `365 + grace`;
|
||||
3. **末笔宽限兜底**:没有任何后继时覆盖 `365 + grace`(真停发仍如实归零)。
|
||||
|
||||
实测 600690 在 2026-07-27 的 `ttm_dps` 从 **0.53840(错误)** 变为
|
||||
**1.23424(= FY2024 年度 + FY2025 中期)**,股息率 5.30%、历史分位 92.98%,
|
||||
**不再触发 P25 清仓**。
|
||||
|
||||
### 缺陷三(设计缺口):卖出只认「已除权的现金」,不认「已公告的分红」
|
||||
|
||||
FY2025 年度分红(0.89151)的**实施公告日在 2026-06-25**(股东大会通过),
|
||||
只是除权日在 2026-08-21。TTM 现金口径看不到它,于是「股息率处于历史低位」
|
||||
在字面上为真、实际上描述的是**现金流时点**而非分红能力恶化。
|
||||
|
||||
修法(修复 3b):新增 `s.exit.confirm`:
|
||||
|
||||
| 参数 | 默认 | 语义 |
|
||||
|---|---|---|
|
||||
| `enabled` | `true` | 关掉即回到修复前行为(可回滚) |
|
||||
| `min_ratio` | `0.8` | `TTM ÷ (TTM + 已公告未除权)` 低于它 → 判定未确认 |
|
||||
| `announce_lookback_days` | `400` | 只回溯这段时间内公告的分红 |
|
||||
|
||||
- 取数:`Repo.announced_dividends(asof)` —— PIT 口径为 `ann_date <= asof AND ex_date > asof`
|
||||
(与 `dividend_records` 的「已除权」互补);
|
||||
- 命中时不再清仓,改为写一条 `HOLD` 信号(`skip_reason=EXIT_UNCONFIRMED`),
|
||||
并把 `exit_confirm` 明细留在 `reason_json`;
|
||||
- `BacktestEngine.exit_unconfirmed` 计数进 run 结果,便于事后核查;
|
||||
- **真降息不会被拦**:公告金额本身就低(或无公告)时 `pending ≈ 0`、比值 ≈ 1,
|
||||
照常清仓。
|
||||
|
||||
### 验收(真实数据)
|
||||
|
||||
| 股票 | 信号日 | 修复前分位 | 修复后 | 结论 |
|
||||
|---|---|---:|---:|---|
|
||||
| 600690.SH | 2026-07-30 | 1.57% | 92.98% | 不再触发卖出(另有复核兜底) |
|
||||
| 600015.SH | 2025-07-04 | 21.10% | 34.20% | 不再触发卖出 |
|
||||
| 601009.SH | 2025-06-20 | 0.08% | 85.71% | 不再触发;复核也拦下 |
|
||||
| 000333.SZ | 2026-06-17 | 0.25% | 93.73% | 不再触发;复核也拦下 |
|
||||
|
||||
### 影响与后续
|
||||
|
||||
- 修复后**必须重跑**筛选 → 画像 → 回测(与 7.6 同理);旧 run 只能作为「修复前」基线;
|
||||
- 买入侧同样受影响:`600690.SH` 2022-2023 的买入信号在真实口径下分位只有
|
||||
47%~74%(< P75),原先的 89%~99% 全部来自重复累加;
|
||||
- 新增测试:`tests/test_dividend_fiscal_year.py`(财年接管 7 例)、
|
||||
`tests/test_dividend_smoothing.py` 的经济事件口径 3 例、
|
||||
`tests/test_backtest.py::test_exit_confirmation_*`(复核 4 例);
|
||||
- 诊断工具:`tools/diag_dividend_artifact.py`(`--symbol` / `--run-id` /
|
||||
`--duplicate-survey`,只读)。
|
||||
|
||||
## 7.7 已修复:公司行为的三处静默错误(分红/送转/配股口径)
|
||||
|
||||
排查「回测如何应对除权」时发现的问题,逐个修复。共同点是**都不会报错**,
|
||||
只会让净值、现金或分红统计悄悄偏离。
|
||||
|
||||
### 问题一:纯送转被整行丢弃(凭空亏损)
|
||||
|
||||
`_apply_dividends` 在算送股**之前**就按 `cash_div_tax <= 0` 整行 `continue`,
|
||||
于是「10 送 10」这类**没有现金分红**的送转完全不调股数 ——
|
||||
而价格是不复权价、除权日照常腰斩,回测于是记出一笔不存在的亏损。
|
||||
|
||||
实测影响面:全库 `实施` 且 `stk_div > 0` 共 13,038 行,其中**纯送转 3,268 行**;
|
||||
高股息池成员在 2015-2026 区间内有 **824 笔**(0.3~1.2 股/股,
|
||||
如 `000793.SZ` 每 10 股转增 12 股 → 单笔约 −54% 的该持仓市值)。
|
||||
|
||||
**修法**:现金与送转**各自独立判断**,只有「既无现金也无送转」的行才跳过;
|
||||
同时把 `stk_bo_rate`(送股)/`stk_co_rate`(转增)写进分红台账留痕。
|
||||
|
||||
### 问题二:同一除权日的重复记录被重复入账
|
||||
|
||||
`hd_dividend` 写入侧刻意保留全量公告记录,而去重键含 `ann_date`,
|
||||
于是同一 `(symbol, ex_date)` 会有多条 `实施` 记录:**全库 1401 组**
|
||||
(1240 组字段完全相同;96 组报告期不同、122 组金额不同)。
|
||||
实测 `002352.SZ 2024-11-07` 同时存在 0.4 / 1.0 / 1.4 三条,而 **1.4 = 0.4 + 1.0**
|
||||
是合计口径 —— 逐行累加会把同一笔分红算两三倍(现金与送转都被放大)。
|
||||
|
||||
**修法**:新增 `factor.dividend_yield.dedupe_dividend_events`,按经济事件聚合:
|
||||
**金额/股数逐字段取最大**(收敛「分项 + 合计」,同时不会在
|
||||
「一行纯现金 + 一行纯送转」时丢掉送转)、**日期取最晚**(不引入未来函数)。
|
||||
三处入口统一调用,不再各写一份:
|
||||
|
||||
| 入口 | 覆盖 |
|
||||
|---|---|
|
||||
| `ttm_dps_series`(**唯一** TTM 口径) | 筛选 `dividend_yield`、画像、回测信号、Web 图表 |
|
||||
| 回测预载 `div_events` | 现金入账与送转股 |
|
||||
| `DividendFilter._stats` / `_payout_and_cover` | 年度 DPS(CAGR/波动)、总现金分红(支付率/FCF 覆盖) |
|
||||
|
||||
### 问题三:分红再投资的声明与行为不一致
|
||||
|
||||
引擎的实际行为一直是「分红现金回落到**与初始资金同一个** `cash` 变量,
|
||||
下次调仓按目标权重再配置」—— 即 `reinvest` + `portfolio_rebalance`。
|
||||
但 `backtest.yml` 写的是 `reinvest_rule: same_stock_next_open`(同股次日开盘再投),
|
||||
于是每次 run 都声明「未实现分红再投资规则,分红留存为现金」,
|
||||
让人以为分红成了不可投资的资金。
|
||||
|
||||
**修法**:配置改为已实现组合 `cash_mode: reinvest` + `reinvest_rule: portfolio_rebalance`;
|
||||
声明逻辑抽成 `dividend_handling_notes()`,**逐档取值**都有 unit test 对应:
|
||||
`hold`/`cash_out`/`same_stock_next_open`/`handle_stock_dividend=false`/配股 才声明未实现。
|
||||
顺带接线一直是**死字段**的 `dividend.apply_dividend_tax`(原读的是 `cost.yml`)。
|
||||
|
||||
### 附带修正
|
||||
|
||||
- **送转后重算每股成本**:`avg_cost` 原先只随买入更新,送转后 `quantity` 增加而
|
||||
`avg_cost` 不变,卖出时 `cost_part = avg_cost × qty` 会多扣成本
|
||||
(1000 股 @10 送 0.3 后全卖 @10 记成 0 而非 +3000)。现在按
|
||||
`cost_basis / quantity` 重算;只影响逐笔 `realized_pnl` 与持仓表,不影响净值与对账。
|
||||
- **TTM 求和的 NaN 传染**:送转行的 `cash_div_tax` 是 NULL,`to_numpy(float64)`
|
||||
会把整条 TTM 序列变成 NaN(Web 股息率图因此空白)。现在按 0 处理。
|
||||
|
||||
### 影响与后续
|
||||
|
||||
实测(同一份代码,仅切换聚合口径):
|
||||
|
||||
**回测 2021-01-01 ~ 2024-06-28**(高股息策略,闸门开):
|
||||
|
||||
| 指标 | 逐行累加(旧) | 经济事件聚合(新) | 差 |
|
||||
|---|---:|---:|---:|
|
||||
| 累计现金分红(税后) | 179,550.91 | 179,258.24 | **−292.67(−0.16%)** |
|
||||
| 累计代扣红利税 | 10,186.27 | 10,446.84 | +260.57 |
|
||||
| 成交笔数 | 68 | 69 | +1 |
|
||||
| 期末资金 | 1,213,171.28 | 1,213,283.32 | +112.04(+0.009%) |
|
||||
| 最大回撤 | −19.31% | −19.31% | 0 |
|
||||
|
||||
税与笔数的变化是**二阶效应**:TTM 股息率变了 → 分位信号变了 → 成交与持仓期变了。
|
||||
换句话说,股息率不是「一个统计数字」,它就是买卖点本身。
|
||||
|
||||
**TTM 每股分红**(`asof=2024-06-28`,全市场 5,128 只有分红历史的股票):
|
||||
**163 只受影响(3.2%)**,虚高中位数 **+100%**、P90 +100%、最大 **+300%**
|
||||
(`688133.SH` 0.4 → 0.1)。
|
||||
|
||||
**真正要紧的是池内成员**:跨所有 run 的高股息池入选成员共 75 只,其中
|
||||
|
||||
| asof | 受影响 | 例子(虚高幅度) |
|
||||
|---|---:|---|
|
||||
| 2021-06-30 | 2 / 75 | `600845.SH` +100%、`601601.SH` +8% |
|
||||
| 2024-06-28 | 5 / 75 | `600489.SH` +100%、`600690.SH` +100%、`601009.SH` +100%、`600188.SH` +40%、`600600.SH` +28% |
|
||||
|
||||
`600690.SH` 正是诊断脚本当初用来举例的那只 —— 说明这个毛刺一直在
|
||||
**直接决定这批股票的买卖点**,而不是只影响展示。
|
||||
|
||||
**最要紧的一层:它会改变谁能进池**。筛选的第一道门是「自算股息率 ≥ 3%」
|
||||
(`universe.yml: yield_source=computed`、`min_dividend_yield=0.03`),
|
||||
而股息率正是被这个毛刺抬高的数字。实测 `asof=2024-06-28`:
|
||||
|
||||
| | 逐行累加(旧) | 经济事件聚合(新) |
|
||||
|---|---:|---:|
|
||||
| `600690.SH` TTM 每股分红 | 1.13384 | **0.56692** |
|
||||
| `600690.SH` 股息率 | **3.99%**(过 3% 门槛) | **2.00%**(不过) |
|
||||
| `600690.SH` 拒绝原因 | FCF 覆盖 0.92x < 1.00x | 自算股息率 2.00% < 3.00% |
|
||||
|
||||
把口径修正后重算受影响股票的门槛判定:**52 / 163 只**在旧口径下股息率 ≥ 3%、
|
||||
新口径下 < 3% —— 也就是说,重复记录**虚假地把 52 只股票送进了高股息候选池**
|
||||
(`600368.SH` 5.99%→3.00%、`600662.SH` 5.99%→2.99%、`300360.SZ` 5.88%→2.94% …)。
|
||||
同时「总现金分红」被算高一倍,会把 `fcf_dividend_cover` 压低一半
|
||||
(`600690.SH` 0.92x vs 修正后的 ~1.84x),等于**用同一个错误把好公司又筛掉一次**。
|
||||
|
||||
- 聚合口径改变 TTM 股息率 → **股票池成员、画像与历史信号都会变**,
|
||||
旧 run 与新 run 不可直接比较,需要重跑(同 §7.6)。
|
||||
- 新增 13 个 unit test 覆盖 dedupe、送转/现金各档组合与未实现项声明;库里
|
||||
`hd_dividend` 数据本身未改动(聚合发生在查询侧,随时可回到逐行口径做对比)。
|
||||
|
||||
## 8. 测试覆盖
|
||||
|
||||
```
|
||||
@@ -1338,3 +1535,70 @@ cProfile 实测:6 个交易日里 `load_config` 被调用 **1008 次、共 16.
|
||||
| `hd_daily_universe` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) |
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 11. 新增:回测层面的排除行业清单(2026-10-05)
|
||||
|
||||
**需求**:回测里要能按行业拉黑名单,先排除房地产。
|
||||
|
||||
**为什么放在 `backtest.yml` 而不是 `universe.yml`**:
|
||||
|
||||
| 文件 | 回答的问题 |
|
||||
|---|---|
|
||||
| `universe.yml` | 「高股息策略**本身**要求什么样的公司」——选股定义,与某次研究无关 |
|
||||
| `backtest.yml: universe_exclusions` | 「**这次回测**特意不要哪些行业」——研究口径,如规避地产周期 |
|
||||
|
||||
语义不同,所以分开放;生效时**取并集**,本清单只做减法。
|
||||
|
||||
### 最大的坑:数据库里没有「房地产业」
|
||||
|
||||
`stock.industry` 用的是 tushare 风格的有限枚举(当前 111 个取值),
|
||||
房地产被拆成四个,**写「房地产」或「房地产业」一只都不会被排除**:
|
||||
|
||||
| 行业名 | 内容 | 只数 |
|
||||
|---|---|---:|
|
||||
| `全国地产` | 全国性开发商(万科A、保利发展、金地集团…) | 26 |
|
||||
| `区域地产` | 区域性开发商(滨江集团、华发股份…) | 43 |
|
||||
| `房产服务` | 物业/中介/房产服务(招商积余、我爱我家…) | 13 |
|
||||
| `园区开发` | 开发区与园区运营商(陆家嘴、张江高科…) | 14 |
|
||||
| | **合计**(占全部 5903 只的 1.6%) | **96** |
|
||||
|
||||
因此 `MarketFilter` 在第一次求值时拿名单与该表的实际取值核对,
|
||||
**写错名字直接抛 `ConfigError`**(并按字符重合度提示最接近的真实取值),
|
||||
而不是安静地什么都不排除 —— 后者正是本项目反复记录的失效形态。
|
||||
|
||||
### 接线(三个入口都必须走生效后的配置)
|
||||
|
||||
| 入口 | 位置 | 说明 |
|
||||
|---|---|---|
|
||||
| `single` / `walkforward` 回测 | `BacktestEngine.__init__` → `self.universe_cfg` | 引擎自行逐调仓日重筛时用它建 `UniverseSelector` |
|
||||
| walk-forward 训练段校准 | `WalkForwardRunner.selector()` | 冻结分布必须在**同一个**池子上标定,否则与测试段口径不一致 |
|
||||
| `--mode daily` 逐日选股 | `DailyRunner.__init__` → `DailyUniverseScreener` | 逐日重建股票池时生效 |
|
||||
|
||||
配套:删掉了 `DailyUniverseScreener.from_strategy(registry, strategy, ...)` ——
|
||||
它拿不到 `backtest.yml`,保留就等于留了一条「逐日选股绕过行业排除」的路。
|
||||
|
||||
### 缓存陷阱(已修)
|
||||
|
||||
`--mode daily` 的逐日选股有本地缓存,原键只含
|
||||
`registry.hash_of(strategy)`(策略 + `universe.yml`)。行业清单来自
|
||||
`backtest.yml`,**不进键就会命中旧缓存**:改了清单、日志写着已排除,
|
||||
跑的却是排除之前的池子。键里已加入 `excl:<清单>`。
|
||||
|
||||
### 锁定它的测试
|
||||
|
||||
| 测试 | 锁什么 |
|
||||
|---|---|
|
||||
| `test_market_filter_excludes_listed_industries` | 名单命中即淘汰,未列入的不受影响 |
|
||||
| `test_industry_exclusion_is_the_reported_reason` | 同时市值不足时,报出的必须是「行业被排除」 |
|
||||
| `test_unknown_industry_name_raises_instead_of_silently_passing` | 写「房地产业」→ 报错并提示「全国地产」 |
|
||||
| `test_backtest_config_excludes_real_estate_industries` | 生效配置必须真的在排房地产,且四个口径齐全 |
|
||||
| `test_resolved_universe_is_a_union_not_an_override` | 叠加而非覆盖;不就地修改传入对象 |
|
||||
| `test_engine_applies_universe_exclusions_to_selector` | 黑名单真的传到了 `market` 滤网 |
|
||||
| `test_walkforward_and_daily_runners_apply_universe_exclusions` | 三个入口都接线 |
|
||||
| `test_cache_key_depends_on_industry_exclusions` | 改清单必须换缓存文件 |
|
||||
|
||||
> **尚未做**:Web 前端「回测条件」摘要(`web/service.describe_strategy`)
|
||||
> 只读策略配置,不含 `backtest.yml`,因此列表页的文案里看不到这条排除规则。
|
||||
> 明细页的「回测配置」原文里能完整看到。
|
||||
|
||||
@@ -255,6 +255,7 @@ CLI 会打印覆盖率,例如:
|
||||
| 分红 | 除权日入账(税后),进的是**与初始资金同一个可投资现金池**,下次调仓按目标权重再配置(`reinvest` + `portfolio_rebalance`,已实现;`hold`/`cash_out` 未实现) |
|
||||
| 送股 / 转增 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降)。**纯送转**(如 10 送 10:股价腰斩、股数翻倍,无现金分红)同样处理,不会被丢弃 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效;库里也没有配股价/比例数据) |
|
||||
| 同一除权日多条记录 | 按**一笔经济事件**聚合(金额/股数逐字段取最大、日期取最晚),不重复入账 |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
|
||||
@@ -627,9 +628,35 @@ industry_exemptions:
|
||||
> 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红
|
||||
> (中期+年度),彼此都保留;超过 `365 + grace_days` 仍无后继则如实归零。
|
||||
>
|
||||
> **财年接管(2026-10-05 增补)**:只看相邻间隔会漏掉
|
||||
> 「上一年度 → 本年度中期 → 本年度年度」这种跳法。实测 600690.SH:
|
||||
> FY2024 年度 2025-07-25、FY2025 中期 2025-11-07、FY2025 年度 2026-08-21 ——
|
||||
> 105 天的间隔让前两笔互不取代,392 天又超过 `365 + grace`,
|
||||
> 于是 2026-07-25 ~ 2026-08-21 出现 28 天空窗(TTM 从 1.23424 掉到 0.26920),
|
||||
> 直接造成一次不该发生的卖出。现在按 `end_date`(报告期)识别财年:
|
||||
> 每个财年的最后一笔会补位到下一财年最后一笔的除权日,仍以 `365 + grace` 封顶,
|
||||
> 真停发照旧归零。事件表没有 `end_date` 时该规则自动跳过(保守,不猜)。
|
||||
>
|
||||
> 实测效果:招商银行 >20% 跳变 21 → 5 次,中国银行虚低归零 77 天 → 0 天。
|
||||
> 若个别股票仍有断档,把 `grace_days` 调大(如 90)。
|
||||
|
||||
**卖出复核(`exit.confirm`,2026-10-05 新增)**
|
||||
|
||||
TTM 现金口径在窗口边界上必然有台阶:一笔分红满 365 天退出,而下一笔年度分红
|
||||
可能**还没除权**。若只看已除权现金,这种台阶会被误读成「分红能力恶化」而清仓。
|
||||
实测 600690.SH 2026-07-30:FY2025 年度分红(0.89151)早在 **2026-06-25**
|
||||
就已实施公告,只是除权日在 2026-08-21。
|
||||
|
||||
| 参数 | 默认 | 语义 |
|
||||
|---|---|---|
|
||||
| `exit.confirm.enabled` | `true` | 关掉即回到修复前行为(可回滚) |
|
||||
| `exit.confirm.min_ratio` | `0.8` | `TTM ÷ (TTM + 已公告未除权)` 低于它 → 判定未确认 |
|
||||
| `exit.confirm.announce_lookback_days` | `400` | 只回溯这段时间内公告的分红 |
|
||||
|
||||
命中时不清仓,改为写一条 `HOLD` 信号(`skip_reason=EXIT_UNCONFIRMED`),
|
||||
`reason_json.exit_confirm` 里有比值明细;被拦次数进 `hd_backtest_run` 结果。
|
||||
**真降息不会被拦**:公告金额本身就低(或无公告)时 `pending ≈ 0`、比值 ≈ 1。
|
||||
|
||||
---
|
||||
|
||||
## 4.3 `config/strategy/high_dividend_v1.yml` — 策略定义 ★
|
||||
@@ -827,6 +854,47 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `step_months` | `12` | 窗口步进 |
|
||||
| `freeze_params_in_test` | `true` | **必须为 true**(配置层强制拒绝关闭) |
|
||||
|
||||
### 排除行业清单 `universe_exclusions`(黑名单)
|
||||
|
||||
```yaml
|
||||
universe_exclusions:
|
||||
# 房地产(DB 没有「房地产业」这个标签,实际是这四个行业名)
|
||||
industries: [全国地产, 区域地产, 房产服务, 园区开发]
|
||||
```
|
||||
|
||||
命中即淘汰,`single` / `walkforward` / `daily` **三种模式一律生效**。
|
||||
|
||||
| 要点 | 说明 |
|
||||
|---|---|
|
||||
| 匹配方式 | 与 `stock.industry` **逐字精确匹配**(不支持通配/模糊) |
|
||||
| 与 `universe.yml` 的关系 | **叠加取并集**,不是覆盖。本清单只会让股票池变小,不会放宽任何条件 |
|
||||
| 留空 | `industries: []` = 不排除任何行业;此时行为与不带本配置逐字一致 |
|
||||
| 写错名字 | **直接报错并提示最接近的真实取值**(不会静默地一只不排) |
|
||||
| 留痕 | 完整写入 `hd_backtest_run.backtest_config_json` |
|
||||
|
||||
> **为什么不在 `universe.yml` 里**:`universe.yml` 描述「高股息策略本身要求
|
||||
> 什么样的公司」(选股定义,与某次研究无关);本清单描述「这次回测特意不要
|
||||
> 哪些行业」(研究口径,如规避地产周期)。语义不同,所以分开放。
|
||||
|
||||
> ⚠️ **「房地产业」在库里不存在**。`stock.industry` 把它拆成了四个值,
|
||||
> 只写「房地产」或「房地产业」会**一只都不排除**(系统会直接报错拦住你):
|
||||
>
|
||||
> | 行业名 | 内容 | 只数 |
|
||||
> |---|---|---:|
|
||||
> | `全国地产` | 全国性开发商(万科A、保利发展、金地集团…) | 26 |
|
||||
> | `区域地产` | 区域性开发商(滨江集团、华发股份…) | 43 |
|
||||
> | `房产服务` | 物业/中介/房产服务(招商积余、我爱我家…) | 13 |
|
||||
> | `园区开发` | 开发区与园区运营商(陆家嘴、张江高科…) | 14 |
|
||||
>
|
||||
> 合计 **96 只**(占全部 5903 只的 1.6%)。查当前全部取值:
|
||||
>
|
||||
> ```sql
|
||||
> SELECT industry, COUNT(*) FROM stock GROUP BY industry ORDER BY 2 DESC;
|
||||
> ```
|
||||
|
||||
> **注意**:`daily` 模式的逐日选股有本地缓存(缓存键已含本清单)。
|
||||
> 改清单后缓存自动失效、会重新筛;也可用 `--refresh-pools` 强制重筛。
|
||||
|
||||
### 其他
|
||||
|
||||
| 字段 | 默认 | 含义 |
|
||||
@@ -1936,6 +2004,30 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
下次调仓时按目标权重再配置;`hold`(永久留存)与 `cash_out`(移出组合)才是未实现分支。
|
||||
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
|
||||
|
||||
**经济事件口径(同一除权日只算一笔)**:`hd_dividend` 写入侧保留全量公告记录,
|
||||
同一 `(symbol, ex_date)` 可能有不止一条 `实施` 记录(全库 1401 组,其中 1240 组字段
|
||||
完全相同;实测 002352.SZ 2024-11-07 同时存在 0.4 / 1.0 / 1.4 三条,1.4 = 0.4 + 1.0)。
|
||||
凡是把 `cash_div_tax` 逐行累加的实现都会重复计入,于是:
|
||||
|
||||
| 位置 | 聚合方式 |
|
||||
|---|---|
|
||||
| 回测分红/送转入账 | 预载时调用 `factor.dividend_yield.dedupe_dividend_events` |
|
||||
| TTM 股息率(`ttm_dps_series`) | **入口统一聚合**,筛选/画像/回测/Web 图表全部受益 |
|
||||
| 筛选的年度 DPS、总现金分红(支付率/FCF 覆盖) | `DividendFilter` 用同一函数聚合 |
|
||||
|
||||
聚合口径:**金额/股数逐字段取最大**(把「分项 + 合计」收敛到合计,同时不会在
|
||||
「一行纯现金 + 一行纯送转」时丢掉送转)、**日期取最晚**(不会引入未来函数)。
|
||||
诊断脚本 `tools/diag_dividend_artifact.py` 可用 `dedupe_events=False` 复现去重前的毛刺。
|
||||
|
||||
> 该口径修正会改变 TTM 股息率 → **股票池成员、画像与历史信号都会变**,
|
||||
> 旧 run 与新 run 的绩效不可直接比较,需要重跑。
|
||||
> 实测规模:`asof=2024-06-28` 时全市场 5,128 只有分红历史的股票里 **163 只**受影响
|
||||
> (虚高中位数 +100%、最大 +300%);其中 **52 只**在旧口径下股息率 ≥ 3%、
|
||||
> 修正后 < 3% —— 重复记录曾**虚假地把它们送进高股息候选池**。
|
||||
> 跨所有 run 的高股息池入选成员 75 只中,2024-06-28 有 5 只受影响
|
||||
> (`600489.SH`/`600690.SH`/`601009.SH` 各虚高 100%,`600690.SH` 3.99% → 2.00%)。
|
||||
> 回测 2021-01-01~2024-06-28:累计现金分红 −0.16%、成交 68→69 笔、最大回撤不变。
|
||||
|
||||
> **两处已知口径简化**(不产生 `unimplemented` 声明,因为这是模型假设而非漏实现):
|
||||
> ① 红利税在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时**
|
||||
> 按「买入→卖出」的实际持有期补缴,且按分笔 FIFO;② **送股**(`stk_bo_rate`)
|
||||
|
||||
Reference in New Issue
Block a user