初始提交:高股息策略研究与回测系统

从 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。
This commit is contained in:
2026-10-03 13:54:56 +08:00
commit fce725e13c
127 changed files with 28001 additions and 0 deletions
+381
View File
@@ -0,0 +1,381 @@
# 实施状态报告
> 对应 `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>` | 图表静默失效 | `| safe` + 校验器专项检测 |
| 14 | `dividend_records` 的 SELECT **漏了 `base_share`** | 支付率与 FCF 覆盖在全库范围内恒为 NULL —— `max_payout_ratio` 与 `min_fcf_dividend_cover` 两个筛选条件**从未生效** | 补上该列 + 回归测试断言 SELECT 列表 |
| 15 | 用 **FY2023 分红 ÷ 2024Q1 净利润** 算支付率 | 美的集团算出 230.9% 的荒谬支付率(真实 61.6%) | 新增 `Repo.annual_financials()`,支付率/FCF 覆盖一律**同财年**比较 |
| 16 | 画像从未计算分红质量指标 | `dividend_quality` 与 `composite` 安全边际得分恒为 NULL,`plan.md §14/§15` 形同未实现 | 画像复用 `DividendFilter` 的口径函数(单一口径来源) |
| 17 | 断点续传只看「日期是否存在」 | 原 qlib 数据在 2019 年仅 243 只/日被当作「已同步」,形成**整年数据空洞** | 判据改为「当日股票数 ≥ 1500」 |
| 19 | **Jinja2 autoescape 把嵌入 `<script>` 的 JSON 转义成 `&#34;`** | 内联 JS 语法非法 → **全部报告的图表都不显示**(页面能开、容器空白) | `_json_for_script` 返回 `Markup` 并把 `< > & '` 转成 `\uXXXX`;校验器新增 `node --check` 真实语法检查 |
| 20 | 校验器只比对「容器数 == init 次数」 | 图表全坏却判定 OK(给了假信心) | 改为「每个容器 id 必须被脚本引用」+ HTML 实体检测 + node 语法检查 |
| 21 | 索引模板用 `g.items` | Jinja2 中解析成 `dict.items` 方法而非该键 → 索引页渲染失败 | 键名改为 `reports` |
| 22 | `report.yml` 声明了无人实现的 `naming.strategy` | 配置承诺了不存在的产物 | 移除该键(策略说明报告属未实现的 P8) |
| 18 | walk-forward 的 train/test 以 `persist=False` 运行 | `hd_walkforward_window` 的 run_id 是**悬空引用**,报告无法下钻 | 一并落库,并把 `wf_id/window_index` 纳入 run_id 指纹(否则同区间会撞 id 互相覆盖) |
---
## 4. 实测回测结果(2015-01-05 ~ 2026-09-30,11.7 年)
> 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、
> 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、
> 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。
### 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:
1. **全期回测允许仓位穿越牛熊**:2019–2021 建的仓在 2022–2023 的下跌中继续持有,
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
训练期校准的阈值在测试期就失灵了。
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大
(稳定性指标 −0.16,说明窗口间差异大于均值本身)。
**因此:不要采信 §4.1 的 +114.80%。** 更接近真实的表述是
「该策略在 2020/2024/2025/2026 的样本外为正,在 2021/2022/2023 为负,
长期看与基准相比没有稳定的超额收益,且回撤更小(防御性成立、进攻性不足)」。
> 值得注意的是,策略的**回撤控制**在样本外依然稳定成立
> (最差 −24.62%,而基准同期回撤 −46% ~ −52%),
> 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**,
> 而非超额收益来源。
## 5. 已知限制(如实声明)
1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
2. **涨跌停与停牌约束覆盖 2019 年起**;2015-2018 区间为近似建模,
引擎会在 `hd_backtest_run.unimplemented_json` 中如实声明。
(停牌同步器起点为 2019-01-01,如需 2015-2018 可改 `--start` 重跑。)
3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。
4. **`index_weight` 为空**:不影响基准收益计算(用指数点位),
仅影响成分股分析。
5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。
6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。
7. **2015-2018 为预热期**(无历史分布可用),见 §4.4 说明。
8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。
9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026),
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻;
单次完整跑完约需 25 分钟,用 `hdiv backtest --mode walkforward` 执行。
---
## 6. 如何运行
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
# 1) 建表(幂等)
.venv/bin/python -m hdiv ddl apply
# 2) 数据同步(按需)
.venv/bin/python -m hdiv sync dividend --only-missing
.venv/bin/python -m hdiv sync financial --interleaved --only-missing
.venv/bin/python -m hdiv sync index
.venv/bin/python -m hdiv sync trading --start 2019-01-01
HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回补
# 3) 数据审计(含 HTML)
.venv/bin/python -m hdiv audit
# 4) 股票池 → 画像
.venv/bin/python -m hdiv universe --asof 2024-06-28
.venv/bin/python -m hdiv profile --universe-run <run_id>
# 5) 策略登记与回测
.venv/bin/python -m hdiv strategy register
.venv/bin/python -m hdiv backtest --start 2021-01-01 --end 2024-06-28
.venv/bin/python -m hdiv backtest --mode walkforward
.venv/bin/python -m hdiv sensitivity
# 6) 校验报告离线可用性
.venv/bin/python -m hdiv report validate
# 7) 测试
.venv/bin/python -m pytest tests/ -q
```
报告输出在 `output/`,从 `output/index.html` 进入。
---
## 7. 可修改的配置
| 文件 | 控制什么 |
|---|---|
| `config/universe.yml` | **股票筛选条件**(市值/上市年限/连续分红/股息率/ROE/行业豁免…) |
| `config/profile.yml` | **个股特性**(统计窗口/分位/波动频率/安全边际权重/TTM 口径) |
| `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) |
| `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 |
| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合 |
| `config/report.yml` | 图表开关/输出命名/版面/资源模式 |
| `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 |
修改任一文件后 `config_hash` 变化,历史 run 仍可完整复现。
---
## 7.1 报告部署(重要)
报告通过**相对路径**引用图表库:``<script src="assets/echarts.min.js">``。
因此发布时必须**整体部署 `output/` 目录**,包括 `output/assets/`。
例如把 `output/` 映射到 `/ggx/`,则下列两者都必须可达:
```text
http://<host>:8080/ggx/index.html ← 报告
http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1 MB)
```
若只复制了 `*.html`,页面能打开但**所有图表都不会显示**。
若你的部署方式无法附带 `assets/` 目录,改用自包含模式:
```yaml
# config/report.yml
asset_mode: inline # 每份 HTML 内嵌图表库,体积由约 10MB 增至约 79MB
```
改完重新生成报告即可(`hdiv report index` 或对应报告命令)。
**部署前自检**(会真实执行 `node --check` 校验每份报告的内联 JS):
```bash
hdiv report validate # 或:python -m hdiv.report.validate output
```
## 7.2 Web 前端(前后端分离)
系统提供统一 Web 前端,替代原先平铺的静态报告:
| 能力 | 实现 |
|---|---|
| 统一入口 | `output/index.html`(hash 路由单页应用) |
| 股票池记录管理 | 列表 / **命名** / **归档** / **软删除** / 打开股票清单 |
| 个股画像 | 点击清单里的个股直接打开画像页(四联图 + 分位 + 雷达) |
| 股票池 ↔ 回测 | `hdiv backtest --universe-run <run_id>` 建立关联,双向可见 |
| 回测主页面 | 只显示**条件与说明**,点击进入详细结果 |
| 详细结果 | 总结果 + 净值曲线 + 绩效指标 + **逐笔成交与理由** |
| 目录归一化 | `hdiv site normalize` 归档历史、静态报告入 `reports/` |
**后端用 Python 标准库实现**(`ThreadingHTTPServer`):零新增依赖、
一条命令启动、nginx 只需反代 `/api`,无版本漂移风险。
部署:`deploy/nginx.conf.example`(两种布局)+ `deploy/serve.sh`(启停脚本)。
## 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 可序列化 |