本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):
1) 修复 stock_daily 量价单位前后不一致
- 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
- 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
按行判定并幂等换算(price_history / avg_amount 都走它);
审计新增 UNIT-OHLCV 防回归。
- 效果:2016/2017/2018 的股票池变为 7/11/13 只。
2) 未来函数守卫(单次回测)
- 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
偏差写入 unimplemented_json。
3) 新增实时(PIT)个股画像闸门
- profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
- 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
- profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
- core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。
4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
- stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
hd_suspend / hd_limit 补到 2010-01-04。
- 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
- 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
- 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。
5) 自我声明如实化
- 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
误报成「hd_suspend 无数据」;改为按表级判定。
- 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。
6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
- 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
- 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。
测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。
注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
1051 lines
56 KiB
Markdown
1051 lines
56 KiB
Markdown
# 实施状态报告
|
||
|
||
> 对应 `docs/development-plan.md`(计划)与 `docs/plan.md`(需求)
|
||
> 更新:2026-10-02
|
||
|
||
---
|
||
|
||
## 0. 一句话结论
|
||
|
||
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
|
||
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
|
||
全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||
|
||
**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 行) |
|
||
|
||
### 1.2 数据库(30 张 `hd_*` 表)
|
||
|
||
全部建表完成,结构迁移幂等(连续两次 `ddl apply` 均返回 0 个动作)。
|
||
**只增不删**由三层保证:SQL 安全钩子 + 源码扫描测试 + 迁移的前置条件守卫。
|
||
|
||
### 1.3 代码模块
|
||
|
||
| 模块 | 文件 | 状态 |
|
||
|---|---|---|
|
||
| 配置层 | `core/config.py`(7 类配置 + 严格校验 + config_hash) | ✅ |
|
||
| 数据安全 | `data/db.py`(SQL 钩子)、`data/ddl.py`(幂等 DDL) | ✅ |
|
||
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
|
||
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
|
||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
|
||
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
|
||
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
|
||
| 因子 | `factor/dividend_yield.py` | ✅ |
|
||
| 个股画像 | `profile/builder.py` | ✅ |
|
||
| 策略管理 | `strategy/registry.py` | ✅ |
|
||
| 回测引擎 | `backtest/engine.py` | ✅ |
|
||
| Walk-forward | `backtest/walk_forward.py` | ✅ |
|
||
| 绩效分析 | `analysis/performance.py` | ✅ |
|
||
| 敏感性 | `analysis/sensitivity.py` | ✅ |
|
||
| 报告渲染 | `report/`(7 类报告 + 离线校验) | ✅ |
|
||
|
||
---
|
||
|
||
## 2. `plan.md §50` 十四项验收
|
||
|
||
| # | 能力 | 状态 | 落库位置 |
|
||
|---|---|---|---|
|
||
| ① | 找出符合条件的股票 | ✅ | `hd_universe_member` |
|
||
| ② | 生成 PIT 股票池 | ✅ | `hd_universe_run` |
|
||
| ③ | 每股历史股息率序列 | ✅ | `hd_profile_series` |
|
||
| ④ | P10/P25/P50/P75/P90 | ✅ | `hd_profile_stat` |
|
||
| ⑤ | 生成买卖信号 | ✅ | `hd_backtest_signal` |
|
||
| ⑥ | 执行历史回测 | ✅ | `hd_backtest_run` |
|
||
| ⑦ | 正确处理分红与除权 | ✅ | `hd_dividend` + 分红台账(含红利税) |
|
||
| ⑧ | 加入交易成本 | ✅ | `hd_backtest_trade.*_cost` |
|
||
| ⑨ | K线 + 买卖点 | ✅ | `output/profile_*.html`(四联图 + P75/P25 阈值线) |
|
||
| ⑩ | 收益/回撤/Sharpe | ✅ | `hd_backtest_metric` |
|
||
| ⑪ | 基准比较 | ✅ | 沪深300 / 中证红利 / 上证指数 |
|
||
| ⑫ | Walk-forward | ✅ | `hd_walkforward_window` |
|
||
| ⑬ | 样本内/外结果 | ✅ | `hd_backtest_metric.scope` |
|
||
| ⑭ | 完整参数与版本 | ✅ | `hd_strategy` + `hd_strategy_param`(36 项) |
|
||
|
||
---
|
||
|
||
## 3. 关键正确性保障
|
||
|
||
以下是开发过程中**真实踩到**并已修复的静默错误 ——
|
||
它们共同特点是「不报错,只是给出错误结论」,因此每一条都配了回归测试。
|
||
|
||
| # | 问题 | 后果 | 修复 |
|
||
|---|---|---|---|
|
||
| 1 | Tushare `total_mv` 单位是**万元**,配置阈值是**元** | 市值过滤选中 **0 只**股票 | `data/units.py` 统一归一化 + `UNIT` 审计(恒等式 + 绝对量级双判据) |
|
||
| 2 | 用**季报累计** ROE 比「年均 8%」阈值 | 几乎所有好公司被误杀 | `annual_financial_averages()` 只用年报口径 |
|
||
| 3 | 银行负债率天然 90%+ | 整个金融板块被误杀(招商银行) | `industry_exemptions` 行业豁免,Risk/Quality 统一口径 |
|
||
| 4 | 年度分红除权间隔中位数 **366 天** > 365 | 股息率被算成 0,污染历史分位 | TTM 加 45 天宽限期 |
|
||
| 5 | 分红除权晚于 asof | 稳定分红公司被误判「连续分红 0 年」(中国神华) | 一年宽限期 |
|
||
| 6 | `NaN or 0.0` 返回 `NaN` | 10 万条 NULL 分红被当成数值参与者 | `_fnum()` NaN 感知转换 |
|
||
| 7 | 费率 side 配置小写 `sell`、代码传大写 `SELL` | **印花税永远为 0**,成本系统性低估 | 统一大写比较 |
|
||
| 8 | 净值曲线漏加现金 | 净值严重失真 | `总市值 = 现金 + 持仓` + 断言测试 |
|
||
| 9 | 建仓阶梯与减仓阶梯各自判定 | 持仓时会「因高分位被减仓」,年换手 8.9、持仓仅 30 天 | 合成唯一阶梯 + 死区(修复后:换手 0.91、持仓 345 天) |
|
||
| 10 | MySQL 唯一约束**不约束 NULL** | 静默重复行 | 显式 `dedup_key`;三张表补 `NOT NULL`;专门测试守护 |
|
||
| 11 | pandas 3.0 字符串列不再是 `object` dtype | 空串 `ts_code` 被写进库 | 改用 `is_numeric_dtype` 判定 |
|
||
| 12 | `is_buy` 在涨跌停分支前未定义 | 一旦 `hd_limit` 有数据即崩溃 | 定义提前 |
|
||
| 13 | Jinja2 autoescape 转义 `<script>` | 图表静默失效 | `| safe` + 校验器专项检测 |
|
||
| 14 | `dividend_records` 的 SELECT **漏了 `base_share`** | 支付率与 FCF 覆盖在全库范围内恒为 NULL —— `max_payout_ratio` 与 `min_fcf_dividend_cover` 两个筛选条件**从未生效** | 补上该列 + 回归测试断言 SELECT 列表 |
|
||
| 15 | 用 **FY2023 分红 ÷ 2024Q1 净利润** 算支付率 | 美的集团算出 230.9% 的荒谬支付率(真实 61.6%) | 新增 `Repo.annual_financials()`,支付率/FCF 覆盖一律**同财年**比较 |
|
||
| 16 | 画像从未计算分红质量指标 | `dividend_quality` 与 `composite` 安全边际得分恒为 NULL,`plan.md §14/§15` 形同未实现 | 画像复用 `DividendFilter` 的口径函数(单一口径来源) |
|
||
| 17 | 断点续传只看「日期是否存在」 | 原 qlib 数据在 2019 年仅 243 只/日被当作「已同步」,形成**整年数据空洞** | 判据改为「当日股票数 ≥ 1500」 |
|
||
| 19 | **Jinja2 autoescape 把嵌入 `<script>` 的 JSON 转义成 `"`** | 内联 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 回补
|
||
|
||
# 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` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(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…`,
|
||
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
|
||
|
||
|