Files
ggx/docs/implementation-status.md
T
simon 14ec0c6c86 修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 2005;手册补全流程
本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):

1) 修复 stock_daily 量价单位前后不一致
   - 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
     而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
     把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
   - 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
     按行判定并幂等换算(price_history / avg_amount 都走它);
     审计新增 UNIT-OHLCV 防回归。
   - 效果:2016/2017/2018 的股票池变为 7/11/13 只。

2) 未来函数守卫(单次回测)
   - 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
     与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
     偏差写入 unimplemented_json。

3) 新增实时(PIT)个股画像闸门
   - profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
     惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
     规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
   - 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
   - profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
     策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
   - core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。

4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
   - stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
     hd_suspend / hd_limit 补到 2010-01-04。
   - 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
     残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
   - 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
     会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
   - 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。

5) 自我声明如实化
   - 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
     误报成「hd_suspend 无数据」;改为按表级判定。
   - 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
     cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
     max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。

6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
   - 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
     含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
   - 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
     修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。

测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。

注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
2026-10-04 12:47:17 +08:00

1051 lines
56 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实施状态报告
> 对应 `docs/development-plan.md`(计划)与 `docs/plan.md`(需求)
> 更新:2026-10-02
---
## 0. 一句话结论
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
**2026-10-03 完成三项正确性改造**(详见 §9):修复 `stock_daily` 量价单位不一致、
拒绝「用未来时点的股票池跑更早区间」、新增**实时(PIT)个股画像闸门**。
三项都让「交易依据更符合实际」,且在 walk-forward 样本外口径下**都改善了绩效**
(样本外均值 −0.95% → **+1.16%**,最差回撤 −24.62% → **−22.97%**;
2026-10-04 又把行情回补到 2005,样本外逐窗口未变、单路径则从 +117.36% 降到 +92.73%),
但**仍未取得正超额**(−1.12pp)。
数据层已补齐:行情/每日指标/复权因子覆盖 **2005-01-04 起**,
停牌与涨跌停价覆盖 **2010-01-04 起**,分红 1990 起、四张财报表 1990/2001 起。
审计 `FAIL=0`(14 OK / 5 WARN,WARN 均为已知且已声明)。
---
## 1. 已交付清单
### 1.1 数据层(P0a / P0b / G1–G6)
| 缺口 | 状态 | 实测结果 |
|---|---|---|
| **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 267,295 行,覆盖 1990–2026 |
| **G2 日线行情** | ✅ 完成 | `stock_daily` **起点 2005-01-04**(2026-10-04 回补,+3,927,792 行),16,007,169 行 / 5,265 个交易日 |
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,16,789,137 行 / 5,267 个交易日 |
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 **2005-01-04**(+4,309,461 行),15,972,821 行 / 5,275 个交易日 |
| **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` **468,388 行**、`hd_limit` **14,837,155 行**,均覆盖 **2010-01-04** 起(2026-10-04 回补,各 +400,623 / +5,787,253 行) |
### 1.2 数据库(30 张 `hd_*` 表)
全部建表完成,结构迁移幂等(连续两次 `ddl apply` 均返回 0 个动作)。
**只增不删**由三层保证:SQL 安全钩子 + 源码扫描测试 + 迁移的前置条件守卫。
### 1.3 代码模块
| 模块 | 文件 | 状态 |
|---|---|---|
| 配置层 | `core/config.py`(7 类配置 + 严格校验 + config_hash) | ✅ |
| 数据安全 | `data/db.py`(SQL 钩子)、`data/ddl.py`(幂等 DDL) | ✅ |
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
| 因子 | `factor/dividend_yield.py` | ✅ |
| 个股画像 | `profile/builder.py` | ✅ |
| 策略管理 | `strategy/registry.py` | ✅ |
| 回测引擎 | `backtest/engine.py` | ✅ |
| Walk-forward | `backtest/walk_forward.py` | ✅ |
| 绩效分析 | `analysis/performance.py` | ✅ |
| 敏感性 | `analysis/sensitivity.py` | ✅ |
| 报告渲染 | `report/`(7 类报告 + 离线校验) | ✅ |
---
## 2. `plan.md §50` 十四项验收
| # | 能力 | 状态 | 落库位置 |
|---|---|---|---|
| ① | 找出符合条件的股票 | ✅ | `hd_universe_member` |
| ② | 生成 PIT 股票池 | ✅ | `hd_universe_run` |
| ③ | 每股历史股息率序列 | ✅ | `hd_profile_series` |
| ④ | P10/P25/P50/P75/P90 | ✅ | `hd_profile_stat` |
| ⑤ | 生成买卖信号 | ✅ | `hd_backtest_signal` |
| ⑥ | 执行历史回测 | ✅ | `hd_backtest_run` |
| ⑦ | 正确处理分红与除权 | ✅ | `hd_dividend` + 分红台账(含红利税) |
| ⑧ | 加入交易成本 | ✅ | `hd_backtest_trade.*_cost` |
| ⑨ | K线 + 买卖点 | ✅ | `output/profile_*.html`(四联图 + P75/P25 阈值线) |
| ⑩ | 收益/回撤/Sharpe | ✅ | `hd_backtest_metric` |
| ⑪ | 基准比较 | ✅ | 沪深300 / 中证红利 / 上证指数 |
| ⑫ | Walk-forward | ✅ | `hd_walkforward_window` |
| ⑬ | 样本内/外结果 | ✅ | `hd_backtest_metric.scope` |
| ⑭ | 完整参数与版本 | ✅ | `hd_strategy` + `hd_strategy_param`(36 项) |
---
## 3. 关键正确性保障
以下是开发过程中**真实踩到**并已修复的静默错误 ——
它们共同特点是「不报错,只是给出错误结论」,因此每一条都配了回归测试。
| # | 问题 | 后果 | 修复 |
|---|---|---|---|
| 1 | Tushare `total_mv` 单位是**万元**,配置阈值是**元** | 市值过滤选中 **0 只**股票 | `data/units.py` 统一归一化 + `UNIT` 审计(恒等式 + 绝对量级双判据) |
| 2 | 用**季报累计** ROE 比「年均 8%」阈值 | 几乎所有好公司被误杀 | `annual_financial_averages()` 只用年报口径 |
| 3 | 银行负债率天然 90%+ | 整个金融板块被误杀(招商银行) | `industry_exemptions` 行业豁免,Risk/Quality 统一口径 |
| 4 | 年度分红除权间隔中位数 **366 天** > 365 | 股息率被算成 0,污染历史分位 | TTM 加 45 天宽限期 |
| 5 | 分红除权晚于 asof | 稳定分红公司被误判「连续分红 0 年」(中国神华) | 一年宽限期 |
| 6 | `NaN or 0.0` 返回 `NaN` | 10 万条 NULL 分红被当成数值参与者 | `_fnum()` NaN 感知转换 |
| 7 | 费率 side 配置小写 `sell`、代码传大写 `SELL` | **印花税永远为 0**,成本系统性低估 | 统一大写比较 |
| 8 | 净值曲线漏加现金 | 净值严重失真 | `总市值 = 现金 + 持仓` + 断言测试 |
| 9 | 建仓阶梯与减仓阶梯各自判定 | 持仓时会「因高分位被减仓」,年换手 8.9、持仓仅 30 天 | 合成唯一阶梯 + 死区(修复后:换手 0.91、持仓 345 天) |
| 10 | MySQL 唯一约束**不约束 NULL** | 静默重复行 | 显式 `dedup_key`;三张表补 `NOT NULL`;专门测试守护 |
| 11 | pandas 3.0 字符串列不再是 `object` dtype | 空串 `ts_code` 被写进库 | 改用 `is_numeric_dtype` 判定 |
| 12 | `is_buy` 在涨跌停分支前未定义 | 一旦 `hd_limit` 有数据即崩溃 | 定义提前 |
| 13 | Jinja2 autoescape 转义 `<script>` | 图表静默失效 | `| safe` + 校验器专项检测 |
| 14 | `dividend_records` 的 SELECT **漏了 `base_share`** | 支付率与 FCF 覆盖在全库范围内恒为 NULL —— `max_payout_ratio` 与 `min_fcf_dividend_cover` 两个筛选条件**从未生效** | 补上该列 + 回归测试断言 SELECT 列表 |
| 15 | 用 **FY2023 分红 ÷ 2024Q1 净利润** 算支付率 | 美的集团算出 230.9% 的荒谬支付率(真实 61.6%) | 新增 `Repo.annual_financials()`,支付率/FCF 覆盖一律**同财年**比较 |
| 16 | 画像从未计算分红质量指标 | `dividend_quality` 与 `composite` 安全边际得分恒为 NULL,`plan.md §14/§15` 形同未实现 | 画像复用 `DividendFilter` 的口径函数(单一口径来源) |
| 17 | 断点续传只看「日期是否存在」 | 原 qlib 数据在 2019 年仅 243 只/日被当作「已同步」,形成**整年数据空洞** | 判据改为「当日股票数 ≥ 1500」 |
| 19 | **Jinja2 autoescape 把嵌入 `<script>` 的 JSON 转义成 `&#34;`** | 内联 JS 语法非法 → **全部报告的图表都不显示**(页面能开、容器空白) | `_json_for_script` 返回 `Markup` 并把 `< > & '` 转成 `\uXXXX`;校验器新增 `node --check` 真实语法检查 |
| 20 | 校验器只比对「容器数 == init 次数」 | 图表全坏却判定 OK(给了假信心) | 改为「每个容器 id 必须被脚本引用」+ HTML 实体检测 + node 语法检查 |
| 21 | 索引模板用 `g.items` | Jinja2 中解析成 `dict.items` 方法而非该键 → 索引页渲染失败 | 键名改为 `reports` |
| 22 | `report.yml` 声明了无人实现的 `naming.strategy` | 配置承诺了不存在的产物 | 移除该键(策略说明报告属未实现的 P8) |
| 18 | walk-forward 的 train/test 以 `persist=False` 运行 | `hd_walkforward_window` 的 run_id 是**悬空引用**,报告无法下钻 | 一并落库,并把 `wf_id/window_index` 纳入 run_id 指纹(否则同区间会撞 id 互相覆盖) |
---
## 4. 实测回测结果(2015-01-05 ~ 2026-09-30,11.7 年)
> 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、
> 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、
> 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。
>
> ⚠️ **口径变更史**:2026-10-03 三项改造(量价单位修复、`--universe-run`
> 未来函数守卫、实时画像闸门默认启用)→ 2026-10-04 **行情回补到 2005**
> (见 §9.3c,让 5/8/10 年窗口首次真正完整)。
>
> **结论先行 —— 两个口径给出相反答案,以 walk-forward 为准**:
>
> | 口径 | 闸门开 | 闸门关 |
> |---|---:|---:|
> | 全期单路径总收益 | +92.73% | **+101.20%** ← 单路径说闸门有害 |
> | Walk-forward 样本外均值 | **+1.16%** | −0.00% ← 样本外说闸门有益 |
> | 样本外最差回撤 | **−22.97%** | −24.22% |
>
> **而回补数据只改变了单条路径,没有改变任何样本外结果** ——
> 单路径从 +117.36% 掉到 +92.73%,样本外 7 个窗口**逐窗口一个数字都没变**。
> 原因见 §4.6d:样本外的测试窗口是 2020–2026,其参照窗口本就落在
> 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
> 过去被数据缺口「挡住」的 2015 年(大牛市 + 股灾)现在会被真实交易。
### 4.1 与基准对比(回补后重跑)
| 指标 | **策略(闸门开)** | 策略(闸门关) | 沪深300 | 中证红利 | 上证指数 |
|---|---:|---:|---:|---:|---:|
| 总收益 | **+92.73%** | +101.20% | +19.66% | +53.35% | +14.67% |
| 年化 CAGR | **+5.75%** | +6.14% | +1.54% | +3.71% | +1.17% |
| 最大回撤 | **−25.38%** | −25.54% | −46.70% | −46.51% | −52.30% |
| Sharpe | 0.22 | 0.23 | — | — | — |
| Calmar | 0.23 | 0.24 | — | — | — |
| 成交笔数 | 197 | 225 | — | — | — |
`run_id`:闸门开 `fa916050ffb647b6ab4cfd90ba1adf36`,
闸门关 `5347fd13dd8fc0b0a512156489c615bc`(两次资金对账残差均为 0)。
**策略仍跑赢天然基准「中证红利」,回撤不到其一半** —— 但见 §4.6:
单条路径的全期数字有严重误导性。
> **回补数据让全期收益*下降*了 24.6pp(+117.36% → +92.73%)。**
> 这不是 bug,而是把「假象」修掉了:回补前 2015 年因缺少历史分布
> 而 100% 现金(收益 0.00%),回补后 2015 年从第一个交易日起就被
> 真实交易,而那年**亏了 3.18%**(大牛市顶部建仓 + 股灾)。
> 靠数据缺口「躲过股灾」不是策略能力 —— 详情见 §4.4。
> **闸门到底拦掉了什么**:全期 141 个决策时点、2428 次画像计算、
> **1027 次买入信号被剔除**(`REJECT`,可在「未成交信号」里逐条查看原因)。
> 剔除使成交从 225 笔降到 197 笔,收益下降 8.5pp,回撤基本不变。
> 该结果是在**修正了支付率与 FCF 覆盖两个筛选条件之后**取得的
> (此前这两个条件因 `base_share` 漏选而静默失效)。
### 4.2 交易与分红(闸门开)
| 项目 | 数值 |
|---|---:|
| 成交笔数 | 197 |
| 期末资金 | 1,927,312 元 |
| **资金对账残差** | **0.0000** ✅ |
| 画像剔除的买入信号 | 1027 |
| 画像计算次数 / 决策时点 | 2428 / 141 |
### 4.3 逐年收益(回补后重跑,闸门开)
| 年 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 策略(闸门开) | **−3.18%** | +5.91% | +28.25% | **−10.86%** | +39.52% | −1.97% | +3.05% | **−14.13%** | +4.37% | +13.37% | +11.23% | +2.43% |
| 闸门关 | −1.26% | +7.04% | +26.83% | −9.95% | +38.22% | +0.14% | +2.99% | −14.35% | +4.56% | +11.06% | +12.00% | +4.30% |
**2015 年不再是 0,而是 −3.18%(闸门开)/ −1.26%(闸门关)。**
这正是全期收益下降 24.6pp 的来源:回补前 2015 年因数据缺口无法产生信号
(100% 现金、收益 0.00%),回补后它被真实交易,而在牛市顶部建仓
随后遭遇股灾是亏钱的。**「靠数据缺口躲过股灾」不是策略能力。**
### 4.4 预热期与「数据缺口假象」(重要)
> **⚠️ 2026-10-04 定论(见 §9.3b/§9.3c)**:本节历史上曾把
> 「2015–2018 组合 100% 现金」解释为「不做未来函数的代价与证明」。
> 追查后确认那是**两个数据缺陷叠加出的假象**,不是纪律的胜利:
>
> 1. `stock_daily` 量价单位不一致 → 流动性门槛在早年低估 1000 倍,
> **把 2015-2019 的股票池整体清空**(见 §9.1);
> 2. 行情只到 2015-01-05 → 滚动参照窗口填不满,即便有候选也算不出分位
> (见 §9.3b)。
>
> 两者都在 2026-10-03/04 修好。现在:
>
> - **2015-01-05 的 PIT 股票池 = 3 只**(此前 0 只)
> - **5 年窗口覆盖率 98.68%**(此前无法计算)
> - **2015 年从第一个交易日起就被真实交易**
>
> 代价是全期收益从 +117.36% 降到 +92.73% —— **修数据让结果变差,
> 但变真实了**。下面保留历史记录以对照。
> **补充(2026-10-03):预热期只在「按周期重新筛选」时成立。**
>
> 用 `--universe-run` 指定**冻结股票池**时,引擎不解自筛选,历史约束(连续分红 5 年、
> 5 年 ROE 等)不再作用于早期,于是**没有预热期**。实测同一段区间:
>
> | 模式 | 总收益 | CAGR | 最大回撤 | Sharpe |
> |---|---:|---:|---:|---:|
> | 重新筛选(无 `--universe-run`) | 114.80% | 6.73% | **−21.05%** | 0.31 |
> | 冻结股票池(`--universe-run`) | 374.61% | 14.19% | **−53.06%** | 0.57 |
>
> **冻结模式的回撤是两倍以上**,因为它在 2015 年满仓吃到了股灾,而重新筛选模式
> 因预热期空仓躲过。预热期不只是技术细节,它**实质影响风险特征**。
>
> **而且冻结模式本身是未来函数**(股票池 asof 晚于回测起点):现已默认拒绝执行,
> 只能加 `--allow-lookahead-universe` 复现,且该偏差会写入 `unimplemented_json`。
> 详见 §9.2。
>
> 另外,冻结模式曾暴露一个真实缺陷(已修复,见 §4.7):
> 行情首日的滚动窗口只有 1 个观测,分位被算成 100%,8 只股票被误买入。
**2015–2018 年组合保持 100% 现金、无任何成交**,这是**预期行为**而非缺陷:
滚动 5 年分位参照窗口需要历史分布,而本项目数据自 2015-01-05 起
(2015 年前无行情/指标)。在参照窗口填满之前,系统无法计算「历史分位」,
因此不产生信号 —— 这正是**不做未来函数**的代价与证明:
若为了让 2015 年就有信号而放宽窗口,就等于用不足的样本编造分位。
组合自 2019 年起建仓,2020 年起现金占比稳定在 1% 以下(基本满仓)。
### 4.5 参数敏感性(plan.md §27)
扫描买入分位 P70/75/80/85/90 的结果:
| 买入分位 | CAGR | 最大回撤 | Sharpe | 成交 |
|---|---:|---:|---:|---:|
| P70 | 3.03% | −22.76% | 0.07 | 70 |
| P75 | 5.26% | −17.03% | 0.22 | 72 |
| P80 | 2.77% | −21.37% | 0.05 | 66 |
| P85 | 7.27% | −19.75% | 0.39 | 62 |
| P90 | 3.99% | −19.85% | 0.14 | 55 |
系统判读:CAGR 跨度 4.50pp、最大跳变 4.50pp、**平滑度 0.00**、
检出 1 处尖峰(P85)→ 判定为
**「存在尖峰或跳变,疑似过拟合,请谨慎解读」**。
> 这是一个**诚实的不利结论**,也正是 plan.md §27 想要暴露的问题。
> 需注意该扫描是在**修正支付率/FCF 筛选条件之前**、且区间仅 2021–2024、
> 股票池仅数十只的条件下完成的。**应在当前口径下于 10 年区间重跑后才作数。**
### 4.6 Walk-forward 样本外验证(plan.md §23/§25)
7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布:
(下表为 2026-10-04 回补后重跑,`wf_id = e855fdf268827e1e4c31510c6736eea3`,
**实时画像闸门启用**)
> **⚠️ 重要:这 7 个数字与「回补前」逐窗口完全相同。**
> 也就是说,把行情从 2015 补到 2005 **没有改变任何样本外结果**,
> 却让单条路径的全期收益从 +117.36% 变成 +92.73%。
> 原因:样本外的测试窗口是 2020–2026,其参照窗口(冻结在训练段)
> 本就落在 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
> 2015 年是否可交易会改变整条路径。**这正是「不要采信单条路径」的又一例证**
> —— 见 §4.6d。
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | Sharpe |
|---|---|---|---:|---:|---:|
| #0 | 2015–2019 | 2020 | **+12.27%** | −12.39% | 0.52 |
| #1 | 2016–2020 | 2021 | **−2.47%** | −15.89% | −0.29 |
| #2 | 2017–2021 | 2022 | **−3.05%** | −16.68% | −0.32 |
| #3 | 2018–2022 | 2023 | **−11.32%** | −22.97% | −0.95 |
| #4 | 2019–2023 | 2024 | **+12.16%** | −15.63% | 0.50 |
| #5 | 2020–2024 | 2025 | **+6.65%** | −11.58% | 0.33 |
| #6 | 2021–2025 | 2026(部分) | **−6.09%** | −18.39% | −0.73 |
**样本外汇总(闸门开):**
| 指标 | 数值 | 改造前(旧口径) |
|---|---:|---:|
| 盈利窗口 | 3 / 7(胜率 42.86%) | 4 / 7(57.14%) |
| 样本外收益 **均值** | **+1.16%** | −0.95% |
| 样本外收益 中位数 | −2.47% | +2.61% |
| 样本外 CAGR 均值 | +0.85% | −0.78% |
| 最差窗口回撤 | −22.97% | −24.62% |
| **基准收益均值** | **+2.29%** | +2.29% |
| **超额收益均值** | **−1.12pp** | −3.24pp |
> **结论没有变,但程度变轻了**:样本外均值由 −0.95% 转为 **+1.16%**,
> 超额仍为负(**−1.12pp**)。胜率反而从 4/7 降到 3/7 —— 均值改善主要来自
> 2020(+4.01% → +12.27%)与 2022(−9.17% → −3.05%),
> 而 2026 部分年份由 +3.83% 转为 −6.09%。
>
> 这轮数字受**三项**改动影响(量价单位修复、行情回补到 2005、画像闸门);
> 其中**回补的贡献为 0**(逐窗口未变),两者已用对照 run 分离 —— 见 §4.6d。
#### 结论:单路径回测显著高估了策略
对比 §4.1 与本节:
| | 全期单路径回测 | Walk-forward 样本外均值 |
|---|---:|---:|
| 收益 | **+117.36%**(11.7 年) | **+1.16%/年** |
| 相对基准 | +97.7pp | **−1.13pp** |
**这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug:
1. **全期回测允许仓位穿越牛熊**:2016–2019 建的仓在 2022–2023 的下跌中继续持有,
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
训练期校准的阈值在测试期就失灵了。
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大。
**因此:不要采信 §4.1 的 +117.36%。** 更接近真实的表述是
「该策略在 2020/2024/2025 的样本外为正,在 2021/2022/2023/2026 为负,
长期看与基准相比没有稳定的超额收益(−1.13pp),且回撤更小
(最差 −22.97%,而基准同期回撤 −46% ~ −52%)」。
### 4.6c 实时画像闸门的净影响(两个口径结论相反)
同一份代码、同一份数据,只切换 `entry.profile_gate.enabled`
(下表为**回补后**的数字):
**① 全期单路径(2015-01-05 ~ 2026-09-30)**
| | 闸门开 | 闸门关 | 差 |
|---|---:|---:|---:|
| 总收益 | +92.73% | +101.20% | **−8.47pp** |
| CAGR | +5.75% | +6.14% | −0.39pp |
| 最大回撤 | −25.38% | −25.54% | +0.16pp(略好) |
| Sharpe | 0.22 | 0.23 | −0.01 |
| 成交笔数 | 197 | 225 | −28 |
| 买入信号被画像剔除 | 1027 | 0 | — |
| `run_id` | `fa916050…` | `5347fd13…` | |
**② Walk-forward 7 窗口样本外**(`wf_id`:开 `e855fdf2…` / 关 `231d0b29…`)
—— **回补前后逐窗口完全相同**
| | 闸门开 | 闸门关 | 差 |
|---|---:|---:|---:|
| 样本外收益 **均值** | **+1.16%** | −0.00% | **+1.16pp** |
| 样本外收益 中位数 | −2.47% | +3.78% | −6.25pp |
| 盈利窗口 | 3 / 7 | 4 / 7 | −1 |
| 样本外 CAGR 均值 | +0.85% | +0.17% | +0.68pp |
| **最差窗口回撤** | **−22.97%** | −24.22% | **+1.25pp** |
| **超额收益均值** | **−1.12pp** | −2.29pp | **+1.17pp** |
逐窗口(超额 = 策略 − 沪深300):
| 测试年 | 闸门开 | 闸门关 | 基准 | 闸门开超额 | 闸门关超额 |
|---|---:|---:|---:|---:|---:|
| 2020 | +12.27% | +10.17% | +25.51% | −13.23pp | −15.34pp |
| 2021 | −2.47% | −2.84% | −6.21% | +3.74pp | +3.37pp |
| 2022 | −3.05% | −9.17% | −21.27% | **+18.23pp** | +12.11pp |
| 2023 | −11.32% | −9.58% | −11.75% | +0.43pp | +2.17pp |
| 2024 | +12.16% | +3.80% | +16.20% | −4.04pp | −12.40pp |
| 2025 | +6.65% | +3.78% | +21.19% | −14.54pp | −17.41pp |
| 2026 | −6.09% | +3.84% | −7.63% | +1.54pp | +11.48pp |
**两个口径给出相反结论。按本项目的一贯立场 —— 以样本外为准 —— 闸门是改善**
(样本外均值 +1.16pp、最差回撤 +1.25pp、超额 +1.17pp),
虽然它**降低了盈利窗口数**(4→3)与中位数,也就是说改善集中在少数年份。
单条路径之所以给出相反答案:它被「2016–2019 一次建仓 + 2024–2026 回本」这段
**穿越牛熊的持有**主导,而闸门剔除的那些买入恰好在单路径上是赚的。
这正是 §4.6 标题那句话的又一个例证 —— **不要采信单条路径**。
### 4.6d 三维归因:三项改动各自贡献多少
因为每一项都有「开关式」的对照 run,可以逐项分离。**结论是样本外只认前两项,
而回补的贡献为 0。**
**样本外(walk-forward 均值 / 超额 / 最差回撤)**
| 版本 | 样本外收益均值 | 样本外超额均值 | 最差回撤 | 盈利窗口 |
|---|---:|---:|---:|---:|
| 改造前(量价单位错误 + 无闸门 + 数据缺 2015 前) | −0.95% | −3.24pp | −24.62% | 4 / 7 |
| 仅修量价单位(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
| + 数据回补到 2005(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
| + 实时画像闸门(**当前**) | **+1.16%** | **−1.12pp** | **−22.97%** | 3 / 7 |
**单条路径(全期总收益)**
| 版本 | 总收益 |
|---|---:|
| 数据缺 2015 前(改造前口径) | +114.80% |
| + 量价单位修复(闸门关) | +134.06% |
| + 数据回补到 2005(闸门关) | **+101.20%** |
| + 实时画像闸门(**当前**) | **+92.73%** |
**读法**:
1. **回补在样本外贡献 0、在单路径贡献 −32.9pp。** 样本外的测试窗口是
2020–2026,参照窗口本就落在 2015 年之后,不依赖 2005–2014;
而单路径从 2015 年起,回补让 2015 年(牛市顶 + 股灾)从「被数据缺口
挡住」变成「被真实交易」(−3.18%),整条路径随之改变。
**同一项数据修正,在两个口径下的"效果"相差 32.9pp —— 这就是为什么
本项目坚持只认样本外。**
2. **闸门在两个口径下依然相反**(样本外 +1.16pp、单路径 −8.47pp),
与回补前的结论一致。
3. **结论没有变**:超额仍为负(−1.12pp),当前策略依然没有稳定的
样本外超额收益。
> 需要提醒的是:+1.16% 的样本外均值建立在 **7 个观测**上,
> 标准误很大。不要把「从 −0.95% 到 +1.16%」读成「策略变好了」。
> 值得注意的是,策略的**回撤控制**在样本外依然稳定成立
> (最差 −22.97%,而基准同期回撤 −46% ~ −52%),
> 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**,
> 而非超额收益来源。
## 5. 已知限制(如实声明)
1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
2. **涨跌停与停牌约束覆盖 2010 年起**(2026-10-04 回补,原为 2019 起);
回测区间 2015-01-05 起已**全部**有真实约束,不再是近似建模。
2010 年之前的涨跌停价 Tushare 无数据(实测 2005 年 `stk_limit` 返回 0 行)。
3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。
4. **`index_weight` 为空**:不影响基准收益计算(用指数点位),
仅影响成分股分析。
5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。
6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。
7. **不再有「预热期空仓」**:2026-10-04 把行情回补到 2005 后,滚动 5 年分位
在 2015-01-05 起即可计算,**2015 年(大牛市 + 股灾)从第一个交易日起
就被真实交易**。此前的「2015 年 100% 现金」不是设计,而是数据缺口造成的
假象(见 §9.3b/§9.3c)。这也使全期收益下降 —— 见 §4.4。
8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。
9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026),
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻。
10. **幸存者偏差尚有 3 只的缺口**:`stock_daily` 里有 3 个代码
(`000022.SZ`、`000043.SZ`、`300114.SZ`,均因吸收合并/重组退市)
不在 `stock` 表中,因此永远不会进入候选集。占 5,903 只的 0.05%。
根因是 `stock` 表只收录在市股票,补它需要扩展 `sync` 的
`backfill_tables` 白名单,属独立的数据补全工作。
11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码**
(其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除;
SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。
---
## 6. 如何运行
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
# 1) 建表(幂等)
.venv/bin/python -m hdiv ddl apply
# 2) 数据同步(按需)
.venv/bin/python -m hdiv sync dividend --only-missing
.venv/bin/python -m hdiv sync financial --interleaved --only-missing
.venv/bin/python -m hdiv sync index
.venv/bin/python -m hdiv sync trading --start 2019-01-01
HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回补
# 3) 数据审计(含 HTML)
.venv/bin/python -m hdiv audit
# 4) 股票池 → 画像
.venv/bin/python -m hdiv universe --asof 2024-06-28
.venv/bin/python -m hdiv profile --universe-run <run_id>
# 5) 策略登记与回测
.venv/bin/python -m hdiv strategy register
.venv/bin/python -m hdiv backtest --start 2021-01-01 --end 2024-06-28
.venv/bin/python -m hdiv backtest --mode walkforward
.venv/bin/python -m hdiv sensitivity
# 6) 校验报告离线可用性
.venv/bin/python -m hdiv report validate
# 7) 测试
.venv/bin/python -m pytest tests/ -q
```
报告输出在 `output/`,从 `output/index.html` 进入。
---
## 7. 可修改的配置
| 文件 | 控制什么 |
|---|---|
| `config/universe.yml` | **股票筛选条件**(市值/上市年限/连续分红/股息率/ROE/行业豁免…) |
| `config/profile.yml` | **个股特性**(统计窗口/分位/波动频率/安全边际权重/TTM 口径) |
| `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) |
| `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 |
| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合 |
| `config/report.yml` | 图表开关/输出命名/版面/资源模式 |
| `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 |
修改任一文件后 `config_hash` 变化,历史 run 仍可完整复现。
---
## 7.1 报告部署(重要)
报告通过**相对路径**引用图表库:``<script src="assets/echarts.min.js">``。
因此发布时必须**整体部署 `output/` 目录**,包括 `output/assets/`。
例如把 `output/` 映射到 `/ggx/`,则下列两者都必须可达:
```text
http://<host>:8080/ggx/index.html ← 报告
http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1 MB)
```
若只复制了 `*.html`,页面能打开但**所有图表都不会显示**。
若你的部署方式无法附带 `assets/` 目录,改用自包含模式:
```yaml
# config/report.yml
asset_mode: inline # 每份 HTML 内嵌图表库,体积由约 10MB 增至约 79MB
```
改完重新生成报告即可(`hdiv report index` 或对应报告命令)。
**部署前自检**(会真实执行 `node --check` 校验每份报告的内联 JS):
```bash
hdiv report validate # 或:python -m hdiv.report.validate output
```
## 7.2 Web 前端(前后端分离)
系统提供统一 Web 前端,替代原先平铺的静态报告:
| 能力 | 实现 |
|---|---|
| 统一入口 | `output/index.html`(hash 路由单页应用) |
| 股票池记录管理 | 列表 / **命名** / **归档** / **软删除** / 打开股票清单 |
| 个股画像 | 点击清单里的个股直接打开画像页(四联图 + 分位 + 雷达) |
| 股票池 ↔ 回测 | `hdiv backtest --universe-run <run_id>` 建立关联,双向可见 |
| 回测主页面 | 只显示**条件与说明**,点击进入详细结果 |
| 详细结果 | 总结果 + 净值曲线 + 绩效指标 + **逐笔成交与理由** |
| 目录归一化 | `hdiv site normalize` 归档历史、静态报告入 `reports/` |
**后端用 Python 标准库实现**(`ThreadingHTTPServer`):零新增依赖、
一条命令启动、nginx 只需反代 `/api`,无版本漂移风险。
部署:`deploy/nginx.conf.example`(两种布局)+ `deploy/serve.sh`(启停脚本)。
## 4.6b 样本外超额的真相:牛市跑输、熊市跑赢(2026-10-03 补充,同日重跑更新)
补上基准对比后(此前接口把 `benchmark_<code>` 行过滤掉了,页面上看不到超额),
逐窗口的超额呈现**高度规律**的形态:
| 测试年 | 策略 | 沪深300 | 超额 | 市场 |
|---|---:|---:|---:|---|
| 2020 | +12.27% | +25.51% | **−13.24pp** | 牛市 |
| 2021 | −2.47% | −6.21% | **+3.74pp** | 熊市 |
| 2022 | −3.05% | −21.27% | **+18.22pp** | 熊市 |
| 2023 | −11.32% | −11.75% | **+0.43pp** | 熊市 |
| 2024 | +12.16% | +16.20% | **−4.04pp** | 牛市 |
| 2025 | +6.65% | +21.19% | **−14.54pp** | 牛市 |
| 2026 | −6.09% | −7.63% | **+1.54pp** | 熊市 |
**4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。** 超额胜率 57.1%。
因此更准确的表述不是「没有超额收益」,而是:
**它是一份低 beta 的防御型配置 —— 用牛市的大幅跑输换取熊市的相对抗跌**。
超额均值 −1.13pp 是样本内牛熊比例的结果,而非策略「无效」;
在熊市占比更高的样本里,这个均值会转正。
**判读时必须分开看牛熊**,只看均值会得出误导性结论。
> **重跑后这个形态反而更清晰**:牛市跑输的幅度收窄
> (−21.50 → −13.23、−13.58 → −4.04、−17.41 → −14.54),
> 熊市跑赢的幅度扩大(2022:+12.11 → +18.23)。
> 归因已用「闸门关闭」的对照 run 分离(§4.6d):两项改造在样本外都是正向的,
> 其中闸门的贡献集中在 2022/2024/2025 三个年份。
## 4.7 已修复:退化分布伪造 100% 分位(2026-10-03)
**现象**:明细里出现「股息率 0.00%;历史分位 100.0%」并触发买入。
**根因**:分位定义为「≤当前值的观测占比」。当参考窗口只剩 1 个观测、
且恰好等于当前值时,占比恒为 100%,足以击穿任何买入阈值。
触发条件:回测起点早于行情数据起点时,滚动窗口伸进空区间。
实测 2015-01-06(行情数据首日)窗口 2010-2015 只有 **1 个观测**,
**8 只股票**因此被买入 —— 且买在 2015 年股灾前的高点。
**影响面**:该次回测 217 笔成交中有 **22 笔(10.1%)** 参考样本不足 250 天。
**修复**:新增 `backtest.yml: percentile_reference.min_observations`(默认 250 ≈ 一年),
窗口样本不足则**当日对该股不产生任何信号**(保持现状,不买不卖),
并把 `min_observations` 一并写入 `reason_json` 便于事后核查。
**修复效果**:弱样本成交 22 → 0;同区间总收益 289.73% → 374.61%
(去掉的是股灾前的错误买入,因此反而更好)。
> 这个缺陷说明:**分位类指标必须带最小样本量**。
> 与 §9.6 绩效指标的 `MIN_OBS_FOR_RISK` 是同一类问题 —— 样本不足时
> 正确做法是「不判断」,而不是照常输出一个看似合理的数字。
## 7.3 Walk-forward 前端与重跑覆盖(2026-10-03)
**补上 Walk-forward 前端入口**:此前 `hd_walkforward_run` 有数据但**没有任何接口或页面**,
跑完 25 分钟在界面上看不到任何东西。现新增 `/api/walkforwards`(列表)、
`/api/walkforwards/{wf_id}`(详情)与 `#/walkforwards` 页面,
逐窗口展示样本内/样本外收益、CAGR、Sharpe、最大回撤、成交数与**冻结阈值**。
**⚠ 判读更正(同日)**:初版页面把「训练段累计收益」与「测试段累计收益」并排比较,
得出「样本内 7.0%→60.7%、样本外仅 −0.95%,明显过拟合」的结论 —— **这是错的**。
训练段是 **1825 天(5 年)**、测试段是 **364 天(1 年)**,两者累计收益不可比。
换算年化后:
| 窗口 | 训练年化 | 测试年化 |
|---|---:|---:|
| #0 | 1.37% | **4.02%** |
| #2 | 5.01% | −9.29% |
| #6 | 9.52% | 5.25% |
窗口 #0 的样本外年化**高于**样本内。7 个窗口里 6 个是样本外年化低于样本内,
方向存在但幅度远没有累计口径显示的那么夸张。
页面与表格已改用**年化**口径,并在图下注明区间长度差异。
**筛选记录改为重跑覆盖**:`run_id` 原先含 `datetime.now()`,
导致同一 asof 反复重跑不断累积(2025-01-21 累积了 11 条内容相同的记录)。
现改为 `stable_id(名称, 时点, config_hash)` —— 不含时间,同输入即同 id,
重跑原地覆盖运行头、成员清单与因子快照。
刻意**不含 `data_version`**:用户要的是「这一天的筛选结果」,
而不是「每次数据快照各存一份」;每次运行实际使用的 data_version 仍完整记录可追溯。
候选集缩小时(新股上市/退市),上次存在而本次不再出现的成员被标记为
`fail_stage='stale'`(UPDATE,非 DELETE),以符合「禁止物理删除」的约束。
界面上的命名/备注/归档状态不在更新列中,重跑不会清掉用户标注。
## 7.4 已修复:`--universe-run` 在 walk-forward 下被静默忽略(2026-10-03)
`hdiv backtest --universe-run X --mode walkforward` 中,`--universe-run` **完全没有生效** ——
`WalkForwardRunner` 根本没有这个参数,CLI 也没校验,于是参数被丢掉且无任何提示。
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
**但正确做法不是「支持」它,而是拒绝**:
| | 时点 |
|---|---|
| 股票池 `b8dd742f…` | asof = **2025-01-21** |
| walk-forward 最早训练区间 | **2015-01-01** 起 |
把 2025 年选出的股票池套到 2015 年的训练窗口上,就是用未来信息选股 ——
恰好破坏了 walk-forward 要守护的无未来函数纪律。
现在该组合会明确报错并说明原因与替代方案。同时新增两层回归测试:
CLI 必须拒绝,且 `WalkForwardRunner` 的签名里不得出现 `universe_run_id`(防止日后被误加回去)。
同类问题:`selector.py` 中 `df["is_fresh"].fillna(False)` 触发 pandas
Downcasting `FutureWarning`(pandas 未来版本会改变行为,可能让筛选结果静默变化)。
已改为 `astype("boolean").fillna(False).astype(bool)`,语义不变;
并新增测试用 `-W error::FutureWarning` 跑筛选路径。
## 7.5 已修复:显示精度配置是死的(2026-10-03)
**现象**:改了 `config/report.yml: layout.decimals.ratio` 对股息率等百分比**毫无影响**。
**根因**:`layout.decimals` 三个设置里,**只有 `price` 曾被读取过一次**
(renderer 的价格格式化)。`ratio` 与 `money` 从未被任何代码引用 —— 纯摆设。
而百分比显示散落在 **20 余处硬编码**:
| 位置 | 原实现 |
|---|---|
| `profile_report._pct` | `f"{x*100:.2f}%"` |
| `backtest_report._pct` / `_fmt_metric` | `f"{x*100:,.2f}%"` |
| `universe_report._pct` | `dec: int = 2`(默认写死) |
| `sensitivity_report._pct` / `walkforward_report._pct` | `f"{x*100:,.2f}%"` |
| `web/service._reason_text`、`web/analysis._reason_text` | 成交理由里的股息率写死 2 位 |
| `analysis/sensitivity.py`、`analysis/performance.py` | CLI 输出写死 |
| `web/app.js` 的 `pct()` | 前端写死 `toFixed(2)` |
**修复**:新增 `src/hdiv/report/format.py`(`NumFmt`),所有格式化统一走它,
精度由配置驱动;前端通过 `/api/config/display` 获取精度,也由配置驱动。
**语义**(与用户确认):`decimals.ratio` 指**原始比率**的小数位。
比率保留 ratio 位后乘 100,恰好少两位 —— 即 **百分比小数位 = ratio - 2**:
| ratio | 原始比率 | 股息率显示 |
|---:|---|---|
| 2 | 0.06 | 6% |
| 4 | 0.0617 | 6.17% |
| 6 | 0.061715 | 6.1715% |
并加了两条硬断言:`src/` 全域不得再出现 `:.2f}%`,前端必须存在 `FMT.percent`。
## 7.6 已修复:股息率毛刺与口径分裂(2026-10-03)
### 问题一:除权间隔不规整造成的毛刺
A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每年除权日附近
制造两种**日历假象**:
| 类型 | 成因 | 实测 |
|---|---|---|
| **重叠虚高** | 间隔 < 365,新旧分红同时在窗口内 | 招商银行 2015-07-03:0.620 → **1.290**(+108%),10 天后回落 0.670 |
| **断档虚低** | 间隔 > 365,旧的已到期新的未入场 | 中国神华 2016-07-04:0.740 → **0.320**(−57%) |
旧实现只在**结果恰好为 0** 时用 `grace_days` 兜底,而实际跌落是**部分跌落**
(1.290→0.670 不是 0),所以完全没兜住。实测:
| 股票 | 修复前 >20% 跳变 | 修复后 | 修复前虚低归零天数 |
|---|---:|---:|---:|
| 招商银行 | 21 | **5** | — |
| 工商银行 | 25 | **7** | — |
| 中国银行 | 25 | **7** | **77 天** |
| 伊利股份 | 24 | **13** | **68 天** |
**修法**:把「硬窗口」换成「按后继接管」。对每次分红 i,若与下一次的间隔
落在 `window_days ± grace_days`(即 320~410 天),视为同一档年度分红,
计入区间延到 `min(下一次除权日, 除权日 + 365 + grace)`:
- 间隔略小于一年 → 后继提前接管,**消除重叠虚高**
- 间隔略大于一年 → 旧的计到新的入场,**填补断档虚低**
- 超过 `365 + grace` 仍无后继(真停发)→ 封顶,**如实归零**
- 间隔 < 320 天视为**年内多次分红**(中期+年度),互不取代 ——
否则会把中期分红误删,人为制造新的低点
### 问题二:同一个「股息率」在四处口径不同
修复过程中发现更严重的问题:TTM 参数在四个调用点来源不一。
| 调用点 | 修复前 |
|---|---|
| `profile/builder.py` | ✓ 读 `profile.yml` |
| `backtest/engine.py` | ✗ **硬编码 365 / 45** |
| `backtest/walk_forward.py` | ✗ 用函数默认值 |
| `web/analysis.py` | ✗ 用函数默认值 |
| `universe/filters/dividend.py` | ✗ **另写了一遍** trailing-12月求和 |
后果:改 `profile.yml` 只有画像会变;更糟的是**筛选器用的股息率与画像/回测不一致**,
而这是**直接决定选股**的数字。
**修法**:新增 `ttm_params()`(单一事实来源)与 `ttm_dps_at()`(单点求值),
五处全部改用同一实现,参数统一来自 `profile.yml: ttm_dividend`。
### 影响与后续
因子值变化约 **1.2% 的交易日**(每只股票 11 年约 30~64 天,即原先的毛刺日),
最大单日差异达 60%+。由于股息率是买卖信号的直接输入:
- **已有的画像与回测结果已过期**,需重跑
- 筛选结果需重新生成(`hd_universe_run` 会原地覆盖)
## 8. 测试覆盖
```
403 passed(pytest 退出码 0)
```
| 测试文件 | 覆盖 |
|---|---|
| `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
| `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
| `test_profile_pit.py` | **实时画像与批量画像逐值等价**、公告日/除权日 PIT 负例、惰性与面板复用、窗口校验、**窗口覆盖率(含左开右闭分母)**、**输入行序无关性** |
| `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数、**画像闸门(剔除/放行/不动仓位/无法验证)**、**未实现声明的诚实性** |
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run` 的未来函数守卫) |
| `test_web.py` | 前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 |
---
# 9. 三项正确性改造(2026-10-03)
三项都是「不报错、只让结果悄悄错」的类型,因此都配了负例测试与如实声明。
## 9.1 已修复:`stock_daily` 量价单位前后不一致
**现象**:2015-01~2019 的股票池被流动性门槛整体清空 —— 实测按当时可见数据筛选,
**2016/2017/2018 各得到 0 只**,2019 只有 3 只;而 `market` 过滤本应留下几十只。
**根因**:`stock_daily` 是「追加进既有 qlib 库」的表。
| 区间 | 来源 | `volume` 单位 | `amount` 单位 |
|---|---|---|---|
| 2015-01 ~ 2019 | 本项目从 Tushare 回补 | 手 | 千元 |
| 2019(同日混合) | 两者重叠 | 3596 行里 337 行已换算 | — |
| 2020 ~ | qlib 存量 | 股 | 元 |
而 `universe.market.min_avg_amount_20d: 20000000` 是按「元」写的,
`Repo.avg_amount` 又直接 `AVG(amount)` —— 于是早年门槛实际变成
**「日均成交额 ≥ 200 亿元」**。`units.py` 里的 `amount_qian_to_yuan`
写了但**从未被调用**,`sync.price.daily_frame` 也是原样落库;
审计的单位自检只看 `daily_basic.total_mv`,所以一直没报警。
**修复(两层)**:
1. 写入端 `sync.price.daily_frame`:`vol ×100`、`amount ×1000`
2. 读取端 `units.normalize_ohlcv_units`:按行判据
`成交额 / (成交量 × 收盘价)`(≈1 已换算 / ≈0.1 原始)判定并换算,**幂等**;
`Repo.price_history` 与 `Repo.avg_amount` 都走它
3. 审计新增 `UNIT-OHLCV`:逐年抽样列出仍为原始单位的行数(防回归)
**效果**(同一份配置、同一批数据):
| asof | 修复前入选 | 修复后入选 |
|---|---:|---:|
| 2016-02-01 | 0 | **7** |
| 2017-02-01 | 0 | **11** |
| 2018-02-01 | 0 | **13** |
| 2019-02-01 | 3 | **14** |
| 2024-12-31 | 46 | 46 |
> **这更正了 §4.4 的一条叙事。** 原文把「2015–2018 组合 100% 现金」
> 归因为「不做未来函数的代价与证明」。真实原因至少有一部分是**单位 bug 把股票池清空了**。
> 预热期(滚动 5 年分位参照需要 250 个观测)确实存在,但它不是唯一原因。
**未做**:没有重刷 2015-2019 的存量数据(写入是 `INSERT IGNORE`,
不动既有行是硬约束)。读取层兜底已使结果正确,存量数据的清理留给一次显式的数据维护。
## 9.2 已修复:单次回测里 `--universe-run` 的未来函数
**现象**:`hdiv backtest --universe-run <股票池>` 会把该股票池的成员
**冻结**到所有调仓日。若股票池的 `asof` 晚于回测起点,2015 年的选股就用了
2025 年的信息。实测库中 `247724798ec2…`:引用股票池 `b8dd742f…`(asof **2025-01-21**),
回测 2015-01-05 起,**2015-01-06 就有 8 笔成交**。
**为什么必须改**:项目自己在 §7.4 已经认定「把 2025 年选出的股票池套到 2015 年
的训练窗口上就是用未来信息选股」,并因此在 walk-forward 下**拒绝**该组合 ——
同一条理由对单次回测同样成立,此前却没有拦。
**修复**:引擎在执行前校验股票池 `asof` 与回测起点:
| 情形 | 行为 |
|---|---|
| `asof <= 回测起点` | 正常执行(此时股票池属于事前信息) |
| `asof > 回测起点` | **拒绝执行**,错误信息给出三种正确做法 |
| 显式 `--allow-lookahead-universe` | 放行,并把偏差写入 `hd_backtest_run.unimplemented_json` |
**测试**:`test_future_universe_is_rejected_by_default` 用**库中最晚 asof** 的股票池
跑更早区间,断言必须抛错且错误信息含放行开关;同时断言 `asof <= 起点` 时正常工作。
## 9.3 新增:实时(PIT)个股画像闸门
**需求**:交易依据要与实际情况相符 —— 2018-05-18 的决策依据应当是
**2013-05-18 ~ 2018-05-17 的画像**,即按当时可见的数据实时重算画像,
剔除「不值得买」的票。
**改造前的真实状态**(这一点必须先说清楚):
| 层 | 是否 PIT |
|---|---|
| 股息率分位(唯一的交易依据) | ✅ 已经是滚动 5 年、只用 `<= 当日` 的数据 |
| 股票池(逐 12 个月重建) | ✅ PIT;但 `--universe-run` 冻结时 ❌(见 §9.2) |
| `hd_profile_stat`(个股画像) | ⚠️ 是 asof 的**快照**,且**回测从不读取它** |
也就是说:改造前画像既不参与交易,也不存在「按每个决策日重算」的形态。
**新增 `src/hdiv/profile/pit.py`**:
- `PitProfileService.snapshot(symbol, asof)` —— 按当时可见数据重算画像。
**指标定义复用 `ProfileBuilder._profile_one`**(同一定义来源),
有**逐值等价测试**保证「回测里的画像」==「页面上的画像」
- `evaluate_gate(rules, snapshot, on_unverifiable)` —— 逐条判定,
三种结局:`PASS` / `REJECT` / 无法验证(按配置保守或放行)。
指标缺失与样本不足**绝不当作 0**
**引擎接入**(`entry.profile_gate`):只在**买入条件已触发之后**才计算 ——
这是「在触发条件的时候计算」的落点。被剔除时产出信号类型 `REJECT`、
`skip_reason = PROFILE_GATE`,前端「未成交信号」里可直接看到
**每个指标的实际值、阈值、状态与是否通过**。
**成本控制**(对应「长周期数据可以沿用」):
| 手段 | 效果 |
|---|---|
| 只在触发时计算 | 成本 ∝ 触发次数,而非「区间长度 × 股票数」 |
| 跨股票共享面板按时点缓存 | 同一 asof 的财报面板只载入一次 |
| 按规则声明所需指标 | 规则里没有财务指标时**完全不查财报表**(各约 30 万行) |
| 财务查询加 symbol 过滤 | 单次 1076ms → 41ms(语义不变,仅追加 `symbol IN (...)`) |
实测一段 2016 全年回测:20 个决策时点、46 次画像计算、
20 次财报面板载入、**0 次流动性查询**(规则未用到)、剔除 18 次。
**等价性测试抓到的两个真实缺陷**(都是「同一指标两个值」):
1. `ttm_dps` 被产出两行 `(ttm_dps, 0)`(序列循环 + 分红质量各一次),
落库后谁胜出取决于写入顺序 → 已删除重复来源
2. `free_cashflow` 同名不同义:`fin_latest`(最近一期)与
`_payout_and_cover`(**与分红同一财年**)。实测格力电器 2018-05-18
两个值分别是 67.1 亿与 70.5 亿 → 后者改名 `dividend_fy_free_cashflow`
3. 实时侧分红超集的下界算错:按 `end` 回看 13 年 →
2018 年的画像拿不到 2006-2012 的分红,格力 `dividend_continuity_years`
被算成 4(真值 10)→ 改为按**回测起点**计算超集下界
**默认策略已启用**(`config/strategy/high_dividend_v1.yml` 的
`entry.profile_gate.enabled: true`),因此**此前所有回测数字都已重跑**。
重跑后的净影响见 §4.6c/§4.6d:**样本外是改善,单条路径是变差。**
**一个必须知道的取舍**:`on_unverifiable: reject` 是默认值,它比股票池筛选
(`universe.dividend.on_missing_data: pass`)更严格。实测 2016 年初的中石化:
最新可见分红属于 FY2015,而 FY2015 年报要到 3 月才公告 ——
**支付率在当时根本无法验证**。`reject` 会放弃买入,`pass` 会照买。
这是策略取舍得由使用者决定,不是 bug。
## 9.3b 窗口覆盖率:「名义 5 年」vs「真有 5 年」(2026-10-03 补充)
**问题**:用户要求「滚动计算过去 5 年的个股画像」。机制在 §9.3 已实现,
但 `window_slice(asof, 5)` 的语义只是「把**已有**数据切成最近 5 年」——
数据起点晚于窗口左端时,窗口会被**静默截短**,而 `status` 仍报 `OK`
(`_stat_row` 的门槛是 `n_obs >= min(min_obs_days, 20)`,即 20 个观测就放行)。
**实测(600036.SH,`dv_yield`,5 年窗口)**:
| asof | 窗口内实际观测 | 应有交易日 | 覆盖率 |
|---|---:|---:|---:|
| 2015-12-31 | 239 | 1214 | **19.7%** |
| 2016-12-30 | 483 | 1214 | **39.8%** |
| 2018-05-18 | 817 | 1219 | **67.0%** |
| 2019-12-31 | 1214 | 1219 | 99.6% |
| 2020-12-31 起 | ≈1218 | ≈1218 | **100%** |
截断的指纹很明显:**2018-05-18 的四个窗口(0/5/8/10)报出完全相同的 `n_obs=817`**。
**根因是数据缺口,不是代码**:`stock_daily` / `daily_basic` / `adjust_factor`
都只从 **2015-01-05** 起(分红、财报、指数、ST 历史都覆盖到 1990 年代)。
因此任何早于 2020-01 的 asof,其 5 年窗口都不完整。
**这次补上的三件事**:
1. **量化**:新增 `profile/coverage.py`,用**交易日历的真实开市天数**作为分母
(不是 243 这种近似),区间口径与 `window_slice` 严格一致(左开右闭 ——
否则覆盖率永远差一天、`min_window_coverage=1.0` 会变成「永远拒绝」)
2. **暴露**:`ProfileSnapshot` 新增 `n_obs` / `coverage`;
gate 的 `checks[]` 记录 `n_obs` 与 `window_coverage`;
`hdiv profile` 打印覆盖率警告
3. **可强制**:策略新增 `entry.profile_gate.min_window_coverage`
(0 = 不因覆盖率淘汰,保持改造前行为;1.0 = 名义 5 年必须真有 5 年数据)
**顺带修正的两个缺陷**:
- `hdiv sync backfill` 的 `basic_start` 未暴露给 CLI(函数默认 2015-01-01),
于是「回补 2010–2014」实际只补了行情与复权因子、**`daily_basic` 仍停在 2015**。
已新增 `--basic-start`,缺省跟随 `--start`。
- `config/profile.yml: sufficiency` 的三个阈值
(`min_history_years_dividend/price`、`min_dividend_records`)
**从未被任何代码使用** —— 与 §7.5 记录的「显示精度配置是死的」同类问题。
现已在已知限制中如实声明;真正的充分性判定由
`min_window_coverage` + `on_unverifiable` 承担。
**回补方案**见 [user-guide §6.6](user-guide.md)。回补是 `INSERT IGNORE`(只追加)。
## 9.3c 回补已执行:行情补到 2005,5/8/10 年窗口全部补齐(2026-10-04)
按 §9.3b 的方案执行完毕。**Tushare 探针实测三个接口在 2005 年都有数据**
(此前担心早年无数据,实际有),因此一次性补到 2005,让
`profile.yml: windows_years = [5, 8, 10]` **三个窗口**都完整。
### 数据量变化
| 表 | 回补前 | 回补后 | 新增 | 新起点 |
|---|---:|---:|---:|---|
| `stock_daily` | 12,079,377 | 16,007,169 | +3,927,792 | **2005-01-04** |
| `adjust_factor` | 12,205,794 | 16,789,137 | +4,583,343 | **2005-01-04** |
| `daily_basic` | 11,663,360 | 15,972,821 | +4,309,461 | **2005-01-04** |
| `hd_suspend` | 67,765 | 468,388 | +400,623 | **2010-01-04** |
| `hd_limit` | 9,049,902 | 14,837,155 | +5,787,253 | **2010-01-04** |
- 命令:`hdiv sync backfill --start 2005-01-01 --end 2014-12-31 --basic-start 2005-01-01 …`
与 `hdiv sync trading --start 2010-01-01 --end 2018-12-31`
- 11,655 次 API 调用,全程 **0 次限频**(把 `daily/adj_factor/daily_basic`
的限频从 480 下调到 170 —— 实测该 token 在约 196 次/分钟即被拒,
「撞墙后冷却 62 秒」远慢于平滑配速)
- 校验:三张表均「只增不减」✅;新写入行已是**正确的「股/元」单位**(实测比值 1.005~1.018)
### 覆盖率:目标达成
| asof | 回补前 5 年覆盖 | 回补后 5 年覆盖 |
|---|---:|---:|
| 2015-01-05 | —(数据起点之外) | **98.68%** |
| 2016-12-30 | 39.79% | **99.01%** |
| 2018-05-18 | 67.02% | **99.10%** |
| 2021-06-30 | 100% | 100% |
**残下的约 1% 已逐日核实为真实停牌,不是数据洞**:600036.SH 在
2010-01-05~2015-01-05 的 5 年窗口内缺 16 个交易日,把它们与
`hd_suspend` 交叉比对,**16/16 全部命中停牌记录**
(2010-03-05~03-12、2013-08-28~09-04 两个整周正是招行的配股停牌)。
### 顺带修掉的两个「同一类」缺陷
回补过程暴露了两处与 §9.1 同源的**固定阈值**问题(早年市场只有一千多只股票,
固定阈值会把正常数据判成异常):
1. **断点续传失效**:`fetched_days` 用固定 `MIN_SYMBOLS_PER_DAY = 1500` 判定
「某日是否已完整同步」。2005-2009 每天只有 ~1,350 只 → **每一天都被判成未完成**,
回补一旦中断就要从第一天重来。已改为
`max(绝对下限, 比例 × 当年应有上市股票数)`(新增 `datasource.yml: sync` 段配置),
实测阈值 2005 年 829、2015 年 1,732、2024 年 3,397。
2. **审计误报**:`G2/G2b/G3` 同样用固定 2,000 只判定「疑似数据稀疏」,
回补后把 2005 年正常数据报成 WARN。已改为复用同一套按年份阈值。
审计结果由 **OK=11 / WARN=8** 改善为 **OK=14 / WARN=5 / FAIL=0**。
### 效果
| 项 | 回补前 | 回补后 |
|---|---|---|
| 2015-01-05 的 PIT 股票池 | 0 只 | **3 只** |
| 2015 年能否交易 | 不能(无历史分布) | **能,从第一个交易日起** |
| 涨跌停/停牌约束覆盖 | 2019 起 | **2010 起**(2015-2018 不再是近似建模) |
> 这也意味着 **§4 的全部回测数字都需要再次重跑** —— 2015 年(大牛市 + 股灾)
> 现在会被真实交易,而此前该年是 100% 现金。
> `min_window_coverage` 保持默认 `0.0`:覆盖率现在已足够高(≥98.7%),
> 强制 1.0 只会因个别停牌日误杀。
## 9.4 这三项改造带来的口径变化(重跑时必须知道)
| 变化 | 影响 |
|---|---|
| 量价单位修复 | 2015-2019 的股票池从 0~3 只变为 7~14 只,早期不再是纯预热期;样本外均值 +0.95pp |
| `--universe-run` 守卫 | 历史「冻结股票池」回测无法再原样复现,需加 `--allow-lookahead-universe` 且结果被标注为含未来信息 |
| 闸门默认启用 | 买入被进一步过滤(全期剔除 998 次);样本外均值再 +1.16pp、最差回撤 +1.25pp,但单路径收益 −16.7pp。`enabled: false` 可关闭以对比 |
| `ttm_dps` / `free_cashflow` 去重改名 | `hd_profile_stat` 的既有画像快照需重跑;`dividend_fy_free_cashflow` 是新指标代码 |
**回补后(当前数据状态)的四个基准 run(可直接复核)**:
| 用途 | run_id |
|---|---|
| 单次回测,闸门开 | `fa916050ffb647b6ab4cfd90ba1adf36` |
| 单次回测,闸门关 | `5347fd13dd8fc0b0a512156489c615bc` |
| Walk-forward,闸门开 | `e855fdf268827e1e4c31510c6736eea3` |
| Walk-forward,闸门关 | `231d0b298a007e1fb68f2e20a6cc42a9` |
(回补前的四个 run 为 `1a7e5b72…` / `e6e65382…` / `697a2ecd…` / `ae296c0f…`,
仍保留在库中,可用于核对「回补是否改变了某项结论」。)