Files
ggx/docs/user-guide.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

2142 lines
94 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 高股息回测系统 · 使用手册
> 版本 1.0 · 对应代码 `hdiv 0.1.0`
> 相关文档:[实施状态](implementation-status.md) · [开发计划](development-plan.md) · [需求原文](plan.md)
---
# 目录
0. [全流程操作](#0-全流程操作) ★ **先读这一节(选股 → 画像 → 回测)**
1. [系统是什么](#1-系统是什么)
2. [快速开始](#2-快速开始)
3. [目录与架构](#3-目录与架构)
4. [配置文件详解](#4-配置文件详解)
5. [命令行手册](#5-命令行手册)
6. [典型工作流](#6-典型工作流)
7. [报告解读](#7-报告解读)
8. [数据库](#8-数据库)
9. [关键口径](#9-关键口径)
10. [Web 前端与部署](#10-web-前端与部署)
11. [故障排查](#11-故障排查)
12. [已知限制](#12-已知限制)
---
# 0. 全流程操作
> **选股 → 画像 → 回测**,三步主路径。
本节是**主操作路径**:从「选出一批股票」到「跑出一份可信的回测」,
每一步都说明**命令做了什么、数据从哪来、落了哪些库、有哪些坑**。
## 0.0 五分钟全流程(可直接复制)
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
# ① 选股:按 2025-01-01 当时可见的数据筛选(实际落到交易日 2024-12-31)
.venv/bin/python -m hdiv universe --asof 2025-01-01
# → 记下打印的 run_id,例如 02485801b2805cbae0e02db66c7bc946
# ② 画像:对这 46 只看它们在**该时点**的 5/8/10 年画像
.venv/bin/python -m hdiv profile --universe-run 02485801b2805cbae0e02db66c7bc946
# ③ 登记策略(首次需要;之后改 YAML 再 register 即可)
.venv/bin/python -m hdiv strategy register
# ④ 回测:用这个池子,**起点不得早于股票池 asof**
.venv/bin/python -m hdiv backtest \
--universe-run 02485801b2805cbae0e02db66c7bc946 \
--start 2025-01-01
# ⑤ Walk-forward 样本外验证(★ 判断策略好坏的唯一依据)
.venv/bin/python -m hdiv backtest --mode walkforward
# ⑥ 打开前端看结果
.venv/bin/python -m hdiv web # → http://127.0.0.1:8099/
```
> **③④ 的顺序无所谓,②和④互不依赖**(见 §0.3 的「关键认知」)。
> **④ 与 ⑤ 的区别是本质性的**:④ 是「一条路径」,⑤ 是「逐年样本外」。
> 本项目只认 ⑤ —— 详见 §7.5。
---
## 0.1 步骤①:选股 `hdiv universe`
```bash
.venv/bin/python -m hdiv universe --asof 2025-01-01 [--no-persist] [--html]
```
### 发生了什么
```
① asof 归一化 → 2025-01-01 是元旦休市,落到「≤ asof 的最近交易日」= 2024-12-31
② 取候选集 → stock 表中 list_date <= asof 且 (delist_date 为空或 > asof)
即「当时已上市、当时未退市」——**包含此后才退市的股票**(消除生存者偏差)
③ 挂 PIT 面板 → daily_basic(当日或最近 5 个交易日内)、20 日均成交额、
最新已公告财报(ann_date <= asof)、已实施且已除权的分红
④ 依次过 4 个滤网 → market → risk → dividend → quality(先便宜的、淘汰率高的)
每只股票记录:每个滤网的通过位 + **首个未通过的滤网 + 原因 + 取值快照**
⑤ 截断 → 按股息率降序取前 output.max_members(默认 200)只
⑥ 落库 → hd_universe_run(头)+ hd_universe_member(逐股留痕)
```
各滤网的判据(全部来自 `config/universe.yml`,改 YAML 即改行为):
| 顺序 | 滤网 | 主要判据 |
|---|---|---|
| 1 | `market` | 交易所 ∈ [SSE, SZSE]、板块 ∈ [主板/创业板/科创板]、上市 ≥ 10 年、总市值 ≥ 500 亿、20 日均成交额 ≥ 2,000 万元、当日有行情 |
| 2 | `risk` | 非 ST(按 `stock_name_history` 还原**当时**的名字)、未退市、未停牌、净资产为正、资产负债率 ≤ 80%(银行/保险/证券/信托豁免) |
| 3 | `dividend` | 股息率 ≥ 3%(**自算 PIT-TTM**,非 `dv_ttm`)、连续分红 ≥ 5 年、6 年窗口内至少 5 个分红年、支付率 ≤ 100%、自由现金流为正、FCF 覆盖分红 ≥ 1 倍 |
| 4 | `quality` | 5 年平均 ROE ≥ 8%、经营现金流/净利润 ≥ 0.6(用**年报**而非季报,否则累计值会误杀) |
### 关键性质
1. **无未来函数**:每一条判据都带 `<= asof` 约束(行情 `trade_date`、财报 `ann_date`、
分红 `imp_ann_date` 与 `ex_date` 双重)。ST 状态按历史名称还原,不看今天的名字。
2. **run_id 是确定性的**:由「配置哈希 + asof」决定,**不含时间戳**。
所以同配置同时点重跑会**原地覆盖同一条记录**,不会积累重复。
3. **`asof` 会被归一化到交易日**。`--asof 2025-01-01` 与 `--asof 2024-12-31`
得到**同一个 run_id**。看到打印的 `asof=2024-12-31` 不是 bug。
4. **`--no-persist` 的后果**:结果不落库 → 前端看不到,**也无法被回测引用**。
CLI 会显式提醒。
### 落库与追溯
| 表 | 内容 |
|---|---|
| `hd_universe_run` | run_id / asof_date / 候选数 / 入选数 / 配置指纹 / 数据版本 |
| `hd_universe_member` | 逐股:每个滤网通过位、`fail_stage`(首个未通过滤网)、`fail_reason`(中文原因)、取值快照 |
> **「为什么没选上」是这个模块最重要的产出。** 前端「股票池」页可逐股下钻。
---
## 0.2 步骤②:画像 `hdiv profile`
```bash
# 对某个股票池的全部成员,按**该股票池的 asof** 画像
.venv/bin/python -m hdiv profile --universe-run <run_id>
# 指定股票 + 指定时点(任意历史时点,PIT)
.venv/bin/python -m hdiv profile --symbols 600036.SH 000651.SZ --asof 2018-05-18
```
### 发生了什么
```
① 确定对象与时点 → --universe-run 时 asof 取该池的 asof_date;--asof 可显式覆盖
② 取数 → 起点 = asof.year − max(windows_years) − 1 的 1 月 1 日,终点 = asof
价格(不复权)、daily_basic(PE/PB/PS)、分红、年报财务、指数
③ 逐股逐指标统计 → 每个指标 × 每个窗口 [全历史, 5, 8, 10] 年:
n_obs、min/max/mean/median/std、P10/P25/P50/P75/P90、
当前值、**当前值在该窗口分布中的分位**
④ 分红质量 → 连续分红年数、DPS 增速与波动、支付率、FCF 覆盖
(与「股票池筛选」共用同一套口径函数)
⑤ 安全边际评分 → 5 个分项(股息率/估值/财务质量/资产负债表/分红质量)
+ 全项齐备时才给 composite 综合分
⑥ 覆盖率自检 → 窗口实际观测数 ÷ 该窗口应有交易日数;不足则打印警告
⑦ 落库 → hd_profile_run / hd_profile_stat / hd_profile_series / hd_profile_score
```
### 输出解读
| 字段 | 含义 |
|---|---|
| `n_obs` | 该窗口内的实际观测数(**注意分位就是在这 n 个观测上算的**) |
| `current_value` | asof 当天的值(取窗口内**最后一个观测**) |
| `current_percentile` | 当前值在窗口分布中的分位(`≤ 当前值的观测占比`) |
| `status` | `OK` / `INSUFFICIENT`(样本不足,**不猜、不用 0 填充**) |
| 窗口覆盖率 | 1.0 = 名义 5 年真有 5 年数据;< 1 说明被数据起点截短 |
CLI 会打印覆盖率,例如:
```
画像 <run_id>:46 只
窗口覆盖率:最差 10 年窗口 94.6%(1.0 = 名义窗口被完整覆盖)
```
### 关键认知(最容易误解的一点)
> **画像是一次「快照」,回测并不读它。**
>
> 回测里每次买入前用的画像,是引擎**在该决策日实时重算**的
> (见 §0.3 第④步),与这里落库的快照是**两条独立路径**。
> 两者由「逐值等价测试」约束,不会给出两个不同的数。
>
> 所以:**画像页是给你看的,不是给回测用的。** 回测每天自己算。
---
## 0.3 步骤③:回测 `hdiv backtest`
```bash
# 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 asof
.venv/bin/python -m hdiv backtest --universe-run <run_id> --start 2025-01-01
# 不冻结:引擎在每个调仓日按当时可见数据重新筛选(判断策略本身用这个)
.venv/bin/python -m hdiv backtest --start 2015-01-01
```
### 发生了什么(逐日事件循环)
```
① 确定区间 → 交易日历取 [start, end],默认取 config/backtest.yml 的 period
② 重建股票池 → --universe-run:守卫校验 asof ≤ 回测首个交易日,然后**冻结**该清单,
所有调仓日复用(每 12 个月不再重筛)
否则:每 universe_refresh_months=12 个月的调仓日,调 selector.run(asof=当日)
—— 用的是**当时可见**的数据(PIT)
③ 预载面板 → 价格(不复权)、分红事件、停牌、涨跌停、指数
若闸门启用:另按最长窗口预载画像面板
④ 逐日循环 ↓
④a 开盘 → 执行**昨日**收盘产生的信号,成交价 = 次日开盘价 ± 滑点
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送转股增加股数
④c 收盘 → 每月一次评估信号(signal_frequency_months=1):
· 算当日股息率 = TTM 每股分红 ÷ 不复权收盘价
· 算历史分位 = 当前值在「(当日−5年, 当日]」分布中的占比
· 分位 ≥ entry.yield_percentile(75) → 买入候选(阶梯定目标仓位)
· 分位 ≤ exit.yield_percentile(25) → 卖出候选
· **买入候选再过「实时画像闸门」**(见下)
④d 收盘 → 盯市:总市值 = 现金 + 持仓 × 不复权收盘价 → 净值曲线
⑤ 绩效与对账 → 收益/CAGR/回撤/Sharpe/Sortino/Calmar、逐年收益、
**资金恒等式残差**(应为 0)
⑥ 落库 → hd_backtest_run / _equity / _position / _trade / _signal / _metric
```
### ④c 的「实时画像闸门」(★ 你关心的那件事)
| 问题 | 答案 |
|---|---|
| 会实时算画像吗? | **会**。每个决策日、对**已触发买入条件**的标的,按当时可见数据重算过去 5 年画像 |
| 用哪一天的画像? | **该决策日自己的**。2025-03-31 用 (2020-03-31, 2025-03-31],不是「2025-01-01 那天」的 |
| 每只股票每天都算吗? | **不是**。惰性:只有股息率分位 ≥ P75 已触发的才去算 |
| 算全量指标吗? | 算 `_profile_one` 的全部指标,但**只加载规则声明用到的面板**(纯估值规则不查财报表) |
| 管卖出吗? | **不管**。卖出/减仓只看股息率分位 |
| 决定仓位大小吗? | **不决定**。`weight_scheme: score` 未实现,仓位由分位阶梯决定 |
| 不通过会怎样? | 产出信号类型 `REJECT` + `skip_reason=PROFILE_GATE`,**不成交**;前端「未成交信号」可逐条看每条规则的实际值/阈值/是否通过 |
| 数据缺失/样本不足? | 按 `on_unverifiable`(默认 `reject`)保守处理 —— **不猜** |
| 关掉它? | `entry.profile_gate.enabled: false`,整条链路不参与,行为回到改造前 |
配置与规则详见 §4.3「实时画像闸门」。
### 未来函数守卫(会拒绝执行的情况)
| 情形 | 行为 |
|---|---|
| `--universe-run` 且**股票池 asof > 回测首个交易日** | **拒绝执行**,退出码 1,错误信息给出三种正确做法 |
| `--universe-run` 用在 `--mode walkforward` | **拒绝执行**(训练窗口比股票池时点更早) |
| `--universe-run` 且 asof ≤ 起点 | 正常执行(股票池属于**事前信息**) |
| 确需复现带未来信息的旧结果 | 显式加 `--allow-lookahead-universe`;偏差会写入 `unimplemented_json` |
> **例**:`--universe-run b8dd742f…`(asof=2025-01-21)+ 默认起点 2015-01-01
> → **直接报错,不会跑出任何结果**。
> 想用这个池子,必须 `--start 2025-01-21`(或更晚)。
### 回测里的成交与成本口径
| 项 | 口径 |
|---|---|
| 成交价 | 信号**次日开盘价** ± 滑点(固定此行为,`same_close` 等分支未实现) |
| 佣金 / 印花税 / 过户费 | 佣金双边取 `max(额×费率, 最低佣金)`;印花税**仅卖出**;过户费双边 |
| 红利税 | 按持股期限:≤1 月 20%、≤1 年 10%、>1 年免征 |
| 整手 | 买入按 100 股取整 |
| 涨跌停 | 开盘即封板 → 该信号**跳过**(记 `skip_reason`) |
| 停牌 | 该信号**跳过**(`backtest.yml` 写的 `defer` **未实现**,不会顺延) |
| 分红 | 除权日入账,**留存为现金**(`cash_mode: reinvest` 未实现),下次调仓按目标权重再配置 |
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
> 你可以直接从库里读到该 run 到底哪些约束没生效 —— 不会静默。
### 验证这次回测「干净」的两个动作
```sql
-- ① 有没有被声明为「含未来信息」(应为 0)
SELECT run_id, start_date, end_date,
(unimplemented_json LIKE '%含未来信息%') AS bias_flag
FROM hd_backtest_run
WHERE universe_run_id = '<你的 run_id>'
ORDER BY created_at DESC;
-- ② 资金对账残差必须为 0(不为 0 时不要采信任何绩效指标)
SELECT run_id, status, unimplemented_json FROM hd_backtest_run
ORDER BY created_at DESC LIMIT 1;
```
CLI 也会直接打印对账残差与 `✓`。
---
## 0.4 步骤④:看结果与「能不能信」
| 入口 | 看什么 |
|---|---|
| `hdiv web` → 首页 | 数据审计、股票池、画像、回测、Walk-forward 的汇总入口 |
| 回测详情 | 净值曲线、逐笔成交(含 `reason_json`:为什么买)、未成交信号(含 `REJECT` 原因)、持仓 |
| **Walk-forward 详情** | **逐窗口样本内/样本外对比** —— 判断策略好坏的唯一依据 |
| `output/reports/` | 静态 HTML 快照(需 `--html` 显式导出) |
**读结论的三条纪律**:
1. **只认 Walk-forward 的样本外结果**,不要采信单路径全期数字。
本项目的实测就是反例:同一份数据,回补前后单路径从 +117% 掉到 +93%,
而样本外 7 个窗口**一个数字都没变**。
2. **对账残差不为 0 就不要看绩效**。
3. **样本太短不要下结论**。用 2025 起的池子跑不到 2 年(约 21 个调仓月),
统计上说明不了任何问题,更**不能拿来调参**。
---
## 0.5 一页速查:每步的命令、产出、坑
| 步骤 | 命令 | 落库 | 最容易踩的坑 |
|---|---|---|---|
| ① 选股 | `hdiv universe --asof <日期>` | `hd_universe_run` / `hd_universe_member` | `asof` 会归一化到交易日;`--no-persist` 后无法被回测引用 |
| ② 画像 | `hdiv profile --universe-run <id>` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) |
| ③ 回测 | `hdiv backtest [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`/`reinvest` 等未实现项在 `unimplemented_json` 里 |
| ④ 验证 | `hdiv backtest --mode walkforward` | `hd_walkforward_run` / `_window` | 必须与 `--universe-run` 分开用;约 25~80 分钟 |
| ⑤ 调参 | `hdiv sensitivity --sweep "..."` | `hd_sensitivity_run` / `_point` | 样本不足时噪声会被误读为过拟合 |
---
# 1. 系统是什么
一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线:
```text
Point-in-Time 股票筛选 → 个股特性画像 → 策略定义 → 历史回测
→ Walk-forward 样本外验证 → 绩效与敏感性分析 → HTML 报告
```
**设计目标不是「预测明天涨跌」**,而是回答:
- 哪些股票长期稳定分红、财务质量好、规模大、上市够久?
- 这只股票当前的股息率,在它自己的历史里处于什么位置?
- 「股息率进入历史高位时买入、回落到低位时卖出」这条规则,历史上真的有效吗?
- 换一组参数,结论还成立吗?样本外呢?
## 1.1 它做什么
| 能力 | 说明 |
|---|---|
| PIT 股票池 | 任意历史时点按当时**可见**的数据筛选,无未来函数 |
| 消除生存者偏差 | 历史股票池包含此后退市的股票 |
| 个股画像 | 股息率/PE/PB 历史分布、P10–P90 分位、分红质量、安全边际分项得分 |
| 参数化策略 | 买卖阈值、分批建仓、仓位与风控全部写在 YAML |
| 回测引擎 | 不复权价 + 独立分红现金流、涨跌停/停牌约束、A 股完整成本 |
| Walk-forward | 训练段定阈值 → 测试段冻结,逐年样本外检验 |
| 参数敏感性 | 量化「策略对参数是否敏感」,识别过拟合 |
| 固定格式报告 | 离线 HTML,数据全部来自数据库,带 SQL 溯源 |
## 1.2 它不做什么
- **不预测涨跌**,不做择时、不做日内、不做高频
- **不自动交易**,不接券商接口
- **不做 AI 策略生成**(`plan.md` 第四版 P8,尚未实现)
- 不做多因子机器学习选股(框架支持扩展,但当前只实现股息率单因子)
## 1.3 数据来源
- **数据库**:本机 MariaDB `127.0.0.1:3306/qlib`,与 `~/project/qlib` 项目**共用**
- **行情与财务**:Tushare Pro
- 本项目只在该库中新增 `hd_` 前缀的表;**qlib 的原有代码零改动**
---
# 2. 快速开始
## 2.1 环境要求
| 项 | 要求 |
|---|---|
| Python | 3.12 |
| 数据库 | 本机 MariaDB / MySQL,可访问 `127.0.0.1:3306` |
| Tushare | 有效的 Pro token(写权限用于同步数据) |
| Node.js | 可选,仅用于报告 JS 语法自检 |
## 2.2 一次性配置
```bash
cd ~/project/高股息回测
cp .env.example .env # 若 .env 已存在则跳过
```
编辑 `.env` 填入凭据(**该文件已在 .gitignore 中,切勿提交**):
```ini
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DB=qlib
MYSQL_USER=qlib
MYSQL_PASSWORD=<你的数据库密码>
TUSHARE_TOKEN=<你的 tushare token>
```
> `.env` 只放密钥。所有**业务阈值**都在 `config/*.yml` 里,两者不混。
## 2.3 三步跑通
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
# ① 建表(幂等,反复执行安全)
.venv/bin/python -m hdiv ddl apply
# ② 同步数据(首次约需 3~4 小时,可后台跑,详见 §6.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
# ③ 审计 + 出报告
.venv/bin/python -m hdiv audit
# 打开 output/index.html
```
## 2.4 完整跑一遍策略研究
> **完整的分步讲解见 §0(先读那一节)。** 这里只给最短的命令序列。
```bash
export PYTHONPATH=src
# ① 股票池(时点:2024-06-28)
.venv/bin/python -m hdiv universe --asof 2024-06-28
# → 记下输出的 run_id
# ② 个股画像(对股票池内全部股票,按该池的时点)
.venv/bin/python -m hdiv profile --universe-run <①的 run_id>
# ③ 登记策略
.venv/bin/python -m hdiv strategy register
# ④ 回测
# - 若要用①这个池子:必须带 --universe-run,且 --start 不得早于该池 asof
# (2024-06-28 的池子 → 起点最早 2024-06-28;否则会被未来函数守卫拒绝)
# - 若要看**策略本身**的历史表现:去掉 --universe-run,让引擎逐调仓日重筛
.venv/bin/python -m hdiv backtest --universe-run <①的 run_id> --start 2024-06-28
.venv/bin/python -m hdiv backtest --start 2015-01-01 # 策略本身
# 注意:不传 --start 时取 config/backtest.yml 的 period.start(2015-01-01),
# 这与 --universe-run 组合会被**拒绝**,详见 §0.3 的守卫表。
# ⑤ Walk-forward 样本外验证(★ 判断策略的唯一依据,约 25~80 分钟)
.venv/bin/python -m hdiv backtest --mode walkforward
# ⑥ 参数敏感性
.venv/bin/python -m hdiv sensitivity
```
---
# 3. 目录与架构
## 3.1 目录结构
```text
高股息回测/
├── config/ ★ 你要改的东西都在这里
│ ├── datasource.yml 数据库 / Tushare / 只读白名单
│ ├── universe.yml ★ 股票筛选条件
│ ├── profile.yml ★ 个股特性配置
│ ├── cost.yml 佣金 / 印花税 / 滑点 / 红利税
│ ├── backtest.yml 区间 / 调度 / Walk-forward / 基准
│ ├── report.yml 报告与图表
│ └── strategy/
│ └── high_dividend_v1.yml ★ 策略定义
├── src/hdiv/ 代码
│ ├── core/ 配置加载与校验、路径
│ ├── data/ 数据库访问、同步器、审计、单位归一化
│ ├── universe/ 股票池筛选(4 类滤网)
│ ├── factor/ 股息率因子
│ ├── profile/ 个股画像
│ ├── strategy/ 策略注册与版本
│ ├── backtest/ 回测引擎、Walk-forward
│ ├── analysis/ 绩效、风险、敏感性
│ ├── report/ 报告装配与渲染
│ └── cli.py 命令行入口
├── templates/ HTML 模板(Jinja2)
├── assets/echarts.min.js 图表库(离线,1MB)
├── output/ ★ 报告输出(部署这个目录)
├── sql/ 建表 SQL 副本(供人工审查)
├── tests/ 224 项自动化测试
├── .env 密钥(不提交)
└── docs/ 文档
```
## 3.2 数据流
```text
Tushare ──► 同步器 ──► MariaDB(qlib)
├─ qlib 原有表(只读:stock / stock_daily / daily_basic …)
└─ hd_* 新表(读写)
│
config/*.yml ──► 配置校验 ────┤
▼
筛选 → 画像 → 策略 → 回测 → 分析
│
▼
报告渲染(只读 DB,不做计算)
▼
output/*.html
```
## 3.3 三条硬边界
| 边界 | 含义 |
|---|---|
| 数据层 ≠ 策略层 | 策略代码不写 SQL,只能调 `data/repo.py` |
| 策略 ≠ 回测引擎 | 策略只产信号,引擎只执行信号 |
| 图表 ≠ 业务 | 报告只做「run_id → SQL → 渲染」,不做任何计算 |
---
# 4. 配置文件详解
> **通用规则**
> - 字段名写错会**直接报错**,不会静默取默认值
> - 修改任一配置,`config_hash` 变化,历史 run 仍可完整复现
> - 配置里**不含任何密钥**,密钥统一走 `.env`
---
## 4.1 `config/universe.yml` — 股票筛选条件 ★
### 行业豁免
```yaml
industry_exemptions:
leverage: [银行, 保险, 证券, 信托] # 豁免负债率上限
free_cash_flow: [银行, 保险, 证券, 信托] # 豁免 FCF 相关要求
```
> **为什么需要**:银行负债率天然 90%+、也没有「自由现金流」概念。
> 不做豁免会把整个金融板块误杀(实测:招商银行 90.2% 会被负债率上限直接淘汰)。
### market — 市场属性
| 字段 | 默认 | 含义 |
|---|---:|---|
| `exchanges` | `[SZSE, SSE]` | 交易所白名单;加 `BSE` 纳入北交所 |
| `markets` | `[主板, 创业板, 科创板]` | 板块白名单 |
| `min_listing_years` | `10` | 上市年限下限(年) |
| `min_market_cap` | `5e10` | 总市值下限(**元**,5e10 = 500 亿) |
| `min_float_market_cap` | `null` | 流通市值下限 |
| `min_avg_amount_20d` | `2e7` | 近 20 日日均成交额下限(元),流动性过滤 |
| `require_trading_on_asof` | `true` | 要求当日正常交易(非停牌) |
### risk — 风险过滤
| 字段 | 默认 | 含义 |
|---|---:|---|
| `exclude_st` | `true` | 排除 ST/\*ST(按**名称历史**还原当时状态) |
| `exclude_delisting` | `true` | 排除退市整理/已退市 |
| `exclude_suspended` | `true` | 排除当日停牌 |
| `exclude_negative_equity` | `true` | 排除净资产为负 |
| `max_debt_to_assets` | `0.80` | 资产负债率上限(金融豁免) |
| `max_pledge_ratio` | `null` | 质押比例上限(**无数据源,暂不可用**) |
| `exclude_major_litigation` | `false` | 重大诉讼(**无数据源**) |
| `exclude_goodwill_anomaly` | `false` | 商誉/净资产 > 50% 时排除 |
### dividend — 分红过滤
| 字段 | 默认 | 含义 |
|---|---:|---|
| `yield_source` | `computed` | 股息率口径:`computed`=自算 PIT-TTM / `dv_ttm`=用 Tushare |
| `min_dividend_yield` | `0.03` | 股息率下限(3%) |
| `min_continuous_years` | `5` | 连续分红年数下限 |
| `window_years` | `6` | 考察窗口(年) |
| `min_dividend_years_in_window` | `5` | 窗口内最少分红年数 |
| `max_payout_ratio` | `1.00` | 支付率上限(>100% 说明分红超过当年利润) |
| `min_payout_ratio` | `null` | 支付率下限 |
| `min_dps_cagr_5y` | `null` | 每股分红 5 年复合增速下限 |
| `require_positive_fcf` | `true` | 要求自由现金流为正 |
| `min_fcf_dividend_cover` | `1.0` | FCF / 现金分红 下限(<1 说明分红靠外部融资) |
| `on_missing_data` | `pass` | 数据缺失时:`pass`=放行(宽松)/ `reject`=淘汰(严格) |
> **`on_missing_data` 怎么选**
> - `pass`:避免因数据未同步而误杀,但会让「无法验证现金流」的股票进入组合,
> 与「安全边际」的初衷相悖
> - `reject`:忠于策略逻辑,但结果会依赖数据完整度
> - 实测:当前全部在市股票均有现金流数据,两种设置结果相同
### quality — 财务质量过滤
| 字段 | 默认 | 含义 |
|---|---:|---|
| `min_roe_5y_avg` | `0.08` | **年报口径** 5 年平均 ROE 下限 |
| `min_roic_5y_avg` | `null` | 5 年平均 ROIC 下限 |
| `min_gross_margin` / `min_net_margin` | `null` | 毛利率 / 净利率下限 |
| `min_ocf_to_profit` | `0.60` | 经营现金流/净利润 下限(盈利质量,金融豁免) |
| `max_debt_to_assets` | `null` | 覆盖 `risk` 段设置;`null`=不覆盖 |
| `pit_rule` | `announce_date_le_asof` | PIT 纪律(不可改) |
> **为什么用年报而不是最新季报**:Tushare 的 ROE 是**年初至今累计值**,
> 一季报 ROE 只有全年的约 1/4。若拿季报 ROE 比「8% 的年均 ROE」,
> 会把几乎所有好公司误杀(实测招商银行 2024Q1 ROE 仅 3.47%)。
### output
| 字段 | 默认 | 含义 |
|---|---:|---|
| `min_members` | `10` | 少于该数触发 WARN |
| `max_members` | `200` | 股票池上限(按股息率降序截断) |
| `warn_on_empty` | `true` | 空池告警 |
---
## 4.2 `config/profile.yml` — 个股特性 ★
| 字段 | 默认 | 含义 |
|---|---:|---|
| `windows_years` | `[5, 8, 10]` | 统计窗口(年),另含「全历史」 |
| `min_obs_days` | `500` | 单窗口最少观测数,不足标记 `INSUFFICIENT` |
| `series_max_points` | `1500` | 落库序列点数上限(等间隔降采样,仅影响画图) |
| `series_metrics` | `[dv_yield, pe_ttm, pb, close, drawdown]` | 哪些指标需要落时间序列 |
| `ttm_dividend.window_days` | `365` | TTM 每股分红回看天数 |
| `ttm_dividend.grace_days` | `45` | 宽限期/容差(见下方说明) |
| `ttm_dividend.smooth_spikes` | `true` | 消除除权间隔不规整造成的毛刺 |
| `percentiles` | `[10,25,50,75,90]` | 需计算的分位数 |
| `dividend_yield_volatility.*` | 250/60/20/10 | 日/月/季/年频波动窗口 |
| `safety_margin.mode` | `separate` | `separate`=分项展示 / `composite`=加权综合 |
| `safety_margin.weights` | 见文件 | 综合分权重(`composite` 模式下必须和为 1.0) |
| `safety_margin.score_anchors` | 见文件 | 各分项的满分/零分锚点 |
> **`grace_days` 的作用**:A 股相邻两次除权间隔经常 ≠ 365 天,
> 硬窗口会在每年除权日附近制造两种日历假象 ——
> **重叠虚高**(间隔 < 365,新旧同时在窗口内,实测招商银行 +108%)
> 与**断档虚低**(间隔 > 365,旧的已到期新的未入场,实测中国神华 −57%)。
>
> `smooth_spikes: true` 时按「同一档年度分红由后继接管」处理:
> 间隔落在 `365 ± grace_days` 内即视为同一次分红的正常漂移,
> 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红
> (中期+年度),彼此都保留;超过 `365 + grace_days` 仍无后继则如实归零。
>
> 实测效果:招商银行 >20% 跳变 21 → 5 次,中国银行虚低归零 77 天 → 0 天。
> 若个别股票仍有断档,把 `grace_days` 调大(如 90)。
---
## 4.3 `config/strategy/high_dividend_v1.yml` — 策略定义 ★
```yaml
strategy:
id: HD_MR_V1
version: "1.0"
status: DRAFT # DRAFT/RESEARCH/BACKTEST/VALIDATED/PAPER/LIVE/ARCHIVED
```
### 阈值与阶梯
| 字段 | 默认 | 含义 |
|---|---:|---|
| `entry.yield_percentile` | `75` | 买入阈值:股息率 ≥ 历史 P75 |
| `entry.scale_in[]` | 75→25%, 80→50%, 85→75%, 90→100% | 分批建仓阶梯 |
| `entry.profile_gate` | 启用,4 条规则 | **实时画像闸门**(见下节) |
| `exit.yield_percentile` | `25` | 卖出阈值:股息率 ≤ 历史 P25 |
| `exit.scale_out[]` | 50→50%, 25→0% | 分批减仓阶梯 |
| `position.max_position` | `0.10` | 单股仓位上限 |
| `position.sector_max_position` | `0.25` | 单行业仓位上限(**尚未实现**,见 §11) |
| `position.max_holdings` | `20` | 最多持仓只数(**尚未实现**,见 §11) |
### 实时画像闸门 `entry.profile_gate`(新增)
**它解决的问题**:股票池每 12 个月才重建一次,期间持有的候选可能早已「不值得买」。
闸门的语义是 —— **股息率分位触发买入之后,再用「当日可见的数据」重算一次个股画像,
不通过的票直接剔除。**
```yaml
entry:
profile_gate:
enabled: true # false 时整条链路不参与,回测行为与启用前完全一致
window_years: 5 # 画像统计窗口;0 = 全历史;其余必须是 profile.yml 的 windows_years 之一
on_unverifiable: reject # 数据缺失/样本不足时:reject = 保守不买(默认)/ pass = 放行
rules:
- { metric: dividend_continuity_years, op: ">=", value: 5 }
- { metric: payout_ratio, op: "<=", value: 1.0 }
- { metric: fcf_dividend_cover, op: ">=", value: 1.0 }
- { metric: roe_avg, op: ">=", value: 0.08 }
```
| 字段 | 含义 |
|---|---|
| `metric` | 画像指标代码,只能是 `src/hdiv/core/metrics.py` 里声明的那批(写错在**配置期**就报错) |
| `stat` | `current_value`(当日值)/ `current_percentile`(当日值在窗口分布中的分位,仅序列型指标可用) |
| `op` | `>=` / `<=` / `>` / `<` |
| `value` | 阈值 |
三条关键性质:
1. **无未来函数**:每个决策日只用「当时可见」的价格、每日指标、分红(`imp_ann_date`
与 `ex_date` 双重约束)与财报(`ann_date <= asof`)。有负例测试守护
(公告日前一天不得看到该年报;除权日前一天不得包含该笔分红)。
2. **与批量画像同一定义**:闸门用的指标与 `hdiv profile` 页面上的指标**逐值一致**
(有等价性测试),不会出现「页面一个数、回测另一个数」。
3. **惰性与复用**:只在买入条件已触发时才计算;跨股票共享的面板按时点缓存,
长周期指标从同一份已载入面板里切窗口。因此成本与**触发次数**成正比,
而不是与「区间长度 × 股票数」成正比;规则里不含财务指标时完全不查财报。
**被剔除的信号怎么看**:信号类型为 `REJECT`,`skip_reason = PROFILE_GATE`,
会出现在「回测 → 未成交信号」列表里,标签为
**「实时画像未通过,主动放弃买入」**;`reason_json.profile_gate.checks`
逐条记录每个指标的**实际值、阈值、状态、是否通过**,可直接回答「为什么没买」。
**`on_unverifiable` 怎么选**:这是本项目的一贯取舍(同 `universe.dividend.on_missing_data`)。
| 取值 | 行为 | 适用 |
|---|---|---|
| `reject`(默认) | 数据缺失或样本不足 → 不买 | 忠于「安全边际」:宁可错过,不可踩雷 |
| `pass` | 无法验证 → 放行 | 与股票池筛选口径一致;结果更依赖数据完整度 |
> 实测差异很大:2016 年初,中石化的**最新可见分红属于 FY2015,而 FY2015 年报尚未公告**
> (约 3 月才披露),因此「支付率」在当时**根本无法验证**。
> `reject` 会放弃买入,`pass` 会照买 —— 这不是 bug,而是策略取舍得由你决定。
### 窗口覆盖率 `min_window_coverage`(新增,重要)
**「名义 5 年」不等于「真有 5 年数据」。** `window_slice(asof, 5)` 的语义是
「把已有数据切成最近 5 年」,数据起点晚于窗口左端时窗口会被**静默截短**。
本项目行情/每日指标自 **2015-01-05** 才有,所以实测(600036.SH,股息率,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%** |
也就是说:**2019 年及之前的「过去 5 年画像」实际只有 0.2~4 年数据**,
而画像的 `status` 仍报 `OK`(它的门槛低到 20 个观测)。
```yaml
entry:
profile_gate:
window_years: 5
min_window_coverage: 0.0 # 0 = 不因覆盖率淘汰(默认);1.0 = 必须完整覆盖
```
| 取值 | 行为 |
|---|---|
| `0.0`(默认) | 覆盖率只**记录**在信号的 `reason_json.profile_gate.checks[].window_coverage`,不影响判定 —— 保持改造前行为 |
| `1.0` | 名义 5 年必须真有 5 年数据;不足时按 `on_unverifiable` 处理(默认 → 不买) |
| 中间值 | 例如 `0.8` = 允许最多缺 20% |
**建议**:先把数据回补到位(见 §6.6),再考虑启用 `min_window_coverage: 1.0`;
否则 2019 年之前会几乎没有买入信号(那是**数据不足**,不是策略判断)。
> `hdiv profile` 命令也会打印覆盖率,例如:
> `⚠ 5 年窗口数据不足:最低覆盖率仅 67.0%(窗口会被数据起点截短)`
### 目标仓位阶梯(重要)
`scale_in` 与 `scale_out` 会**合成唯一的目标仓位函数**:
| 股息率历史分位 | 目标仓位(占 `max_position`) |
|---|---|
| ≥ 90 | 100% |
| ≥ 85 | 75% |
| ≥ 80 | 50% |
| ≥ 75 | 25% |
| **(50, 75)** | **死区 —— 保持现有仓位,不交易** |
| ≤ 50 | 50% |
| ≤ 25 | 0%(清仓) |
> **死区的作用**:抑制因分位抖动造成的频繁交易。
> 早期实现让两套阶梯各自判定,导致「持仓时因高分位被减仓」的逻辑冲突,
> 表现为年换手 8.9、平均持仓仅 30 天;加入死区后为年换手 0.91、持仓 345 天。
>
> **校验规则**:`scale_in` 首档必须等于 `entry.yield_percentile`;
> `scale_out` 末档必须等于 `exit.yield_percentile` 且权重为 0。
### 生命周期状态
```
DRAFT → RESEARCH → BACKTEST → VALIDATED → PAPER → LIVE → ARCHIVED
```
只允许向前迁移(`ARCHIVED` 除外)。状态变更用 `hdiv strategy` 相关命令或直接改 YAML 后重新 `register`。
---
## 4.4 `config/cost.yml` — 交易成本
| 字段 | 默认 | 含义 |
|---|---:|---|
| `commission.rate` | `0.00025` | 佣金 万 2.5(双边) |
| `commission.min` | `5.0` | 单笔最低佣金(元) |
| `stamp_duty.rate` | `0.0005` | 印花税 万 5(**仅卖出**) |
| `transfer_fee.rate` | `0.00001` | 过户费 万 0.1(双边) |
| `slippage.mode` | `bps` | `bps` / `fixed`(元/股)/ `tick`(0.01 元倍数) |
| `slippage.value` | `10` | 10 bps;买入上滑、卖出下滑 |
| `dividend_tax.rates` | `{1m:0.20, 1y:0.10, gt1y:0.00}` | 按持股期限差异化红利税 |
> **`side` 字段**:`both` / `buy` / `sell`,大小写不敏感。
---
## 4.5 `config/backtest.yml` — 回测配置
### 区间与资金
```yaml
capital: { initial: 1000000 } # 元
period: { start: 2015-01-01, end: latest }
```
### 调度
| 字段 | 默认 | 含义 |
|---|---:|---|
| `schedule.signal_frequency_months` | `1` | 多久评估一次买卖信号 |
| `schedule.universe_refresh_months` | `12` | 多久重建一次股票池 |
> 股票池成员(大市值/长上市/连续分红)非常稳定,每年重建一次即可。
> 调小更严格但显著更慢。
### 分位参照口径
| 字段 | 默认 | 含义 |
|---|---:|---|
| `percentile_reference.mode` | `rolling` | `rolling`=滚动窗口(PIT 自适应)/ `frozen`=冻结窗口 |
| `percentile_reference.lookback_years` | `5` | rolling 模式回看年数 |
> **Walk-forward 测试段强制使用 `frozen`**,由代码传入冻结区间,不受此配置影响。
### Walk-forward
| 字段 | 默认 | 含义 |
|---|---:|---|
| `scheme` | `rolling` | `rolling`=固定长度训练窗 / `expanding`=递增 |
| `train_years` / `test_years` | `5` / `1` | 训练/测试年数 |
| `step_months` | `12` | 窗口步进 |
| `freeze_params_in_test` | `true` | **必须为 true**(配置层强制拒绝关闭) |
### 其他
| 字段 | 默认 | 含义 |
|---|---:|---|
| `benchmark[]` | 沪深300 / 中证红利 / 上证指数 | 基准列表 |
| `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) |
| `fill.price` | `next_open` | 信号次日开盘成交 |
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
| `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
| `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
---
## 4.6 `config/report.yml` — 报告
| 字段 | 默认 | 含义 |
|---|---:|---|
| `theme` | `light` | `light` / `dark` / `auto` |
| `asset_mode` | `shared` | `shared`=引用 `assets/echarts.min.js` / `inline`=内嵌 |
| `include_sql_provenance` | `true` | 页脚展示数据来源 SQL |
| `charts.*` | 全部 `true` | 各图表开关 |
| `naming.*` | 见文件 | 输出文件命名模板 |
| `layout.max_width` | `1440` | 页面最大宽度(px) |
| `layout.decimals.ratio` | `4` | **比率的小数位**。百分比小数位 = ratio − 2(ratio=4 → 股息率 6.17%;ratio=6 → 6.1715%)。同时作用于静态报告与 Web 前端 |
| `layout.decimals.money` | `2` | 金额小数位 |
| `layout.decimals.price` | `2` | 价格小数位 |
> **`asset_mode` 怎么选**:见 [§7.8 报告部署](#78-报告部署)。
---
## 4.7 `config/datasource.yml` — 数据源
```yaml
database:
host / port / db / user / charset # 连接信息(明文可提交)
password_env: MYSQL_PASSWORD # 密码只从 .env 读
read_only_tables: [...] # 只读白名单(拦截写操作)
allow_write_tables: [index_weight] # 例外的可写 qlib 表
backfill_tables: [stock_daily, adjust_factor, daily_basic]
allow_backfill: false # 回补总开关(或设 HDIV_ALLOW_BACKFILL=1)
own_prefix: hd_ # 本项目自有表前缀
forbidden_tables: [alembic_version] # 禁触表
forbid_delete: true # 禁止 DELETE/DROP/TRUNCATE
tushare:
rate_limit_default: 380 # 每分钟默认限频
rate_limits: { dividend: 180, ... } # 按接口覆盖(Tushare 限频是按接口算的)
rate_limit_cooldown_sec: 62 # 命中限频后的冷却秒数
```
> **一般情况下你不需要改这个文件**,除非换数据库或调整同步速度。
---
# 5. 命令行手册
统一入口:`python -m hdiv <命令> [选项]`(需 `export PYTHONPATH=src`)。
## 5.1 `ddl` — 建表与结构迁移
```bash
hdiv ddl plan # 只显示将要执行什么,不改库
hdiv ddl apply # 执行(幂等,可反复运行)
hdiv ddl verify # 校验库中表结构是否符合代码定义
```
- 只做 `CREATE TABLE IF NOT EXISTS` 与 `ALTER TABLE ... ADD COLUMN`
- **从不** `DROP` / `TRUNCATE` / `DELETE`
- 迁移带前置条件(如「仅当表为空」),非空表会跳过并提示
## 5.2 `sync` — 数据同步
```bash
hdiv sync dividend [--symbols ...] [--only-missing] [--limit N]
hdiv sync financial [--interleaved] [--only-missing] [--apis ...] [--limit N]
hdiv sync index [--no-weight] [--start YYYYMMDD]
hdiv sync price --which daily|adj_factor|daily_basic --start --end [--no-resume]
hdiv sync trading [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--no-resume]
hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
[--basic-start <同 --start>] [--basic-end 2019-12-31] [--no-resume]
```
| 目标 | 说明 | 首次耗时 |
|---|---|---:|
| `dividend` | 分红送转全明细(逐只股票) | ~35 分钟 |
| `financial` | 四张财务报表;**加 `--interleaved` 按股票交错拉取**(推荐) | ~3 小时 |
| `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
| `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 |
| `trading` | 停牌与涨跌停 | ~20 分钟 |
| `backfill` | 历史回补(写入 qlib 原有表,`INSERT IGNORE` **不覆盖既有行**) | 视区间 |
> **`--only-missing`**:跳过已同步的标的,支持断点续传
> **`--interleaved`**:按股票一次性拉齐四张报表,使策略相关的大市值股票优先就绪
> **回补需要显式授权**:`HDIV_ALLOW_BACKFILL=1` 或改 `datasource.yml`
> **`--basic-start` 必须显式给出**:`run_backfill` 的默认 `basic_start=2015-01-01`,
> 早期 CLI 没有这个参数,于是「回补 2010–2014」实际只补了行情与复权因子,
> **`daily_basic` 仍停在 2015** —— 画像里的 PE/PB/股息率照样拿不到早年数据。
> 现在 `--basic-start` 缺省时跟随 `--start`。
## 5.3 `audit` — 数据审计
```bash
hdiv audit [--no-persist] [--html]
```
执行 15 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、量价单位一致性、代码有效性),
结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。
退出码:总体 FAIL 时为 1。
> 与单位有关的两项:`UNIT`(`daily_basic` 的市值恒等式 + 量级)与
> `UNIT-OHLCV`(`stock_daily` 逐年抽样的量价单位一致性)。
> 后者当前会报 **WARN**:2015-2019 的存量行仍是 Tushare 原始单位,
> 读取层已兜底换算(结果正确),但存量数据本身仍应择机重刷。
> 详见 §9.1。
## 5.4 `universe` — 股票池筛选
```bash
hdiv universe [-c config/universe.yml] [--asof 2024-06-28 | latest] [--no-persist] [--html]
```
> `--no-persist` 不写数据库(因此前端看不到);`--html` 额外导出静态报告。
输出:
```
[market ] 候选 5363 → 淘汰 5191 → 存活 172
[risk ] 候选 172 → 淘汰 7 → 存活 165
[dividend] 候选 165 → 淘汰 133 → 存活 32
[quality ] 候选 32 → 淘汰 4 → 存活 28
股票池「high_dividend_universe」asof=2024-06-28:候选 5363 → 入选 28
股票池 <run_id> asof=2024-06-28 候选 5363 入选 28
```
**记下 `run_id`**,下一步要用。
## 5.5 `profile` — 个股画像
```bash
hdiv profile --universe-run <run_id> # 对股票池全部股票(asof 取该股票池的时点)
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票与**时点**
hdiv profile --symbols 600519.SH --asof 2018-05-18 # 任意历史时点的 PIT 画像
hdiv profile --universe-run <run_id> --html --html-limit 20 # 导出前 20 只的静态报告
```
> `--asof` 决定画像用**哪些数据**:只使用 `<= asof` 的行情、每日指标、分红与财报。
> 例如 `--asof 2018-05-18` 得到的就是「2018-05-18 当天能算出的画像」,
> 5 年窗口覆盖 (2013-05-18, 2018-05-18]。
> 不带 `--asof` 且带 `--universe-run` 时,asof 取该股票池的筛选时点。
>
> **注意**:画像是一次**快照**(每个 asof 一份)。回测并不读取它 ——
> 回测的交易依据是引擎在每个决策日**实时重算**的画像,见
> §4.3「实时画像闸门」与 §9.2。
## 5.6 `strategy` — 策略管理
```bash
hdiv strategy validate [-f config/strategy/high_dividend_v1.yml]
hdiv strategy register [-f ...] # 写入 hd_strategy / hd_strategy_param
hdiv strategy list # 列出已登记的所有版本
hdiv strategy diff -f A.yml --other B.yml # 比较两版差异
```
## 5.7 `backtest` — 回测与 Walk-forward
```bash
hdiv backtest [--start 2015-01-01] [--end 2026-09-30] [--no-persist] [--html]
hdiv backtest --mode walkforward # 7 个滚动窗口
hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建立关联
# 注意 1:--universe-run 与 --mode walkforward 不能同时使用(会报错说明原因)。
# 注意 2:单次回测里,若股票池的 asof 晚于回测起点,同样会被**拒绝**
# (同一条未来函数纪律)。错误信息会给出三种正确做法:
# a) 去掉 --universe-run,让引擎逐调仓日按当时可见数据重新筛选(推荐)
# b) 把 --start 改到股票池 asof 之后
# c) 确需复现带未来信息的历史结果:显式加 --allow-lookahead-universe
# —— 该偏差会写入 hd_backtest_run.unimplemented_json,可被检索出来
```
> **为什么单次回测也要拒绝**:股票池带 asof 时点。用 2025-01-21 选出的名单去跑
> 2015 年起的区间,名单里含有 2015 年不可能知道的信息(哪些公司此后仍满足
> 连续分红、5 年 ROE、自由现金流覆盖等条件)。实测:这样跑出的回测
> **在 2015-01-06 就有 8 笔成交**,而按当时可见数据筛选,那段时间的股票池
> 只有个位数只股票。
输出示例(当前配置:实时画像闸门启用):
```
回测 HD_MR_V1 v1.0:2015-01-05 ~ 2026-09-30(2846 个交易日)
实时画像闸门已启用:窗口 5 年,4 条规则,面板自 2004-01-01 起载入(86 只)
实时画像:计算 2428 次(缓存命中 0),涉及 141 个决策时点,
财报面板载入 141 次,流动性查询 0 次 | 画像剔除 1027 次
期初 1,000,000 → 期末 1,927,312
总收益 92.73% CAGR 5.75% 最大回撤 -25.38% Sharpe 0.22 Calmar 0.23 成交 197 笔
累计现金分红 …(已扣红利税 …) | 对账残差 -0.0000 ✓
```
> **「对账残差」是资金恒等式的校验值**(期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红)。
> 应为 0;若不为 0 说明成本或分红入账有遗漏,**此时不要采信绩效指标**。
>
> 「画像剔除 1027 次」= 有多少个买入信号被实时画像拦下。它们全部以
> `REJECT` 记录在库,可在前端「未成交信号」里逐条查看每条规则的实际值。
## 5.8 `sensitivity` — 参数敏感性
```bash
hdiv sensitivity # 默认扫描 entry.yield_percentile=70,75,80,85,90
hdiv sensitivity --sweep "entry.yield_percentile=70,75,80;exit.yield_percentile=20,25,30"
```
扫描路径支持点号导航,多参数用 `;` 分隔(笛卡尔积,上限 400 组合)。
> 扫描 `entry.yield_percentile` 时会**整体平移** `scale_in` 阶梯(放不下时按比例压缩),
> 保证阶梯形状不变 —— 否则扫出的差异来自形状畸变而非阈值本身。
## 5.9 `report` — 报告索引与校验
```bash
hdiv report index # 重建 output/index.html
hdiv report validate # 校验全部报告的离线合规性与 JS 语法
```
---
# 6. 典型工作流
## 6.1 首次建库(一次性)
```bash
export PYTHONPATH=src
.venv/bin/python -m hdiv ddl apply
.venv/bin/python -m hdiv ddl verify # 应输出「OK:30 张 hd_* 表结构全部符合 schema 定义」
```
## 6.2 数据同步(首次约 3~4 小时)
建议**后台运行**,日志重定向到 `logs/`:
```bash
export PYTHONPATH=src
# ① 分红(约 35 分钟)
nohup .venv/bin/python -m hdiv sync dividend --only-missing > logs/s_dividend.log 2>&1 &
# ② 财报:按股票交错(约 3 小时;与 ① 可并行,限频是按接口独立的)
nohup .venv/bin/python -m hdiv sync financial --interleaved --only-missing > logs/s_financial.log 2>&1 &
# ③ 指数(约 2 分钟)
.venv/bin/python -m hdiv sync index
# ④ 停牌与涨跌停(约 20 分钟)
nohup .venv/bin/python -m hdiv sync trading --start 2019-01-01 > logs/s_trading.log 2>&1 &
# ⑤ 2015–2018 历史行情回补(约 15 分钟)
HDIV_ALLOW_BACKFILL=1 nohup .venv/bin/python -m hdiv sync backfill > logs/s_backfill.log 2>&1 &
# 随时查看进度
tail -f logs/s_financial.log
```
**同步完成后自检**:
```bash
.venv/bin/python -m hdiv audit
# 期望:总体 WARN 或 OK,FAIL=0
```
## 6.3 日常数据更新
```bash
export PYTHONPATH=src
.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 price --which daily_basic --start 2026-09-01 --end 2026-10-02
.venv/bin/python -m hdiv audit
```
## 6.4 研究一个新策略
```bash
# ① 复制策略模板
cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
# 改 strategy.id / name / version,调整 entry/exit/position
# ② 校验 + 登记
.venv/bin/python -m hdiv strategy validate -f config/strategy/my_strategy.yml
.venv/bin/python -m hdiv strategy register -f config/strategy/my_strategy.yml
# ③ 回测
.venv/bin/python -m hdiv backtest -s config/strategy/my_strategy.yml
```
**注意**:换策略 ID 会得到新的策略版本,历史 run 互不干扰。
## 6.5 调参流程(推荐顺序)
```bash
# ① 先看单参数敏感性 —— 判断结论稳不稳
.venv/bin/python -m hdiv sensitivity --sweep "entry.yield_percentile=70,75,80,85,90"
# ② 再做 Walk-forward —— 看样本外是否成立
.venv/bin/python -m hdiv backtest --mode walkforward
# ③ 只有 ① 平滑 且 ② 样本外为正,才考虑把状态推到 VALIDATED
```
> ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §7.5)。
## 6.6 把「过去 5 年」补齐(数据回补)
> **✅ 本仓库当前的数据状态:已回补完成(2026-10-04)** ——
> `stock_daily`/`adjust_factor`/`daily_basic` 均覆盖到 **2005-01-04**,
> `hd_suspend`/`hd_limit` 覆盖到 **2010-01-04**。
> 5 年窗口覆盖率自 2015-01-05 起为 **98.7%~100%**,残差已逐日与
> `hd_suspend` 交叉核实为**真实停牌**(16/16 命中)。
> 所以下面这节现在是**方法说明与重做指引**,而不是待办事项。
> 完整记录见 [implementation-status §9.3c](implementation-status.md)。
### 先看缺口:哪些数据支持 5 年回看
| 数据 | 回补前起点 | 当前 |
|---|---|---|
| `stock_daily` 行情 | 2015-01-05 | **2005-01-04** ✅ |
| `daily_basic` PE/PB/股息率 | 2015-01-05 | **2005-01-04** ✅ |
| `adjust_factor` 复权因子 | 2015-01-05 | **2005-01-04** ✅ |
| `hd_suspend` / `hd_limit` | 2019-01-02 | **2010-01-04** ✅ |
| `hd_dividend` 分红 | 1991 | 不变 ✅ |
| `hd_fina_indicator` / `hd_income` / `hd_cashflow` | 1990 / 1990 / 2001 | 不变 ✅ |
| `hd_index_daily` 基准 | 1990 | 不变 ✅ |
| `stock_name_history` ST 历史 | 1990 | 不变 ✅ |
| `trading_calendar` 交易日历 | 2000 | 不变 ✅ |
**结论:卡住「5 年画像」的只有行情与每日指标,缺口就在 2015-01-05 之前。**
### 回补到哪一年,取决于你要覆盖多早的决策日
「asof 的 5 年窗口完整」要求数据起点 ≤ `asof − 5 年`:
| 想覆盖的决策日 | 必须回补区间 | 新增交易日(约) |
|---|---|---:|
| 2015-01-05 起 | **2010-01-01 ~ 2014-12-31** | 1,212 |
| 2018-01-01 起 | 2013-01-01 ~ 2014-12-31 | 486 |
| 同时补齐 8/10 年窗口(`profile.yml: windows_years`) | **2005-01-01 ~ 2014-12-31** | 2,430 |
### 执行
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
export HDIV_ALLOW_BACKFILL=1 # 回补写入 qlib 原有表需显式授权
# ① 行情 + 复权因子 + 每日指标(三者一起补,缺一个画像就残)
.venv/bin/python -m hdiv sync backfill \
--start 2010-01-01 --end 2014-12-31 \
--basic-start 2010-01-01 --basic-end 2014-12-31
# ② 若还要成交约束(涨跌停/停牌)覆盖到早年
.venv/bin/python -m hdiv sync trading --start 2010-01-01 --end 2014-12-31
# ③ 核对:只增不减,且量价单位一致
.venv/bin/python -m hdiv audit
```
**必须知道的四件事**:
1. **只追加、不改既有行**(`INSERT IGNORE`)。因此 2015-2019 已存在的行不会被
修成正确单位 —— 那由读取层兜底(见 §9.1)。回补的新行走的是**正确单位**写入路径。
2. **耗时与点数**:`daily`/`adj_factor`/`daily_basic` 的限频是 480 次/分钟,
但瓶颈是 HTTP 往返(每日 3 次调用)。1,212 个交易日 ≈ 3,600 次调用,
实测量级 **1~3 小时**;补到 2005 年约翻倍。先用 `--limit` 或在测试库试跑。
3. **Tushare 权限**:早年数据需要相应积分。若某日返回空,`sync_days` 会记录在
`hd_sync_log`,不会中断整体任务。
4. **回补后必须重跑**:股票池、画像、回测、Walk-forward 的结论都会变
(2015-2019 从「几乎无候选」变成有完整 5 年画像的候选)。
### 回补后怎么确认「5 年真的齐了」
```bash
# 画像命令会打印覆盖率(不足时给警告)
.venv/bin/python -m hdiv profile --symbols 600036.SH --asof 2015-06-30
# ⚠ 5 年窗口数据不足:最低覆盖率仅 xx%(窗口会被数据起点截短)
# 或者在 SQL 里直接量:某股在 (asof-5y, asof] 内的行情观测数
```
也可以把策略的 `entry.profile_gate.min_window_coverage` 设为 `1.0`,
让回测**只**在 5 年窗口完整时才允许买入 —— 不完整的决策日会产出
`REJECT`(`skip_reason=PROFILE_GATE`),理由写明 `window_coverage=67%`。
---
# 7. 报告解读
所有报告从 `output/index.html` 进入。索引按类型分组,每组首份标注「最新」。
## 7.1 数据审计报告
**看什么**:`FAIL` 数量必须为 0,否则后续结论不可信。
| 检查码 | 关注点 |
|---|---|
| G1 分红 | 覆盖股票数、可用分红记录数 |
| G2/G2b/G3 行情 | 起始日期是否够早(回测窗口依赖) |
| G4 财务 | 4 张表覆盖与公告日齐全率 |
| G5 基准 | `backtest.yml` 声明的基准是否齐全 |
| G6 停牌/涨跌停 | 成交约束是否有数据支撑 |
| **UNIT 单位自检** | **恒等式 + 绝对量级双判据**(见 §9.1) |
| PIT | 是否存在未来函数迹象 |
| DUP / ORPHAN | 唯一性、代码有效性 |
## 7.2 股票池报告
**看什么**:
1. **漏斗图** —— 哪一层淘汰最多(通常是 `market` 的市值门槛)
2. **入选股票表** —— 按股息率降序,确认没有明显不合理的标的
3. **行业分布** —— 判断是否过度集中(银行/煤炭/电力常见)
4. **被淘汰股票与原因表** —— 可回答「为什么某某没选上」
> 若入选数为 0 或很少,先确认分红/财报数据是否同步完成,再看是否条件过严。
## 7.3 个股画像报告
**看什么**:
1. **K线 · 股息率 · PE · PB · 回撤 四联图** —— 共享 X 轴;股息率面板上有
P75(买入)与 P25(卖出)虚线
2. **当前历史分位** —— 顶部 KPI。≥ 75% 触发买入信号,≤ 25% 触发卖出
3. **历史分布统计表** —— 每个指标的 Min/P10/P25/P50/P75/P90/Max
4. **分红质量** —— 连续分红年数、支付率、FCF 覆盖、DPS 增速
5. **安全边际雷达图** —— 五个分项得分 + 综合分
> 「数据不足」会显式标注,**不会用 0 或推测值填充**。
## 7.4 回测报告
**看什么**:
1. **资金对账横幅** —— 必须是「通过」
2. **净值曲线 vs 基准** —— 相对表现比绝对收益更重要
3. **回撤曲线** —— 高股息策略的价值主要体现在这里
4. **基准比较表** —— 与中证红利对比最有意义(同类策略)
5. **成交流水** —— 每笔都带 `reason`(触发时的股息率、历史分位、所用规则)
6. **未成交信号** —— 区分「想买」与「买到了」,判断约束是否实质影响绩效
7. **未建模部分** —— 引擎如实声明哪些约束没生效
> 若顶部出现 **「这是一份历史运行报告,已被更新的运行取代」** 的红色横幅,
> 说明该 run 早于某次修复,**请以标注「最新」的报告为准**。
## 7.5 Walk-forward 报告 ★ 最重要
> **前端入口**:`#/walkforwards`(导航栏「样本外」)。
> 每个 wf_id 是一条独立记录,点进去可看逐窗口的样本内/样本外对比与冻结阈值。
> 该页面同时给出**样本外均值、胜率、稳定性**三个核心判据。
**这是判断策略是否真的有效的核心依据。**
**看什么**:
1. **样本外胜率** —— 7 个窗口里几个为正
2. **样本外收益均值 vs 基准收益均值** → **超额收益**
3. **稳定性指标**(样本外均值 / 标准差)—— < 1 说明窗口间差异大于均值本身
4. **逐窗口明细表** —— 每个测试年的表现
5. **冻结阈值图** —— 各窗口校准出的绝对股息率阈值是否稳定
**判读标准**:
| 现象 | 含义 |
|---|---|
| 样本外超额 > 0 且稳定性 > 1 | 策略可能真的有效 |
| 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 |
| 样本外超额 < 0 | 看是否集中在牛市(见下) |
| 全期回测远好于样本外均值 | **单路径回测高估了策略** |
> **务必用年化口径比较样本内/外**:训练段 5 年、测试段 1 年,
> 直接比累计收益会得出「样本内远高于样本外」的错误印象(本项目实测踩过这个坑,
> 详见 implementation-status.md §7.3)。页面上统一使用年化。
> **务必按牛熊分开看超额**。高股息/低估值策略的典型形态是
> **牛市跑输、熊市跑赢**(用上涨弹性换取下跌保护)。
> 此时只看「超额均值」会被样本里牛熊比例误导 ——
> 本项目实测:4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。
> 判断这类策略要问的是「我用上涨弹性换下跌保护,值不值」,
> 而不是「它有没有 alpha」。
> **本系统的实测结论**:全期回测 +92.73%,而 7 窗口样本外收益均值 **+1.16%**
> (基准 +2.29%,超额 **−1.13pp**)。即**当前策略没有稳定的样本外超额收益**。
> 它的价值在于**回撤控制**(样本外最差 −22.97%,基准同期 −46%~−52%),
> 更像降低波动的配置工具,而非超额收益来源。
>
> 这个反差不是 bug,正是 Walk-forward 存在的意义 —— 详见
> [implementation-status.md §4.6](implementation-status.md)。
>
> 另外注意:实时画像闸门在两个口径下结论相反 ——
> **全期单路径**收益从 +101.20% 降到 +92.73%(更差),
> 但 **walk-forward 样本外**均值从 −0.00% 升到 +1.16%、最差回撤从 −24.22%
> 改善到 −22.97%(更好)。按本项目一贯立场以样本外为准,闸门是改善。
> 详见 §4.6c。
## 7.6 参数敏感性报告
**看什么**:
1. **平滑度**(0~1,越接近 1 越平滑)—— ≥ 0.6 且无尖峰视为稳健
2. **尖峰检测** —— 某点的 CAGR 显著高于左右邻居
3. **CAGR 随参数的变化曲线** —— 平缓下降可接受,锯齿状或孤立高点不可信
**判读标准**(`plan.md §27`):
| 参数扫描结果 | 结论 |
|---|---|
| P75→13%, P80→13.5%, P85→13.2% | 对参数**不敏感**,相对可信 |
| P79→18%, P80→20%, P81→8% | 存在**尖峰**,很可能过拟合 |
> 敏感性结论依赖**扫描区间长度与股票池规模**。若区间仅数年、池内仅数十只,
> 即使平滑度低也可能只是样本噪声。应与 Walk-forward 结果共同判断。
## 7.7 静态报告与统一前端的关系
**统一前端(SPA)是主界面**,日常浏览、命名、归档、下钻都在那里。
`output/reports/` 下的静态 HTML 报告已降级为**导出件**:
| | 统一前端(SPA) | 静态报告(`reports/`) |
|---|---|---|
| 默认生成 | 始终可用 | **否**,需 `--html` |
| 数据来源 | 实时读数据库 | 生成时刻的快照 |
| 依赖 | 需后端进程 | **零依赖**,单文件可离线打开 |
| 适合场景 | 日常浏览、管理记录 | 打印、分享、长期存档 |
**为什么不默认生成**:改造前静态 HTML 是唯一界面,默认生成合理;现在 SPA 已提供
同样内容,默认再产出一份纯属冗余,且旧命名(按 asof)会让同一天多次运行互相覆盖。
**导出命令**:
```bash
hdiv universe --asof 2025-01-21 --html # 导出股票池报告
hdiv profile --universe-run <run_id> --html # 导出画像报告
hdiv backtest --html # 导出回测报告
hdiv audit --html
hdiv sensitivity --html
```
**文件名带执行 id**,所以同一天跑多次不会覆盖:
```text
output/reports/universe_2025-01-21_af8a2c80736a992237c6f949aff0ce74.html
└────────── run_id ──────────┘
```
> `--no-html` 仍然存在但**已是空操作**(默认就不生成),保留是为了让历史命令与脚本
> 不报错。
## 7.8 报告部署
报告通过**相对路径**引用图表库:
```html
<script src="assets/echarts.min.js"></script>
```
因此发布时**必须整体部署 `output/` 目录**,包括 `output/assets/`。
例如映射到 `/ggx/`,则两个 URL 都必须可达:
```text
http://<host>:8080/ggx/index.html ← 报告
http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1MB)
```
**若第二个 404**:页面能打开但所有图表空白。两种解决办法:
| 办法 | 操作 | 代价 |
|---|---|---|
| 补部署 assets | 把 `output/assets/` 一起同步 | 无 |
| 自包含模式 | `config/report.yml` 设 `asset_mode: inline`,重新生成 | 体积 10MB → 79MB |
**部署前自检**:
```bash
.venv/bin/python -m hdiv report validate
# 会真实执行 node --check 校验每份报告的内联 JS
```
---
# 8. 数据库
## 8.1 表清单(30 张,全部 `hd_` 前缀)
### 数据同步层
| 表 | 用途 |
|---|---|
| `hd_dividend` | 分红送转全明细(PIT 基座) |
| `hd_fina_indicator` | 扩展财务指标(ROE/ROIC/负债率…) |
| `hd_cashflow` / `hd_balancesheet` / `hd_income` | 三张财务报表 |
| `hd_index_daily` | 基准指数行情 |
| `hd_suspend` / `hd_limit` | 停牌 / 涨跌停 |
| `hd_sync_log` | 同步台账(含回补前后行数基线) |
| `hd_data_audit` | 审计结果 |
### 研究层
| 表 | 用途 |
|---|---|
| `hd_universe_run` / `hd_universe_member` | 股票池运行头 / 成员与逐滤网留痕 |
| `hd_factor_snapshot` | 决策时点因子值 |
| `hd_profile_run` / `hd_profile_stat` / `hd_profile_series` / `hd_profile_score` | 画像头 / 分布统计 / 时间序列 / 安全边际得分 |
| `hd_strategy` / `hd_strategy_param` | 策略版本 / 参数扁平表 |
### 回测层
| 表 | 用途 |
|---|---|
| `hd_backtest_run` | 运行头(可复现四元组 + 资金明细) |
| `hd_backtest_equity` / `hd_backtest_position` | 逐日净值 / 逐日持仓 |
| `hd_backtest_trade` / `hd_backtest_signal` | 成交(含 reason) / 信号(含未成交原因) |
| `hd_backtest_metric` | 绩效指标长表(`scope` 区分样本内/外/年度) |
| `hd_walkforward_run` / `hd_walkforward_window` | Walk-forward 头 / 各窗口与冻结参数 |
| `hd_sensitivity_run` / `hd_sensitivity_point` | 敏感性头 / 各参数组合绩效 |
| `hd_report` | 报告元数据(含 SQL 溯源) |
## 8.2 常用查询
```sql
-- 最新股票池成员
SELECT m.symbol, m.name, m.industry
FROM hd_universe_member m
WHERE m.run_id = (SELECT run_id FROM hd_universe_run ORDER BY created_at DESC LIMIT 1)
AND m.passed = 1;
-- 某只股票的股息率历史分位
SELECT metric_code, window_years, n_obs, p10, p25, p50, p75, p90,
current_value, current_percentile
FROM hd_profile_stat
WHERE run_id = (SELECT run_id FROM hd_profile_run ORDER BY created_at DESC LIMIT 1)
AND symbol = '600036.SH' AND metric_code = 'dv_yield';
-- 某次回测的绩效指标
SELECT category, metric_code, metric_value
FROM hd_backtest_metric
WHERE run_id = '<run_id>' AND scope = 'all'
ORDER BY category, metric_code;
-- 某次回测的成交明细(含买入理由)
SELECT execution_date, symbol, side, price, quantity, total_cost, reason_json
FROM hd_backtest_trade WHERE run_id = '<run_id>'
ORDER BY execution_date;
-- 为什么某只股票没被选上
SELECT fail_stage, fail_reason, values_json
FROM hd_universe_member
WHERE run_id = '<run_id>' AND symbol = '000002.SZ';
-- 各窗口的样本外表现
SELECT w.window_index, w.test_start, w.test_end, w.frozen_params_json,
m.metric_value AS test_return
FROM hd_walkforward_window w
JOIN hd_backtest_metric m ON m.run_id = w.test_run_id
WHERE w.wf_id = '<wf_id>' AND m.metric_code = 'total_return' AND m.scope = 'all';
-- 资金对账核对
SELECT initial_capital, total_dividend_net, total_dividend_tax, total_fees, cash_final
FROM hd_backtest_run WHERE run_id = '<run_id>';
```
## 8.3 数据安全约束
| 约束 | 实现 |
|---|---|
| 禁止删除 | SQL 钩子拦截 `DELETE`/`DROP`/`TRUNCATE`;源码扫描测试 |
| 只写自有表 | 只允许写 `hd_` 前缀表(例外见 `allow_write_tables`) |
| qlib 表只读 | `read_only_tables` 白名单,写操作直接抛异常 |
| 回补可审计 | `INSERT IGNORE` 保证既有行零改动;`hd_sync_log` 记录前后行数 |
| 不碰 Alembic | `alembic_version` 在禁触列表 |
---
# 9. 关键口径
> 这一节解释「数字是怎么算出来的」。**口径错了不会报错,只会给出错误结论**,
> 因此系统开发过程中每个口径都有回归测试守护。
## 9.1 单位(最容易出错的地方)
Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/units.py` **统一归一化**:
| 字段 | Tushare 单位 | 系统内统一为 |
|---|---|---|
| `total_mv` / `circ_mv` | 万元 | **元**(×1e4) |
| `total_share` / `float_share` | 万股 | **股**(×1e4) |
| `dv_ratio` / `dv_ttm` / `turnover_rate` | 百分数(5.08) | **小数**(0.0508) |
| `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** |
| 财务报表金额 | 元 | 元(不变) |
| `dividend.base_share` | 万股 | **股** |
| `stock_daily.vol` | 手 | **股**(×100) |
| `stock_daily.amount` | 千元 | **元**(×1000) |
**自检手段**(审计中的 `UNIT` 与 `UNIT-OHLCV` 检查):
1. **恒等式** `总市值 ≈ 收盘价 × 总股本` —— 能发现「只换算了一个字段」
2. **绝对量级** 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段
3. **量价一致性**(`UNIT-OHLCV`):`成交额 / (成交量 × 收盘价)` 应 ≈ 1。
≈ 0.1 说明该行还是 Tushare 原始口径(手 / 千元)。审计会逐年抽样并列出
> 为什么必须前两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**,
> 单靠它无法发现「万元当元用」。实测该错误曾导致市值过滤选中 **0 只**股票。
> **`stock_daily` 的量价单位曾经不一致(已修复)**:该表是「追加进既有 qlib 库」的,
> 2015-01~2019 的行由本项目从 Tushare 回补,写的是**原始单位**(手 / 千元);
> 2020 起沿用 qlib 存量(股 / 元);2019 年**同日混着两种**。
> 而 `min_avg_amount_20d: 20000000` 是按「元」写的 ——
> 于是 2015-2019 的 20 日均额被低估 1000 倍,流动性门槛实际变成
> 「日均成交额 ≥ 200 亿元」,**把 2015-2019 的股票池整体清空**
> (实测 2016/2017/2018 各筛选出 0 只)。
>
> 修复方式有两层:写入端(`sync.price.daily_frame`)统一换算;
> 读取端(`units.normalize_ohlcv_units`,按行判定、**幂等**)兜住存量数据。
> 修好后 2016-02 的股票池是 7 只、2017 是 11 只、2018 是 13 只。
> 审计新增 `UNIT-OHLCV` 防止回归。
## 9.2 Point-in-Time(无未来函数)
| 数据 | 可见性规则 |
|---|---|
| 财务 | `announce_date <= 评估日`,且只取**最新已公告**的那一期 |
| 分红 | `imp_ann_date <= 评估日` **且** `ex_date <= 评估日` |
| 行情/指标 | `trade_date <= 评估日` |
| ST 状态 | 按 `stock_name_history` 的名称生效区间还原,**不看今天的名字** |
| 股票池 | 包含**此后才退市**的股票(消除生存者偏差) |
| **实时画像** | 每个决策日按上述规则重算;窗口只覆盖 `(评估日 − N 年, 评估日]` |
| **窗口覆盖率** | `n_obs / 该窗口应有交易日数`;< 1 说明窗口被数据起点截短(见 §4.3、§6.6) |
| **股票池 vs 回测区间** | 股票池的 `asof` 晚于回测起点即**拒绝执行**(可显式放行并留痕) |
另外:
- **成交在信号次日开盘**,信号日只产生信号
- 滚动分位窗口的**右端必须是评估日本身**
- 画像的窗口切片是 `(起点, asof]`(左开右闭),与分位参照窗口一致
- Walk-forward 测试段使用**训练段冻结**的分布
(但实时画像闸门**不需要冻结** —— 它只用当时可见数据做过滤,
不带任何用测试期数据拟合出来的参数)
**三类未来函数,系统的处理方式不同**:
| 类型 | 处理 |
|---|---|
| 用未来数据算**当日因子** | 代码层杜绝(`Repo` 是唯一取数出口,有负例测试) |
| 用未来时点选出的**股票池**跑更早区间 | **拒绝执行**(`--universe-run` 的 asof 校验) |
| 用未来数据给人看的**研究快照**(画像/筛选页) | 允许,但**不进入回测**;回测每天自己重算 |
## 9.3 分红口径
| 项 | 口径 |
|---|---|
| 认定一次分红 | `div_proc = '实施'` **且** 有 `ex_date` 或 `pay_date` |
| 分红金额 | **税前**现金分红 `cash_div_tax` |
| 分红年度归属 | 按**报告期年份** `end_date.year`(即「哪个财年的利润分了红」) |
| 连续分红年数 | 从「最近一个**年报已公告**的财年」向前逐年数 |
| **一年宽限期** | 最近可见分红财年不早于目标年 −1 时,从该年起算 |
| 支付率 | **同财年**:该财年总现金分红 ÷ 该财年归母净利润 |
| 总现金分红 | Σ(每股税前分红 × `base_share`) |
| FCF 覆盖 | **同财年**自由现金流 ÷ 该财年总现金分红 |
> **为什么需要一年宽限期**:最近一个财年的年报虽已公告,但其分红方案往往
> 要等次年 6–7 月才除权。实测中国神华 FY2023 的分红在 2024-07 才除权,
> 若在 asof=2024-06-28 严格比对,会被误判为「连续分红 0 年」。
>
> **为什么要同财年**:曾用「FY2023 分红 ÷ 2024Q1 净利润」算出美的集团
> 230.9% 的支付率(真实 61.6%)。
## 9.4 股息率口径
```text
股息率(t) = TTM 每股分红(t) / 不复权收盘价(t)
```
- **TTM 每股分红** = 过去 365 天内**已除权**的税前现金分红之和
- **宽限期 45 天**:严格窗口结果为零时回退到 410 天窗口
- **必须用不复权价**:分子是每股现金、分母是每股价格,量纲才一致
- **历史分位** = 当前股息率在自身历史分布中的位置(`≤ 当前值的观测占比`)
## 9.5 回测成交与成本
| 项 | 口径 |
|---|---|
| 成交价 | 信号**次日开盘价** ± 滑点 |
| 滑点 | 买入上滑、卖出下滑(方向反了会凭空产生收益) |
| 佣金 | `max(成交额 × 费率, 最低佣金)` |
| 印花税 | **仅卖出**收取 |
| 过户费 | 双边 |
| 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 |
| 整手 | 买入按 100 股取整 |
| 涨跌停 | 开盘即封板则该信号**当日跳过**,记录 `skip_reason`(`defer` 未实现) |
| 停牌 | **当日跳过**(⚠️ `fill.suspended_rule: defer` **未实现**,不会顺延;见 §0.3) |
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税),
**留存为现金**,在下次调仓时按目标权重重新配置
(⚠️ `cash_mode: reinvest` / `reinvest_rule` **未实现**)。
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
> 以上每一项未实现都会逐条写入 `hd_backtest_run.unimplemented_json` —— 可直接查库核对。
**资金对账**(每次回测都会校验):
```text
期末现金 == 期初资金 − 买入额 − 费用 + 卖出额 + 净分红
```
残差应为 0(浮点误差量级)。不为 0 说明有遗漏。
## 9.6 绩效指标
| 指标 | 口径 |
|---|---|
| 年化因子 | 252 个交易日 |
| Sharpe | (CAGR − 无风险利率) / 年化波动率 |
| Sortino | 分母改用下行波动率 |
| Calmar | CAGR / \|最大回撤\| |
| 最大回撤 | 相对历史高点的最大跌幅(**负值**) |
| 胜率 | 按**已实现盈亏**统计(仅平仓交易) |
| 换手率 | 年成交额 / 平均组合市值 |
> **最小样本量保护**:日收益观测少于 20 个时,波动率/Sharpe/Sortino 返回
> 「不可得」而非计算 —— 2~3 个观测算出的年化波动率会产生 300+ 的荒谬 Sharpe。
> 系统**宁可显示「—」,也不用噪声冒充指标**。
---
# 10. Web 前端与部署
系统采用**前后端分离**:
```text
浏览器
│
├── 静态资源 ──► nginx 直接返回 output/ 目录
│
└── /api/* ──► nginx 反向代理 ──► hdiv web 进程(Python)──► MariaDB
```
- **前端**:`output/` 目录下的统一单页应用,只负责展示与交互,**不做任何指标计算**
- **后端**:`hdiv web` 提供的 REST API,负责所有查询与变更
- 前端使用 **hash 路由**(`#/universes/xxx`),因此 nginx 不需要配置 rewrite 规则
## 10.1 目录结构(归一化后)
```text
output/
├── index.html 统一前端入口(部署后访问这个)
├── app/
│ ├── app.css 样式
│ └── app.js 应用逻辑
├── assets/
│ └── echarts.min.js 图表库(离线,1MB)
├── reports/ 静态报告(导出件,需 --html;可离线打开)
└── archive/
└── 2026-10-03_legacy/ 历史平铺报告归档
```
**归一化命令**:
```bash
hdiv site normalize # 归档历史 + 静态报告入 reports/ + 同步前端
hdiv site status # 查看当前目录状态
hdiv site archive # 只归档根目录的平铺 HTML
hdiv site build # 只同步前端
```
`normalize` 可重复执行:已归档的内容不会被再次搬动,同名归档目录会自动加序号。
## 10.2 本地预览
```bash
# 方式一:脚本(推荐)
./deploy/serve.sh start-dev # API + 静态,浏览器打开 http://127.0.0.1:8099/
./deploy/serve.sh status
./deploy/serve.sh stop
# 方式二:直接命令
export PYTHONPATH=src
.venv/bin/python -m hdiv web --host 127.0.0.1 --port 8099
```
默认监听**仅本机**(`127.0.0.1`),避免误暴露到局域网。
## 10.3 生产部署(nginx)
### 第 1 步:同步前端
```bash
rsync -av --delete output/ user@192.168.1.166:/srv/hddiv/site/
```
### 第 2 步:启动后端(并托管它)
**macOS(推荐)**:注册为 launchd 用户级服务,登录自启、崩溃自愈:
```bash
./deploy/install-service.sh install # 安装并启动(幂等)
./deploy/install-service.sh status # 查看状态 + 连通性
./deploy/install-service.sh uninstall # 移除
```
**Linux**:用 systemd 或 `./deploy/serve.sh start`(见下)。
**手动启动**(重启后需再次执行):
```bash
./deploy/serve.sh start # 默认 api-only,监听 127.0.0.1:8099
./deploy/serve.sh start-dev # API + 静态,本机预览用
./deploy/serve.sh status
./deploy/serve.sh stop
```
或直接:
```bash
PYTHONPATH=src nohup .venv/bin/python -m hdiv web \
--host 127.0.0.1 --port 8099 --api-only > logs/web.log 2>&1 &
```
> **为什么必须托管**:nginx 由 `brew services` 托管、开机自启。
> 如果后端只是个 `nohup` 进程,机器重启后它就不在了 ——
> 页面能打开,但右上角会显示「API 不可用」。两者必须同等对待。
>
> 子系统与后端自检的区别:`deploy/install-service.sh status` 查后端,
> nginx 侧用 `bash scripts/mac_nginx_ggx.sh status`(在 system 项目里)。
### 第 3 步:配置 nginx
参见 **[deploy/nginx.conf.example](../deploy/nginx.conf.example)**,含两种布局:
| 布局 | 访问地址 | 说明 |
|---|---|---|
| 挂在子路径 | `http://host:8080/ggx/` | 你当前使用的方式 |
| 挂在站点根 | `http://host:8080/` | 更简洁 |
**最容易漏的一步**:`/ggx/api/` 的反代块必须先于静态规则,且 location 与
`proxy_pass` 的末尾斜杠要成对。少了它,`/ggx/api/health` 会被当作静态文件去
`output/api/health` 找,必然 404 —— 页面能打开但显示「API 不可用」。
子路径布局的关键两段:
```nginx
# API 必须放在静态规则之前
location /ggx/api/ {
proxy_pass http://127.0.0.1:8099/api/;
proxy_set_header Host $host;
add_header Cache-Control "no-store" always;
}
location /ggx/ {
alias /srv/hddiv/site/;
try_files $uri $uri/ /ggx/index.html;
}
```
```bash
nginx -t && nginx -s reload
```
### 第 4 步:验证
```bash
# 后端健康
curl http://127.0.0.1:8099/api/health
# 经 nginx 的接口(应与上面返回一致)
curl http://192.168.1.166:8080/ggx/api/health
# 前端入口
curl -I http://192.168.1.166:8080/ggx/index.html
```
浏览器打开 `http://192.168.1.166:8080/ggx/`,右上角应显示 **「API 正常」**。
若显示「API 不可用」,说明 nginx 的 `/ggx/api/` 反代未生效。
## 10.4 前端功能
| 页面 | 路径 | 功能 |
|---|---|---|
| 概览 | `#/` | 统计卡片、最近筛选与回测 |
| 股票池筛选 | `#/universes` | 记录列表:**命名 / 归档 / 删除 / 打开清单** |
| 股票清单 | `#/universes/<run_id>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** |
| 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 |
| 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** |
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 |
| 个股买卖点 | `#/backtests/<run_id>/stocks/<symbol>` | 股价/股息率/PE/ROE 趋势图 + 买卖点标注 |
| **样本外** | `#/walkforwards` | Walk-forward 记录;**样本内 vs 样本外**逐窗口对比 |
| 归档 | `#/archive` | 已归档与已删除的记录,可恢复 |
### 记录管理
- **重跑覆盖**:同一份配置 + 同一个 `asof` 时点 → 同一个 `run_id`,重跑会**原地覆盖**
旧记录(含成员清单与因子快照),不会累积重复条目。
你在界面上的**命名与备注会被保留**,不会被重跑清掉。
改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。
- **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上
- **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档
- **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。
本项目对研究数据坚持「只增不删」,因此不提供物理删除
### 股票池 ↔ 回测 的关联
有两条途径建立关联:
1. **自动**:用指定股票池跑回测
```bash
python -m hdiv backtest --universe-run <run_id>
```
2. **手动**:在回测列表点「命名」,或直接改数据库 `hd_backtest_run.universe_run_id`
关联后:
- 股票池详情页的「关联的回测」区块会列出这些回测
- 回测详情页顶部会显示「来源股票池」链接
## 10.5 API 一览
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/health` | 健康检查 |
| GET | `/api/summary` | 概览统计 |
| GET | `/api/universes` | 筛选记录列表(`?include_archived=&include_deleted=&q=`) |
| GET | `/api/universes/{run_id}` | 单条记录详情 |
| PATCH | `/api/universes/{run_id}` | 命名 / 备注 / 归档 / 软删除 |
| GET | `/api/universes/{run_id}/members` | 股票清单(`?passed=1\|0\|all&page=&size=&q=`) |
| GET | `/api/universes/{run_id}/members/{symbol}` | 单只股票的完整指标快照 |
| GET | `/api/universes/{run_id}/backtests` | 关联的回测 |
| GET | `/api/stocks/{symbol}` | 个股画像(含序列、分位、得分) |
| GET | `/api/backtests` | 回测记录列表(含条件说明) |
| GET | `/api/backtests/{run_id}` | 回测详情(含资金对账) |
| PATCH | `/api/backtests/{run_id}` | 命名 / 归档 / 软删除 / 关联股票池 |
| GET | `/api/backtests/{run_id}/metrics` | 绩效指标 |
| GET | `/api/backtests/{run_id}/equity` | 净值曲线 |
| GET | `/api/backtests/{run_id}/trades` | 逐笔成交与理由 |
| GET | `/api/backtests/{run_id}/positions` | 持仓明细 |
| GET | `/api/backtests/{run_id}/signals` | 未成交信号与原因 |
## 10.6 CLI 命令
```bash
hdiv web [--host 127.0.0.1] [--port 8099] [--api-only] [--out-dir output]
hdiv site {normalize|archive|build|status}
```
---
# 11. 故障排查
## 11.1 图表不显示(页面能开、容器空白)
**原因**:内联 JS 被 HTML 转义(`"` → `&#34;`)导致语法错误,或图表库未部署。
**排查**:
```bash
.venv/bin/python -m hdiv report validate
```
它会做三件事:检测 `<script>` 内的 HTML 实体、用 `node --check` 校验 JS 语法、
检查每个图表容器是否被脚本引用。
**解决**:
- 若是语法错误 → 重新生成报告(代码已修复)
- 若是 404 → 部署 `output/assets/` 目录,或改用 `asset_mode: inline`
## 11.2 页面显示「API 不可用」
右上角的状态徽章来自 `GET api/health`。显示「API 不可用」说明这个请求失败了。
**按顺序排查(三层,从内到外)**:
```bash
# ① 后端进程本身是否活着
curl http://127.0.0.1:8099/api/health
# 失败 → 后端没跑。启动:./deploy/install-service.sh install
# ② nginx 是否把 /api 反代出去了
curl http://127.0.0.1:8080/ggx/api/health
# ①通 ②不通 → nginx 配置缺 /ggx/api/ 反代块(最常见)
# ③ 从浏览器所在机器验证
curl http://192.168.1.166:8080/ggx/api/health
# ②通 ③不通 → 防火墙 / 监听地址问题
```
**看 nginx 错误日志最直接**:
```bash
tail -5 /usr/local/var/log/nginx/ggx.error.log
```
若看到类似这一行,就确认是缺反代块:
```text
open() "/Users/summer/project/高股息回测/output/api/health" failed (2: No such file or directory)
```
它说明 nginx 把 `/ggx/api/health` 当成**静态文件**去 `output/` 里找了。
需要在 vhost 里补上(注意 location 与 proxy_pass 的末尾斜杠成对):
```nginx
location /ggx/api/ {
proxy_pass http://127.0.0.1:8099/api/;
proxy_set_header Host $host;
add_header Cache-Control "no-store" always;
}
```
本项目的 nginx 配置由 `scripts/mac_nginx_ggx.sh` 生成(在 system 项目里),
改脚本后执行 `bash scripts/mac_nginx_ggx.sh apply` 重生成并热加载,
不要直接改生成的 `ggx.conf`。
**体检命令**(一次性检查 nginx、后端、反代三项):
```bash
bash scripts/mac_nginx_ggx.sh status # system 项目
./deploy/install-service.sh status # 本项目(后端侧)
```
### 11.2.1 `/api/health` 通,但页面报「配置校验失败」
现象:状态徽章正常、`api/health` 通,可页面整体报错,形如:
```text
加载失败:配置校验失败:/…/config/datasource.yml
1 validation error for DataSourceConfig
sync
Extra inputs are not permitted [type=extra_forbidden, input_value={'min_symbols_floor': 200…}]
```
**这不是配置写错了,是后端进程太旧**。`hdiv` 在进程启动时把
`src/hdiv/core/config.py` 的模型和 `config/*.yml` 一起读进内存(配置还经
`lru_cache` 缓存),所以:
- 你新加了配置字段 + 对应的模型字段,
- launchd 里那个进程却还在用**加字段之前**的模型校验文件,
- 于是「新文件里的 `sync` 段」成了模型眼里的多余键 → `extra_forbidden`。
注意 `KeepAlive` 只负责**崩溃后**拉起,正常运行的进程不会因为你改文件而重启。
症状最迷惑的地方是:报错看起来像 YAML 写错了,实际 YAML 完全合法。
**验证与修复**(一条命令即可):
```bash
# 绕过服务,用当前源码直接校验该文件:能通过就说明文件没问题
PYTHONPATH=src .venv/bin/python -c \
"from hdiv.core.config import load_config; print(load_config('datasource'))"
# 改完 src/ 或 config/ 之后必须重启后端,让它重新 import 模块、重新读配置
./deploy/install-service.sh restart
```
`restart` 用的是 `launchctl kickstart -k`(先杀后拉,PID 会变);
手工 nohup 启动的(`deploy/serve.sh start`)对应执行 `./deploy/serve.sh restart`。
**记住这条规则:改 `src/` 或 `config/` 之后,一律 `restart` 一次。**
只改 `web/`、`templates/` 这类静态产物不需要——它们由 nginx 每次请求重新读盘。
## 11.3 股票池为空或很少
**按顺序排查**:
1. **数据是否同步完成?** 看审计报告 G1/G4 是否 OK
```bash
.venv/bin/python -m hdiv audit
```
2. **看漏斗哪一层淘汰最多**
```sql
SELECT fail_stage, COUNT(*) FROM hd_universe_member
WHERE run_id='<run_id>' GROUP BY fail_stage;
```
3. **看具体原因分布**
```sql
SELECT fail_reason, COUNT(*) FROM hd_universe_member
WHERE run_id='<run_id>' AND fail_stage='dividend'
GROUP BY fail_reason ORDER BY 2 DESC LIMIT 10;
```
4. 常见原因:市值门槛过高、`min_dividend_yield` 过高、财报未同步导致 ROE 不可得
## 11.4 回测报「资金对账未通过」
**含义**:成本或分红入账有遗漏,绩效指标不可信。
**排查**:对比 `hd_backtest_run` 的 `cash_final` / `total_dividend_net` / `total_fees`
与 `hd_backtest_trade` 的汇总值。若分红为 0 但持仓期间有除权,检查 `hd_dividend` 是否有该股票数据。
## 11.5 回测收益为 0 / 完全空仓
**最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。
若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。
注意:**用 `--universe-run` 冻结股票池时没有预热期** —— 引擎不再自筛选,
历史约束不作用于早期,2015 年起即可能满仓。这也是为什么冻结模式的回撤
显著大于重新筛选模式。若你看到早期就满仓,那是冻结模式的正常表现,不是 bug。
**确认方法**:
```sql
SELECT YEAR(trade_date) AS 年, MAX(holding_count) AS 最大持仓,
ROUND(AVG(cash)/AVG(total_value)*100,1) AS 平均现金占比
FROM hd_backtest_equity WHERE run_id='<run_id>'
GROUP BY YEAR(trade_date) ORDER BY 年;
```
## 11.6 同步报「频率超限」
Tushare 限频是**按接口**计算的(如 `dividend` 上限 200 次/分钟)。
系统命中限频会**冷却 62 秒**后重试(滑动窗口)。
**调整**:改 `config/datasource.yml` 的 `tushare.rate_limits`,调低对应接口的值。
## 11.7 配置报错
系统对配置做**严格校验**,字段名写错会直接报错并指出位置:
```
配置校验失败:config/universe.yml
1 validation error for UniverseConfig
market.min_market_capp
Extra inputs are not permitted
```
按提示修正字段名即可。**这是刻意设计** —— 静默取默认值会导致「改了配置但没生效」。
## 11.8 回测很慢
| 手段 | 效果 |
|---|---|
| 调大 `backtest.yml: schedule.universe_refresh_months` | 减少股票池重建次数 |
| 调大 `schedule.signal_frequency_months` | 减少信号评估次数 |
| 缩小回测区间 | 线性加速 |
| 调小 `universe.yml: output.max_members` | 减少画像与信号计算量 |
---
# 12. 已知限制
> 以下均为**如实声明**,不是待办清单的措辞修饰。
| # | 限制 | 影响 |
|---|---|---|
| 1 | **策略缺少稳定的样本外超额收益** | 见 §7.5。这是最重要的一条 |
| 2 | 涨跌停/停牌约束覆盖 **2019 年起** | 2015–2018 为近似建模,引擎在 `unimplemented_json` 中声明 |
| 3 | `index_weight` 成分股权重为空 | 不影响基准收益计算(用指数点位),仅影响成分股分析 |
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
| 7 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:<br>`position.max_holdings`、`position.sector_max_position`、`position.weight_scheme`、`risk.max_portfolio_drawdown`、`risk.max_single_drawdown`、`risk.liquidity_limit_pct_adv`、`exit.stop_loss_pct`、`exit.max_holding_days`、`fill.max_volume_pct`、`fill.partial_fill`、<br>**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode=reinvest` / `dividend.reinvest_rule`**(分红留存为现金,在下次调仓再配置)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
| 8 | `stock_daily` 2015–2019 的存量行仍是 Tushare 原始单位 | 读取层已兜底换算(结果正确),审计 `UNIT-OHLCV` 报 WARN;重刷数据可消除 |
| 9 | ~~行情/每日指标只到 2015-01-05~~ → **已修复(2026-10-04 回补到 2005-01-04)** | 曾使「过去 5 年画像」在 2019 年前只有 0.2~4 年数据(覆盖率 20%~67%);回补后 5 年窗口覆盖率为 **98.7%~100%**,残差经逐日核实为真实停牌。另:**修好数据后全期收益从 +117.36% 降到 +92.73%**,因为 2015 年(牛市顶 + 股灾)从「被数据缺口挡住」变成被真实交易。见 §6.6 与 implementation-status §9.3c |
| 10 | `config/profile.yml: sufficiency` 三个阈值**尚未被任何代码使用** | `min_history_years_dividend` / `min_history_years_price` / `min_dividend_records` 目前是死配置。真正的充分性判定由 `profile_gate.min_window_coverage` + `on_unverifiable` 承担 |
| 11 | 实时画像闸门默认 `on_unverifiable: reject` | 数据不全时会放弃部分本可买入的标的(刻意保守,可改为 `pass`)。回补后早年数据已基本完整,影响大幅缩小 |
| 12 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 |
| 13 | 幸存者偏差尚有 **3 只**缺口 | `000022.SZ`/`000043.SZ`/`300114.SZ`(均因吸收合并退市)有行情但不在 `stock` 表,永远不会进入候选集;占 5,903 只的 0.05%。根因是 `stock` 只收录在市股票 |
| 14 | `hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录代码 | 其中 356 中 250+106 为北交所(按交易所白名单设计排除);SZ 部分与第 13 条同源。不影响可交易标的 |
## 12.1 使用前请务必知道
1. **系统不会替你判断策略好坏**。它提供的是证据与规范化口径,
最终判断(尤其「样本外超额为负」这一条)需要你自己权衡。
2. **单条路径的全期回测数字会显著高估策略**。请始终以 Walk-forward 的
样本外结果为主要依据。
3. **数据口径有回归测试守护,但数据本身来自 Tushare**,
个别记录可能有问题(实测有 2 条公告日早于报告期的错误记录,
PIT 查询层已过滤并在审计中如实报告)。
4. **所有输出都可追溯**:报告页脚有数据来源 SQL,数据库里有完整
`run_id` / `config_hash` / `data_version`,同输入必同输出。
---
# 附:一分钟速查
```bash
export PYTHONPATH=src
# 建表 / 查表
.venv/bin/python -m hdiv ddl apply
.venv/bin/python -m hdiv ddl verify
# 数据
.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
# 审计
.venv/bin/python -m hdiv audit
# 研究
.venv/bin/python -m hdiv universe --asof 2024-06-28
.venv/bin/python -m hdiv profile --universe-run <run_id>
.venv/bin/python -m hdiv strategy register
.venv/bin/python -m hdiv backtest
.venv/bin/python -m hdiv backtest --mode walkforward
.venv/bin/python -m hdiv sensitivity
# 报告
.venv/bin/python -m hdiv report index
.venv/bin/python -m hdiv report validate
```
**要改的东西**:
| 想改什么 | 改哪个文件 |
|---|---|
| 筛选条件(市值/分红/ROE…) | `config/universe.yml` |
| 个股画像口径(窗口/分位/权重) | `config/profile.yml` |
| 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` |
| 手续费/滑点/红利税 | `config/cost.yml` |
| 回测区间/Walk-forward/基准/分位最小样本量 | `config/backtest.yml` |
| 报告外观与部署方式 | `config/report.yml` |
| 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) |