从 Point-in-Time 股票筛选到统一 Web 前端的完整链路: 筛选 → 画像 → 策略 → 回测 → Walk-forward → 绩效分析 → 报告/前端。 架构 - 数据层与策略层分离;策略代码不写 SQL,只经 data/repo.py 取数 - 所有业务阈值集中在 config/*.yml,代码零硬编码(字段写错直接报错) - 报告只做「run_id → SQL → 渲染」,不做任何计算,数字可追溯 - 前后端分离:output/ 静态站点 + hdiv web 提供的 REST API 数据安全 - 只增不删:SQL 钩子拦截 DELETE/DROP/TRUNCATE,并有源码扫描测试守护 - qlib 原有表只读,本项目数据写入 hd_ 前缀表 - 回补使用 INSERT IGNORE,保证既有行零改动 - .env 存密钥且已 gitignore;output/、logs/、.venv/ 不入库 交付物 - 30 张 hd_* 表、7 个 YAML 配置、283 项自动化测试 - 统一 Web 前端(hash 路由 SPA)+ nginx 部署配置与 launchd 托管脚本 如实声明的限制 - 策略缺少稳定的样本外超额收益(Walk-forward 7 窗口均值 -0.95%, 基准 +2.29%);其价值体现在回撤控制,而非超额收益 - 涨跌停/停牌约束仅覆盖 2019 年起;index_weight 尚未填充 - AI Agent 层(plan.md 第四版 P8)未实现 详见 docs/user-guide.md 与 docs/implementation-status.md。
20 KiB
实施状态报告
对应
docs/development-plan.md(计划)与docs/plan.md(需求) 更新:2026-10-02
0. 一句话结论
系统已端到端可运行:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
全链路打通并通过 262 项自动化测试。plan.md §50 的 14 项验收能力全部具备。
数据同步仍在后台补齐最后 ~15%(分红 99.6%、财报 ~87%), 但这不影响系统功能 —— 它只影响股票池的绝对规模。
1. 已交付清单
1.1 数据层(P0a / P0b / G1–G6)
| 缺口 | 状态 | 实测结果 |
|---|---|---|
| G1 分红明细 | ✅ 完成 | hd_dividend 5,880 只 / 253,879 行(其中 56,513 条实施且有现金分红),覆盖 1990–2026 |
| G2 日线行情 | ✅ 完成 | stock_daily 起点 2015-01-05(2015 年首个交易日),2,838 个交易日;2019 年空洞已补齐(+89 万行) |
| G2b 复权因子 | ✅ 完成 | adjust_factor 同区间,2,840 个交易日 |
| G3 每日指标 | ✅ 完成 | daily_basic 起点 2015-01-05,2,286 个交易日 |
| G4 扩展财务 | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
| G5 基准指数 | ✅ 完成 | hd_index_daily 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
| G6 停牌/涨跌停 | ✅ 完成 | hd_suspend 67,765 行;hd_limit 669 万行 |
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(18 项检查) |
✅ |
| 股票池 | 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 只。
4.1 与基准对比
| 指标 | 策略 | 沪深300 | 中证红利 | 上证指数 |
|---|---|---|---|---|
| 总收益 | +114.80% | +19.66% | +53.35% | +14.67% |
| 年化 CAGR | +6.73% | +1.54% | +3.71% | +1.17% |
| 最大回撤 | −21.05% | −46.70% | −46.51% | −52.30% |
| Sharpe | 0.31 | — | — | — |
策略跑赢天然基准「中证红利」61 个百分点,而回撤不到其一半。
⚠ 但这个数字有严重误导性,请看 §4.6 的 Walk-forward 结果。 单条路径的全期回测会把「持有可能穿越熊市并在后期回本」的效应放大, 而逐年样本外检验给出的是一幅完全不同的图景。
该结果是在修正了支付率与 FCF 覆盖两个筛选条件之后取得的 (此前这两个条件因
base_share漏选而静默失效)。修正后股票池由 49 只 收紧到 28 只,收益反而提高 —— 说明「分红可持续性」这一安全边际条件 确实在筛选优质标的。
4.2 交易与分红
| 项目 | 数值 |
|---|---|
| 成交笔数 | 180 |
| 累计现金分红 | 558,797 元(占期初资金 55.9%) |
| 已扣红利税 | 13,676 元 |
| 期末资金 | 2,148,010 元 |
| 资金对账残差 | 0.0000 ✅ |
4.3 逐年收益
| 年 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|---|---|---|---|---|---|---|---|---|
| 策略 | +18.56% | +6.64% | +11.85% | −3.99% | +1.25% | +22.22% | +14.67% | +7.32% |
11.7 年中仅 2022 一年为负,且跌幅远小于同期市场。
4.4 预热期说明(重要)
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 个滚动窗口,每个窗口用训练段校准分位分布、测试段冻结该分布:
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 |
|---|---|---|---|---|
| #0 | 2015–2019 | 2020 | +4.01% | −11.94% |
| #1 | 2016–2020 | 2021 | −3.07% | −15.79% |
| #2 | 2017–2021 | 2022 | −9.17% | −24.22% |
| #3 | 2018–2022 | 2023 | −8.63% | −18.00% |
| #4 | 2019–2023 | 2024 | +2.61% | −24.62% |
| #5 | 2020–2024 | 2025 | +3.78% | −10.50% |
| #6 | 2021–2025 | 2026(部分) | +3.83% | −14.88% |
样本外汇总:
| 指标 | 数值 |
|---|---|
| 盈利窗口 | 4 / 7(胜率 57.14%) |
| 样本外收益 均值 | −0.95% |
| 样本外收益 中位数 | +2.61% |
| 样本外 CAGR 均值 | −0.78% |
| 最差窗口回撤 | −24.62% |
| 基准收益均值 | +2.29% |
| 超额收益均值 | −3.24% |
结论:单路径回测显著高估了策略
对比 §4.1 与本节:
| 全期单路径回测 | Walk-forward 样本外均值 | |
|---|---|---|
| 收益 | +114.80%(11.7 年) | −0.95%/年 |
| 相对基准 | +95pp | −3.24pp |
这个反差是 Walk-forward 存在的全部意义。 两者的差异来自方法论而非 bug:
- 全期回测允许仓位穿越牛熊:2019–2021 建的仓在 2022–2023 的下跌中继续持有, 到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
- Walk-forward 逐年冻结参照分布:测试年必须用训练年校准的股息率分布, 不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移), 训练期校准的阈值在测试期就失灵了。
- 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大 (稳定性指标 −0.16,说明窗口间差异大于均值本身)。
因此:不要采信 §4.1 的 +114.80%。 更接近真实的表述是 「该策略在 2020/2024/2025/2026 的样本外为正,在 2021/2022/2023 为负, 长期看与基准相比没有稳定的超额收益,且回撤更小(防御性成立、进攻性不足)」。
值得注意的是,策略的回撤控制在样本外依然稳定成立 (最差 −24.62%,而基准同期回撤 −46% ~ −52%), 这与「高股息 + 安全边际」的定位一致 —— 它更像一个降低波动的配置工具, 而非超额收益来源。
5. 已知限制(如实声明)
- 分红与财报已基本完成(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重
index_weight仍为空(不影响基准收益计算)。 - 涨跌停与停牌约束覆盖 2019 年起;2015-2018 区间为近似建模,
引擎会在
hd_backtest_run.unimplemented_json中如实声明。 (停牌同步器起点为 2019-01-01,如需 2015-2018 可改--start重跑。) - 未实现部分成交(按信号全额成交,受资金与权重上限约束)。
index_weight为空:不影响基准收益计算(用指数点位), 仅影响成分股分析。- AI Agent 层(P8)未实现 —— 属
plan.md第四版扩展。 - 敏感性结论受限于扫描区间与股票池规模,见 §4.5 说明。
- 2015-2018 为预热期(无历史分布可用),见 §4.4 说明。
- 策略缺少稳定的样本外超额收益(见 §4.6),这是最重要的结论。
- Walk-forward 已具备 7 个滚动窗口(2015-2019/2020 … 2021-2025/2026),
与
plan.md §23的示例完全一致,train/test 均落库可下钻; 单次完整跑完约需 25 分钟,用hdiv backtest --mode walkforward执行。
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(启停脚本)。
8. 测试覆盖
262 passed
| 测试文件 | 覆盖 |
|---|---|
test_config.py |
配置正向加载 + 18 类非法配置必须被拒 |
test_safety.py |
SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
test_schema.py |
30 张表结构、前缀、幂等性、唯一键列不得可空(NULL 绕过唯一约束) |
test_sync.py |
单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
test_universe.py |
单位换算与量级检测、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
test_backtest.py |
成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数 |
test_cli_contract.py |
CLI 与使用手册的接口契约(含 web/site 命令、--universe-run) |
test_web.py |
前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 |