本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):
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 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
94 KiB
高股息回测系统 · 使用手册
目录
0. 全流程操作
选股 → 画像 → 回测,三步主路径。
本节是主操作路径:从「选出一批股票」到「跑出一份可信的回测」, 每一步都说明命令做了什么、数据从哪来、落了哪些库、有哪些坑。
0.0 五分钟全流程(可直接复制)
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
.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(用年报而非季报,否则累计值会误杀) |
关键性质
- 无未来函数:每一条判据都带
<= asof约束(行情trade_date、财报ann_date、 分红imp_ann_date与ex_date双重)。ST 状态按历史名称还原,不看今天的名字。 - run_id 是确定性的:由「配置哈希 + asof」决定,不含时间戳。 所以同配置同时点重跑会原地覆盖同一条记录,不会积累重复。
asof会被归一化到交易日。--asof 2025-01-01与--asof 2024-12-31得到同一个 run_id。看到打印的asof=2024-12-31不是 bug。--no-persist的后果:结果不落库 → 前端看不到,也无法被回测引用。 CLI 会显式提醒。
落库与追溯
| 表 | 内容 |
|---|---|
hd_universe_run |
run_id / asof_date / 候选数 / 入选数 / 配置指纹 / 数据版本 |
hd_universe_member |
逐股:每个滤网通过位、fail_stage(首个未通过滤网)、fail_reason(中文原因)、取值快照 |
「为什么没选上」是这个模块最重要的产出。 前端「股票池」页可逐股下钻。
0.2 步骤②:画像 hdiv profile
# 对某个股票池的全部成员,按**该股票池的 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
# 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 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 到底哪些约束没生效 —— 不会静默。
验证这次回测「干净」的两个动作
-- ① 有没有被声明为「含未来信息」(应为 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 显式导出) |
读结论的三条纪律:
- 只认 Walk-forward 的样本外结果,不要采信单路径全期数字。 本项目的实测就是反例:同一份数据,回补前后单路径从 +117% 掉到 +93%, 而样本外 7 个窗口一个数字都没变。
- 对账残差不为 0 就不要看绩效。
- 样本太短不要下结论。用 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 股高股息策略的研究与回测系统。它把下面这条链路串成一条可复现的流水线:
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 一次性配置
cd ~/project/高股息回测
cp .env.example .env # 若 .env 已存在则跳过
编辑 .env 填入凭据(该文件已在 .gitignore 中,切勿提交):
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 三步跑通
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(先读那一节)。 这里只给最短的命令序列。
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 目录结构
高股息回测/
├── 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 数据流
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 — 股票筛选条件 ★
行业豁免
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 — 策略定义 ★
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 个月才重建一次,期间持有的候选可能早已「不值得买」。 闸门的语义是 —— 股息率分位触发买入之后,再用「当日可见的数据」重算一次个股画像, 不通过的票直接剔除。
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 |
阈值 |
三条关键性质:
- 无未来函数:每个决策日只用「当时可见」的价格、每日指标、分红(
imp_ann_date与ex_date双重约束)与财报(ann_date <= asof)。有负例测试守护 (公告日前一天不得看到该年报;除权日前一天不得包含该笔分红)。 - 与批量画像同一定义:闸门用的指标与
hdiv profile页面上的指标逐值一致 (有等价性测试),不会出现「页面一个数、回测另一个数」。 - 惰性与复用:只在买入条件已触发时才计算;跨股票共享的面板按时点缓存, 长周期指标从同一份已载入面板里切窗口。因此成本与触发次数成正比, 而不是与「区间长度 × 股票数」成正比;规则里不含财务指标时完全不查财报。
被剔除的信号怎么看:信号类型为 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 个观测)。
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 — 回测配置
区间与资金
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 报告部署。
4.7 config/datasource.yml — 数据源
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 — 建表与结构迁移
hdiv ddl plan # 只显示将要执行什么,不改库
hdiv ddl apply # 执行(幂等,可反复运行)
hdiv ddl verify # 校验库中表结构是否符合代码定义
- 只做
CREATE TABLE IF NOT EXISTS与ALTER TABLE ... ADD COLUMN - 从不
DROP/TRUNCATE/DELETE - 迁移带前置条件(如「仅当表为空」),非空表会跳过并提示
5.2 sync — 数据同步
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 — 数据审计
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 — 股票池筛选
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 — 个股画像
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 — 策略管理
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
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 — 参数敏感性
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 — 报告索引与校验
hdiv report index # 重建 output/index.html
hdiv report validate # 校验全部报告的离线合规性与 JS 语法
6. 典型工作流
6.1 首次建库(一次性)
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/:
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
同步完成后自检:
.venv/bin/python -m hdiv audit
# 期望:总体 WARN 或 OK,FAIL=0
6.3 日常数据更新
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 研究一个新策略
# ① 复制策略模板
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 调参流程(推荐顺序)
# ① 先看单参数敏感性 —— 判断结论稳不稳
.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。
先看缺口:哪些数据支持 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 |
执行
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
必须知道的四件事:
- 只追加、不改既有行(
INSERT IGNORE)。因此 2015-2019 已存在的行不会被 修成正确单位 —— 那由读取层兜底(见 §9.1)。回补的新行走的是正确单位写入路径。 - 耗时与点数:
daily/adj_factor/daily_basic的限频是 480 次/分钟, 但瓶颈是 HTTP 往返(每日 3 次调用)。1,212 个交易日 ≈ 3,600 次调用, 实测量级 1~3 小时;补到 2005 年约翻倍。先用--limit或在测试库试跑。 - Tushare 权限:早年数据需要相应积分。若某日返回空,
sync_days会记录在hd_sync_log,不会中断整体任务。 - 回补后必须重跑:股票池、画像、回测、Walk-forward 的结论都会变 (2015-2019 从「几乎无候选」变成有完整 5 年画像的候选)。
回补后怎么确认「5 年真的齐了」
# 画像命令会打印覆盖率(不足时给警告)
.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 股票池报告
看什么:
- 漏斗图 —— 哪一层淘汰最多(通常是
market的市值门槛) - 入选股票表 —— 按股息率降序,确认没有明显不合理的标的
- 行业分布 —— 判断是否过度集中(银行/煤炭/电力常见)
- 被淘汰股票与原因表 —— 可回答「为什么某某没选上」
若入选数为 0 或很少,先确认分红/财报数据是否同步完成,再看是否条件过严。
7.3 个股画像报告
看什么:
- K线 · 股息率 · PE · PB · 回撤 四联图 —— 共享 X 轴;股息率面板上有 P75(买入)与 P25(卖出)虚线
- 当前历史分位 —— 顶部 KPI。≥ 75% 触发买入信号,≤ 25% 触发卖出
- 历史分布统计表 —— 每个指标的 Min/P10/P25/P50/P75/P90/Max
- 分红质量 —— 连续分红年数、支付率、FCF 覆盖、DPS 增速
- 安全边际雷达图 —— 五个分项得分 + 综合分
「数据不足」会显式标注,不会用 0 或推测值填充。
7.4 回测报告
看什么:
- 资金对账横幅 —— 必须是「通过」
- 净值曲线 vs 基准 —— 相对表现比绝对收益更重要
- 回撤曲线 —— 高股息策略的价值主要体现在这里
- 基准比较表 —— 与中证红利对比最有意义(同类策略)
- 成交流水 —— 每笔都带
reason(触发时的股息率、历史分位、所用规则) - 未成交信号 —— 区分「想买」与「买到了」,判断约束是否实质影响绩效
- 未建模部分 —— 引擎如实声明哪些约束没生效
若顶部出现 「这是一份历史运行报告,已被更新的运行取代」 的红色横幅, 说明该 run 早于某次修复,请以标注「最新」的报告为准。
7.5 Walk-forward 报告 ★ 最重要
前端入口:
#/walkforwards(导航栏「样本外」)。 每个 wf_id 是一条独立记录,点进去可看逐窗口的样本内/样本外对比与冻结阈值。 该页面同时给出样本外均值、胜率、稳定性三个核心判据。
这是判断策略是否真的有效的核心依据。
看什么:
- 样本外胜率 —— 7 个窗口里几个为正
- 样本外收益均值 vs 基准收益均值 → 超额收益
- 稳定性指标(样本外均值 / 标准差)—— < 1 说明窗口间差异大于均值本身
- 逐窗口明细表 —— 每个测试年的表现
- 冻结阈值图 —— 各窗口校准出的绝对股息率阈值是否稳定
判读标准:
| 现象 | 含义 |
|---|---|
| 样本外超额 > 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。
另外注意:实时画像闸门在两个口径下结论相反 —— 全期单路径收益从 +101.20% 降到 +92.73%(更差), 但 walk-forward 样本外均值从 −0.00% 升到 +1.16%、最差回撤从 −24.22% 改善到 −22.97%(更好)。按本项目一贯立场以样本外为准,闸门是改善。 详见 §4.6c。
7.6 参数敏感性报告
看什么:
- 平滑度(0~1,越接近 1 越平滑)—— ≥ 0.6 且无尖峰视为稳健
- 尖峰检测 —— 某点的 CAGR 显著高于左右邻居
- 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)会让同一天多次运行互相覆盖。
导出命令:
hdiv universe --asof 2025-01-21 --html # 导出股票池报告
hdiv profile --universe-run <run_id> --html # 导出画像报告
hdiv backtest --html # 导出回测报告
hdiv audit --html
hdiv sensitivity --html
文件名带执行 id,所以同一天跑多次不会覆盖:
output/reports/universe_2025-01-21_af8a2c80736a992237c6f949aff0ce74.html
└────────── run_id ──────────┘
--no-html仍然存在但已是空操作(默认就不生成),保留是为了让历史命令与脚本 不报错。
7.8 报告部署
报告通过相对路径引用图表库:
<script src="assets/echarts.min.js"></script>
因此发布时必须整体部署 output/ 目录,包括 output/assets/。
例如映射到 /ggx/,则两个 URL 都必须可达:
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 |
部署前自检:
.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 常用查询
-- 最新股票池成员
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 检查):
- 恒等式
总市值 ≈ 收盘价 × 总股本—— 能发现「只换算了一个字段」 - 绝对量级 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段
- 量价一致性(
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 股息率口径
股息率(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—— 可直接查库核对。
资金对账(每次回测都会校验):
期末现金 == 期初资金 − 买入额 − 费用 + 卖出额 + 净分红
残差应为 0(浮点误差量级)。不为 0 说明有遗漏。
9.6 绩效指标
| 指标 | 口径 |
|---|---|
| 年化因子 | 252 个交易日 |
| Sharpe | (CAGR − 无风险利率) / 年化波动率 |
| Sortino | 分母改用下行波动率 |
| Calmar | CAGR / |最大回撤| |
| 最大回撤 | 相对历史高点的最大跌幅(负值) |
| 胜率 | 按已实现盈亏统计(仅平仓交易) |
| 换手率 | 年成交额 / 平均组合市值 |
最小样本量保护:日收益观测少于 20 个时,波动率/Sharpe/Sortino 返回 「不可得」而非计算 —— 2~3 个观测算出的年化波动率会产生 300+ 的荒谬 Sharpe。 系统宁可显示「—」,也不用噪声冒充指标。
10. Web 前端与部署
系统采用前后端分离:
浏览器
│
├── 静态资源 ──► nginx 直接返回 output/ 目录
│
└── /api/* ──► nginx 反向代理 ──► hdiv web 进程(Python)──► MariaDB
- 前端:
output/目录下的统一单页应用,只负责展示与交互,不做任何指标计算 - 后端:
hdiv web提供的 REST API,负责所有查询与变更 - 前端使用 hash 路由(
#/universes/xxx),因此 nginx 不需要配置 rewrite 规则
10.1 目录结构(归一化后)
output/
├── index.html 统一前端入口(部署后访问这个)
├── app/
│ ├── app.css 样式
│ └── app.js 应用逻辑
├── assets/
│ └── echarts.min.js 图表库(离线,1MB)
├── reports/ 静态报告(导出件,需 --html;可离线打开)
└── archive/
└── 2026-10-03_legacy/ 历史平铺报告归档
归一化命令:
hdiv site normalize # 归档历史 + 静态报告入 reports/ + 同步前端
hdiv site status # 查看当前目录状态
hdiv site archive # 只归档根目录的平铺 HTML
hdiv site build # 只同步前端
normalize 可重复执行:已归档的内容不会被再次搬动,同名归档目录会自动加序号。
10.2 本地预览
# 方式一:脚本(推荐)
./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 步:同步前端
rsync -av --delete output/ user@192.168.1.166:/srv/hddiv/site/
第 2 步:启动后端(并托管它)
macOS(推荐):注册为 launchd 用户级服务,登录自启、崩溃自愈:
./deploy/install-service.sh install # 安装并启动(幂等)
./deploy/install-service.sh status # 查看状态 + 连通性
./deploy/install-service.sh uninstall # 移除
Linux:用 systemd 或 ./deploy/serve.sh start(见下)。
手动启动(重启后需再次执行):
./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
或直接:
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,含两种布局:
| 布局 | 访问地址 | 说明 |
|---|---|---|
| 挂在子路径 | http://host:8080/ggx/ |
你当前使用的方式 |
| 挂在站点根 | http://host:8080/ |
更简洁 |
最容易漏的一步:/ggx/api/ 的反代块必须先于静态规则,且 location 与
proxy_pass 的末尾斜杠要成对。少了它,/ggx/api/health 会被当作静态文件去
output/api/health 找,必然 404 —— 页面能打开但显示「API 不可用」。
子路径布局的关键两段:
# 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;
}
nginx -t && nginx -s reload
第 4 步:验证
# 后端健康
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,重跑会原地覆盖 旧记录(含成员清单与因子快照),不会累积重复条目。 你在界面上的命名与备注会被保留,不会被重跑清掉。 改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。 - 命名:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上
- 归档:归档后默认列表不再显示,可在「归档」页找到并取消归档
- 删除:软删除 —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。 本项目对研究数据坚持「只增不删」,因此不提供物理删除
股票池 ↔ 回测 的关联
有两条途径建立关联:
-
自动:用指定股票池跑回测
python -m hdiv backtest --universe-run <run_id> -
手动:在回测列表点「命名」,或直接改数据库
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 命令
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 转义(" → ")导致语法错误,或图表库未部署。
排查:
.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 不可用」说明这个请求失败了。
按顺序排查(三层,从内到外):
# ① 后端进程本身是否活着
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 错误日志最直接:
tail -5 /usr/local/var/log/nginx/ggx.error.log
若看到类似这一行,就确认是缺反代块:
open() "/Users/summer/project/高股息回测/output/api/health" failed (2: No such file or directory)
它说明 nginx 把 /ggx/api/health 当成静态文件去 output/ 里找了。
需要在 vhost 里补上(注意 location 与 proxy_pass 的末尾斜杠成对):
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 scripts/mac_nginx_ggx.sh status # system 项目
./deploy/install-service.sh status # 本项目(后端侧)
11.2.1 /api/health 通,但页面报「配置校验失败」
现象:状态徽章正常、api/health 通,可页面整体报错,形如:
加载失败:配置校验失败:/…/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 完全合法。
验证与修复(一条命令即可):
# 绕过服务,用当前源码直接校验该文件:能通过就说明文件没问题
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 股票池为空或很少
按顺序排查:
- 数据是否同步完成? 看审计报告 G1/G4 是否 OK
.venv/bin/python -m hdiv audit - 看漏斗哪一层淘汰最多
SELECT fail_stage, COUNT(*) FROM hd_universe_member WHERE run_id='<run_id>' GROUP BY fail_stage; - 看具体原因分布
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; - 常见原因:市值门槛过高、
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。
确认方法:
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 | 策略/回测配置里下列字段尚未实现 | 改了它们回测结果不会变: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、fill.suspended_rule / fill.limit_up_down_rule 的 defer(未成交信号当日即被丢弃,不会顺延)、dividend.cash_mode=reinvest / dividend.reinvest_rule(分红留存为现金,在下次调仓再配置)、dividend.handle_rights_issue(配股不入账)、execution.signal_to_execution(固定次日开盘成交)。这些都会逐条写入 hd_backtest_run.unimplemented_json,可直接从库里查 |
| 8 | stock_daily 2015–2019 的存量行仍是 Tushare 原始单位 |
读取层已兜底换算(结果正确),审计 UNIT-OHLCV 报 WARN;重刷数据可消除 |
| 9 | 曾使「过去 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 使用前请务必知道
- 系统不会替你判断策略好坏。它提供的是证据与规范化口径, 最终判断(尤其「样本外超额为负」这一条)需要你自己权衡。
- 单条路径的全期回测数字会显著高估策略。请始终以 Walk-forward 的 样本外结果为主要依据。
- 数据口径有回归测试守护,但数据本身来自 Tushare, 个别记录可能有问题(实测有 2 条公告日早于报告期的错误记录, PIT 查询层已过滤并在审计中如实报告)。
- 所有输出都可追溯:报告页脚有数据来源 SQL,数据库里有完整
run_id/config_hash/data_version,同输入必同输出。
附:一分钟速查
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) |