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

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

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

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

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

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

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

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

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

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

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

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

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

## 四、其它

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

## 待验证

本次按要求**未执行测试**。上述「实测/验收」数字均引自文档中作者自己的记录,
非本次会话验证结果。建议合入后跑一次全量测试(注意:daily 的 DB 标记测试
因 `hd_cashflow` 无界扫描仍然很慢)。
This commit is contained in:
2026-10-05 16:19:07 +08:00
parent cf6d4d2c56
commit fdfdd152d8
23 changed files with 1678 additions and 93 deletions
+92
View File
@@ -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`)