# 高股息回测系统 · 使用手册 > 版本 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 # 指定股票 + 指定时点(任意历史时点,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 会打印覆盖率,例如: ``` 画像 :46 只 窗口覆盖率:最差 10 年窗口 94.6%(1.0 = 名义窗口被完整覆盖) ``` ### 关键认知(最容易误解的一点) > **画像是一次「快照」,回测并不读它。** > > 回测里每次买入前用的画像,是引擎**在该决策日实时重算**的 > (见 §0.3 第④步),与这里落库的快照是**两条独立路径**。 > 两者由「逐值等价测试」约束,不会给出两个不同的数。 > > 所以:**画像页是给你看的,不是给回测用的。** 回测每天自己算。 --- ## 0.3 步骤③:回测 `hdiv backtest` ```bash # 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 asof .venv/bin/python -m hdiv backtest --universe-run --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 ` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) | | ③ 回测 | `hdiv backtest [--universe-run ] [--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_.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 股票池 asof=2024-06-28 候选 5363 入选 28 ``` **记下 `run_id`**,下一步要用。 ## 5.5 `profile` — 个股画像 ```bash hdiv profile --universe-run # 对股票池全部股票(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 --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 # 用指定股票池(冻结)并建立关联 # 注意 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 --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 ``` 因此发布时**必须整体部署 `output/` 目录**,包括 `output/assets/`。 例如映射到 `/ggx/`,则两个 URL 都必须可达: ```text http://:8080/ggx/index.html ← 报告 http://: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 = '' 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 = '' ORDER BY execution_date; -- 为什么某只股票没被选上 SELECT fail_stage, fail_reason, values_json FROM hd_universe_member WHERE 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 = '' 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 = ''; ``` ## 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/` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** | | 个股画像 | `#/stocks/` | K线+股息率+PE 四联图、历史分布、安全边际雷达 | | 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** | | 回测详情 | `#/backtests/` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 | | 个股买卖点 | `#/backtests//stocks/` | 股价/股息率/PE/ROE 趋势图 + 买卖点标注 | | **样本外** | `#/walkforwards` | Walk-forward 记录;**样本内 vs 样本外**逐窗口对比 | | 归档 | `#/archive` | 已归档与已删除的记录,可恢复 | ### 记录管理 - **重跑覆盖**:同一份配置 + 同一个 `asof` 时点 → 同一个 `run_id`,重跑会**原地覆盖** 旧记录(含成员清单与因子快照),不会累积重复条目。 你在界面上的**命名与备注会被保留**,不会被重跑清掉。 改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。 - **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上 - **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档 - **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。 本项目对研究数据坚持「只增不删」,因此不提供物理删除 ### 股票池 ↔ 回测 的关联 有两条途径建立关联: 1. **自动**:用指定股票池跑回测 ```bash python -m hdiv backtest --universe-run ``` 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 转义(`"` → `"`)导致语法错误,或图表库未部署。 **排查**: ```bash .venv/bin/python -m hdiv report validate ``` 它会做三件事:检测 `