Files
ggx/docs/implementation-status.md
T
simon cf6d4d2c56 功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层
说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。

## 新增能力

1) `hdiv backtest --mode daily --start <日期>`
   - src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
   - 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
   - `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
   - `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
     (卖出/减仓同样留痕,否则「为什么卖」缺证据)
   - 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
     因此不使用训练段、不冻结分布,阈值口径一律 rolling
   - 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)

2) PIT 批量取数层 src/hdiv/universe/pit.py
   - PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
     派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
     —— 以保证与逐日单点查询**结果等价**
   - 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
   - src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)

3) 每日增量同步 `hdiv sync daily`
   - src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
     按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
     支持 `--dry-run` 先看待抓清单
   - deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
     deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
   - 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
     (该表已存在于库中,`ddl plan` 返回 0 个待执行动作)

4) Web 与文档
   - 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
     web/favicon.svg)
   - README / docs/user-guide.md / docs/implementation-status.md 同步更新:
     三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段

## 测试

tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。

已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**

未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
  `SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
  (31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
  0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
  区间 2024-03-01~03-15),说明该路径可正常完成。

## 已知待改进

- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
  symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
2026-10-05 11:57:13 +08:00

1341 lines
77 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 实施状态报告
> 对应 `docs/development-plan.md`(计划)与 `docs/plan.md`(需求)
> 更新:2026-10-04
---
## 0. 一句话结论
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
回测 → Walk-forward → **每日动态股票池推演(`--mode daily`)** → 绩效分析 →
参数敏感性 → 固定格式 HTML 报告,
全链路打通并通过 **439 项自动化测试**。`plan.md §50` 的 14 项验收能力**全部具备**。
**2026-10-04 新增「每日动态股票池」**(详见 §10):
`backtest --mode daily --start <日期>` 从给定日期起**每个交易日**重新选股(PIT)、
每个交易日判断买卖点,持仓掉出当日池子默认**只减不加**,买卖决策都留个股画像证据,
每日入选成员落库 `hd_daily_universe`。它与 walk-forward **不是替代关系** ——
后者检验参数稳定性(过拟合),前者回答「动态池下这条路径长什么样」。
代价是逐日全市场筛选:6.7 年区间约 **1.5 小时**(3 个月实测 5 分钟)。
**2026-10-03 完成三项正确性改造**(详见 §9):修复 `stock_daily` 量价单位不一致、
拒绝「用未来时点的股票池跑更早区间」、新增**实时(PIT)个股画像闸门**。
三项都让「交易依据更符合实际」,且在 walk-forward 样本外口径下**都改善了绩效**
(样本外均值 −0.95% → **+1.16%**,最差回撤 −24.62% → **−22.97%**;
2026-10-04 又把行情回补到 2005,样本外逐窗口未变、单路径则从 +117.36% 降到 +92.73%),
但**仍未取得正超额**(−1.12pp)。
数据层已补齐:行情/每日指标/复权因子覆盖 **2005-01-04 起**,
停牌与涨跌停价覆盖 **2010-01-04 起**,分红 1990 起、四张财报表 1990/2001 起。
审计 `FAIL=0`(14 OK / 5 WARN,WARN 均为已知且已声明)。
---
## 1. 已交付清单
### 1.1 数据层(P0a / P0b / G1–G6)
| 缺口 | 状态 | 实测结果 |
|---|---|---|
| **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 267,295 行,覆盖 1990–2026 |
| **G2 日线行情** | ✅ 完成 | `stock_daily` **起点 2005-01-04**(2026-10-04 回补,+3,927,792 行),16,007,169 行 / 5,265 个交易日 |
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,16,789,137 行 / 5,267 个交易日 |
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 **2005-01-04**(+4,309,461 行),15,972,821 行 / 5,275 个交易日 |
| **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` **468,388 行**、`hd_limit` **14,837,155 行**,均覆盖 **2010-01-04** 起(2026-10-04 回补,各 +400,623 / +5,787,253 行) |
| **日常增量** | ✅ 完成 | `hdiv sync daily`(缺几天抓几天)+ `deploy/install-sync-schedule.sh` 注册每天 17:00 的 launchd 任务;2026-10-05 首次运行补齐 21 个交易日(行情 17.8 万行、指数权重 1,800 行、财报 6.5 万行),耗时约 2 分钟 |
### 1.2 数据库(31 张 `hd_*` 表)
全部建表完成,结构迁移幂等(连续两次 `ddl apply` 均返回 0 个动作)。
**只增不删**由三层保证:SQL 安全钩子 + 源码扫描测试 + 迁移的前置条件守卫。
### 1.3 代码模块
| 模块 | 文件 | 状态 |
|---|---|---|
| 配置层 | `core/config.py`(7 类配置 + 严格校验 + config_hash) | ✅ |
| 数据安全 | `data/db.py`(SQL 钩子)、`data/ddl.py`(幂等 DDL) | ✅ |
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停 + `daily.py` 每日增量) | ✅ |
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
| PIT 批量取数 | `universe/pit.py`(`PitRepo`:与 `Repo` 逐值一致,逐日筛选的基座) | ✅ |
| 每日选股 | `universe/daily.py`(逐日重建池 + 保守预剪枝) | ✅ |
| 因子 | `factor/dividend_yield.py` | ✅ |
| 个股画像 | `profile/builder.py`、`profile/pit.py`(实时画像) | ✅ |
| 策略管理 | `strategy/registry.py` | ✅ |
| 回测引擎 | `backtest/engine.py` | ✅ |
| Walk-forward | `backtest/walk_forward.py` | ✅ |
| **每日动态股票池** | `backtest/daily.py`(`--mode daily`) | ✅ |
| 绩效分析 | `analysis/performance.py` | ✅ |
| 敏感性 | `analysis/sensitivity.py` | ✅ |
| 报告渲染 | `report/`(7 类报告 + 离线校验) | ✅ |
---
## 2. `plan.md §50` 十四项验收
| # | 能力 | 状态 | 落库位置 |
|---|---|---|---|
| ① | 找出符合条件的股票 | ✅ | `hd_universe_member` |
| ② | 生成 PIT 股票池 | ✅ | `hd_universe_run` |
| ③ | 每股历史股息率序列 | ✅ | `hd_profile_series` |
| ④ | P10/P25/P50/P75/P90 | ✅ | `hd_profile_stat` |
| ⑤ | 生成买卖信号 | ✅ | `hd_backtest_signal` |
| ⑥ | 执行历史回测 | ✅ | `hd_backtest_run` |
| ⑦ | 正确处理分红与除权 | ✅ | `hd_dividend` + 分红台账(含红利税) |
| ⑧ | 加入交易成本 | ✅ | `hd_backtest_trade.*_cost` |
| ⑨ | K线 + 买卖点 | ✅ | `output/profile_*.html`(四联图 + P75/P25 阈值线) |
| ⑩ | 收益/回撤/Sharpe | ✅ | `hd_backtest_metric` |
| ⑪ | 基准比较 | ✅ | 沪深300 / 中证红利 / 上证指数 |
| ⑫ | Walk-forward | ✅ | `hd_walkforward_window` |
| ⑬ | 样本内/外结果 | ✅ | `hd_backtest_metric.scope` |
| ⑭ | 完整参数与版本 | ✅ | `hd_strategy` + `hd_strategy_param`(36 项) |
---
## 3. 关键正确性保障
以下是开发过程中**真实踩到**并已修复的静默错误 ——
它们共同特点是「不报错,只是给出错误结论」,因此每一条都配了回归测试。
| # | 问题 | 后果 | 修复 |
|---|---|---|---|
| 1 | Tushare `total_mv` 单位是**万元**,配置阈值是**元** | 市值过滤选中 **0 只**股票 | `data/units.py` 统一归一化 + `UNIT` 审计(恒等式 + 绝对量级双判据) |
| 2 | 用**季报累计** ROE 比「年均 8%」阈值 | 几乎所有好公司被误杀 | `annual_financial_averages()` 只用年报口径 |
| 3 | 银行负债率天然 90%+ | 整个金融板块被误杀(招商银行) | `industry_exemptions` 行业豁免,Risk/Quality 统一口径 |
| 4 | 年度分红除权间隔中位数 **366 天** > 365 | 股息率被算成 0,污染历史分位 | TTM 加 45 天宽限期 |
| 5 | 分红除权晚于 asof | 稳定分红公司被误判「连续分红 0 年」(中国神华) | 一年宽限期 |
| 6 | `NaN or 0.0` 返回 `NaN` | 10 万条 NULL 分红被当成数值参与者 | `_fnum()` NaN 感知转换 |
| 7 | 费率 side 配置小写 `sell`、代码传大写 `SELL` | **印花税永远为 0**,成本系统性低估 | 统一大写比较 |
| 8 | 净值曲线漏加现金 | 净值严重失真 | `总市值 = 现金 + 持仓` + 断言测试 |
| 9 | 建仓阶梯与减仓阶梯各自判定 | 持仓时会「因高分位被减仓」,年换手 8.9、持仓仅 30 天 | 合成唯一阶梯 + 死区(修复后:换手 0.91、持仓 345 天) |
| 10 | MySQL 唯一约束**不约束 NULL** | 静默重复行 | 显式 `dedup_key`;三张表补 `NOT NULL`;专门测试守护 |
| 11 | pandas 3.0 字符串列不再是 `object` dtype | 空串 `ts_code` 被写进库 | 改用 `is_numeric_dtype` 判定 |
| 12 | `is_buy` 在涨跌停分支前未定义 | 一旦 `hd_limit` 有数据即崩溃 | 定义提前 |
| 13 | Jinja2 autoescape 转义 `<script>` | 图表静默失效 | `| safe` + 校验器专项检测 |
| 14 | `dividend_records` 的 SELECT **漏了 `base_share`** | 支付率与 FCF 覆盖在全库范围内恒为 NULL —— `max_payout_ratio` 与 `min_fcf_dividend_cover` 两个筛选条件**从未生效** | 补上该列 + 回归测试断言 SELECT 列表 |
| 15 | 用 **FY2023 分红 ÷ 2024Q1 净利润** 算支付率 | 美的集团算出 230.9% 的荒谬支付率(真实 61.6%) | 新增 `Repo.annual_financials()`,支付率/FCF 覆盖一律**同财年**比较 |
| 16 | 画像从未计算分红质量指标 | `dividend_quality` 与 `composite` 安全边际得分恒为 NULL,`plan.md §14/§15` 形同未实现 | 画像复用 `DividendFilter` 的口径函数(单一口径来源) |
| 17 | 断点续传只看「日期是否存在」 | 原 qlib 数据在 2019 年仅 243 只/日被当作「已同步」,形成**整年数据空洞** | 判据改为「当日股票数 ≥ 1500」 |
| 19 | **Jinja2 autoescape 把嵌入 `<script>` 的 JSON 转义成 `&#34;`** | 内联 JS 语法非法 → **全部报告的图表都不显示**(页面能开、容器空白) | `_json_for_script` 返回 `Markup` 并把 `< > & '` 转成 `\uXXXX`;校验器新增 `node --check` 真实语法检查 |
| 20 | 校验器只比对「容器数 == init 次数」 | 图表全坏却判定 OK(给了假信心) | 改为「每个容器 id 必须被脚本引用」+ HTML 实体检测 + node 语法检查 |
| 21 | 索引模板用 `g.items` | Jinja2 中解析成 `dict.items` 方法而非该键 → 索引页渲染失败 | 键名改为 `reports` |
| 22 | `report.yml` 声明了无人实现的 `naming.strategy` | 配置承诺了不存在的产物 | 移除该键(策略说明报告属未实现的 P8) |
| 18 | walk-forward 的 train/test 以 `persist=False` 运行 | `hd_walkforward_window` 的 run_id 是**悬空引用**,报告无法下钻 | 一并落库,并把 `wf_id/window_index` 纳入 run_id 指纹(否则同区间会撞 id 互相覆盖) |
---
## 4. 实测回测结果(2015-01-05 ~ 2026-09-30,11.7 年)
> 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、
> 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、
> 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。
>
> ⚠️ **口径变更史**:2026-10-03 三项改造(量价单位修复、`--universe-run`
> 未来函数守卫、实时画像闸门默认启用)→ 2026-10-04 **行情回补到 2005**
> (见 §9.3c,让 5/8/10 年窗口首次真正完整)。
>
> **结论先行 —— 两个口径给出相反答案,以 walk-forward 为准**:
>
> | 口径 | 闸门开 | 闸门关 |
> |---|---:|---:|
> | 全期单路径总收益 | +92.73% | **+101.20%** ← 单路径说闸门有害 |
> | Walk-forward 样本外均值 | **+1.16%** | −0.00% ← 样本外说闸门有益 |
> | 样本外最差回撤 | **−22.97%** | −24.22% |
>
> **而回补数据只改变了单条路径,没有改变任何样本外结果** ——
> 单路径从 +117.36% 掉到 +92.73%,样本外 7 个窗口**逐窗口一个数字都没变**。
> 原因见 §4.6d:样本外的测试窗口是 2020–2026,其参照窗口本就落在
> 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
> 过去被数据缺口「挡住」的 2015 年(大牛市 + 股灾)现在会被真实交易。
### 4.1 与基准对比(回补后重跑)
| 指标 | **策略(闸门开)** | 策略(闸门关) | 沪深300 | 中证红利 | 上证指数 |
|---|---:|---:|---:|---:|---:|
| 总收益 | **+92.73%** | +101.20% | +19.66% | +53.35% | +14.67% |
| 年化 CAGR | **+5.75%** | +6.14% | +1.54% | +3.71% | +1.17% |
| 最大回撤 | **−25.38%** | −25.54% | −46.70% | −46.51% | −52.30% |
| Sharpe | 0.22 | 0.23 | — | — | — |
| Calmar | 0.23 | 0.24 | — | — | — |
| 成交笔数 | 197 | 225 | — | — | — |
`run_id`:闸门开 `fa916050ffb647b6ab4cfd90ba1adf36`,
闸门关 `5347fd13dd8fc0b0a512156489c615bc`(两次资金对账残差均为 0)。
**策略仍跑赢天然基准「中证红利」,回撤不到其一半** —— 但见 §4.6:
单条路径的全期数字有严重误导性。
> **回补数据让全期收益*下降*了 24.6pp(+117.36% → +92.73%)。**
> 这不是 bug,而是把「假象」修掉了:回补前 2015 年因缺少历史分布
> 而 100% 现金(收益 0.00%),回补后 2015 年从第一个交易日起就被
> 真实交易,而那年**亏了 3.18%**(大牛市顶部建仓 + 股灾)。
> 靠数据缺口「躲过股灾」不是策略能力 —— 详情见 §4.4。
> **闸门到底拦掉了什么**:全期 141 个决策时点、2428 次画像计算、
> **1027 次买入信号被剔除**(`REJECT`,可在「未成交信号」里逐条查看原因)。
> 剔除使成交从 225 笔降到 197 笔,收益下降 8.5pp,回撤基本不变。
> 该结果是在**修正了支付率与 FCF 覆盖两个筛选条件之后**取得的
> (此前这两个条件因 `base_share` 漏选而静默失效)。
### 4.2 交易与分红(闸门开)
| 项目 | 数值 |
|---|---:|
| 成交笔数 | 197 |
| 期末资金 | 1,927,312 元 |
| **资金对账残差** | **0.0000** ✅ |
| 画像剔除的买入信号 | 1027 |
| 画像计算次数 / 决策时点 | 2428 / 141 |
### 4.3 逐年收益(回补后重跑,闸门开)
| 年 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 策略(闸门开) | **−3.18%** | +5.91% | +28.25% | **−10.86%** | +39.52% | −1.97% | +3.05% | **−14.13%** | +4.37% | +13.37% | +11.23% | +2.43% |
| 闸门关 | −1.26% | +7.04% | +26.83% | −9.95% | +38.22% | +0.14% | +2.99% | −14.35% | +4.56% | +11.06% | +12.00% | +4.30% |
**2015 年不再是 0,而是 −3.18%(闸门开)/ −1.26%(闸门关)。**
这正是全期收益下降 24.6pp 的来源:回补前 2015 年因数据缺口无法产生信号
(100% 现金、收益 0.00%),回补后它被真实交易,而在牛市顶部建仓
随后遭遇股灾是亏钱的。**「靠数据缺口躲过股灾」不是策略能力。**
### 4.4 预热期与「数据缺口假象」(重要)
> **⚠️ 2026-10-04 定论(见 §9.3b/§9.3c)**:本节历史上曾把
> 「2015–2018 组合 100% 现金」解释为「不做未来函数的代价与证明」。
> 追查后确认那是**两个数据缺陷叠加出的假象**,不是纪律的胜利:
>
> 1. `stock_daily` 量价单位不一致 → 流动性门槛在早年低估 1000 倍,
> **把 2015-2019 的股票池整体清空**(见 §9.1);
> 2. 行情只到 2015-01-05 → 滚动参照窗口填不满,即便有候选也算不出分位
> (见 §9.3b)。
>
> 两者都在 2026-10-03/04 修好。现在:
>
> - **2015-01-05 的 PIT 股票池 = 3 只**(此前 0 只)
> - **5 年窗口覆盖率 98.68%**(此前无法计算)
> - **2015 年从第一个交易日起就被真实交易**
>
> 代价是全期收益从 +117.36% 降到 +92.73% —— **修数据让结果变差,
> 但变真实了**。下面保留历史记录以对照。
> **补充(2026-10-03):预热期只在「按周期重新筛选」时成立。**
>
> 用 `--universe-run` 指定**冻结股票池**时,引擎不解自筛选,历史约束(连续分红 5 年、
> 5 年 ROE 等)不再作用于早期,于是**没有预热期**。实测同一段区间:
>
> | 模式 | 总收益 | CAGR | 最大回撤 | Sharpe |
> |---|---:|---:|---:|---:|
> | 重新筛选(无 `--universe-run`) | 114.80% | 6.73% | **−21.05%** | 0.31 |
> | 冻结股票池(`--universe-run`) | 374.61% | 14.19% | **−53.06%** | 0.57 |
>
> **冻结模式的回撤是两倍以上**,因为它在 2015 年满仓吃到了股灾,而重新筛选模式
> 因预热期空仓躲过。预热期不只是技术细节,它**实质影响风险特征**。
>
> **而且冻结模式本身是未来函数**(股票池 asof 晚于回测起点):现已默认拒绝执行,
> 只能加 `--allow-lookahead-universe` 复现,且该偏差会写入 `unimplemented_json`。
> 详见 §9.2。
>
> 另外,冻结模式曾暴露一个真实缺陷(已修复,见 §4.7):
> 行情首日的滚动窗口只有 1 个观测,分位被算成 100%,8 只股票被误买入。
**2015–2018 年组合保持 100% 现金、无任何成交**,这是**预期行为**而非缺陷:
滚动 5 年分位参照窗口需要历史分布,而本项目数据自 2015-01-05 起
(2015 年前无行情/指标)。在参照窗口填满之前,系统无法计算「历史分位」,
因此不产生信号 —— 这正是**不做未来函数**的代价与证明:
若为了让 2015 年就有信号而放宽窗口,就等于用不足的样本编造分位。
组合自 2019 年起建仓,2020 年起现金占比稳定在 1% 以下(基本满仓)。
### 4.5 参数敏感性(plan.md §27)
扫描买入分位 P70/75/80/85/90 的结果:
| 买入分位 | CAGR | 最大回撤 | Sharpe | 成交 |
|---|---:|---:|---:|---:|
| P70 | 3.03% | −22.76% | 0.07 | 70 |
| P75 | 5.26% | −17.03% | 0.22 | 72 |
| P80 | 2.77% | −21.37% | 0.05 | 66 |
| P85 | 7.27% | −19.75% | 0.39 | 62 |
| P90 | 3.99% | −19.85% | 0.14 | 55 |
系统判读:CAGR 跨度 4.50pp、最大跳变 4.50pp、**平滑度 0.00**、
检出 1 处尖峰(P85)→ 判定为
**「存在尖峰或跳变,疑似过拟合,请谨慎解读」**。
> 这是一个**诚实的不利结论**,也正是 plan.md §27 想要暴露的问题。
> 需注意该扫描是在**修正支付率/FCF 筛选条件之前**、且区间仅 2021–2024、
> 股票池仅数十只的条件下完成的。**应在当前口径下于 10 年区间重跑后才作数。**
### 4.6 Walk-forward 样本外验证(plan.md §23/§25)
7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布:
(下表为 2026-10-04 回补后重跑,`wf_id = e855fdf268827e1e4c31510c6736eea3`,
**实时画像闸门启用**)
> **⚠️ 重要:这 7 个数字与「回补前」逐窗口完全相同。**
> 也就是说,把行情从 2015 补到 2005 **没有改变任何样本外结果**,
> 却让单条路径的全期收益从 +117.36% 变成 +92.73%。
> 原因:样本外的测试窗口是 2020–2026,其参照窗口(冻结在训练段)
> 本就落在 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
> 2015 年是否可交易会改变整条路径。**这正是「不要采信单条路径」的又一例证**
> —— 见 §4.6d。
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | Sharpe |
|---|---|---|---:|---:|---:|
| #0 | 2015–2019 | 2020 | **+12.27%** | −12.39% | 0.52 |
| #1 | 2016–2020 | 2021 | **−2.47%** | −15.89% | −0.29 |
| #2 | 2017–2021 | 2022 | **−3.05%** | −16.68% | −0.32 |
| #3 | 2018–2022 | 2023 | **−11.32%** | −22.97% | −0.95 |
| #4 | 2019–2023 | 2024 | **+12.16%** | −15.63% | 0.50 |
| #5 | 2020–2024 | 2025 | **+6.65%** | −11.58% | 0.33 |
| #6 | 2021–2025 | 2026(部分) | **−6.09%** | −18.39% | −0.73 |
**样本外汇总(闸门开):**
| 指标 | 数值 | 改造前(旧口径) |
|---|---:|---:|
| 盈利窗口 | 3 / 7(胜率 42.86%) | 4 / 7(57.14%) |
| 样本外收益 **均值** | **+1.16%** | −0.95% |
| 样本外收益 中位数 | −2.47% | +2.61% |
| 样本外 CAGR 均值 | +0.85% | −0.78% |
| 最差窗口回撤 | −22.97% | −24.62% |
| **基准收益均值** | **+2.29%** | +2.29% |
| **超额收益均值** | **−1.12pp** | −3.24pp |
> **结论没有变,但程度变轻了**:样本外均值由 −0.95% 转为 **+1.16%**,
> 超额仍为负(**−1.12pp**)。胜率反而从 4/7 降到 3/7 —— 均值改善主要来自
> 2020(+4.01% → +12.27%)与 2022(−9.17% → −3.05%),
> 而 2026 部分年份由 +3.83% 转为 −6.09%。
>
> 这轮数字受**三项**改动影响(量价单位修复、行情回补到 2005、画像闸门);
> 其中**回补的贡献为 0**(逐窗口未变),两者已用对照 run 分离 —— 见 §4.6d。
#### 结论:单路径回测显著高估了策略
对比 §4.1 与本节:
| | 全期单路径回测 | Walk-forward 样本外均值 |
|---|---:|---:|
| 收益 | **+117.36%**(11.7 年) | **+1.16%/年** |
| 相对基准 | +97.7pp | **−1.13pp** |
**这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug:
1. **全期回测允许仓位穿越牛熊**:2016–2019 建的仓在 2022–2023 的下跌中继续持有,
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
训练期校准的阈值在测试期就失灵了。
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大。
**因此:不要采信 §4.1 的 +117.36%。** 更接近真实的表述是
「该策略在 2020/2024/2025 的样本外为正,在 2021/2022/2023/2026 为负,
长期看与基准相比没有稳定的超额收益(−1.13pp),且回撤更小
(最差 −22.97%,而基准同期回撤 −46% ~ −52%)」。
### 4.6c 实时画像闸门的净影响(两个口径结论相反)
同一份代码、同一份数据,只切换 `entry.profile_gate.enabled`
(下表为**回补后**的数字):
**① 全期单路径(2015-01-05 ~ 2026-09-30)**
| | 闸门开 | 闸门关 | 差 |
|---|---:|---:|---:|
| 总收益 | +92.73% | +101.20% | **−8.47pp** |
| CAGR | +5.75% | +6.14% | −0.39pp |
| 最大回撤 | −25.38% | −25.54% | +0.16pp(略好) |
| Sharpe | 0.22 | 0.23 | −0.01 |
| 成交笔数 | 197 | 225 | −28 |
| 买入信号被画像剔除 | 1027 | 0 | — |
| `run_id` | `fa916050…` | `5347fd13…` | |
**② Walk-forward 7 窗口样本外**(`wf_id`:开 `e855fdf2…` / 关 `231d0b29…`)
—— **回补前后逐窗口完全相同**
| | 闸门开 | 闸门关 | 差 |
|---|---:|---:|---:|
| 样本外收益 **均值** | **+1.16%** | −0.00% | **+1.16pp** |
| 样本外收益 中位数 | −2.47% | +3.78% | −6.25pp |
| 盈利窗口 | 3 / 7 | 4 / 7 | −1 |
| 样本外 CAGR 均值 | +0.85% | +0.17% | +0.68pp |
| **最差窗口回撤** | **−22.97%** | −24.22% | **+1.25pp** |
| **超额收益均值** | **−1.12pp** | −2.29pp | **+1.17pp** |
逐窗口(超额 = 策略 − 沪深300):
| 测试年 | 闸门开 | 闸门关 | 基准 | 闸门开超额 | 闸门关超额 |
|---|---:|---:|---:|---:|---:|
| 2020 | +12.27% | +10.17% | +25.51% | −13.23pp | −15.34pp |
| 2021 | −2.47% | −2.84% | −6.21% | +3.74pp | +3.37pp |
| 2022 | −3.05% | −9.17% | −21.27% | **+18.23pp** | +12.11pp |
| 2023 | −11.32% | −9.58% | −11.75% | +0.43pp | +2.17pp |
| 2024 | +12.16% | +3.80% | +16.20% | −4.04pp | −12.40pp |
| 2025 | +6.65% | +3.78% | +21.19% | −14.54pp | −17.41pp |
| 2026 | −6.09% | +3.84% | −7.63% | +1.54pp | +11.48pp |
**两个口径给出相反结论。按本项目的一贯立场 —— 以样本外为准 —— 闸门是改善**
(样本外均值 +1.16pp、最差回撤 +1.25pp、超额 +1.17pp),
虽然它**降低了盈利窗口数**(4→3)与中位数,也就是说改善集中在少数年份。
单条路径之所以给出相反答案:它被「2016–2019 一次建仓 + 2024–2026 回本」这段
**穿越牛熊的持有**主导,而闸门剔除的那些买入恰好在单路径上是赚的。
这正是 §4.6 标题那句话的又一个例证 —— **不要采信单条路径**。
### 4.6d 三维归因:三项改动各自贡献多少
因为每一项都有「开关式」的对照 run,可以逐项分离。**结论是样本外只认前两项,
而回补的贡献为 0。**
**样本外(walk-forward 均值 / 超额 / 最差回撤)**
| 版本 | 样本外收益均值 | 样本外超额均值 | 最差回撤 | 盈利窗口 |
|---|---:|---:|---:|---:|
| 改造前(量价单位错误 + 无闸门 + 数据缺 2015 前) | −0.95% | −3.24pp | −24.62% | 4 / 7 |
| 仅修量价单位(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
| + 数据回补到 2005(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
| + 实时画像闸门(**当前**) | **+1.16%** | **−1.12pp** | **−22.97%** | 3 / 7 |
**单条路径(全期总收益)**
| 版本 | 总收益 |
|---|---:|
| 数据缺 2015 前(改造前口径) | +114.80% |
| + 量价单位修复(闸门关) | +134.06% |
| + 数据回补到 2005(闸门关) | **+101.20%** |
| + 实时画像闸门(**当前**) | **+92.73%** |
**读法**:
1. **回补在样本外贡献 0、在单路径贡献 −32.9pp。** 样本外的测试窗口是
2020–2026,参照窗口本就落在 2015 年之后,不依赖 2005–2014;
而单路径从 2015 年起,回补让 2015 年(牛市顶 + 股灾)从「被数据缺口
挡住」变成「被真实交易」(−3.18%),整条路径随之改变。
**同一项数据修正,在两个口径下的"效果"相差 32.9pp —— 这就是为什么
本项目坚持只认样本外。**
2. **闸门在两个口径下依然相反**(样本外 +1.16pp、单路径 −8.47pp),
与回补前的结论一致。
3. **结论没有变**:超额仍为负(−1.12pp),当前策略依然没有稳定的
样本外超额收益。
> 需要提醒的是:+1.16% 的样本外均值建立在 **7 个观测**上,
> 标准误很大。不要把「从 −0.95% 到 +1.16%」读成「策略变好了」。
> 值得注意的是,策略的**回撤控制**在样本外依然稳定成立
> (最差 −22.97%,而基准同期回撤 −46% ~ −52%),
> 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**,
> 而非超额收益来源。
## 5. 已知限制(如实声明)
1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
2. **涨跌停与停牌约束覆盖 2010 年起**(2026-10-04 回补,原为 2019 起);
回测区间 2015-01-05 起已**全部**有真实约束,不再是近似建模。
2010 年之前的涨跌停价 Tushare 无数据(实测 2005 年 `stk_limit` 返回 0 行)。
3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。
4. **`index_weight` 为空**:不影响基准收益计算(用指数点位),
仅影响成分股分析。
5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。
6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。
7. **不再有「预热期空仓」**:2026-10-04 把行情回补到 2005 后,滚动 5 年分位
在 2015-01-05 起即可计算,**2015 年(大牛市 + 股灾)从第一个交易日起
就被真实交易**。此前的「2015 年 100% 现金」不是设计,而是数据缺口造成的
假象(见 §9.3b/§9.3c)。这也使全期收益下降 —— 见 §4.4。
8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。
9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026),
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻。
10. **幸存者偏差尚有 3 只的缺口**:`stock_daily` 里有 3 个代码
(`000022.SZ`、`000043.SZ`、`300114.SZ`,均因吸收合并/重组退市)
不在 `stock` 表中,因此永远不会进入候选集。占 5,903 只的 0.05%。
根因是 `stock` 表只收录在市股票,补它需要扩展 `sync` 的
`backfill_tables` 白名单,属独立的数据补全工作。
11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码**
(其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除;
SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。
---
## 6. 如何运行
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
# 1) 建表(幂等)
.venv/bin/python -m hdiv ddl apply
# 2) 数据同步(按需)
.venv/bin/python -m hdiv sync dividend --only-missing
.venv/bin/python -m hdiv sync financial --interleaved --only-missing
.venv/bin/python -m hdiv sync index
.venv/bin/python -m hdiv sync trading --start 2019-01-01
HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回补
# 2b) 日常增量:缺几天抓几天(首次全量之后,每天只需要这一条)
.venv/bin/python -m hdiv sync daily --dry-run
.venv/bin/python -m hdiv sync daily
./deploy/install-sync-schedule.sh install # 注册为每天 17:00 的 launchd 任务
# 3) 数据审计(含 HTML)
.venv/bin/python -m hdiv audit
# 4) 股票池 → 画像
.venv/bin/python -m hdiv universe --asof 2024-06-28
.venv/bin/python -m hdiv profile --universe-run <run_id>
# 5) 策略登记与回测
.venv/bin/python -m hdiv strategy register
.venv/bin/python -m hdiv backtest --start 2021-01-01 --end 2024-06-28
.venv/bin/python -m hdiv backtest --mode walkforward
.venv/bin/python -m hdiv sensitivity
# 6) 校验报告离线可用性
.venv/bin/python -m hdiv report validate
# 7) 测试
.venv/bin/python -m pytest tests/ -q
```
报告输出在 `output/`,从 `output/index.html` 进入。
---
## 7. 可修改的配置
| 文件 | 控制什么 |
|---|---|
| `config/universe.yml` | **股票筛选条件**(市值/上市年限/连续分红/股息率/ROE/行业豁免…) |
| `config/profile.yml` | **个股特性**(统计窗口/分位/波动频率/安全边际权重/TTM 口径) |
| `config/strategy/high_dividend_v1.yml` | **策略定义**(买卖分位/建仓阶梯/仓位上限/风控/生命周期状态) |
| `config/cost.yml` | 佣金/印花税/过户费/滑点/红利税 |
| `config/backtest.yml` | 区间/调度/分位参照口径/Walk-forward/基准/撮合 |
| `config/report.yml` | 图表开关/输出命名/版面/资源模式 |
| `config/datasource.yml` | 数据库/只读白名单/回补许可/Tushare 限频 |
修改任一文件后 `config_hash` 变化,历史 run 仍可完整复现。
---
## 7.1 报告部署(重要)
报告通过**相对路径**引用图表库:``<script src="assets/echarts.min.js">``。
因此发布时必须**整体部署 `output/` 目录**,包括 `output/assets/`。
例如把 `output/` 映射到 `/ggx/`,则下列两者都必须可达:
```text
http://<host>:8080/ggx/index.html ← 报告
http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1 MB)
```
若只复制了 `*.html`,页面能打开但**所有图表都不会显示**。
若你的部署方式无法附带 `assets/` 目录,改用自包含模式:
```yaml
# config/report.yml
asset_mode: inline # 每份 HTML 内嵌图表库,体积由约 10MB 增至约 79MB
```
改完重新生成报告即可(`hdiv report index` 或对应报告命令)。
**部署前自检**(会真实执行 `node --check` 校验每份报告的内联 JS):
```bash
hdiv report validate # 或:python -m hdiv.report.validate output
```
## 7.2 Web 前端(前后端分离)
系统提供统一 Web 前端,替代原先平铺的静态报告:
| 能力 | 实现 |
|---|---|
| 统一入口 | `output/index.html`(hash 路由单页应用) |
| 股票池记录管理 | 列表 / **命名** / **归档** / **软删除** / 打开股票清单 |
| 个股画像 | 点击清单里的个股直接打开画像页(四联图 + 分位 + 雷达) |
| 股票池 ↔ 回测 | `hdiv backtest --universe-run <run_id>` 建立关联,双向可见 |
| 回测主页面 | 只显示**条件与说明**,点击进入详细结果 |
| 详细结果 | 总结果 + 净值曲线 + 绩效指标 + **逐笔成交与理由** |
| 目录归一化 | `hdiv site normalize` 归档历史、静态报告入 `reports/` |
**后端用 Python 标准库实现**(`ThreadingHTTPServer`):零新增依赖、
一条命令启动、nginx 只需反代 `/api`,无版本漂移风险。
部署:`deploy/nginx.conf.example`(两种布局)+ `deploy/serve.sh`(启停脚本)。
## 4.6b 样本外超额的真相:牛市跑输、熊市跑赢(2026-10-03 补充,同日重跑更新)
补上基准对比后(此前接口把 `benchmark_<code>` 行过滤掉了,页面上看不到超额),
逐窗口的超额呈现**高度规律**的形态:
| 测试年 | 策略 | 沪深300 | 超额 | 市场 |
|---|---:|---:|---:|---|
| 2020 | +12.27% | +25.51% | **−13.24pp** | 牛市 |
| 2021 | −2.47% | −6.21% | **+3.74pp** | 熊市 |
| 2022 | −3.05% | −21.27% | **+18.22pp** | 熊市 |
| 2023 | −11.32% | −11.75% | **+0.43pp** | 熊市 |
| 2024 | +12.16% | +16.20% | **−4.04pp** | 牛市 |
| 2025 | +6.65% | +21.19% | **−14.54pp** | 牛市 |
| 2026 | −6.09% | −7.63% | **+1.54pp** | 熊市 |
**4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。** 超额胜率 57.1%。
因此更准确的表述不是「没有超额收益」,而是:
**它是一份低 beta 的防御型配置 —— 用牛市的大幅跑输换取熊市的相对抗跌**。
超额均值 −1.13pp 是样本内牛熊比例的结果,而非策略「无效」;
在熊市占比更高的样本里,这个均值会转正。
**判读时必须分开看牛熊**,只看均值会得出误导性结论。
> **重跑后这个形态反而更清晰**:牛市跑输的幅度收窄
> (−21.50 → −13.23、−13.58 → −4.04、−17.41 → −14.54),
> 熊市跑赢的幅度扩大(2022:+12.11 → +18.23)。
> 归因已用「闸门关闭」的对照 run 分离(§4.6d):两项改造在样本外都是正向的,
> 其中闸门的贡献集中在 2022/2024/2025 三个年份。
## 4.7 已修复:退化分布伪造 100% 分位(2026-10-03)
**现象**:明细里出现「股息率 0.00%;历史分位 100.0%」并触发买入。
**根因**:分位定义为「≤当前值的观测占比」。当参考窗口只剩 1 个观测、
且恰好等于当前值时,占比恒为 100%,足以击穿任何买入阈值。
触发条件:回测起点早于行情数据起点时,滚动窗口伸进空区间。
实测 2015-01-06(行情数据首日)窗口 2010-2015 只有 **1 个观测**,
**8 只股票**因此被买入 —— 且买在 2015 年股灾前的高点。
**影响面**:该次回测 217 笔成交中有 **22 笔(10.1%)** 参考样本不足 250 天。
**修复**:新增 `backtest.yml: percentile_reference.min_observations`(默认 250 ≈ 一年),
窗口样本不足则**当日对该股不产生任何信号**(保持现状,不买不卖),
并把 `min_observations` 一并写入 `reason_json` 便于事后核查。
**修复效果**:弱样本成交 22 → 0;同区间总收益 289.73% → 374.61%
(去掉的是股灾前的错误买入,因此反而更好)。
> 这个缺陷说明:**分位类指标必须带最小样本量**。
> 与 §9.6 绩效指标的 `MIN_OBS_FOR_RISK` 是同一类问题 —— 样本不足时
> 正确做法是「不判断」,而不是照常输出一个看似合理的数字。
## 7.3 Walk-forward 前端与重跑覆盖(2026-10-03)
**补上 Walk-forward 前端入口**:此前 `hd_walkforward_run` 有数据但**没有任何接口或页面**,
跑完 25 分钟在界面上看不到任何东西。现新增 `/api/walkforwards`(列表)、
`/api/walkforwards/{wf_id}`(详情)与 `#/walkforwards` 页面,
逐窗口展示样本内/样本外收益、CAGR、Sharpe、最大回撤、成交数与**冻结阈值**。
**⚠ 判读更正(同日)**:初版页面把「训练段累计收益」与「测试段累计收益」并排比较,
得出「样本内 7.0%→60.7%、样本外仅 −0.95%,明显过拟合」的结论 —— **这是错的**。
训练段是 **1825 天(5 年)**、测试段是 **364 天(1 年)**,两者累计收益不可比。
换算年化后:
| 窗口 | 训练年化 | 测试年化 |
|---|---:|---:|
| #0 | 1.37% | **4.02%** |
| #2 | 5.01% | −9.29% |
| #6 | 9.52% | 5.25% |
窗口 #0 的样本外年化**高于**样本内。7 个窗口里 6 个是样本外年化低于样本内,
方向存在但幅度远没有累计口径显示的那么夸张。
页面与表格已改用**年化**口径,并在图下注明区间长度差异。
**筛选记录改为重跑覆盖**:`run_id` 原先含 `datetime.now()`,
导致同一 asof 反复重跑不断累积(2025-01-21 累积了 11 条内容相同的记录)。
现改为 `stable_id(名称, 时点, config_hash)` —— 不含时间,同输入即同 id,
重跑原地覆盖运行头、成员清单与因子快照。
刻意**不含 `data_version`**:用户要的是「这一天的筛选结果」,
而不是「每次数据快照各存一份」;每次运行实际使用的 data_version 仍完整记录可追溯。
候选集缩小时(新股上市/退市),上次存在而本次不再出现的成员被标记为
`fail_stage='stale'`(UPDATE,非 DELETE),以符合「禁止物理删除」的约束。
界面上的命名/备注/归档状态不在更新列中,重跑不会清掉用户标注。
## 7.4 已修复:`--universe-run` 在 walk-forward 下被静默忽略(2026-10-03)
`hdiv backtest --universe-run X --mode walkforward` 中,`--universe-run` **完全没有生效** ——
`WalkForwardRunner` 根本没有这个参数,CLI 也没校验,于是参数被丢掉且无任何提示。
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
**但正确做法不是「支持」它,而是拒绝**:
| | 时点 |
|---|---|
| 股票池 `b8dd742f…` | asof = **2025-01-21** |
| walk-forward 最早训练区间 | **2015-01-01** 起 |
把 2025 年选出的股票池套到 2015 年的训练窗口上,就是用未来信息选股 ——
恰好破坏了 walk-forward 要守护的无未来函数纪律。
现在该组合会明确报错并说明原因与替代方案。同时新增两层回归测试:
CLI 必须拒绝,且 `WalkForwardRunner` 的签名里不得出现 `universe_run_id`(防止日后被误加回去)。
同类问题:`selector.py` 中 `df["is_fresh"].fillna(False)` 触发 pandas
Downcasting `FutureWarning`(pandas 未来版本会改变行为,可能让筛选结果静默变化)。
已改为 `astype("boolean").fillna(False).astype(bool)`,语义不变;
并新增测试用 `-W error::FutureWarning` 跑筛选路径。
## 7.5 已修复:显示精度配置是死的(2026-10-03)
**现象**:改了 `config/report.yml: layout.decimals.ratio` 对股息率等百分比**毫无影响**。
**根因**:`layout.decimals` 三个设置里,**只有 `price` 曾被读取过一次**
(renderer 的价格格式化)。`ratio` 与 `money` 从未被任何代码引用 —— 纯摆设。
而百分比显示散落在 **20 余处硬编码**:
| 位置 | 原实现 |
|---|---|
| `profile_report._pct` | `f"{x*100:.2f}%"` |
| `backtest_report._pct` / `_fmt_metric` | `f"{x*100:,.2f}%"` |
| `universe_report._pct` | `dec: int = 2`(默认写死) |
| `sensitivity_report._pct` / `walkforward_report._pct` | `f"{x*100:,.2f}%"` |
| `web/service._reason_text`、`web/analysis._reason_text` | 成交理由里的股息率写死 2 位 |
| `analysis/sensitivity.py`、`analysis/performance.py` | CLI 输出写死 |
| `web/app.js` 的 `pct()` | 前端写死 `toFixed(2)` |
**修复**:新增 `src/hdiv/report/format.py`(`NumFmt`),所有格式化统一走它,
精度由配置驱动;前端通过 `/api/config/display` 获取精度,也由配置驱动。
**语义**(与用户确认):`decimals.ratio` 指**原始比率**的小数位。
比率保留 ratio 位后乘 100,恰好少两位 —— 即 **百分比小数位 = ratio - 2**:
| ratio | 原始比率 | 股息率显示 |
|---:|---|---|
| 2 | 0.06 | 6% |
| 4 | 0.0617 | 6.17% |
| 6 | 0.061715 | 6.1715% |
并加了两条硬断言:`src/` 全域不得再出现 `:.2f}%`,前端必须存在 `FMT.percent`。
## 7.6 已修复:股息率毛刺与口径分裂(2026-10-03)
### 问题一:除权间隔不规整造成的毛刺
A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每年除权日附近
制造两种**日历假象**:
| 类型 | 成因 | 实测 |
|---|---|---|
| **重叠虚高** | 间隔 < 365,新旧分红同时在窗口内 | 招商银行 2015-07-03:0.620 → **1.290**(+108%),10 天后回落 0.670 |
| **断档虚低** | 间隔 > 365,旧的已到期新的未入场 | 中国神华 2016-07-04:0.740 → **0.320**(−57%) |
旧实现只在**结果恰好为 0** 时用 `grace_days` 兜底,而实际跌落是**部分跌落**
(1.290→0.670 不是 0),所以完全没兜住。实测:
| 股票 | 修复前 >20% 跳变 | 修复后 | 修复前虚低归零天数 |
|---|---:|---:|---:|
| 招商银行 | 21 | **5** | — |
| 工商银行 | 25 | **7** | — |
| 中国银行 | 25 | **7** | **77 天** |
| 伊利股份 | 24 | **13** | **68 天** |
**修法**:把「硬窗口」换成「按后继接管」。对每次分红 i,若与下一次的间隔
落在 `window_days ± grace_days`(即 320~410 天),视为同一档年度分红,
计入区间延到 `min(下一次除权日, 除权日 + 365 + grace)`:
- 间隔略小于一年 → 后继提前接管,**消除重叠虚高**
- 间隔略大于一年 → 旧的计到新的入场,**填补断档虚低**
- 超过 `365 + grace` 仍无后继(真停发)→ 封顶,**如实归零**
- 间隔 < 320 天视为**年内多次分红**(中期+年度),互不取代 ——
否则会把中期分红误删,人为制造新的低点
### 问题二:同一个「股息率」在四处口径不同
修复过程中发现更严重的问题:TTM 参数在四个调用点来源不一。
| 调用点 | 修复前 |
|---|---|
| `profile/builder.py` | ✓ 读 `profile.yml` |
| `backtest/engine.py` | ✗ **硬编码 365 / 45** |
| `backtest/walk_forward.py` | ✗ 用函数默认值 |
| `web/analysis.py` | ✗ 用函数默认值 |
| `universe/filters/dividend.py` | ✗ **另写了一遍** trailing-12月求和 |
后果:改 `profile.yml` 只有画像会变;更糟的是**筛选器用的股息率与画像/回测不一致**,
而这是**直接决定选股**的数字。
**修法**:新增 `ttm_params()`(单一事实来源)与 `ttm_dps_at()`(单点求值),
五处全部改用同一实现,参数统一来自 `profile.yml: ttm_dividend`。
### 影响与后续
因子值变化约 **1.2% 的交易日**(每只股票 11 年约 30~64 天,即原先的毛刺日),
最大单日差异达 60%+。由于股息率是买卖信号的直接输入:
- **已有的画像与回测结果已过期**,需重跑
- 筛选结果需重新生成(`hd_universe_run` 会原地覆盖)
## 8. 测试覆盖
```
403 passed(pytest 退出码 0)
```
| 测试文件 | 覆盖 |
|---|---|
| `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
| `test_schema.py` | 31 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
| `test_profile_pit.py` | **实时画像与批量画像逐值等价**、公告日/除权日 PIT 负例、惰性与面板复用、窗口校验、**窗口覆盖率(含左开右闭分母)**、**输入行序无关性** |
| `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数、**画像闸门(剔除/放行/不动仓位/无法验证)**、**未实现声明的诚实性** |
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run` 的未来函数守卫) |
| `test_web.py` | 前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 |
---
# 9. 三项正确性改造(2026-10-03)
三项都是「不报错、只让结果悄悄错」的类型,因此都配了负例测试与如实声明。
## 9.1 已修复:`stock_daily` 量价单位前后不一致
**现象**:2015-01~2019 的股票池被流动性门槛整体清空 —— 实测按当时可见数据筛选,
**2016/2017/2018 各得到 0 只**,2019 只有 3 只;而 `market` 过滤本应留下几十只。
**根因**:`stock_daily` 是「追加进既有 qlib 库」的表。
| 区间 | 来源 | `volume` 单位 | `amount` 单位 |
|---|---|---|---|
| 2015-01 ~ 2019 | 本项目从 Tushare 回补 | 手 | 千元 |
| 2019(同日混合) | 两者重叠 | 3596 行里 337 行已换算 | — |
| 2020 ~ | qlib 存量 | 股 | 元 |
而 `universe.market.min_avg_amount_20d: 20000000` 是按「元」写的,
`Repo.avg_amount` 又直接 `AVG(amount)` —— 于是早年门槛实际变成
**「日均成交额 ≥ 200 亿元」**。`units.py` 里的 `amount_qian_to_yuan`
写了但**从未被调用**,`sync.price.daily_frame` 也是原样落库;
审计的单位自检只看 `daily_basic.total_mv`,所以一直没报警。
**修复(两层)**:
1. 写入端 `sync.price.daily_frame`:`vol ×100`、`amount ×1000`
2. 读取端 `units.normalize_ohlcv_units`:按行判据
`成交额 / (成交量 × 收盘价)`(≈1 已换算 / ≈0.1 原始)判定并换算,**幂等**;
`Repo.price_history` 与 `Repo.avg_amount` 都走它
3. 审计新增 `UNIT-OHLCV`:逐年抽样列出仍为原始单位的行数(防回归)
**效果**(同一份配置、同一批数据):
| asof | 修复前入选 | 修复后入选 |
|---|---:|---:|
| 2016-02-01 | 0 | **7** |
| 2017-02-01 | 0 | **11** |
| 2018-02-01 | 0 | **13** |
| 2019-02-01 | 3 | **14** |
| 2024-12-31 | 46 | 46 |
> **这更正了 §4.4 的一条叙事。** 原文把「2015–2018 组合 100% 现金」
> 归因为「不做未来函数的代价与证明」。真实原因至少有一部分是**单位 bug 把股票池清空了**。
> 预热期(滚动 5 年分位参照需要 250 个观测)确实存在,但它不是唯一原因。
**未做**:没有重刷 2015-2019 的存量数据(写入是 `INSERT IGNORE`,
不动既有行是硬约束)。读取层兜底已使结果正确,存量数据的清理留给一次显式的数据维护。
## 9.2 已修复:单次回测里 `--universe-run` 的未来函数
**现象**:`hdiv backtest --universe-run <股票池>` 会把该股票池的成员
**冻结**到所有调仓日。若股票池的 `asof` 晚于回测起点,2015 年的选股就用了
2025 年的信息。实测库中 `247724798ec2…`:引用股票池 `b8dd742f…`(asof **2025-01-21**),
回测 2015-01-05 起,**2015-01-06 就有 8 笔成交**。
**为什么必须改**:项目自己在 §7.4 已经认定「把 2025 年选出的股票池套到 2015 年
的训练窗口上就是用未来信息选股」,并因此在 walk-forward 下**拒绝**该组合 ——
同一条理由对单次回测同样成立,此前却没有拦。
**修复**:引擎在执行前校验股票池 `asof` 与回测起点:
| 情形 | 行为 |
|---|---|
| `asof <= 回测起点` | 正常执行(此时股票池属于事前信息) |
| `asof > 回测起点` | **拒绝执行**,错误信息给出三种正确做法 |
| 显式 `--allow-lookahead-universe` | 放行,并把偏差写入 `hd_backtest_run.unimplemented_json` |
**测试**:`test_future_universe_is_rejected_by_default` 用**库中最晚 asof** 的股票池
跑更早区间,断言必须抛错且错误信息含放行开关;同时断言 `asof <= 起点` 时正常工作。
## 9.3 新增:实时(PIT)个股画像闸门
**需求**:交易依据要与实际情况相符 —— 2018-05-18 的决策依据应当是
**2013-05-18 ~ 2018-05-17 的画像**,即按当时可见的数据实时重算画像,
剔除「不值得买」的票。
**改造前的真实状态**(这一点必须先说清楚):
| 层 | 是否 PIT |
|---|---|
| 股息率分位(唯一的交易依据) | ✅ 已经是滚动 5 年、只用 `<= 当日` 的数据 |
| 股票池(逐 12 个月重建) | ✅ PIT;但 `--universe-run` 冻结时 ❌(见 §9.2) |
| `hd_profile_stat`(个股画像) | ⚠️ 是 asof 的**快照**,且**回测从不读取它** |
也就是说:改造前画像既不参与交易,也不存在「按每个决策日重算」的形态。
**新增 `src/hdiv/profile/pit.py`**:
- `PitProfileService.snapshot(symbol, asof)` —— 按当时可见数据重算画像。
**指标定义复用 `ProfileBuilder._profile_one`**(同一定义来源),
有**逐值等价测试**保证「回测里的画像」==「页面上的画像」
- `evaluate_gate(rules, snapshot, on_unverifiable)` —— 逐条判定,
三种结局:`PASS` / `REJECT` / 无法验证(按配置保守或放行)。
指标缺失与样本不足**绝不当作 0**
**引擎接入**(`entry.profile_gate`):只在**买入条件已触发之后**才计算 ——
这是「在触发条件的时候计算」的落点。被剔除时产出信号类型 `REJECT`、
`skip_reason = PROFILE_GATE`,前端「未成交信号」里可直接看到
**每个指标的实际值、阈值、状态与是否通过**。
**成本控制**(对应「长周期数据可以沿用」):
| 手段 | 效果 |
|---|---|
| 只在触发时计算 | 成本 ∝ 触发次数,而非「区间长度 × 股票数」 |
| 跨股票共享面板按时点缓存 | 同一 asof 的财报面板只载入一次 |
| 按规则声明所需指标 | 规则里没有财务指标时**完全不查财报表**(各约 30 万行) |
| 财务查询加 symbol 过滤 | 单次 1076ms → 41ms(语义不变,仅追加 `symbol IN (...)`) |
实测一段 2016 全年回测:20 个决策时点、46 次画像计算、
20 次财报面板载入、**0 次流动性查询**(规则未用到)、剔除 18 次。
**等价性测试抓到的两个真实缺陷**(都是「同一指标两个值」):
1. `ttm_dps` 被产出两行 `(ttm_dps, 0)`(序列循环 + 分红质量各一次),
落库后谁胜出取决于写入顺序 → 已删除重复来源
2. `free_cashflow` 同名不同义:`fin_latest`(最近一期)与
`_payout_and_cover`(**与分红同一财年**)。实测格力电器 2018-05-18
两个值分别是 67.1 亿与 70.5 亿 → 后者改名 `dividend_fy_free_cashflow`
3. 实时侧分红超集的下界算错:按 `end` 回看 13 年 →
2018 年的画像拿不到 2006-2012 的分红,格力 `dividend_continuity_years`
被算成 4(真值 10)→ 改为按**回测起点**计算超集下界
**默认策略已启用**(`config/strategy/high_dividend_v1.yml` 的
`entry.profile_gate.enabled: true`),因此**此前所有回测数字都已重跑**。
重跑后的净影响见 §4.6c/§4.6d:**样本外是改善,单条路径是变差。**
**一个必须知道的取舍**:`on_unverifiable: reject` 是默认值,它比股票池筛选
(`universe.dividend.on_missing_data: pass`)更严格。实测 2016 年初的中石化:
最新可见分红属于 FY2015,而 FY2015 年报要到 3 月才公告 ——
**支付率在当时根本无法验证**。`reject` 会放弃买入,`pass` 会照买。
这是策略取舍得由使用者决定,不是 bug。
## 9.3b 窗口覆盖率:「名义 5 年」vs「真有 5 年」(2026-10-03 补充)
**问题**:用户要求「滚动计算过去 5 年的个股画像」。机制在 §9.3 已实现,
但 `window_slice(asof, 5)` 的语义只是「把**已有**数据切成最近 5 年」——
数据起点晚于窗口左端时,窗口会被**静默截短**,而 `status` 仍报 `OK`
(`_stat_row` 的门槛是 `n_obs >= min(min_obs_days, 20)`,即 20 个观测就放行)。
**实测(600036.SH,`dv_yield`,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%** |
截断的指纹很明显:**2018-05-18 的四个窗口(0/5/8/10)报出完全相同的 `n_obs=817`**。
**根因是数据缺口,不是代码**:`stock_daily` / `daily_basic` / `adjust_factor`
都只从 **2015-01-05** 起(分红、财报、指数、ST 历史都覆盖到 1990 年代)。
因此任何早于 2020-01 的 asof,其 5 年窗口都不完整。
**这次补上的三件事**:
1. **量化**:新增 `profile/coverage.py`,用**交易日历的真实开市天数**作为分母
(不是 243 这种近似),区间口径与 `window_slice` 严格一致(左开右闭 ——
否则覆盖率永远差一天、`min_window_coverage=1.0` 会变成「永远拒绝」)
2. **暴露**:`ProfileSnapshot` 新增 `n_obs` / `coverage`;
gate 的 `checks[]` 记录 `n_obs` 与 `window_coverage`;
`hdiv profile` 打印覆盖率警告
3. **可强制**:策略新增 `entry.profile_gate.min_window_coverage`
(0 = 不因覆盖率淘汰,保持改造前行为;1.0 = 名义 5 年必须真有 5 年数据)
**顺带修正的两个缺陷**:
- `hdiv sync backfill` 的 `basic_start` 未暴露给 CLI(函数默认 2015-01-01),
于是「回补 2010–2014」实际只补了行情与复权因子、**`daily_basic` 仍停在 2015**。
已新增 `--basic-start`,缺省跟随 `--start`。
- `config/profile.yml: sufficiency` 的三个阈值
(`min_history_years_dividend/price`、`min_dividend_records`)
**从未被任何代码使用** —— 与 §7.5 记录的「显示精度配置是死的」同类问题。
现已在已知限制中如实声明;真正的充分性判定由
`min_window_coverage` + `on_unverifiable` 承担。
**回补方案**见 [user-guide §6.6](user-guide.md)。回补是 `INSERT IGNORE`(只追加)。
## 9.3c 回补已执行:行情补到 2005,5/8/10 年窗口全部补齐(2026-10-04)
按 §9.3b 的方案执行完毕。**Tushare 探针实测三个接口在 2005 年都有数据**
(此前担心早年无数据,实际有),因此一次性补到 2005,让
`profile.yml: windows_years = [5, 8, 10]` **三个窗口**都完整。
### 数据量变化
| 表 | 回补前 | 回补后 | 新增 | 新起点 |
|---|---:|---:|---:|---|
| `stock_daily` | 12,079,377 | 16,007,169 | +3,927,792 | **2005-01-04** |
| `adjust_factor` | 12,205,794 | 16,789,137 | +4,583,343 | **2005-01-04** |
| `daily_basic` | 11,663,360 | 15,972,821 | +4,309,461 | **2005-01-04** |
| `hd_suspend` | 67,765 | 468,388 | +400,623 | **2010-01-04** |
| `hd_limit` | 9,049,902 | 14,837,155 | +5,787,253 | **2010-01-04** |
- 命令:`hdiv sync backfill --start 2005-01-01 --end 2014-12-31 --basic-start 2005-01-01 …`
与 `hdiv sync trading --start 2010-01-01 --end 2018-12-31`
- 11,655 次 API 调用,全程 **0 次限频**(把 `daily/adj_factor/daily_basic`
的限频从 480 下调到 170 —— 实测该 token 在约 196 次/分钟即被拒,
「撞墙后冷却 62 秒」远慢于平滑配速)
- 校验:三张表均「只增不减」✅;新写入行已是**正确的「股/元」单位**(实测比值 1.005~1.018)
### 覆盖率:目标达成
| asof | 回补前 5 年覆盖 | 回补后 5 年覆盖 |
|---|---:|---:|
| 2015-01-05 | —(数据起点之外) | **98.68%** |
| 2016-12-30 | 39.79% | **99.01%** |
| 2018-05-18 | 67.02% | **99.10%** |
| 2021-06-30 | 100% | 100% |
**残下的约 1% 已逐日核实为真实停牌,不是数据洞**:600036.SH 在
2010-01-05~2015-01-05 的 5 年窗口内缺 16 个交易日,把它们与
`hd_suspend` 交叉比对,**16/16 全部命中停牌记录**
(2010-03-05~03-12、2013-08-28~09-04 两个整周正是招行的配股停牌)。
### 顺带修掉的两个「同一类」缺陷
回补过程暴露了两处与 §9.1 同源的**固定阈值**问题(早年市场只有一千多只股票,
固定阈值会把正常数据判成异常):
1. **断点续传失效**:`fetched_days` 用固定 `MIN_SYMBOLS_PER_DAY = 1500` 判定
「某日是否已完整同步」。2005-2009 每天只有 ~1,350 只 → **每一天都被判成未完成**,
回补一旦中断就要从第一天重来。已改为
`max(绝对下限, 比例 × 当年应有上市股票数)`(新增 `datasource.yml: sync` 段配置),
实测阈值 2005 年 829、2015 年 1,732、2024 年 3,397。
2. **审计误报**:`G2/G2b/G3` 同样用固定 2,000 只判定「疑似数据稀疏」,
回补后把 2005 年正常数据报成 WARN。已改为复用同一套按年份阈值。
审计结果由 **OK=11 / WARN=8** 改善为 **OK=14 / WARN=5 / FAIL=0**。
### 效果
| 项 | 回补前 | 回补后 |
|---|---|---|
| 2015-01-05 的 PIT 股票池 | 0 只 | **3 只** |
| 2015 年能否交易 | 不能(无历史分布) | **能,从第一个交易日起** |
| 涨跌停/停牌约束覆盖 | 2019 起 | **2010 起**(2015-2018 不再是近似建模) |
> 这也意味着 **§4 的全部回测数字都需要再次重跑** —— 2015 年(大牛市 + 股灾)
> 现在会被真实交易,而此前该年是 100% 现金。
> `min_window_coverage` 保持默认 `0.0`:覆盖率现在已足够高(≥98.7%),
> 强制 1.0 只会因个别停牌日误杀。
## 9.4 这三项改造带来的口径变化(重跑时必须知道)
| 变化 | 影响 |
|---|---|
| 量价单位修复 | 2015-2019 的股票池从 0~3 只变为 7~14 只,早期不再是纯预热期;样本外均值 +0.95pp |
| `--universe-run` 守卫 | 历史「冻结股票池」回测无法再原样复现,需加 `--allow-lookahead-universe` 且结果被标注为含未来信息 |
| 闸门默认启用 | 买入被进一步过滤(全期剔除 998 次);样本外均值再 +1.16pp、最差回撤 +1.25pp,但单路径收益 −16.7pp。`enabled: false` 可关闭以对比 |
| `ttm_dps` / `free_cashflow` 去重改名 | `hd_profile_stat` 的既有画像快照需重跑;`dividend_fy_free_cashflow` 是新指标代码 |
**回补后(当前数据状态)的四个基准 run(可直接复核)**:
| 用途 | run_id |
|---|---|
| 单次回测,闸门开 | `fa916050ffb647b6ab4cfd90ba1adf36` |
| 单次回测,闸门关 | `5347fd13dd8fc0b0a512156489c615bc` |
| Walk-forward,闸门开 | `e855fdf268827e1e4c31510c6736eea3` |
| Walk-forward,闸门关 | `231d0b298a007e1fb68f2e20a6cc42a9` |
(回补前的四个 run 为 `1a7e5b72…` / `e6e65382…` / `697a2ecd…` / `ae296c0f…`,
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
---
## 10. 每日动态股票池(`--mode daily`,2026-10-04 新增)
### 10.1 它补的是哪个空缺
原有两种回测各有各的问题,都回答不了「股票池每天都在变」这件事:
| | 原有 `--mode single` | 原有 `--mode walkforward` | **新增 `--mode daily`** |
|---|---|---|---|
| 股票池 | 每 12 个月重筛一次(`universe_refresh_months`) | 每个窗口按 asof 重筛 | **每个交易日**重筛 |
| 信号 | 每月评估(`signal_frequency_months`) | 每月 | **每个交易日** |
| 时间结构 | 一条 `[start, end]` | 多个 `(train, test)` | 一条 `[start, latest]` |
| 阈值口径 | rolling | 测试段冻结训练分布 | rolling(无训练段) |
| 目的 | 全期表现 | **过拟合检验** | **单路径连续推演** |
`daily` **不是** walk-forward 的替代品:它没有训练段,因此**不检验**参数稳定性。
它检验的是另一件事 —— 「动态股票池下这条路径长什么样」。
### 10.2 动态池的语义(这是本功能的核心决定)
| 决定 | 取值 | 理由 |
|---|---|---|
| 持仓掉出当日池子 | **只减不加**(`pool_exit_action: hold`) | 池子回答「今天能**买**什么」,不回答「必须卖什么」。掉出即清仓会把「市场先生没报价」误判成「公司变坏了」 |
| 买卖决策的画像 | **买卖都算,且不区分是否在池内** | 卖出更需要「当时它长什么样」的证据;池外持仓被卖出时尤其如此 |
| 每日选股留痕 | 落库 `hd_daily_universe` | 事后可回答「某天为什么是这些股票」 |
| 可选:掉出即清仓 | `pool_exit_action: sell` | 供对比用,不是默认 |
### 10.3 实测(2024-03-01 ~ 2024-03-29,21 个交易日;另有 58 日区间见 §10.4)
```
选股 21/21 个决策日,池内 21~24 只,累计出现过的股票 25 只
股票池变动:累计进入 4 次 / 移出 7 次(平均每日 0.2 进 0.3 出)
市场候选 5353~5359 只 → 预剪枝后实际筛选 183~184 只 → 入选 21~24 只
绩效:−5.92% / 最大回撤 −8.71% / 成交 14 笔 / 对账残差 −0.0000 ✓
画像:计算 244 次(缓存命中 75),画像剔除 31 次
落库:hd_daily_universe 477 行 / 21 个时点
58 日区间(2024-01-02 ~ 2024-03-29)的信号分布:
信号:TRIM 284 / ADD 261 / BUY 183 / REJECT 163 / HOLD 45
skip_reason:NO_CASH 334、BELOW_MIN_TRADE 284、PROFILE_GATE 163、
ALREADY_AT_TARGET 110、OUT_OF_UNIVERSE 45
画像留痕:773 条信号带 reason_json.profile
```
- **`HOLD 45` + `skip_reason=OUT_OF_UNIVERSE`** 就是「掉出池子、只减不加」的直接证据:
45 次「本想加仓但被池子挡住」,全部留有原因,可在前端「未成交信号」查看。
- **773 条信号带画像留痕**:买卖决策都附上了当日可见数据算出的画像。
**两个「候选」必须分开记**(否则页面上同一个词有两种含义):
| 列 | 含义 |
|---|---|
| `hd_daily_universe.listed_count` | 当日**市场候选数**(未预剪枝),如 5359 |
| `hd_daily_universe.candidate_count` | 当日**实际参与筛选**的候选数(已预剪枝),如 183 |
预剪枝是纯性能开关,不该让可见数字的含义随之改变 —— 这是本轮自查抓到的两个
**静默数据缺陷**之一,另一个是 `dividend_yield` 整列为 NULL(详见 §10.5 第 6 条)。
### 10.4 性能:怎么把 5~8 小时压到可接受
单次 `UniverseSelector.run` 实测 **10~18 秒**(其中 `financial_panel` 独占 6.2 秒,
且是**表量级**开销、与查哪天无关)。逐日 1600 次 = 5~8 小时,不可用。
三处改造(**都不改变判定口径**):
| 改造 | 效果 | 口径保证 |
|---|---|---|
| `PitRepo` 批量预载(只覆盖最底层取数方法) | 单次筛选 10~18s → 2.0s | `test_pit_repo_matches_direct_repo` 逐值比对 |
| 财务按「已公告条数」缓存 + 存储预排序 | 模拟每 asof 4s → 0.38s | 同上(缓存键是充分统计量) |
| 候选集保守预剪枝(交易所/板块/上市年限/市值上界) | 候选 5903 → 412 只 | `test_prune_does_not_change_selection` 锁定入选集合完全相同 |
另一处**顺带修掉的真实缺陷**:`ttm_params()` 每次调用都走 `load_config`
(重新读盘 + YAML 解析 + pydantic 校验,约 16ms),而它在筛选器里是**逐股**调用的。
cProfile 实测:6 个交易日里 `load_config` 被调用 **1008 次、共 16.6 秒**,
占整个筛选时间的 **22%**,而每次返回的是完全相同的一份配置。
已改为使用本模块早有的带缓存入口 `get_config`。这不是「优化」,是修一个明显的浪费。
**实测速率与取舍**:
| 阶段 | 实测 | 依据 |
|---|---|---|
| 逐日选股 | **2.66 秒/交易日** | 2026-08-01 起 43 个交易日用 114 秒;全区间 1635 日实测 2.38 秒/日(65 分钟) |
| 模拟(修正前) | 3.45 秒/交易日 | 21 日窗口,池内约 22 只、**面板仅 25 只** |
| 模拟(修正前,全区间) | **6.5 小时仍未结束** | 面板 123 只;跑到 6.5h 后中止 |
| 模拟(修正后) | **3.04 秒/交易日** | 43 日窗口,池内 30~36 只、**面板 41 只**(直接计时,2026-10-05 复测) |
| 全区间(修正后) | 选股 65 分钟 + 模拟 **约 1.5~2.5 小时** | **推算**:按 3.04 秒/日 × 1635 日 ≈ 1.4 小时,但全区间面板为 123 只、后期池内约 59 只,会比该窗口更慢 |
**2026-10-04 追加修正的性能缺陷**:`PitProfileService._compute` 原先
「先按日期剪裁全市场面板(50 万行)、再筛出当前这一只股票」,实测 **174 毫秒/次**;
两个条件互相独立,改为「先筛股票、再剪裁」后 **11 毫秒/次(16 倍)**。
每次画像快照都要付两遍(`_price` 与 `_basics`)—— 这正是「21 日窗口(面板 25 只)」
与「全区间(面板 123 只)」差约 4 倍的原因:**剪裁成本与面板里的股票数成正比**。
等价性由 `tests/test_profile_pit.py` **27 passed** 锁定(含实时画像 vs 批量画像逐值比对)。
**选股结果可复用(已实测)**:逐日选股按「策略 + 区间 + 筛选配置 + **重建频率**」的指纹
缓存到 `output/cache/daily_pools_<key>.json`(键不含时间戳;重建频率必须进键 ——
1 日 / 5 日 筛出的池子是不同的输入,混用会静默给出错的股票池)。
同一区间第二次运行会打印「复用已缓存的选股结果」并**跳过 114 秒的选股**,
且指标与首次完全一致(0.29% / CAGR 1.73% / 回撤 −3.93% / Sharpe −0.03 / 17 笔 ——
两次运行逐项相同);`--refresh-pools` 强制重筛。
缓存目录可随时整个删掉(它只是缓存,删了下次重筛)。
### 10.4a 程序设计层面的提速(P1/P2/P4/P6,2026-10-05 实施并验收)
四项都**不改变判定规则**,逐项做了等价性验证;但实测收益与最初的估计**差很多**,
如实记录如下(这正是「不要靠估计,要测」的又一例):
| 项 | 改动 | 等价性验证 | **实测收益** |
|---|---|---|---|
| **P4** | 选股的 `dividend_records` 按候选股收窄(原先每天把 2 万余行分红建 dict,只为查其中一两百只) | 5 个样本日入选集合**逐只相同**(`test_dividend_scope_does_not_change_selection`);收窄结果是精确子集 | 选股 **2.34 → 2.02 秒/日(−14%)** |
| **P1** | 画像内部日期统一 `datetime64`(原先逐 asof 把 object 的 `datetime.date` 再转一遍) | 分红窗口谓词 4 个时点**逐行相同**(61/71/81/93 行) | 与 P2 合计仅 **−2%** |
| **P2** | 每只股票整段股息率序列只算一次(原先每个评估日按前缀重算,O(交易日数²)) | 整段 vs 前缀 **20/20 点一致**(`test_ttm_dps_series_prefix_equals_full`) | 同上;区间越长收益越大 |
| **P6** | 价格只取一次并交给画像复用;`dividend_events` 走常驻内存 | `dividend_events` 与直连**逐值一致**(4 个区间含 28,301 行);外部传入 price 与自取**画像逐值一致** | 一次性,约 −2% |
**为什么 P1/P2 几乎没省**:最初的判断依据是 cProfile —— 它显示日期转换占模拟 28%。
但那个 profile 里混进了 `_prepare` 的**一次性取数**(约 30 万行 DB 读取),
把占比算高了。改成按组件直接计时后,模拟阶段的开销分布是:
| 组件 | 单次成本 | 折算(43 日窗口) | 占比 |
|---|---:|---:|---:|
| `_profile_one`(每次画像快照算约 40 个指标) | **185 ms × 593 次** | 99.6 s | **78%** |
| `_context`(每个 asof 重建财报面板) | **374 ms × 43 次** | 16.1 s | 13% |
| 其余(盯市、分红入账、收益率切片…) | — | 约 12 s | 9% |
**结论**:模拟阶段的 91% 集中在这两处,而它们**都不是** P1/P2 触及的地方。
所以下一步该动的是:
1. **把财报面板按 `symbols` 收窄后再建**(`_latest_financial` / `annual_financial_history`
等现在都是「先建全市场、再取子集」,而缓存键里没有 symbol 集合 ——
年报季可见条数天天变,于是天天重建全市场)。预计 `_context` 374 ms → 数十毫秒,
即模拟 **−13%**;风险低(窗口函数是逐股票的,先筛 symbol 不改变任何一只股票的取值)。
2. **闸门按「公告/分红可见性指纹」缓存 + 画像留痕改在成交时落**
(闸门那 4 个指标与价格无关、一年只变几次;而 593 次快照里绝大多数是为
「信号留痕」付的,最终只有 17 笔真正成交)。预计模拟 **−60~70%**;
但**会改变留痕位置**(`hd_backtest_signal` 不再带画像、`hd_backtest_trade` 带上),
需要使用者确认。用户要的「所有成交个股的实时画像」在这个方案下**反而更准确**。
> 另有一处**外部数据变化**必须记下:本次 A/B 期间,每日同步任务把 `stock_daily`
> 从 2026-09-04 补到了 **2026-09-30**(+94,398 行),使同一区间 2026-08-01 起的结果
> 从 **+0.29%** 变成 **−2.54%**(持仓数量、成交价、每日股票池均逐行相同,
> 只有最后 4 周的价格是新的)。这不是代码问题,但它再次说明
> **比较两次回测之前必须确认底层数据没变**。
### 10.4b 提速方案:把「每日」改成「每 N 日」(2026-10-05 新增)
原先两个频率只能改 YAML,现已接到命令行:
| 参数 | 作用对象 | 默认 |
|---|---|---|
| `--every-n-days N` | 股票池**重建**频率(选股那一趟) | 1(每个交易日) |
| `--signal-every-n-days N` | 买卖**判断**频率(模拟那一趟) | 1(每个交易日) |
**设计要点:两个旋钮都不改判定规则,只改「多久看一次」。** 所以它们是
「粗粒度版本」而不是「同一策略的加速版」—— 程序启动时会明确打印这句话并提示
「结果不可与逐日口径直接比较」,避免有人拿 5 日口径的数字去和逐日口径比。
实测(2026-08-01 起 43 个交易日,**同一区间**):
| 口径 | 选股 | 模拟 | 画像计算次数 |
|---|---:|---:|---:|
| 选股每 1 日 / 信号每 1 日 | 114 秒 | 131 秒 | 537 |
| 选股每 5 日 / 信号每 5 日 | 43 秒 | 约 28 秒 | 127 |
由此把模拟耗时拆成两项(**这是本节的关键修正**):每交易日的记账约 0.05 秒
(很小),**每次「评估买卖」约 3.0 秒**(收益率序列 + 逐笔决策画像)——
只有后者随信号频率下降。早先的模型把整段模拟都写成「随交易日数增长」,
会把粗粒度方案的耗时**高估**约 1.6 倍。
按此模型,6.7 年(1635 交易日)的预计耗时:
| 方案 | 预计 |
|---|---:|
| 逐日 / 逐日(默认) | 约 2.6 小时 |
| `--every-n-days 5` | 约 1.7 小时 |
| `--signal-every-n-days 5` | 约 1.5 小时 |
| `--every-n-days 5 --signal-every-n-days 5` | **约 35 分钟** |
| `--every-n-days 21 --signal-every-n-days 21` | 约 12 分钟 |
> 上表是**推算**(单点实测只有 43 个交易日那两行)。全区间面板 123 只、
> 后期池内约 59 只,实际会**更慢**。命令启动时会按同一模型打印本次预计时长。
### 10.5 已知限制(如实声明)
1. **6.7 年区间未实跑到底**:选股阶段实测跑完(65 分钟),模拟阶段未跑完
(修正前跑到 6.5 小时中止)。修正后的总耗时是**推算**(约 3.5~4.5 小时),
不是实测值。已实测到底的只有 3 个月区间(5 分钟)与选股阶段。
2. **预剪枝依赖「市值上界」这一判据**:`min_avg_amount_20d` **刻意没有预剪枝** ——
`stock_daily` 的量价单位在 2015-2019 是「手/千元」、2020 起是「股/元」,
用 `MAX(amount)` 做上界会在早年低估 1000 倍、误剪本该通过的股票。
宁可少一项优化,也不接受会改变结果的上界。
3. **不落库逐股淘汰原因**:daily 只落库每日**入选**成员。要查「某只股票为什么没选上」,
需用 `hdiv universe --asof <日期>` 单独跑一天。
4. **起点即路径**:不同起点的 daily 结果不可直接比较策略优劣(早年池子、
估值水平都不同)。
5. **不支持 `--universe-run`**:固定池与「每日动态」定义互斥,且冻结名单自带未来信息。
6. **提交前自查抓到两个静默数据缺陷**(都属于「回测照跑、只是数是错的」那一类):
- **`hd_daily_universe.dividend_yield` 整列为 NULL**:该列不在
`UniverseSelector.run()` 返回的 `selected` 里 —— 股息率是**滤网算出来的**
`values` 字段,不在行情/财报列里。只从 `selected` 取列就会静默写 NULL。
已改为「滤网 values 优先、selected 列兜底」(`total_mv`/`roe_avg` 反向)。
- **`candidate_count` 在开/关预剪枝时含义不同**:预剪枝前它是 5359、
预剪枝后变成 183,而列名与页面文案都没变。已新增 `listed_count` 列,
两者分开记录(并加 `ddl` 幂等 `ADD COLUMN`)。
教训与 §4.6 的第 3 条同源:**口径要靠测试与自查锁住,不能靠「看起来有数」**。
7. **顺带修掉一个既有的崩溃(非本功能引入)**:任何**短区间**回测
(实测 15 个交易日)里 `sharpe` 不可计算为 `None`,而引擎与 CLI 的汇总打印
直接做 `:.2f` / `:.2%` 格式化 → `TypeError: unsupported format string passed
to NoneType`,以完整 traceback 结束。这是把「指标不可计算」这一**正常状态**
说成了程序缺陷,违反了本项目「HdivError 只打印信息、不打 traceback」的约定。
已改为统一的容忍 `None` 的格式化(`engine._pct` / `engine._num`,显示「—」),
引擎与 CLI 的四处打印一并处理,`walk_forward` 的窗口打印同样修掉。
由 `tests/test_daily.py::TestMetricFormatting` 锁定(含源码扫描,防止回退)。
### 10.6 提交前的验证清单
| 验证 | 方式 | 结果 |
|---|---|---|
| 取数口径与直连一致 | `tests/test_daily.py::test_pit_repo_matches_direct_repo` | 11 个方法逐值一致 |
| 缓存键是充分统计量 | `::test_pit_repo_caches_are_exact` | 命中与冷算逐值相同 |
| `symbols` 子集精确 | `::test_pit_repo_symbols_subset_is_exact` | 与直连一致 |
| 区间外报错而非空表 | `::test_pit_repo_out_of_range_raises` | 抛 `HdivError` |
| 预剪枝不改变入选 | `::test_prune_does_not_change_selection` | 5 个样本日入选集合完全相同 |
| 预剪枝集合是上界 | `::test_prune_set_keeps_every_actual_member` | 实际成员全在保留集内 |
| 每日选股无未来函数 | `::test_daily_pool_is_point_in_time` | 参照终点推后一年,同日选股逐只相同 |
| 端到端落库 | `::test_daily_run_persists_members_and_run` | mode=daily、`hd_daily_universe` 有数据 |
| 新增参数默认关闭 | `::TestEngineDefaultsAreOff` | single/walkforward 行为逐字不变 |
| 既有模式无回归 | `pytest tests/ --ignore=tests/test_daily.py -q` | **408 项全通过**(本轮最后一次全量:439 项 100%) |
| 契约与安全扫描 | `test_cli_contract` / `test_safety` / `test_schema` / `test_web` | 全通过(表数 30→31 已同步) |
| 前端语法与接口契约 | `node --check web/app.js` + `FRONTEND_CALLS` | 通过(新路由已登记) |
| 资金对账 | 实测 run 的 `residual` | `−0.0000 ✓` |
| DDL 幂等 | `ddl plan` 连续两次 | `共 0 个待执行动作` |
| 画像取数优化等价 | `pytest tests/test_profile_pit.py` | **27 passed**(含实时画像 vs 批量画像逐值比对) |
**本轮未完成的验证(必须诚实标注)**:
| 未验证项 | 原因 |
|---|---|
| **2020-01-05 起至今的完整回测结果** | 未跑:按约定「不做长时间测试」。修正后的全区间耗时是**推算**(见 §10.4);已实测到底的是 2026-08-01 起 43 个交易日(4 分钟) |
| 浏览器里的实际渲染 | 已用 JavaScriptCore 做语法检查、经 HTTP 确认静态产物含新卡片、并逐项核对接口返回的数据结构;但**没有真机打开页面** |
**已补验(2026-10-05 复测,开发机重启后)**:
| 项目 | 结果 |
|---|---|
| 选股缓存复用 | ✅ 第二次运行打印「复用已缓存的选股结果」,跳过 114 秒选股,指标与首次**逐项相同** |
| 前端「成交个股的实时画像」数据源 | ✅ `list_backtest_trades` 与 `analysis.stock_detail` 均返回 `reason.profile`(`test_stock_detail_exposes_decision_time_profile` 通过) |
| `hd_daily_universe` 三列非空 | ✅ 1152 行,`dividend_yield`/`listed_count`/`roe_avg` **零 NULL** |
| 动态池语义 | ✅ 该 run 有 `OUT_OF_UNIVERSE` 55 次(掉出池子只减不加)、`HOLD` 55 条 |
| `test_pit_profile_caches_are_bounded` | ✅ 通过(修掉了该用例自身的 `PitRepo` 未导入问题) |
| 一条命令给出前端位置 | ✅ `test_cli_points_at_the_frontend` 通过 |
| `scripts/daily_result.py` | 已删除 —— 用户要的是「一条命令 + 前端看明细」,多一个导出脚本等于要求第二条命令 |
> **恢复后要做的**:`.venv/bin/python -m hdiv backtest --mode daily --start 2020-01-05`,
> 然后打开 Web 前端(`./deploy/serve.sh start-dev`)→「回测记录」→ 选中该 run
> 看明细。**不需要第二条命令**:绩效、净值、持仓、逐笔成交、
> 成交个股的实时画像、每日股票池都在页面里。
**一次命令的产出(全部落库,前端可下钻)**:
| 表 | 内容 |
|---|---|
| `hd_backtest_run` | 运行头(mode=daily、区间、资金、可复现四元组、未建模声明) |
| `hd_backtest_equity` | 逐日净值 / 现金 / 持仓市值 / 回撤 / 基准净值 → **综合曲线** |
| `hd_backtest_metric` | 总收益 / CAGR / 最大回撤 / Sharpe / Sortino / Calmar / 换手 / 逐年 |
| `hd_backtest_position` | 逐日持仓明细 |
| `hd_backtest_trade` | **全部成交** + `reason_json`(触发理由 + 决策时点画像) |
| `hd_backtest_signal` | 全部信号(含未成交原因:画像未通过 / 掉出池子 / 现金不足…) |
| `hd_daily_universe` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) |