说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。
## 新增能力
1) `hdiv backtest --mode daily --start <日期>`
- src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
- 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
- `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
- `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
(卖出/减仓同样留痕,否则「为什么卖」缺证据)
- 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
因此不使用训练段、不冻结分布,阈值口径一律 rolling
- 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)
2) PIT 批量取数层 src/hdiv/universe/pit.py
- PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
—— 以保证与逐日单点查询**结果等价**
- 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
- src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)
3) 每日增量同步 `hdiv sync daily`
- src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
支持 `--dry-run` 先看待抓清单
- deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
- 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
(该表已存在于库中,`ddl plan` 返回 0 个待执行动作)
4) Web 与文档
- 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
web/favicon.svg)
- README / docs/user-guide.md / docs/implementation-status.md 同步更新:
三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段
## 测试
tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。
已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**
未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
`SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
(31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
区间 2024-03-01~03-15),说明该路径可正常完成。
## 已知待改进
- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
77 KiB
实施状态报告
对应
docs/development-plan.md(计划)与docs/plan.md(需求) 更新:2026-10-04
0. 一句话结论
系统已端到端可运行:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
回测 → Walk-forward → 每日动态股票池推演(--mode daily) → 绩效分析 →
参数敏感性 → 固定格式 HTML 报告,
全链路打通并通过 439 项自动化测试。plan.md §50 的 14 项验收能力全部具备。
2026-10-04 新增「每日动态股票池」(详见 §10):
backtest --mode daily --start <日期> 从给定日期起每个交易日重新选股(PIT)、
每个交易日判断买卖点,持仓掉出当日池子默认只减不加,买卖决策都留个股画像证据,
每日入选成员落库 hd_daily_universe。它与 walk-forward 不是替代关系 ——
后者检验参数稳定性(过拟合),前者回答「动态池下这条路径长什么样」。
代价是逐日全市场筛选:6.7 年区间约 1.5 小时(3 个月实测 5 分钟)。
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 行) |
| 日常增量 | ✅ 完成 | hdiv sync daily(缺几天抓几天)+ deploy/install-sync-schedule.sh 注册每天 17:00 的 launchd 任务;2026-10-05 首次运行补齐 21 个交易日(行情 17.8 万行、指数权重 1,800 行、财报 6.5 万行),耗时约 2 分钟 |
1.2 数据库(31 张 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/(分红/财报/指数/行情/停牌涨跌停 + daily.py 每日增量) |
✅ |
| 数据审计 | data/audit.py(15 项检查,含量价单位一致性) |
✅ |
| 股票池 | universe/selector.py + 4 个 Filter |
✅ |
| PIT 批量取数 | universe/pit.py(PitRepo:与 Repo 逐值一致,逐日筛选的基座) |
✅ |
| 每日选股 | universe/daily.py(逐日重建池 + 保守预剪枝) |
✅ |
| 因子 | factor/dividend_yield.py |
✅ |
| 个股画像 | profile/builder.py、profile/pit.py(实时画像) |
✅ |
| 策略管理 | strategy/registry.py |
✅ |
| 回测引擎 | backtest/engine.py |
✅ |
| Walk-forward | backtest/walk_forward.py |
✅ |
| 每日动态股票池 | backtest/daily.py(--mode daily) |
✅ |
| 绩效分析 | 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 回补
# 2b) 日常增量:缺几天抓几天(首次全量之后,每天只需要这一条)
.venv/bin/python -m hdiv sync daily --dry-run
.venv/bin/python -m hdiv sync daily
./deploy/install-sync-schedule.sh install # 注册为每天 17:00 的 launchd 任务
# 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 |
31 张表结构、前缀、幂等性、唯一键列不得可空(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…,
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
10. 每日动态股票池(--mode daily,2026-10-04 新增)
10.1 它补的是哪个空缺
原有两种回测各有各的问题,都回答不了「股票池每天都在变」这件事:
原有 --mode single |
原有 --mode walkforward |
新增 --mode daily |
|
|---|---|---|---|
| 股票池 | 每 12 个月重筛一次(universe_refresh_months) |
每个窗口按 asof 重筛 | 每个交易日重筛 |
| 信号 | 每月评估(signal_frequency_months) |
每月 | 每个交易日 |
| 时间结构 | 一条 [start, end] |
多个 (train, test) |
一条 [start, latest] |
| 阈值口径 | rolling | 测试段冻结训练分布 | rolling(无训练段) |
| 目的 | 全期表现 | 过拟合检验 | 单路径连续推演 |
daily 不是 walk-forward 的替代品:它没有训练段,因此不检验参数稳定性。
它检验的是另一件事 —— 「动态股票池下这条路径长什么样」。
10.2 动态池的语义(这是本功能的核心决定)
| 决定 | 取值 | 理由 |
|---|---|---|
| 持仓掉出当日池子 | 只减不加(pool_exit_action: hold) |
池子回答「今天能买什么」,不回答「必须卖什么」。掉出即清仓会把「市场先生没报价」误判成「公司变坏了」 |
| 买卖决策的画像 | 买卖都算,且不区分是否在池内 | 卖出更需要「当时它长什么样」的证据;池外持仓被卖出时尤其如此 |
| 每日选股留痕 | 落库 hd_daily_universe |
事后可回答「某天为什么是这些股票」 |
| 可选:掉出即清仓 | pool_exit_action: sell |
供对比用,不是默认 |
10.3 实测(2024-03-01 ~ 2024-03-29,21 个交易日;另有 58 日区间见 §10.4)
选股 21/21 个决策日,池内 21~24 只,累计出现过的股票 25 只
股票池变动:累计进入 4 次 / 移出 7 次(平均每日 0.2 进 0.3 出)
市场候选 5353~5359 只 → 预剪枝后实际筛选 183~184 只 → 入选 21~24 只
绩效:−5.92% / 最大回撤 −8.71% / 成交 14 笔 / 对账残差 −0.0000 ✓
画像:计算 244 次(缓存命中 75),画像剔除 31 次
落库:hd_daily_universe 477 行 / 21 个时点
58 日区间(2024-01-02 ~ 2024-03-29)的信号分布:
信号:TRIM 284 / ADD 261 / BUY 183 / REJECT 163 / HOLD 45
skip_reason:NO_CASH 334、BELOW_MIN_TRADE 284、PROFILE_GATE 163、
ALREADY_AT_TARGET 110、OUT_OF_UNIVERSE 45
画像留痕:773 条信号带 reason_json.profile
HOLD 45+skip_reason=OUT_OF_UNIVERSE就是「掉出池子、只减不加」的直接证据: 45 次「本想加仓但被池子挡住」,全部留有原因,可在前端「未成交信号」查看。- 773 条信号带画像留痕:买卖决策都附上了当日可见数据算出的画像。
两个「候选」必须分开记(否则页面上同一个词有两种含义):
| 列 | 含义 |
|---|---|
hd_daily_universe.listed_count |
当日市场候选数(未预剪枝),如 5359 |
hd_daily_universe.candidate_count |
当日实际参与筛选的候选数(已预剪枝),如 183 |
预剪枝是纯性能开关,不该让可见数字的含义随之改变 —— 这是本轮自查抓到的两个
静默数据缺陷之一,另一个是 dividend_yield 整列为 NULL(详见 §10.5 第 6 条)。
10.4 性能:怎么把 5~8 小时压到可接受
单次 UniverseSelector.run 实测 10~18 秒(其中 financial_panel 独占 6.2 秒,
且是表量级开销、与查哪天无关)。逐日 1600 次 = 5~8 小时,不可用。
三处改造(都不改变判定口径):
| 改造 | 效果 | 口径保证 |
|---|---|---|
PitRepo 批量预载(只覆盖最底层取数方法) |
单次筛选 10~18s → 2.0s | test_pit_repo_matches_direct_repo 逐值比对 |
| 财务按「已公告条数」缓存 + 存储预排序 | 模拟每 asof 4s → 0.38s | 同上(缓存键是充分统计量) |
| 候选集保守预剪枝(交易所/板块/上市年限/市值上界) | 候选 5903 → 412 只 | test_prune_does_not_change_selection 锁定入选集合完全相同 |
另一处顺带修掉的真实缺陷:ttm_params() 每次调用都走 load_config
(重新读盘 + YAML 解析 + pydantic 校验,约 16ms),而它在筛选器里是逐股调用的。
cProfile 实测:6 个交易日里 load_config 被调用 1008 次、共 16.6 秒,
占整个筛选时间的 22%,而每次返回的是完全相同的一份配置。
已改为使用本模块早有的带缓存入口 get_config。这不是「优化」,是修一个明显的浪费。
实测速率与取舍:
| 阶段 | 实测 | 依据 |
|---|---|---|
| 逐日选股 | 2.66 秒/交易日 | 2026-08-01 起 43 个交易日用 114 秒;全区间 1635 日实测 2.38 秒/日(65 分钟) |
| 模拟(修正前) | 3.45 秒/交易日 | 21 日窗口,池内约 22 只、面板仅 25 只 |
| 模拟(修正前,全区间) | 6.5 小时仍未结束 | 面板 123 只;跑到 6.5h 后中止 |
| 模拟(修正后) | 3.04 秒/交易日 | 43 日窗口,池内 30~36 只、面板 41 只(直接计时,2026-10-05 复测) |
| 全区间(修正后) | 选股 65 分钟 + 模拟 约 1.5~2.5 小时 | 推算:按 3.04 秒/日 × 1635 日 ≈ 1.4 小时,但全区间面板为 123 只、后期池内约 59 只,会比该窗口更慢 |
2026-10-04 追加修正的性能缺陷:PitProfileService._compute 原先
「先按日期剪裁全市场面板(50 万行)、再筛出当前这一只股票」,实测 174 毫秒/次;
两个条件互相独立,改为「先筛股票、再剪裁」后 11 毫秒/次(16 倍)。
每次画像快照都要付两遍(_price 与 _basics)—— 这正是「21 日窗口(面板 25 只)」
与「全区间(面板 123 只)」差约 4 倍的原因:剪裁成本与面板里的股票数成正比。
等价性由 tests/test_profile_pit.py 27 passed 锁定(含实时画像 vs 批量画像逐值比对)。
选股结果可复用(已实测):逐日选股按「策略 + 区间 + 筛选配置 + 重建频率」的指纹
缓存到 output/cache/daily_pools_<key>.json(键不含时间戳;重建频率必须进键 ——
1 日 / 5 日 筛出的池子是不同的输入,混用会静默给出错的股票池)。
同一区间第二次运行会打印「复用已缓存的选股结果」并跳过 114 秒的选股,
且指标与首次完全一致(0.29% / CAGR 1.73% / 回撤 −3.93% / Sharpe −0.03 / 17 笔 ——
两次运行逐项相同);--refresh-pools 强制重筛。
缓存目录可随时整个删掉(它只是缓存,删了下次重筛)。
10.4a 程序设计层面的提速(P1/P2/P4/P6,2026-10-05 实施并验收)
四项都不改变判定规则,逐项做了等价性验证;但实测收益与最初的估计差很多, 如实记录如下(这正是「不要靠估计,要测」的又一例):
| 项 | 改动 | 等价性验证 | 实测收益 |
|---|---|---|---|
| P4 | 选股的 dividend_records 按候选股收窄(原先每天把 2 万余行分红建 dict,只为查其中一两百只) |
5 个样本日入选集合逐只相同(test_dividend_scope_does_not_change_selection);收窄结果是精确子集 |
选股 2.34 → 2.02 秒/日(−14%) |
| P1 | 画像内部日期统一 datetime64(原先逐 asof 把 object 的 datetime.date 再转一遍) |
分红窗口谓词 4 个时点逐行相同(61/71/81/93 行) | 与 P2 合计仅 −2% |
| P2 | 每只股票整段股息率序列只算一次(原先每个评估日按前缀重算,O(交易日数²)) | 整段 vs 前缀 20/20 点一致(test_ttm_dps_series_prefix_equals_full) |
同上;区间越长收益越大 |
| P6 | 价格只取一次并交给画像复用;dividend_events 走常驻内存 |
dividend_events 与直连逐值一致(4 个区间含 28,301 行);外部传入 price 与自取画像逐值一致 |
一次性,约 −2% |
为什么 P1/P2 几乎没省:最初的判断依据是 cProfile —— 它显示日期转换占模拟 28%。
但那个 profile 里混进了 _prepare 的一次性取数(约 30 万行 DB 读取),
把占比算高了。改成按组件直接计时后,模拟阶段的开销分布是:
| 组件 | 单次成本 | 折算(43 日窗口) | 占比 |
|---|---|---|---|
_profile_one(每次画像快照算约 40 个指标) |
185 ms × 593 次 | 99.6 s | 78% |
_context(每个 asof 重建财报面板) |
374 ms × 43 次 | 16.1 s | 13% |
| 其余(盯市、分红入账、收益率切片…) | — | 约 12 s | 9% |
结论:模拟阶段的 91% 集中在这两处,而它们都不是 P1/P2 触及的地方。 所以下一步该动的是:
- 把财报面板按
symbols收窄后再建(_latest_financial/annual_financial_history等现在都是「先建全市场、再取子集」,而缓存键里没有 symbol 集合 —— 年报季可见条数天天变,于是天天重建全市场)。预计_context374 ms → 数十毫秒, 即模拟 −13%;风险低(窗口函数是逐股票的,先筛 symbol 不改变任何一只股票的取值)。 - 闸门按「公告/分红可见性指纹」缓存 + 画像留痕改在成交时落
(闸门那 4 个指标与价格无关、一年只变几次;而 593 次快照里绝大多数是为
「信号留痕」付的,最终只有 17 笔真正成交)。预计模拟 −60~70%;
但会改变留痕位置(
hd_backtest_signal不再带画像、hd_backtest_trade带上), 需要使用者确认。用户要的「所有成交个股的实时画像」在这个方案下反而更准确。
另有一处外部数据变化必须记下:本次 A/B 期间,每日同步任务把
stock_daily从 2026-09-04 补到了 2026-09-30(+94,398 行),使同一区间 2026-08-01 起的结果 从 +0.29% 变成 −2.54%(持仓数量、成交价、每日股票池均逐行相同, 只有最后 4 周的价格是新的)。这不是代码问题,但它再次说明 比较两次回测之前必须确认底层数据没变。
10.4b 提速方案:把「每日」改成「每 N 日」(2026-10-05 新增)
原先两个频率只能改 YAML,现已接到命令行:
| 参数 | 作用对象 | 默认 |
|---|---|---|
--every-n-days N |
股票池重建频率(选股那一趟) | 1(每个交易日) |
--signal-every-n-days N |
买卖判断频率(模拟那一趟) | 1(每个交易日) |
设计要点:两个旋钮都不改判定规则,只改「多久看一次」。 所以它们是 「粗粒度版本」而不是「同一策略的加速版」—— 程序启动时会明确打印这句话并提示 「结果不可与逐日口径直接比较」,避免有人拿 5 日口径的数字去和逐日口径比。
实测(2026-08-01 起 43 个交易日,同一区间):
| 口径 | 选股 | 模拟 | 画像计算次数 |
|---|---|---|---|
| 选股每 1 日 / 信号每 1 日 | 114 秒 | 131 秒 | 537 |
| 选股每 5 日 / 信号每 5 日 | 43 秒 | 约 28 秒 | 127 |
由此把模拟耗时拆成两项(这是本节的关键修正):每交易日的记账约 0.05 秒 (很小),每次「评估买卖」约 3.0 秒(收益率序列 + 逐笔决策画像)—— 只有后者随信号频率下降。早先的模型把整段模拟都写成「随交易日数增长」, 会把粗粒度方案的耗时高估约 1.6 倍。
按此模型,6.7 年(1635 交易日)的预计耗时:
| 方案 | 预计 |
|---|---|
| 逐日 / 逐日(默认) | 约 2.6 小时 |
--every-n-days 5 |
约 1.7 小时 |
--signal-every-n-days 5 |
约 1.5 小时 |
--every-n-days 5 --signal-every-n-days 5 |
约 35 分钟 |
--every-n-days 21 --signal-every-n-days 21 |
约 12 分钟 |
上表是推算(单点实测只有 43 个交易日那两行)。全区间面板 123 只、 后期池内约 59 只,实际会更慢。命令启动时会按同一模型打印本次预计时长。
10.5 已知限制(如实声明)
-
6.7 年区间未实跑到底:选股阶段实测跑完(65 分钟),模拟阶段未跑完 (修正前跑到 6.5 小时中止)。修正后的总耗时是推算(约 3.5~4.5 小时), 不是实测值。已实测到底的只有 3 个月区间(5 分钟)与选股阶段。
-
预剪枝依赖「市值上界」这一判据:
min_avg_amount_20d刻意没有预剪枝 ——stock_daily的量价单位在 2015-2019 是「手/千元」、2020 起是「股/元」, 用MAX(amount)做上界会在早年低估 1000 倍、误剪本该通过的股票。 宁可少一项优化,也不接受会改变结果的上界。 -
不落库逐股淘汰原因:daily 只落库每日入选成员。要查「某只股票为什么没选上」, 需用
hdiv universe --asof <日期>单独跑一天。 -
起点即路径:不同起点的 daily 结果不可直接比较策略优劣(早年池子、 估值水平都不同)。
-
不支持
--universe-run:固定池与「每日动态」定义互斥,且冻结名单自带未来信息。 -
提交前自查抓到两个静默数据缺陷(都属于「回测照跑、只是数是错的」那一类):
hd_daily_universe.dividend_yield整列为 NULL:该列不在UniverseSelector.run()返回的selected里 —— 股息率是滤网算出来的values字段,不在行情/财报列里。只从selected取列就会静默写 NULL。 已改为「滤网 values 优先、selected 列兜底」(total_mv/roe_avg反向)。candidate_count在开/关预剪枝时含义不同:预剪枝前它是 5359、 预剪枝后变成 183,而列名与页面文案都没变。已新增listed_count列, 两者分开记录(并加ddl幂等ADD COLUMN)。
教训与 §4.6 的第 3 条同源:口径要靠测试与自查锁住,不能靠「看起来有数」。
- 顺带修掉一个既有的崩溃(非本功能引入):任何短区间回测
(实测 15 个交易日)里
sharpe不可计算为None,而引擎与 CLI 的汇总打印 直接做:.2f/:.2%格式化 →TypeError: unsupported format string passed to NoneType,以完整 traceback 结束。这是把「指标不可计算」这一正常状态 说成了程序缺陷,违反了本项目「HdivError 只打印信息、不打 traceback」的约定。 已改为统一的容忍None的格式化(engine._pct/engine._num,显示「—」), 引擎与 CLI 的四处打印一并处理,walk_forward的窗口打印同样修掉。 由tests/test_daily.py::TestMetricFormatting锁定(含源码扫描,防止回退)。
10.6 提交前的验证清单
| 验证 | 方式 | 结果 |
|---|---|---|
| 取数口径与直连一致 | tests/test_daily.py::test_pit_repo_matches_direct_repo |
11 个方法逐值一致 |
| 缓存键是充分统计量 | ::test_pit_repo_caches_are_exact |
命中与冷算逐值相同 |
symbols 子集精确 |
::test_pit_repo_symbols_subset_is_exact |
与直连一致 |
| 区间外报错而非空表 | ::test_pit_repo_out_of_range_raises |
抛 HdivError |
| 预剪枝不改变入选 | ::test_prune_does_not_change_selection |
5 个样本日入选集合完全相同 |
| 预剪枝集合是上界 | ::test_prune_set_keeps_every_actual_member |
实际成员全在保留集内 |
| 每日选股无未来函数 | ::test_daily_pool_is_point_in_time |
参照终点推后一年,同日选股逐只相同 |
| 端到端落库 | ::test_daily_run_persists_members_and_run |
mode=daily、hd_daily_universe 有数据 |
| 新增参数默认关闭 | ::TestEngineDefaultsAreOff |
single/walkforward 行为逐字不变 |
| 既有模式无回归 | pytest tests/ --ignore=tests/test_daily.py -q |
408 项全通过(本轮最后一次全量:439 项 100%) |
| 契约与安全扫描 | test_cli_contract / test_safety / test_schema / test_web |
全通过(表数 30→31 已同步) |
| 前端语法与接口契约 | node --check web/app.js + FRONTEND_CALLS |
通过(新路由已登记) |
| 资金对账 | 实测 run 的 residual |
−0.0000 ✓ |
| DDL 幂等 | ddl plan 连续两次 |
共 0 个待执行动作 |
| 画像取数优化等价 | pytest tests/test_profile_pit.py |
27 passed(含实时画像 vs 批量画像逐值比对) |
本轮未完成的验证(必须诚实标注):
| 未验证项 | 原因 |
|---|---|
| 2020-01-05 起至今的完整回测结果 | 未跑:按约定「不做长时间测试」。修正后的全区间耗时是推算(见 §10.4);已实测到底的是 2026-08-01 起 43 个交易日(4 分钟) |
| 浏览器里的实际渲染 | 已用 JavaScriptCore 做语法检查、经 HTTP 确认静态产物含新卡片、并逐项核对接口返回的数据结构;但没有真机打开页面 |
已补验(2026-10-05 复测,开发机重启后):
| 项目 | 结果 |
|---|---|
| 选股缓存复用 | ✅ 第二次运行打印「复用已缓存的选股结果」,跳过 114 秒选股,指标与首次逐项相同 |
| 前端「成交个股的实时画像」数据源 | ✅ list_backtest_trades 与 analysis.stock_detail 均返回 reason.profile(test_stock_detail_exposes_decision_time_profile 通过) |
hd_daily_universe 三列非空 |
✅ 1152 行,dividend_yield/listed_count/roe_avg 零 NULL |
| 动态池语义 | ✅ 该 run 有 OUT_OF_UNIVERSE 55 次(掉出池子只减不加)、HOLD 55 条 |
test_pit_profile_caches_are_bounded |
✅ 通过(修掉了该用例自身的 PitRepo 未导入问题) |
| 一条命令给出前端位置 | ✅ test_cli_points_at_the_frontend 通过 |
scripts/daily_result.py |
已删除 —— 用户要的是「一条命令 + 前端看明细」,多一个导出脚本等于要求第二条命令 |
恢复后要做的:
.venv/bin/python -m hdiv backtest --mode daily --start 2020-01-05, 然后打开 Web 前端(./deploy/serve.sh start-dev)→「回测记录」→ 选中该 run 看明细。不需要第二条命令:绩效、净值、持仓、逐笔成交、 成交个股的实时画像、每日股票池都在页面里。
一次命令的产出(全部落库,前端可下钻):
| 表 | 内容 |
|---|---|
hd_backtest_run |
运行头(mode=daily、区间、资金、可复现四元组、未建模声明) |
hd_backtest_equity |
逐日净值 / 现金 / 持仓市值 / 回撤 / 基准净值 → 综合曲线 |
hd_backtest_metric |
总收益 / CAGR / 最大回撤 / Sharpe / Sortino / Calmar / 换手 / 逐年 |
hd_backtest_position |
逐日持仓明细 |
hd_backtest_trade |
全部成交 + reason_json(触发理由 + 决策时点画像) |
hd_backtest_signal |
全部信号(含未成交原因:画像未通过 / 掉出池子 / 现金不足…) |
hd_daily_universe |
每日选股留痕(逐日入选成员 + 入选时因子快照) |