Files
ggx/docs/implementation-status.md
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

56 KiB
Raw Permalink Blame History

实施状态报告

对应 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 转义成 &#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. 如何运行

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,所以一直没报警。

修复(两层):

  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。回补是 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 个交易日,把它们与 hd_suspend 交叉比对,16/16 全部命中停牌记录 (2010-03-0503-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 的股票池从 03 只变为 714 只,早期不再是纯预热期;样本外均值 +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…, 仍保留在库中,可用于核对「回补是否改变了某项结论」。)