本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):
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 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
56 KiB
实施状态报告
对应
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> |
图表静默失效 | ` |
| 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 转义成 " |
内联 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_V1v1.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% 现金」解释为「不做未来函数的代价与证明」。 追查后确认那是两个数据缺陷叠加出的假象,不是纪律的胜利:
stock_daily量价单位不一致 → 流动性门槛在早年低估 1000 倍, 把 2015-2019 的股票池整体清空(见 §9.1);- 行情只到 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:
- 全期回测允许仓位穿越牛熊:2016–2019 建的仓在 2022–2023 的下跌中继续持有, 到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
- Walk-forward 逐年冻结参照分布:测试年必须用训练年校准的股息率分布, 不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移), 训练期校准的阈值在测试期就失灵了。
- 样本量小:每年只是一个观测点,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% |
读法:
- 回补在样本外贡献 0、在单路径贡献 −32.9pp。 样本外的测试窗口是 2020–2026,参照窗口本就落在 2015 年之后,不依赖 2005–2014; 而单路径从 2015 年起,回补让 2015 年(牛市顶 + 股灾)从「被数据缺口 挡住」变成「被真实交易」(−3.18%),整条路径随之改变。 同一项数据修正,在两个口径下的"效果"相差 32.9pp —— 这就是为什么 本项目坚持只认样本外。
- 闸门在两个口径下依然相反(样本外 +1.16pp、单路径 −8.47pp), 与回补前的结论一致。
- 结论没有变:超额仍为负(−1.12pp),当前策略依然没有稳定的 样本外超额收益。
需要提醒的是:+1.16% 的样本外均值建立在 7 个观测上, 标准误很大。不要把「从 −0.95% 到 +1.16%」读成「策略变好了」。
值得注意的是,策略的回撤控制在样本外依然稳定成立 (最差 −22.97%,而基准同期回撤 −46% ~ −52%), 这与「高股息 + 安全边际」的定位一致 —— 它更像一个降低波动的配置工具, 而非超额收益来源。
5. 已知限制(如实声明)
- 分红与财报已基本完成(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重
index_weight仍为空(不影响基准收益计算)。 - 涨跌停与停牌约束覆盖 2010 年起(2026-10-04 回补,原为 2019 起);
回测区间 2015-01-05 起已全部有真实约束,不再是近似建模。
2010 年之前的涨跌停价 Tushare 无数据(实测 2005 年
stk_limit返回 0 行)。 - 未实现部分成交(按信号全额成交,受资金与权重上限约束)。
index_weight为空:不影响基准收益计算(用指数点位), 仅影响成分股分析。- AI Agent 层(P8)未实现 —— 属
plan.md第四版扩展。 - 敏感性结论受限于扫描区间与股票池规模,见 §4.5 说明。
- 不再有「预热期空仓」:2026-10-04 把行情回补到 2005 后,滚动 5 年分位 在 2015-01-05 起即可计算,2015 年(大牛市 + 股灾)从第一个交易日起 就被真实交易。此前的「2015 年 100% 现金」不是设计,而是数据缺口造成的 假象(见 §9.3b/§9.3c)。这也使全期收益下降 —— 见 §4.4。
- 策略缺少稳定的样本外超额收益(见 §4.6),这是最重要的结论。
- Walk-forward 已具备 7 个滚动窗口(2015-2019/2020 … 2021-2025/2026),
与
plan.md §23的示例完全一致,train/test 均落库可下钻。 - 幸存者偏差尚有 3 只的缺口:
stock_daily里有 3 个代码 (000022.SZ、000043.SZ、300114.SZ,均因吸收合并/重组退市) 不在stock表中,因此永远不会进入候选集。占 5,903 只的 0.05%。 根因是stock表只收录在市股票,补它需要扩展sync的backfill_tables白名单,属独立的数据补全工作。 hd_suspend/hd_limit含 356 个stock表未收录的代码 (其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除; SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。
6. 如何运行
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/,则下列两者都必须可达:
http://<host>:8080/ggx/index.html ← 报告
http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1 MB)
若只复制了 *.html,页面能打开但所有图表都不会显示。
若你的部署方式无法附带 assets/ 目录,改用自包含模式:
# config/report.yml
asset_mode: inline # 每份 HTML 内嵌图表库,体积由约 10MB 增至约 79MB
改完重新生成报告即可(hdiv report index 或对应报告命令)。
部署前自检(会真实执行 node --check 校验每份报告的内联 JS):
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,所以一直没报警。
修复(两层):
- 写入端
sync.price.daily_frame:vol ×100、amount ×1000 - 读取端
units.normalize_ohlcv_units:按行判据成交额 / (成交量 × 收盘价)(≈1 已换算 / ≈0.1 原始)判定并换算,幂等;Repo.price_history与Repo.avg_amount都走它 - 审计新增
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 次。
等价性测试抓到的两个真实缺陷(都是「同一指标两个值」):
ttm_dps被产出两行(ttm_dps, 0)(序列循环 + 分红质量各一次), 落库后谁胜出取决于写入顺序 → 已删除重复来源free_cashflow同名不同义:fin_latest(最近一期)与_payout_and_cover(与分红同一财年)。实测格力电器 2018-05-18 两个值分别是 67.1 亿与 70.5 亿 → 后者改名dividend_fy_free_cashflow- 实时侧分红超集的下界算错:按
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 年窗口都不完整。
这次补上的三件事:
- 量化:新增
profile/coverage.py,用交易日历的真实开市天数作为分母 (不是 243 这种近似),区间口径与window_slice严格一致(左开右闭 —— 否则覆盖率永远差一天、min_window_coverage=1.0会变成「永远拒绝」) - 暴露:
ProfileSnapshot新增n_obs/coverage; gate 的checks[]记录n_obs与window_coverage;hdiv profile打印覆盖率警告 - 可强制:策略新增
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。回补是 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-052015-01-05 的 5 年窗口内缺 16 个交易日,把它们与
03-12、2013-08-28~09-04 两个整周正是招行的配股停牌)。hd_suspend 交叉比对,16/16 全部命中停牌记录
(2010-03-05
顺带修掉的两个「同一类」缺陷
回补过程暴露了两处与 §9.1 同源的固定阈值问题(早年市场只有一千多只股票, 固定阈值会把正常数据判成异常):
- 断点续传失效:
fetched_days用固定MIN_SYMBOLS_PER_DAY = 1500判定 「某日是否已完整同步」。2005-2009 每天只有 ~1,350 只 → 每一天都被判成未完成, 回补一旦中断就要从第一天重来。已改为max(绝对下限, 比例 × 当年应有上市股票数)(新增datasource.yml: sync段配置), 实测阈值 2005 年 829、2015 年 1,732、2024 年 3,397。 - 审计误报:
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 |
--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…,
仍保留在库中,可用于核对「回补是否改变了某项结论」。)