修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 2005;手册补全流程
本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):
1) 修复 stock_daily 量价单位前后不一致
- 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
- 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
按行判定并幂等换算(price_history / avg_amount 都走它);
审计新增 UNIT-OHLCV 防回归。
- 效果:2016/2017/2018 的股票池变为 7/11/13 只。
2) 未来函数守卫(单次回测)
- 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
偏差写入 unimplemented_json。
3) 新增实时(PIT)个股画像闸门
- profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
- 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
- profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
- core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。
4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
- stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
hd_suspend / hd_limit 补到 2010-01-04。
- 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
- 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
- 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。
5) 自我声明如实化
- 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
误报成「hd_suspend 无数据」;改为按表级判定。
- 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。
6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
- 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
- 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。
测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。
注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
This commit is contained in:
+741
-72
@@ -9,10 +9,18 @@
|
||||
|
||||
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
|
||||
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
|
||||
全链路打通并通过 262 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
|
||||
数据同步仍在后台补齐最后 ~15%(分红 99.6%、财报 ~87%),
|
||||
但这不影响系统功能 —— 它只影响股票池的绝对规模。
|
||||
**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 均为已知且已声明)。
|
||||
|
||||
---
|
||||
|
||||
@@ -22,13 +30,13 @@
|
||||
|
||||
| 缺口 | 状态 | 实测结果 |
|
||||
|---|---|---|
|
||||
| **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 253,879 行(其中 56,513 条实施且有现金分红),覆盖 1990–2026 |
|
||||
| **G2 日线行情** | ✅ 完成 | `stock_daily` 起点 **2015-01-05**(2015 年首个交易日),2,838 个交易日;**2019 年空洞已补齐**(+89 万行) |
|
||||
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,2,840 个交易日 |
|
||||
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 2015-01-05,2,286 个交易日 |
|
||||
| **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` 67,765 行;`hd_limit` **669 万行** |
|
||||
| **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_*` 表)
|
||||
|
||||
@@ -44,7 +52,7 @@
|
||||
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
|
||||
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
|
||||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
|
||||
| 数据审计 | `data/audit.py`(18 项检查) | ✅ |
|
||||
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
|
||||
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
|
||||
| 因子 | `factor/dividend_yield.py` | ✅ |
|
||||
| 个股画像 | `profile/builder.py` | ✅ |
|
||||
@@ -115,46 +123,116 @@
|
||||
> 策略:`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 与基准对比
|
||||
### 4.1 与基准对比(回补后重跑)
|
||||
|
||||
| 指标 | **策略** | 沪深300 | 中证红利 | 上证指数 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 总收益 | **+114.80%** | +19.66% | +53.35% | +14.67% |
|
||||
| 年化 CAGR | **+6.73%** | +1.54% | +3.71% | +1.17% |
|
||||
| 最大回撤 | **−21.05%** | −46.70% | −46.51% | −52.30% |
|
||||
| Sharpe | 0.31 | — | — | — |
|
||||
| 指标 | **策略(闸门开)** | 策略(闸门关) | 沪深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 | — | — | — |
|
||||
|
||||
**策略跑赢天然基准「中证红利」61 个百分点,而回撤不到其一半。**
|
||||
`run_id`:闸门开 `fa916050ffb647b6ab4cfd90ba1adf36`,
|
||||
闸门关 `5347fd13dd8fc0b0a512156489c615bc`(两次资金对账残差均为 0)。
|
||||
|
||||
> ⚠ **但这个数字有严重误导性,请看 §4.6 的 Walk-forward 结果。**
|
||||
> 单条路径的全期回测会把「持有可能穿越熊市并在后期回本」的效应放大,
|
||||
> 而逐年样本外检验给出的是一幅**完全不同的图景**。
|
||||
**策略仍跑赢天然基准「中证红利」,回撤不到其一半** —— 但见 §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` 漏选而静默失效)。修正后股票池由 49 只
|
||||
> 收紧到 28 只,收益反而提高 —— 说明「分红可持续性」这一安全边际条件
|
||||
> 确实在筛选优质标的。
|
||||
> (此前这两个条件因 `base_share` 漏选而静默失效)。
|
||||
|
||||
### 4.2 交易与分红
|
||||
### 4.2 交易与分红(闸门开)
|
||||
|
||||
| 项目 | 数值 |
|
||||
|---|---:|
|
||||
| 成交笔数 | 180 |
|
||||
| 累计现金分红 | 558,797 元(占期初资金 55.9%) |
|
||||
| 已扣红利税 | 13,676 元 |
|
||||
| 期末资金 | 2,148,010 元 |
|
||||
| 成交笔数 | 197 |
|
||||
| 期末资金 | 1,927,312 元 |
|
||||
| **资金对账残差** | **0.0000** ✅ |
|
||||
| 画像剔除的买入信号 | 1027 |
|
||||
| 画像计算次数 / 决策时点 | 2428 / 141 |
|
||||
|
||||
### 4.3 逐年收益
|
||||
### 4.3 逐年收益(回补后重跑,闸门开)
|
||||
|
||||
| 年 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 策略 | +18.56% | +6.64% | +11.85% | **−3.99%** | +1.25% | +22.22% | +14.67% | +7.32% |
|
||||
| 年 | 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% |
|
||||
|
||||
11.7 年中仅 2022 一年为负,且跌幅远小于同期市场。
|
||||
**2015 年不再是 0,而是 −3.18%(闸门开)/ −1.26%(闸门关)。**
|
||||
这正是全期收益下降 24.6pp 的来源:回补前 2015 年因数据缺口无法产生信号
|
||||
(100% 现金、收益 0.00%),回补后它被真实交易,而在牛市顶部建仓
|
||||
随后遭遇股灾是亏钱的。**「靠数据缺口躲过股灾」不是策略能力。**
|
||||
|
||||
### 4.4 预热期说明(重要)
|
||||
### 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% 现金、无任何成交**,这是**预期行为**而非缺陷:
|
||||
|
||||
@@ -188,28 +266,46 @@
|
||||
### 4.6 Walk-forward 样本外验证(plan.md §23/§25)
|
||||
|
||||
7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布:
|
||||
(下表为 2026-10-04 回补后重跑,`wf_id = e855fdf268827e1e4c31510c6736eea3`,
|
||||
**实时画像闸门启用**)
|
||||
|
||||
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 |
|
||||
|---|---|---|---:|---:|
|
||||
| #0 | 2015–2019 | 2020 | **+4.01%** | −11.94% |
|
||||
| #1 | 2016–2020 | 2021 | **−3.07%** | −15.79% |
|
||||
| #2 | 2017–2021 | 2022 | **−9.17%** | −24.22% |
|
||||
| #3 | 2018–2022 | 2023 | **−8.63%** | −18.00% |
|
||||
| #4 | 2019–2023 | 2024 | **+2.61%** | −24.62% |
|
||||
| #5 | 2020–2024 | 2025 | **+3.78%** | −10.50% |
|
||||
| #6 | 2021–2025 | 2026(部分) | **+3.83%** | −14.88% |
|
||||
> **⚠️ 重要:这 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 |
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 盈利窗口 | 4 / 7(胜率 57.14%) |
|
||||
| 样本外收益 **均值** | **−0.95%** |
|
||||
| 样本外收益 中位数 | +2.61% |
|
||||
| 样本外 CAGR 均值 | −0.78% |
|
||||
| 最差窗口回撤 | −24.62% |
|
||||
| **基准收益均值** | **+2.29%** |
|
||||
| **超额收益均值** | **−3.24%** |
|
||||
**样本外汇总(闸门开):**
|
||||
|
||||
| 指标 | 数值 | 改造前(旧口径) |
|
||||
|---|---:|---:|
|
||||
| 盈利窗口 | 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。
|
||||
|
||||
#### 结论:单路径回测显著高估了策略
|
||||
|
||||
@@ -217,25 +313,113 @@
|
||||
|
||||
| | 全期单路径回测 | Walk-forward 样本外均值 |
|
||||
|---|---:|---:|
|
||||
| 收益 | **+114.80%**(11.7 年) | **−0.95%/年** |
|
||||
| 相对基准 | +95pp | **−3.24pp** |
|
||||
| 收益 | **+117.36%**(11.7 年) | **+1.16%/年** |
|
||||
| 相对基准 | +97.7pp | **−1.13pp** |
|
||||
|
||||
**这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug:
|
||||
|
||||
1. **全期回测允许仓位穿越牛熊**:2019–2021 建的仓在 2022–2023 的下跌中继续持有,
|
||||
1. **全期回测允许仓位穿越牛熊**:2016–2019 建的仓在 2022–2023 的下跌中继续持有,
|
||||
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
|
||||
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
|
||||
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
|
||||
训练期校准的阈值在测试期就失灵了。
|
||||
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大
|
||||
(稳定性指标 −0.16,说明窗口间差异大于均值本身)。
|
||||
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大。
|
||||
|
||||
**因此:不要采信 §4.1 的 +114.80%。** 更接近真实的表述是
|
||||
「该策略在 2020/2024/2025/2026 的样本外为正,在 2021/2022/2023 为负,
|
||||
长期看与基准相比没有稳定的超额收益,且回撤更小(防御性成立、进攻性不足)」。
|
||||
**因此:不要采信 §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%」读成「策略变好了」。
|
||||
|
||||
> 值得注意的是,策略的**回撤控制**在样本外依然稳定成立
|
||||
> (最差 −24.62%,而基准同期回撤 −46% ~ −52%),
|
||||
> (最差 −22.97%,而基准同期回撤 −46% ~ −52%),
|
||||
> 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**,
|
||||
> 而非超额收益来源。
|
||||
|
||||
@@ -243,19 +427,29 @@
|
||||
|
||||
1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
|
||||
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
|
||||
2. **涨跌停与停牌约束覆盖 2019 年起**;2015-2018 区间为近似建模,
|
||||
引擎会在 `hd_backtest_run.unimplemented_json` 中如实声明。
|
||||
(停牌同步器起点为 2019-01-01,如需 2015-2018 可改 `--start` 重跑。)
|
||||
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. **2015-2018 为预热期**(无历史分布可用),见 §4.4 说明。
|
||||
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 均落库可下钻;
|
||||
单次完整跑完约需 25 分钟,用 `hdiv backtest --mode walkforward` 执行。
|
||||
与 `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 条同源)。不影响可交易标的,仅影响审计洁净度。
|
||||
|
||||
---
|
||||
|
||||
@@ -363,19 +557,494 @@ hdiv report validate # 或:python -m hdiv.report.validate output
|
||||
|
||||
部署:`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. 测试覆盖
|
||||
|
||||
```
|
||||
262 passed
|
||||
403 passed(pytest 退出码 0)
|
||||
```
|
||||
|
||||
| 测试文件 | 覆盖 |
|
||||
|---|---|
|
||||
| `test_config.py` | 配置正向加载 + 18 类非法配置必须被拒 |
|
||||
| `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_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数 |
|
||||
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run`) |
|
||||
| `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…`,
|
||||
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
|
||||
|
||||
|
||||
|
||||
+673
-41
@@ -7,6 +7,7 @@
|
||||
|
||||
# 目录
|
||||
|
||||
0. [全流程操作](#0-全流程操作) ★ **先读这一节(选股 → 画像 → 回测)**
|
||||
1. [系统是什么](#1-系统是什么)
|
||||
2. [快速开始](#2-快速开始)
|
||||
3. [目录与架构](#3-目录与架构)
|
||||
@@ -22,6 +23,288 @@
|
||||
|
||||
---
|
||||
|
||||
# 0. 全流程操作
|
||||
|
||||
> **选股 → 画像 → 回测**,三步主路径。
|
||||
|
||||
本节是**主操作路径**:从「选出一批股票」到「跑出一份可信的回测」,
|
||||
每一步都说明**命令做了什么、数据从哪来、落了哪些库、有哪些坑**。
|
||||
|
||||
## 0.0 五分钟全流程(可直接复制)
|
||||
|
||||
```bash
|
||||
cd ~/project/高股息回测
|
||||
export PYTHONPATH=src
|
||||
|
||||
# ① 选股:按 2025-01-01 当时可见的数据筛选(实际落到交易日 2024-12-31)
|
||||
.venv/bin/python -m hdiv universe --asof 2025-01-01
|
||||
# → 记下打印的 run_id,例如 02485801b2805cbae0e02db66c7bc946
|
||||
|
||||
# ② 画像:对这 46 只看它们在**该时点**的 5/8/10 年画像
|
||||
.venv/bin/python -m hdiv profile --universe-run 02485801b2805cbae0e02db66c7bc946
|
||||
|
||||
# ③ 登记策略(首次需要;之后改 YAML 再 register 即可)
|
||||
.venv/bin/python -m hdiv strategy register
|
||||
|
||||
# ④ 回测:用这个池子,**起点不得早于股票池 asof**
|
||||
.venv/bin/python -m hdiv backtest \
|
||||
--universe-run 02485801b2805cbae0e02db66c7bc946 \
|
||||
--start 2025-01-01
|
||||
|
||||
# ⑤ Walk-forward 样本外验证(★ 判断策略好坏的唯一依据)
|
||||
.venv/bin/python -m hdiv backtest --mode walkforward
|
||||
|
||||
# ⑥ 打开前端看结果
|
||||
.venv/bin/python -m hdiv web # → http://127.0.0.1:8099/
|
||||
```
|
||||
|
||||
> **③④ 的顺序无所谓,②和④互不依赖**(见 §0.3 的「关键认知」)。
|
||||
> **④ 与 ⑤ 的区别是本质性的**:④ 是「一条路径」,⑤ 是「逐年样本外」。
|
||||
> 本项目只认 ⑤ —— 详见 §7.5。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 步骤①:选股 `hdiv universe`
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m hdiv universe --asof 2025-01-01 [--no-persist] [--html]
|
||||
```
|
||||
|
||||
### 发生了什么
|
||||
|
||||
```
|
||||
① asof 归一化 → 2025-01-01 是元旦休市,落到「≤ asof 的最近交易日」= 2024-12-31
|
||||
② 取候选集 → stock 表中 list_date <= asof 且 (delist_date 为空或 > asof)
|
||||
即「当时已上市、当时未退市」——**包含此后才退市的股票**(消除生存者偏差)
|
||||
③ 挂 PIT 面板 → daily_basic(当日或最近 5 个交易日内)、20 日均成交额、
|
||||
最新已公告财报(ann_date <= asof)、已实施且已除权的分红
|
||||
④ 依次过 4 个滤网 → market → risk → dividend → quality(先便宜的、淘汰率高的)
|
||||
每只股票记录:每个滤网的通过位 + **首个未通过的滤网 + 原因 + 取值快照**
|
||||
⑤ 截断 → 按股息率降序取前 output.max_members(默认 200)只
|
||||
⑥ 落库 → hd_universe_run(头)+ hd_universe_member(逐股留痕)
|
||||
```
|
||||
|
||||
各滤网的判据(全部来自 `config/universe.yml`,改 YAML 即改行为):
|
||||
|
||||
| 顺序 | 滤网 | 主要判据 |
|
||||
|---|---|---|
|
||||
| 1 | `market` | 交易所 ∈ [SSE, SZSE]、板块 ∈ [主板/创业板/科创板]、上市 ≥ 10 年、总市值 ≥ 500 亿、20 日均成交额 ≥ 2,000 万元、当日有行情 |
|
||||
| 2 | `risk` | 非 ST(按 `stock_name_history` 还原**当时**的名字)、未退市、未停牌、净资产为正、资产负债率 ≤ 80%(银行/保险/证券/信托豁免) |
|
||||
| 3 | `dividend` | 股息率 ≥ 3%(**自算 PIT-TTM**,非 `dv_ttm`)、连续分红 ≥ 5 年、6 年窗口内至少 5 个分红年、支付率 ≤ 100%、自由现金流为正、FCF 覆盖分红 ≥ 1 倍 |
|
||||
| 4 | `quality` | 5 年平均 ROE ≥ 8%、经营现金流/净利润 ≥ 0.6(用**年报**而非季报,否则累计值会误杀) |
|
||||
|
||||
### 关键性质
|
||||
|
||||
1. **无未来函数**:每一条判据都带 `<= asof` 约束(行情 `trade_date`、财报 `ann_date`、
|
||||
分红 `imp_ann_date` 与 `ex_date` 双重)。ST 状态按历史名称还原,不看今天的名字。
|
||||
2. **run_id 是确定性的**:由「配置哈希 + asof」决定,**不含时间戳**。
|
||||
所以同配置同时点重跑会**原地覆盖同一条记录**,不会积累重复。
|
||||
3. **`asof` 会被归一化到交易日**。`--asof 2025-01-01` 与 `--asof 2024-12-31`
|
||||
得到**同一个 run_id**。看到打印的 `asof=2024-12-31` 不是 bug。
|
||||
4. **`--no-persist` 的后果**:结果不落库 → 前端看不到,**也无法被回测引用**。
|
||||
CLI 会显式提醒。
|
||||
|
||||
### 落库与追溯
|
||||
|
||||
| 表 | 内容 |
|
||||
|---|---|
|
||||
| `hd_universe_run` | run_id / asof_date / 候选数 / 入选数 / 配置指纹 / 数据版本 |
|
||||
| `hd_universe_member` | 逐股:每个滤网通过位、`fail_stage`(首个未通过滤网)、`fail_reason`(中文原因)、取值快照 |
|
||||
|
||||
> **「为什么没选上」是这个模块最重要的产出。** 前端「股票池」页可逐股下钻。
|
||||
|
||||
---
|
||||
|
||||
## 0.2 步骤②:画像 `hdiv profile`
|
||||
|
||||
```bash
|
||||
# 对某个股票池的全部成员,按**该股票池的 asof** 画像
|
||||
.venv/bin/python -m hdiv profile --universe-run <run_id>
|
||||
|
||||
# 指定股票 + 指定时点(任意历史时点,PIT)
|
||||
.venv/bin/python -m hdiv profile --symbols 600036.SH 000651.SZ --asof 2018-05-18
|
||||
```
|
||||
|
||||
### 发生了什么
|
||||
|
||||
```
|
||||
① 确定对象与时点 → --universe-run 时 asof 取该池的 asof_date;--asof 可显式覆盖
|
||||
② 取数 → 起点 = asof.year − max(windows_years) − 1 的 1 月 1 日,终点 = asof
|
||||
价格(不复权)、daily_basic(PE/PB/PS)、分红、年报财务、指数
|
||||
③ 逐股逐指标统计 → 每个指标 × 每个窗口 [全历史, 5, 8, 10] 年:
|
||||
n_obs、min/max/mean/median/std、P10/P25/P50/P75/P90、
|
||||
当前值、**当前值在该窗口分布中的分位**
|
||||
④ 分红质量 → 连续分红年数、DPS 增速与波动、支付率、FCF 覆盖
|
||||
(与「股票池筛选」共用同一套口径函数)
|
||||
⑤ 安全边际评分 → 5 个分项(股息率/估值/财务质量/资产负债表/分红质量)
|
||||
+ 全项齐备时才给 composite 综合分
|
||||
⑥ 覆盖率自检 → 窗口实际观测数 ÷ 该窗口应有交易日数;不足则打印警告
|
||||
⑦ 落库 → hd_profile_run / hd_profile_stat / hd_profile_series / hd_profile_score
|
||||
```
|
||||
|
||||
### 输出解读
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `n_obs` | 该窗口内的实际观测数(**注意分位就是在这 n 个观测上算的**) |
|
||||
| `current_value` | asof 当天的值(取窗口内**最后一个观测**) |
|
||||
| `current_percentile` | 当前值在窗口分布中的分位(`≤ 当前值的观测占比`) |
|
||||
| `status` | `OK` / `INSUFFICIENT`(样本不足,**不猜、不用 0 填充**) |
|
||||
| 窗口覆盖率 | 1.0 = 名义 5 年真有 5 年数据;< 1 说明被数据起点截短 |
|
||||
|
||||
CLI 会打印覆盖率,例如:
|
||||
|
||||
```
|
||||
画像 <run_id>:46 只
|
||||
窗口覆盖率:最差 10 年窗口 94.6%(1.0 = 名义窗口被完整覆盖)
|
||||
```
|
||||
|
||||
### 关键认知(最容易误解的一点)
|
||||
|
||||
> **画像是一次「快照」,回测并不读它。**
|
||||
>
|
||||
> 回测里每次买入前用的画像,是引擎**在该决策日实时重算**的
|
||||
> (见 §0.3 第④步),与这里落库的快照是**两条独立路径**。
|
||||
> 两者由「逐值等价测试」约束,不会给出两个不同的数。
|
||||
>
|
||||
> 所以:**画像页是给你看的,不是给回测用的。** 回测每天自己算。
|
||||
|
||||
---
|
||||
|
||||
## 0.3 步骤③:回测 `hdiv backtest`
|
||||
|
||||
```bash
|
||||
# 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 asof
|
||||
.venv/bin/python -m hdiv backtest --universe-run <run_id> --start 2025-01-01
|
||||
|
||||
# 不冻结:引擎在每个调仓日按当时可见数据重新筛选(判断策略本身用这个)
|
||||
.venv/bin/python -m hdiv backtest --start 2015-01-01
|
||||
```
|
||||
|
||||
### 发生了什么(逐日事件循环)
|
||||
|
||||
```
|
||||
① 确定区间 → 交易日历取 [start, end],默认取 config/backtest.yml 的 period
|
||||
② 重建股票池 → --universe-run:守卫校验 asof ≤ 回测首个交易日,然后**冻结**该清单,
|
||||
所有调仓日复用(每 12 个月不再重筛)
|
||||
否则:每 universe_refresh_months=12 个月的调仓日,调 selector.run(asof=当日)
|
||||
—— 用的是**当时可见**的数据(PIT)
|
||||
③ 预载面板 → 价格(不复权)、分红事件、停牌、涨跌停、指数
|
||||
若闸门启用:另按最长窗口预载画像面板
|
||||
④ 逐日循环 ↓
|
||||
④a 开盘 → 执行**昨日**收盘产生的信号,成交价 = 次日开盘价 ± 滑点
|
||||
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送转股增加股数
|
||||
④c 收盘 → 每月一次评估信号(signal_frequency_months=1):
|
||||
· 算当日股息率 = TTM 每股分红 ÷ 不复权收盘价
|
||||
· 算历史分位 = 当前值在「(当日−5年, 当日]」分布中的占比
|
||||
· 分位 ≥ entry.yield_percentile(75) → 买入候选(阶梯定目标仓位)
|
||||
· 分位 ≤ exit.yield_percentile(25) → 卖出候选
|
||||
· **买入候选再过「实时画像闸门」**(见下)
|
||||
④d 收盘 → 盯市:总市值 = 现金 + 持仓 × 不复权收盘价 → 净值曲线
|
||||
⑤ 绩效与对账 → 收益/CAGR/回撤/Sharpe/Sortino/Calmar、逐年收益、
|
||||
**资金恒等式残差**(应为 0)
|
||||
⑥ 落库 → hd_backtest_run / _equity / _position / _trade / _signal / _metric
|
||||
```
|
||||
|
||||
### ④c 的「实时画像闸门」(★ 你关心的那件事)
|
||||
|
||||
| 问题 | 答案 |
|
||||
|---|---|
|
||||
| 会实时算画像吗? | **会**。每个决策日、对**已触发买入条件**的标的,按当时可见数据重算过去 5 年画像 |
|
||||
| 用哪一天的画像? | **该决策日自己的**。2025-03-31 用 (2020-03-31, 2025-03-31],不是「2025-01-01 那天」的 |
|
||||
| 每只股票每天都算吗? | **不是**。惰性:只有股息率分位 ≥ P75 已触发的才去算 |
|
||||
| 算全量指标吗? | 算 `_profile_one` 的全部指标,但**只加载规则声明用到的面板**(纯估值规则不查财报表) |
|
||||
| 管卖出吗? | **不管**。卖出/减仓只看股息率分位 |
|
||||
| 决定仓位大小吗? | **不决定**。`weight_scheme: score` 未实现,仓位由分位阶梯决定 |
|
||||
| 不通过会怎样? | 产出信号类型 `REJECT` + `skip_reason=PROFILE_GATE`,**不成交**;前端「未成交信号」可逐条看每条规则的实际值/阈值/是否通过 |
|
||||
| 数据缺失/样本不足? | 按 `on_unverifiable`(默认 `reject`)保守处理 —— **不猜** |
|
||||
| 关掉它? | `entry.profile_gate.enabled: false`,整条链路不参与,行为回到改造前 |
|
||||
|
||||
配置与规则详见 §4.3「实时画像闸门」。
|
||||
|
||||
### 未来函数守卫(会拒绝执行的情况)
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| `--universe-run` 且**股票池 asof > 回测首个交易日** | **拒绝执行**,退出码 1,错误信息给出三种正确做法 |
|
||||
| `--universe-run` 用在 `--mode walkforward` | **拒绝执行**(训练窗口比股票池时点更早) |
|
||||
| `--universe-run` 且 asof ≤ 起点 | 正常执行(股票池属于**事前信息**) |
|
||||
| 确需复现带未来信息的旧结果 | 显式加 `--allow-lookahead-universe`;偏差会写入 `unimplemented_json` |
|
||||
|
||||
> **例**:`--universe-run b8dd742f…`(asof=2025-01-21)+ 默认起点 2015-01-01
|
||||
> → **直接报错,不会跑出任何结果**。
|
||||
> 想用这个池子,必须 `--start 2025-01-21`(或更晚)。
|
||||
|
||||
### 回测里的成交与成本口径
|
||||
|
||||
| 项 | 口径 |
|
||||
|---|---|
|
||||
| 成交价 | 信号**次日开盘价** ± 滑点(固定此行为,`same_close` 等分支未实现) |
|
||||
| 佣金 / 印花税 / 过户费 | 佣金双边取 `max(额×费率, 最低佣金)`;印花税**仅卖出**;过户费双边 |
|
||||
| 红利税 | 按持股期限:≤1 月 20%、≤1 年 10%、>1 年免征 |
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板 → 该信号**跳过**(记 `skip_reason`) |
|
||||
| 停牌 | 该信号**跳过**(`backtest.yml` 写的 `defer` **未实现**,不会顺延) |
|
||||
| 分红 | 除权日入账,**留存为现金**(`cash_mode: reinvest` 未实现),下次调仓按目标权重再配置 |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
|
||||
> 你可以直接从库里读到该 run 到底哪些约束没生效 —— 不会静默。
|
||||
|
||||
### 验证这次回测「干净」的两个动作
|
||||
|
||||
```sql
|
||||
-- ① 有没有被声明为「含未来信息」(应为 0)
|
||||
SELECT run_id, start_date, end_date,
|
||||
(unimplemented_json LIKE '%含未来信息%') AS bias_flag
|
||||
FROM hd_backtest_run
|
||||
WHERE universe_run_id = '<你的 run_id>'
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- ② 资金对账残差必须为 0(不为 0 时不要采信任何绩效指标)
|
||||
SELECT run_id, status, unimplemented_json FROM hd_backtest_run
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
CLI 也会直接打印对账残差与 `✓`。
|
||||
|
||||
---
|
||||
|
||||
## 0.4 步骤④:看结果与「能不能信」
|
||||
|
||||
| 入口 | 看什么 |
|
||||
|---|---|
|
||||
| `hdiv web` → 首页 | 数据审计、股票池、画像、回测、Walk-forward 的汇总入口 |
|
||||
| 回测详情 | 净值曲线、逐笔成交(含 `reason_json`:为什么买)、未成交信号(含 `REJECT` 原因)、持仓 |
|
||||
| **Walk-forward 详情** | **逐窗口样本内/样本外对比** —— 判断策略好坏的唯一依据 |
|
||||
| `output/reports/` | 静态 HTML 快照(需 `--html` 显式导出) |
|
||||
|
||||
**读结论的三条纪律**:
|
||||
|
||||
1. **只认 Walk-forward 的样本外结果**,不要采信单路径全期数字。
|
||||
本项目的实测就是反例:同一份数据,回补前后单路径从 +117% 掉到 +93%,
|
||||
而样本外 7 个窗口**一个数字都没变**。
|
||||
2. **对账残差不为 0 就不要看绩效**。
|
||||
3. **样本太短不要下结论**。用 2025 起的池子跑不到 2 年(约 21 个调仓月),
|
||||
统计上说明不了任何问题,更**不能拿来调参**。
|
||||
|
||||
---
|
||||
|
||||
## 0.5 一页速查:每步的命令、产出、坑
|
||||
|
||||
| 步骤 | 命令 | 落库 | 最容易踩的坑 |
|
||||
|---|---|---|---|
|
||||
| ① 选股 | `hdiv universe --asof <日期>` | `hd_universe_run` / `hd_universe_member` | `asof` 会归一化到交易日;`--no-persist` 后无法被回测引用 |
|
||||
| ② 画像 | `hdiv profile --universe-run <id>` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) |
|
||||
| ③ 回测 | `hdiv backtest [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`/`reinvest` 等未实现项在 `unimplemented_json` 里 |
|
||||
| ④ 验证 | `hdiv backtest --mode walkforward` | `hd_walkforward_run` / `_window` | 必须与 `--universe-run` 分开用;约 25~80 分钟 |
|
||||
| ⑤ 调参 | `hdiv sensitivity --sweep "..."` | `hd_sensitivity_run` / `_point` | 样本不足时噪声会被误读为过拟合 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 1. 系统是什么
|
||||
|
||||
一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线:
|
||||
@@ -120,26 +403,34 @@ export PYTHONPATH=src
|
||||
|
||||
## 2.4 完整跑一遍策略研究
|
||||
|
||||
> **完整的分步讲解见 §0(先读那一节)。** 这里只给最短的命令序列。
|
||||
|
||||
```bash
|
||||
export PYTHONPATH=src
|
||||
|
||||
# 股票池(时点:2024-06-28)
|
||||
# ① 股票池(时点: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 profile --universe-run <①的 run_id>
|
||||
|
||||
# 登记策略
|
||||
# ③ 登记策略
|
||||
.venv/bin/python -m hdiv strategy register
|
||||
|
||||
# 回测(默认区间取 config/backtest.yml 的 period)
|
||||
.venv/bin/python -m hdiv backtest
|
||||
# ④ 回测
|
||||
# - 若要用①这个池子:必须带 --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 分钟)
|
||||
# ⑤ Walk-forward 样本外验证(★ 判断策略的唯一依据,约 25~80 分钟)
|
||||
.venv/bin/python -m hdiv backtest --mode walkforward
|
||||
|
||||
# 参数敏感性
|
||||
# ⑥ 参数敏感性
|
||||
.venv/bin/python -m hdiv sensitivity
|
||||
```
|
||||
|
||||
@@ -310,17 +601,26 @@ industry_exemptions:
|
||||
| `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.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 股年度分红的除权间隔中位数约 **366 天**
|
||||
> (实测招商银行 5 次间隔 > 365 天,最长 393 天)。严格 365 天窗口会制造
|
||||
> 1~3 天的「空窗期」,把股息率算成 0 —— 这是统计假象,会污染历史分位。
|
||||
> 仅当严格窗口结果为零时,才回退到 `365 + grace_days`。
|
||||
> **`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)。
|
||||
|
||||
---
|
||||
|
||||
@@ -339,11 +639,101 @@ strategy:
|
||||
|---|---:|---|
|
||||
| `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` | 单行业仓位上限 |
|
||||
| `position.max_holdings` | `20` | 最多持仓只数 |
|
||||
| `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%(窗口会被数据起点截短)`
|
||||
|
||||
### 目标仓位阶梯(重要)
|
||||
|
||||
@@ -436,9 +826,9 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `benchmark[]` | 沪深300 / 中证红利 / 上证指数 | 基准列表 |
|
||||
| `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) |
|
||||
| `fill.price` | `next_open` | 信号次日开盘成交 |
|
||||
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过 |
|
||||
| `fill.suspended_rule` | `defer` | 停牌时顺延 |
|
||||
| `dividend.cash_mode` | `reinvest` | 分红处理:`reinvest`/`hold`/`cash_out` |
|
||||
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
|
||||
| `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
|
||||
| `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -452,6 +842,9 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `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-报告部署)。
|
||||
|
||||
@@ -504,7 +897,8 @@ 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-end 2019-12-31] [--no-resume]
|
||||
hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
|
||||
[--basic-start <同 --start>] [--basic-end 2019-12-31] [--no-resume]
|
||||
```
|
||||
|
||||
| 目标 | 说明 | 首次耗时 |
|
||||
@@ -514,11 +908,15 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-1
|
||||
| `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
|
||||
| `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 |
|
||||
| `trading` | 停牌与涨跌停 | ~20 分钟 |
|
||||
| `backfill` | 2015–2018 历史回补(写入 qlib 原有表,`INSERT IGNORE` 不覆盖既有行) | ~15 分钟 |
|
||||
| `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` — 数据审计
|
||||
|
||||
@@ -526,10 +924,16 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-1
|
||||
hdiv audit [--no-persist] [--html]
|
||||
```
|
||||
|
||||
执行 18 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、代码有效性),
|
||||
执行 15 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、量价单位一致性、代码有效性),
|
||||
结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。
|
||||
退出码:总体 FAIL 时为 1。
|
||||
|
||||
> 与单位有关的两项:`UNIT`(`daily_basic` 的市值恒等式 + 量级)与
|
||||
> `UNIT-OHLCV`(`stock_daily` 逐年抽样的量价单位一致性)。
|
||||
> 后者当前会报 **WARN**:2015-2019 的存量行仍是 Tushare 原始单位,
|
||||
> 读取层已兜底换算(结果正确),但存量数据本身仍应择机重刷。
|
||||
> 详见 §9.1。
|
||||
|
||||
## 5.4 `universe` — 股票池筛选
|
||||
|
||||
```bash
|
||||
@@ -554,11 +958,21 @@ hdiv universe [-c config/universe.yml] [--asof 2024-06-28 | latest] [--no-persis
|
||||
## 5.5 `profile` — 个股画像
|
||||
|
||||
```bash
|
||||
hdiv profile --universe-run <run_id> # 对股票池全部股票
|
||||
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票
|
||||
hdiv profile --universe-run <run_id> # 对股票池全部股票(asof 取该股票池的时点)
|
||||
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票与**时点**
|
||||
hdiv profile --symbols 600519.SH --asof 2018-05-18 # 任意历史时点的 PIT 画像
|
||||
hdiv profile --universe-run <run_id> --html --html-limit 20 # 导出前 20 只的静态报告
|
||||
```
|
||||
|
||||
> `--asof` 决定画像用**哪些数据**:只使用 `<= asof` 的行情、每日指标、分红与财报。
|
||||
> 例如 `--asof 2018-05-18` 得到的就是「2018-05-18 当天能算出的画像」,
|
||||
> 5 年窗口覆盖 (2013-05-18, 2018-05-18]。
|
||||
> 不带 `--asof` 且带 `--universe-run` 时,asof 取该股票池的筛选时点。
|
||||
>
|
||||
> **注意**:画像是一次**快照**(每个 asof 一份)。回测并不读取它 ——
|
||||
> 回测的交易依据是引擎在每个决策日**实时重算**的画像,见
|
||||
> §4.3「实时画像闸门」与 §9.2。
|
||||
|
||||
## 5.6 `strategy` — 策略管理
|
||||
|
||||
```bash
|
||||
@@ -572,19 +986,41 @@ hdiv strategy diff -f A.yml --other B.yml # 比较两版差异
|
||||
|
||||
```bash
|
||||
hdiv backtest [--start 2015-01-01] [--end 2026-09-30] [--no-persist] [--html]
|
||||
hdiv backtest --mode walkforward # 7 个滚动窗口,约 25 分钟
|
||||
hdiv backtest --mode walkforward # 7 个滚动窗口
|
||||
hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建立关联
|
||||
|
||||
# 注意 1:--universe-run 与 --mode walkforward 不能同时使用(会报错说明原因)。
|
||||
# 注意 2:单次回测里,若股票池的 asof 晚于回测起点,同样会被**拒绝**
|
||||
# (同一条未来函数纪律)。错误信息会给出三种正确做法:
|
||||
# a) 去掉 --universe-run,让引擎逐调仓日按当时可见数据重新筛选(推荐)
|
||||
# b) 把 --start 改到股票池 asof 之后
|
||||
# c) 确需复现带未来信息的历史结果:显式加 --allow-lookahead-universe
|
||||
# —— 该偏差会写入 hd_backtest_run.unimplemented_json,可被检索出来
|
||||
```
|
||||
|
||||
输出示例:
|
||||
> **为什么单次回测也要拒绝**:股票池带 asof 时点。用 2025-01-21 选出的名单去跑
|
||||
> 2015 年起的区间,名单里含有 2015 年不可能知道的信息(哪些公司此后仍满足
|
||||
> 连续分红、5 年 ROE、自由现金流覆盖等条件)。实测:这样跑出的回测
|
||||
> **在 2015-01-06 就有 8 笔成交**,而按当时可见数据筛选,那段时间的股票池
|
||||
> 只有个位数只股票。
|
||||
|
||||
输出示例(当前配置:实时画像闸门启用):
|
||||
|
||||
```
|
||||
期初 1,000,000 → 期末 2,148,010 | 总收益 114.80% | CAGR 6.73% | 最大回撤 -21.05% | Sharpe 0.31 | 成交 180 笔
|
||||
累计现金分红 558,797(已扣红利税 13,676) | 对账残差 -0.0000 ✓
|
||||
回测 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` — 参数敏感性
|
||||
|
||||
@@ -692,6 +1128,87 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|
||||
|
||||
> ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §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. 报告解读
|
||||
@@ -754,6 +1271,11 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|
||||
|
||||
## 7.5 Walk-forward 报告 ★ 最重要
|
||||
|
||||
> **前端入口**:`#/walkforwards`(导航栏「样本外」)。
|
||||
> 每个 wf_id 是一条独立记录,点进去可看逐窗口的样本内/样本外对比与冻结阈值。
|
||||
> 该页面同时给出**样本外均值、胜率、稳定性**三个核心判据。
|
||||
|
||||
|
||||
**这是判断策略是否真的有效的核心依据。**
|
||||
|
||||
**看什么**:
|
||||
@@ -770,16 +1292,33 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|
||||
|---|---|
|
||||
| 样本外超额 > 0 且稳定性 > 1 | 策略可能真的有效 |
|
||||
| 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 |
|
||||
| 样本外超额 < 0 | **策略未通过样本外检验** |
|
||||
| 样本外超额 < 0 | 看是否集中在牛市(见下) |
|
||||
| 全期回测远好于样本外均值 | **单路径回测高估了策略** |
|
||||
|
||||
> **本系统的实测结论**:全期回测 +114.80%,而 7 窗口样本外收益均值 **−0.95%**
|
||||
> (基准 +2.29%,超额 **−3.24pp**)。即**当前策略没有稳定的样本外超额收益**。
|
||||
> 它的价值在于**回撤控制**(样本外最差 −24.62%,基准同期 −46%~−52%),
|
||||
> **务必用年化口径比较样本内/外**:训练段 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 参数敏感性报告
|
||||
|
||||
@@ -977,15 +1516,32 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** |
|
||||
| 财务报表金额 | 元 | 元(不变) |
|
||||
| `dividend.base_share` | 万股 | **股** |
|
||||
| `stock_daily.vol` | 手 | **股**(×100) |
|
||||
| `stock_daily.amount` | 千元 | **元**(×1000) |
|
||||
|
||||
**自检手段**(审计中的 `UNIT` 检查):
|
||||
**自检手段**(审计中的 `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(无未来函数)
|
||||
|
||||
| 数据 | 可见性规则 |
|
||||
@@ -995,11 +1551,25 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| 行情/指标 | `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 分红口径
|
||||
|
||||
@@ -1043,11 +1613,18 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| 过户费 | 双边 |
|
||||
| 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 |
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板则该信号不成交,记录 `skip_reason` |
|
||||
| 停牌 | 信号顺延到下一可成交日 |
|
||||
| 涨跌停 | 开盘即封板则该信号**当日跳过**,记录 `skip_reason`(`defer` 未实现) |
|
||||
| 停牌 | **当日跳过**(⚠️ `fill.suspended_rule: defer` **未实现**,不会顺延;见 §0.3) |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**。
|
||||
这样从根上避免了「复权收益 + 分红」的重复计算。
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税),
|
||||
**留存为现金**,在下次调仓时按目标权重重新配置
|
||||
(⚠️ `cash_mode: reinvest` / `reinvest_rule` **未实现**)。
|
||||
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
|
||||
|
||||
> 以上每一项未实现都会逐条写入 `hd_backtest_run.unimplemented_json` —— 可直接查库核对。
|
||||
|
||||
**资金对账**(每次回测都会校验):
|
||||
|
||||
@@ -1233,11 +1810,17 @@ curl -I http://192.168.1.166:8080/ggx/index.html
|
||||
| 股票清单 | `#/universes/<run_id>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** |
|
||||
| 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 |
|
||||
| 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** |
|
||||
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、绩效指标、**逐笔成交与理由** |
|
||||
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 |
|
||||
| 个股买卖点 | `#/backtests/<run_id>/stocks/<symbol>` | 股价/股息率/PE/ROE 趋势图 + 买卖点标注 |
|
||||
| **样本外** | `#/walkforwards` | Walk-forward 记录;**样本内 vs 样本外**逐窗口对比 |
|
||||
| 归档 | `#/archive` | 已归档与已删除的记录,可恢复 |
|
||||
|
||||
### 记录管理
|
||||
|
||||
- **重跑覆盖**:同一份配置 + 同一个 `asof` 时点 → 同一个 `run_id`,重跑会**原地覆盖**
|
||||
旧记录(含成员清单与因子快照),不会累积重复条目。
|
||||
你在界面上的**命名与备注会被保留**,不会被重跑清掉。
|
||||
改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。
|
||||
- **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上
|
||||
- **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档
|
||||
- **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。
|
||||
@@ -1363,6 +1946,45 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
|
||||
./deploy/install-service.sh status # 本项目(后端侧)
|
||||
```
|
||||
|
||||
### 11.2.1 `/api/health` 通,但页面报「配置校验失败」
|
||||
|
||||
现象:状态徽章正常、`api/health` 通,可页面整体报错,形如:
|
||||
|
||||
```text
|
||||
加载失败:配置校验失败:/…/config/datasource.yml
|
||||
1 validation error for DataSourceConfig
|
||||
sync
|
||||
Extra inputs are not permitted [type=extra_forbidden, input_value={'min_symbols_floor': 200…}]
|
||||
```
|
||||
|
||||
**这不是配置写错了,是后端进程太旧**。`hdiv` 在进程启动时把
|
||||
`src/hdiv/core/config.py` 的模型和 `config/*.yml` 一起读进内存(配置还经
|
||||
`lru_cache` 缓存),所以:
|
||||
|
||||
- 你新加了配置字段 + 对应的模型字段,
|
||||
- launchd 里那个进程却还在用**加字段之前**的模型校验文件,
|
||||
- 于是「新文件里的 `sync` 段」成了模型眼里的多余键 → `extra_forbidden`。
|
||||
|
||||
注意 `KeepAlive` 只负责**崩溃后**拉起,正常运行的进程不会因为你改文件而重启。
|
||||
症状最迷惑的地方是:报错看起来像 YAML 写错了,实际 YAML 完全合法。
|
||||
|
||||
**验证与修复**(一条命令即可):
|
||||
|
||||
```bash
|
||||
# 绕过服务,用当前源码直接校验该文件:能通过就说明文件没问题
|
||||
PYTHONPATH=src .venv/bin/python -c \
|
||||
"from hdiv.core.config import load_config; print(load_config('datasource'))"
|
||||
|
||||
# 改完 src/ 或 config/ 之后必须重启后端,让它重新 import 模块、重新读配置
|
||||
./deploy/install-service.sh restart
|
||||
```
|
||||
|
||||
`restart` 用的是 `launchctl kickstart -k`(先杀后拉,PID 会变);
|
||||
手工 nohup 启动的(`deploy/serve.sh start`)对应执行 `./deploy/serve.sh restart`。
|
||||
|
||||
**记住这条规则:改 `src/` 或 `config/` 之后,一律 `restart` 一次。**
|
||||
只改 `web/`、`templates/` 这类静态产物不需要——它们由 nginx 每次请求重新读盘。
|
||||
|
||||
## 11.3 股票池为空或很少
|
||||
|
||||
**按顺序排查**:
|
||||
@@ -1396,6 +2018,10 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
|
||||
**最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。
|
||||
若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。
|
||||
|
||||
注意:**用 `--universe-run` 冻结股票池时没有预热期** —— 引擎不再自筛选,
|
||||
历史约束不作用于早期,2015 年起即可能满仓。这也是为什么冻结模式的回撤
|
||||
显著大于重新筛选模式。若你看到早期就满仓,那是冻结模式的正常表现,不是 bug。
|
||||
|
||||
**确认方法**:
|
||||
|
||||
```sql
|
||||
@@ -1448,8 +2074,14 @@ market.min_market_capp
|
||||
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
|
||||
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
|
||||
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
|
||||
| 7 | 回测 2015–2018 为预热期 | 100% 现金,无信号(见 §10.4) |
|
||||
| 8 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 |
|
||||
| 7 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:<br>`position.max_holdings`、`position.sector_max_position`、`position.weight_scheme`、`risk.max_portfolio_drawdown`、`risk.max_single_drawdown`、`risk.liquidity_limit_pct_adv`、`exit.stop_loss_pct`、`exit.max_holding_days`、`fill.max_volume_pct`、`fill.partial_fill`、<br>**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode=reinvest` / `dividend.reinvest_rule`**(分红留存为现金,在下次调仓再配置)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
|
||||
| 8 | `stock_daily` 2015–2019 的存量行仍是 Tushare 原始单位 | 读取层已兜底换算(结果正确),审计 `UNIT-OHLCV` 报 WARN;重刷数据可消除 |
|
||||
| 9 | ~~行情/每日指标只到 2015-01-05~~ → **已修复(2026-10-04 回补到 2005-01-04)** | 曾使「过去 5 年画像」在 2019 年前只有 0.2~4 年数据(覆盖率 20%~67%);回补后 5 年窗口覆盖率为 **98.7%~100%**,残差经逐日核实为真实停牌。另:**修好数据后全期收益从 +117.36% 降到 +92.73%**,因为 2015 年(牛市顶 + 股灾)从「被数据缺口挡住」变成被真实交易。见 §6.6 与 implementation-status §9.3c |
|
||||
| 10 | `config/profile.yml: sufficiency` 三个阈值**尚未被任何代码使用** | `min_history_years_dividend` / `min_history_years_price` / `min_dividend_records` 目前是死配置。真正的充分性判定由 `profile_gate.min_window_coverage` + `on_unverifiable` 承担 |
|
||||
| 11 | 实时画像闸门默认 `on_unverifiable: reject` | 数据不全时会放弃部分本可买入的标的(刻意保守,可改为 `pass`)。回补后早年数据已基本完整,影响大幅缩小 |
|
||||
| 12 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 |
|
||||
| 13 | 幸存者偏差尚有 **3 只**缺口 | `000022.SZ`/`000043.SZ`/`300114.SZ`(均因吸收合并退市)有行情但不在 `stock` 表,永远不会进入候选集;占 5,903 只的 0.05%。根因是 `stock` 只收录在市股票 |
|
||||
| 14 | `hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录代码 | 其中 356 中 250+106 为北交所(按交易所白名单设计排除);SZ 部分与第 13 条同源。不影响可交易标的 |
|
||||
|
||||
## 12.1 使用前请务必知道
|
||||
|
||||
@@ -1504,6 +2136,6 @@ export PYTHONPATH=src
|
||||
| 个股画像口径(窗口/分位/权重) | `config/profile.yml` |
|
||||
| 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` |
|
||||
| 手续费/滑点/红利税 | `config/cost.yml` |
|
||||
| 回测区间/Walk-forward/基准 | `config/backtest.yml` |
|
||||
| 回测区间/Walk-forward/基准/分位最小样本量 | `config/backtest.yml` |
|
||||
| 报告外观与部署方式 | `config/report.yml` |
|
||||
| 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) |
|
||||
|
||||
Reference in New Issue
Block a user