修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 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:
2026-10-04 12:47:17 +08:00
parent fb6608193b
commit 14ec0c6c86
25 changed files with 4543 additions and 209 deletions
+29 -3
View File
@@ -74,12 +74,38 @@ docs/ 文档
**本系统的实测结论是:策略没有稳定的样本外超额收益。** **本系统的实测结论是:策略没有稳定的样本外超额收益。**
全期回测(2015–2026)显示 +114.80% / CAGR 6.73%, 全期回测(2015–2026)显示 +92.73% / CAGR 5.75%,
但 7 窗口 Walk-forward 的样本外收益均值仅 **−0.95%**(基准 +2.29%,超额 **−3.24pp**)。 但 7 窗口 Walk-forward 的样本外收益均值仅 **+1.16%**(基准 +2.29%,超额 **−1.12pp**)。
它真正的价值在于**回撤控制**(样本外最差 −24.62%,基准同期 −46%~−52%)—— 它真正的价值在于**回撤控制**(样本外最差 −22.97%,基准同期 −46%~−52%)——
更像降低波动的配置工具,而非超额收益来源。 更像降低波动的配置工具,而非超额收益来源。
> **四条必须说的结论**(详见[实施状态 §4.6c/§4.6d/§9](docs/implementation-status.md)):
>
> 1. **修数据让结果变差、但变真实了。** 行情原先只到 2015-01-05,
> 使「过去 5 年」窗口在 2019 年前被静默截短(覆盖率仅 20%~67%)。
> 2026-10-04 已把行情回补到 **2005-01-04**、涨跌停/停牌到 **2010**,
> 5 年窗口覆盖率提升到 **98.7%~100%**。代价是全期收益从 +117.36%
> 降到 **+92.73%** —— 因为 **2015 年(牛市顶 + 股灾)从「被数据缺口
> 挡住」变成被真实交易(−3.18%)**。靠数据缺口躲过股灾不是策略能力。
> 2. **实时画像闸门在两个口径下结论相反**:
> 单条路径 **−8.47pp**(有害),样本外 **+1.16pp**(有益)。
>
> | 口径 | 闸门开 | 闸门关 |
> |---|---:|---:|
> | 全期单路径总收益 | +92.73% | **+101.20%** |
> | Walk-forward 样本外均值 | **+1.16%** | −0.00% |
> | 样本外最差回撤 | **−22.97%** | −24.22% |
>
> **按本项目一贯立场以样本外为准**:闸门是改善。是否启用由你决定
> (`entry.profile_gate.enabled`);全期剔除 1027 次买入信号。
> 3. **同一项数据修正,在两个口径下的「效果」相差 32.9pp**
> (回补对样本外贡献 **0** —— 7 个窗口逐窗口未变;对单路径贡献 −32.9pp)。
> 这是「不要采信单条路径」最有力的例证。
> 4. **`stock_daily` 的量价单位曾前后不一致**(2015-2019 存「手/千元」,
> 2020 起存「股/元」),使流动性门槛在早年低估 1000 倍、**把 2015-2019
> 的股票池整体清空**。已修复(读取层幂等归一化 + 审计 `UNIT-OHLCV` 防回归)。
**请始终以 Walk-forward 的样本外结果为主要依据,不要采信单条路径的全期数字。** **请始终以 Walk-forward 的样本外结果为主要依据,不要采信单条路径的全期数字。**
详见[实施状态 §4.6](docs/implementation-status.md)。 详见[实施状态 §4.6](docs/implementation-status.md)。
+24 -3
View File
@@ -67,9 +67,14 @@ tushare:
index_weight: 180 index_weight: 180
suspend_d: 180 suspend_d: 180
stk_limit: 180 stk_limit: 180
daily: 480 # daily/adj_factor/daily_basic:**不要设成 480**。
adj_factor: 480 # 2026-10-04 回补 2005-2014 时实测:以约 196 次/分钟跑 `daily`,
daily_basic: 480 # 300 次调用后即被 Tushare 拒绝(此 token 的实际上限 ≤ 200/min)。
# 一旦触发限频,客户端要冷却 62 秒再重试 —— 逐日回补 2,400+ 天时,
# 这种「撞墙再等」远比「按额度平滑配速」慢,而且有耗尽 4 次重试的风险。
daily: 170
adj_factor: 170
daily_basic: 170
index_daily: 180 index_daily: 180
retry: 4 retry: 4
retry_backoff_sec: 2 retry_backoff_sec: 2
@@ -84,3 +89,19 @@ paths:
templates_dir: templates templates_dir: templates
assets_dir: assets assets_dir: assets
log_dir: logs log_dir: logs
# ------------------------------------------------------------
# 行情同步的「完整性」判定(决定断点续传是否重拉整段历史)
#
# 一个交易日被视为已完整同步,要求:
# 当日股票数 >= max(min_symbols_floor, min_symbols_ratio × 当年应有上市股票数)
#
# 早年市场小得多(2005 年约 1,350 只、2010 年约 1,700 只、2024 年约 5,400 只),
# 用单一绝对阈值会把 2005-2009 的每一天都判成「未完成」——回补一旦中断就得
# 从第一天重来。所以改为「按当年规模成比例」判定。
# ------------------------------------------------------------
sync:
# 绝对下限:低于它一定判为不完整(防止比例判定在极早年失效)
min_symbols_floor: 200
# 相对下限:占「当年应有上市股票数」的比例
min_symbols_ratio: 0.6
+32
View File
@@ -38,6 +38,38 @@ entry:
# 必须未进入风险状态 # 必须未进入风险状态
require_risk_pass: true require_risk_pass: true
# ----------------------------------------------------------
# 实时(PIT)个股画像闸门(plan.md §14/§15 的落地形态)
#
# 语义:股息率分位触发买入之后,再用**当日可见的数据**重算一次画像,
# 不通过的票直接剔除(信号类型 REJECT,可在「未成交信号」里看到
# 每一条规则的实际值与阈值)。
#
# 代价:只在触发时计算;跨股票共享的面板按时点缓存,因此成本与
# 「触发次数」成正比,而不是「区间长度 × 股票数」。
#
# enabled=false 时整条链路不参与,回测行为与启用前完全一致。
# ----------------------------------------------------------
profile_gate:
enabled: true
# 画像统计窗口(年):0 = 全历史;其余必须是 config/profile.yml 的
# windows_years 之一(当前 [5, 8, 10])
window_years: 5
# 数据缺失/样本不足时:reject = 保守不买(默认);pass = 放行
on_unverifiable: reject
# 逐条规则:metric 的 stat 必须满足 op value
# stat: current_value(当日值)/ current_percentile(当日值在窗口分布中的分位)
# metric 只能是 hdiv/core/metrics.py 里声明的代码
rules:
# 分红必须可持续:连续分红年数与窗口内分红年数
- { metric: dividend_continuity_years, op: ">=", value: 5 }
# 支付率不得超过 100%(分红不能靠借钱或吃老本)
- { metric: payout_ratio, op: "<=", value: 1.0 }
# 自由现金流必须覆盖分红(与分红同一财年口径)
- { metric: fcf_dividend_cover, op: ">=", value: 1.0 }
# 5 年平均 ROE 下限(与 universe.quality.min_roe_5y_avg 同一口径)
- { metric: roe_avg, op: ">=", value: 0.08 }
# 分批建仓(plan.md §19)。留空 => 一次性建满。 # 分批建仓(plan.md §19)。留空 => 一次性建满。
scale_in: scale_in:
- { percentile: 75, weight: 0.25 } - { percentile: 75, weight: 0.25 }
+741 -72
View File
@@ -9,10 +9,18 @@
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 → **系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告, 回测 → 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 | | **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 267,295 行,覆盖 1990–2026 |
| **G2 日线行情** | ✅ 完成 | `stock_daily` 起点 **2015-01-05**(2015 年首个交易日),2,838 个交易日;**2019 年空洞已补齐**(+89 万行) | | **G2 日线行情** | ✅ 完成 | `stock_daily` **起点 2005-01-04**(2026-10-04 回补,+3,927,792 行),16,007,169 行 / 5,265 个交易日 |
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,2,840 个交易日 | | **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,16,789,137 行 / 5,267 个交易日 |
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 2015-01-05,2,286 个交易日 | | **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 无报表) | | **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 | | **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_*` 表) ### 1.2 数据库(30 张 `hd_*` 表)
@@ -44,7 +52,7 @@
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ | | 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ | | PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ | | 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
| 数据审计 | `data/audit.py`(18 项检查) | ✅ | | 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ | | 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
| 因子 | `factor/dividend_yield.py` | ✅ | | 因子 | `factor/dividend_yield.py` | ✅ |
| 个股画像 | `profile/builder.py` | ✅ | | 个股画像 | `profile/builder.py` | ✅ |
@@ -115,46 +123,116 @@
> 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、 > 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、
> 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、 > 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、
> 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。 > 单股 ≤ 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 | 中证红利 | 上证指数 | | 指标 | **策略(闸门开)** | 策略(闸门关) | 沪深300 | 中证红利 | 上证指数 |
|---|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|
| 总收益 | **+114.80%** | +19.66% | +53.35% | +14.67% | | 总收益 | **+92.73%** | +101.20% | +19.66% | +53.35% | +14.67% |
| 年化 CAGR | **+6.73%** | +1.54% | +3.71% | +1.17% | | 年化 CAGR | **+5.75%** | +6.14% | +1.54% | +3.71% | +1.17% |
| 最大回撤 | **−21.05%** | −46.70% | −46.51% | −52.30% | | 最大回撤 | **−25.38%** | −25.54% | −46.70% | −46.51% | −52.30% |
| Sharpe | 0.31 | — | — | — | | 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 覆盖两个筛选条件之后**取得的 > 该结果是在**修正了支付率与 FCF 覆盖两个筛选条件之后**取得的
> (此前这两个条件因 `base_share` 漏选而静默失效)。修正后股票池由 49 只 > (此前这两个条件因 `base_share` 漏选而静默失效)。
> 收紧到 28 只,收益反而提高 —— 说明「分红可持续性」这一安全边际条件
> 确实在筛选优质标的。
### 4.2 交易与分红 ### 4.2 交易与分红(闸门开)
| 项目 | 数值 | | 项目 | 数值 |
|---|---:| |---|---:|
| 成交笔数 | 180 | | 成交笔数 | 197 |
| 累计现金分红 | 558,797 元(占期初资金 55.9%) | | 期末资金 | 1,927,312 元 |
| 已扣红利税 | 13,676 元 |
| 期末资金 | 2,148,010 元 |
| **资金对账残差** | **0.0000** ✅ | | **资金对账残差** | **0.0000** ✅ |
| 画像剔除的买入信号 | 1027 |
| 画像计算次数 / 决策时点 | 2428 / 141 |
### 4.3 逐年收益 ### 4.3 逐年收益(回补后重跑,闸门开)
| 年 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 | | 年 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|---|---:|---:|---:|---:|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 策略 | +18.56% | +6.64% | +11.85% | **−3.99%** | +1.25% | +22.22% | +14.67% | +7.32% | | 策略(闸门开) | **−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% 现金、无任何成交**,这是**预期行为**而非缺陷: **2015–2018 年组合保持 100% 现金、无任何成交**,这是**预期行为**而非缺陷:
@@ -188,28 +266,46 @@
### 4.6 Walk-forward 样本外验证(plan.md §23/§25) ### 4.6 Walk-forward 样本外验证(plan.md §23/§25)
7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布: 7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布:
(下表为 2026-10-04 回补后重跑,`wf_id = e855fdf268827e1e4c31510c6736eea3`,
**实时画像闸门启用**)
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | > **⚠️ 重要:这 7 个数字与「回补前」逐窗口完全相同。**
|---|---|---|---:|---:| > 也就是说,把行情从 2015 补到 2005 **没有改变任何样本外结果**,
| #0 | 2015–2019 | 2020 | **+4.01%** | −11.94% | > 却让单条路径的全期收益从 +117.36% 变成 +92.73%。
| #1 | 2016–2020 | 2021 | **−3.07%** | −15.79% | > 原因:样本外的测试窗口是 2020–2026,其参照窗口(冻结在训练段)
| #2 | 2017–2021 | 2022 | **−9.17%** | −24.22% | > 本就落在 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
| #3 | 2018–2022 | 2023 | **−8.63%** | −18.00% | > 2015 年是否可交易会改变整条路径。**这正是「不要采信单条路径」的又一例证**
| #4 | 2019–2023 | 2024 | **+2.61%** | −24.62% | > —— 见 §4.6d。
| #5 | 2020–2024 | 2025 | **+3.78%** | −10.50% |
| #6 | 2021–2025 | 2026(部分) | **+3.83%** | −14.88% |
**样本外汇总:** | 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | 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% | | 盈利窗口 | 3 / 7(胜率 42.86%) | 4 / 7(57.14%) |
| 样本外 CAGR 均值 | −0.78% | | 样本外收益 **均值** | **+1.16%** | −0.95% |
| 最差窗口回撤 | −24.62% | | 样本外收益 中位数 | −2.47% | +2.61% |
| **基准收益均值** | **+2.29%** | | 样本外 CAGR 均值 | +0.85% | −0.78% |
| **超额收益均值** | **−3.24%** | | 最差窗口回撤 | −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 样本外均值 | | | 全期单路径回测 | Walk-forward 样本外均值 |
|---|---:|---:| |---|---:|---:|
| 收益 | **+114.80%**(11.7 年) | **−0.95%/年** | | 收益 | **+117.36%**(11.7 年) | **+1.16%/年** |
| 相对基准 | +95pp | **−3.24pp** | | 相对基准 | +97.7pp | **−1.13pp** |
**这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug: **这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug:
1. **全期回测允许仓位穿越牛熊**:2019–2021 建的仓在 2022–2023 的下跌中继续持有, 1. **全期回测允许仓位穿越牛熊**:2016–2019 建的仓在 2022–2023 的下跌中继续持有,
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。 到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布, 2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移), 不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
训练期校准的阈值在测试期就失灵了。 训练期校准的阈值在测试期就失灵了。
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大 3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大。
(稳定性指标 −0.16,说明窗口间差异大于均值本身)。
**因此:不要采信 §4.1 的 +114.80%。** 更接近真实的表述是 **因此:不要采信 §4.1 的 +117.36%。** 更接近真实的表述是
「该策略在 2020/2024/2025/2026 的样本外为正,在 2021/2022/2023 为负, 「该策略在 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); 1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。 指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
2. **涨跌停与停牌约束覆盖 2019 年起**;2015-2018 区间为近似建模, 2. **涨跌停与停牌约束覆盖 2010 年起**(2026-10-04 回补,原为 2019 起);
引擎会在 `hd_backtest_run.unimplemented_json` 中如实声明。 回测区间 2015-01-05 起已**全部**有真实约束,不再是近似建模。
(停牌同步器起点为 2019-01-01,如需 2015-2018 可改 `--start` 重跑。) 2010 年之前的涨跌停价 Tushare 无数据(实测 2005 年 `stk_limit` 返回 0 行)。
3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。 3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。
4. **`index_weight` 为空**:不影响基准收益计算(用指数点位), 4. **`index_weight` 为空**:不影响基准收益计算(用指数点位),
仅影响成分股分析。 仅影响成分股分析。
5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。 5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。
6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。 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),这是最重要的结论。 8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。
9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026), 9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026),
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻; 与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻。
单次完整跑完约需 25 分钟,用 `hdiv backtest --mode walkforward` 执行。 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`(启停脚本)。 部署:`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. 测试覆盖 ## 8. 测试覆盖
``` ```
262 passed 403 passed(pytest 退出码 0)
``` ```
| 测试文件 | 覆盖 | | 测试文件 | 覆盖 |
|---|---| |---|---|
| `test_config.py` | 配置正向加载 + 18 类非法配置必须被拒 | | `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import | | `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
| `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) | | `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 | | `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 | | `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
| `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数 | | `test_profile_pit.py` | **实时画像与批量画像逐值等价**、公告日/除权日 PIT 负例、惰性与面板复用、窗口校验、**窗口覆盖率(含左开右闭分母)**、**输入行序无关性** |
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run`) | | `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数、**画像闸门(剔除/放行/不动仓位/无法验证)**、**未实现声明的诚实性** |
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run` 的未来函数守卫) |
| `test_web.py` | 前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 | | `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
View File
@@ -7,6 +7,7 @@
# 目录 # 目录
0. [全流程操作](#0-全流程操作) ★ **先读这一节(选股 → 画像 → 回测)**
1. [系统是什么](#1-系统是什么) 1. [系统是什么](#1-系统是什么)
2. [快速开始](#2-快速开始) 2. [快速开始](#2-快速开始)
3. [目录与架构](#3-目录与架构) 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. 系统是什么 # 1. 系统是什么
一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线: 一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线:
@@ -120,26 +403,34 @@ export PYTHONPATH=src
## 2.4 完整跑一遍策略研究 ## 2.4 完整跑一遍策略研究
> **完整的分步讲解见 §0(先读那一节)。** 这里只给最短的命令序列。
```bash ```bash
export PYTHONPATH=src export PYTHONPATH=src
# 股票池(时点:2024-06-28) # ① 股票池(时点:2024-06-28)
.venv/bin/python -m hdiv universe --asof 2024-06-28 .venv/bin/python -m hdiv universe --asof 2024-06-28
# → 记下输出的 run_id # → 记下输出的 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 .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 backtest --mode walkforward
# 参数敏感性 # ⑥ 参数敏感性
.venv/bin/python -m hdiv sensitivity .venv/bin/python -m hdiv sensitivity
``` ```
@@ -310,17 +601,26 @@ industry_exemptions:
| `series_max_points` | `1500` | 落库序列点数上限(等间隔降采样,仅影响画图) | | `series_max_points` | `1500` | 落库序列点数上限(等间隔降采样,仅影响画图) |
| `series_metrics` | `[dv_yield, pe_ttm, pb, close, drawdown]` | 哪些指标需要落时间序列 | | `series_metrics` | `[dv_yield, pe_ttm, pb, close, drawdown]` | 哪些指标需要落时间序列 |
| `ttm_dividend.window_days` | `365` | TTM 每股分红回看天数 | | `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]` | 需计算的分位数 | | `percentiles` | `[10,25,50,75,90]` | 需计算的分位数 |
| `dividend_yield_volatility.*` | 250/60/20/10 | 日/月/季/年频波动窗口 | | `dividend_yield_volatility.*` | 250/60/20/10 | 日/月/季/年频波动窗口 |
| `safety_margin.mode` | `separate` | `separate`=分项展示 / `composite`=加权综合 | | `safety_margin.mode` | `separate` | `separate`=分项展示 / `composite`=加权综合 |
| `safety_margin.weights` | 见文件 | 综合分权重(`composite` 模式下必须和为 1.0) | | `safety_margin.weights` | 见文件 | 综合分权重(`composite` 模式下必须和为 1.0) |
| `safety_margin.score_anchors` | 见文件 | 各分项的满分/零分锚点 | | `safety_margin.score_anchors` | 见文件 | 各分项的满分/零分锚点 |
> **`grace_days` 为什么必需**:A 股年度分红的除权间隔中位数约 **366 天** > **`grace_days` 的作用**:A 股相邻两次除权间隔经常 ≠ 365 天,
> (实测招商银行 5 次间隔 > 365 天,最长 393 天)。严格 365 天窗口会制造 > 硬窗口会在每年除权日附近制造两种日历假象 ——
> 1~3 天的「空窗期」,把股息率算成 0 —— 这是统计假象,会污染历史分位。 > **重叠虚高**(间隔 < 365,新旧同时在窗口内,实测招商银行 +108%)
> 仅当严格窗口结果为零时,才回退到 `365 + grace_days`。 > 与**断档虚低**(间隔 > 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.yield_percentile` | `75` | 买入阈值:股息率 ≥ 历史 P75 |
| `entry.scale_in[]` | 75→25%, 80→50%, 85→75%, 90→100% | 分批建仓阶梯 | | `entry.scale_in[]` | 75→25%, 80→50%, 85→75%, 90→100% | 分批建仓阶梯 |
| `entry.profile_gate` | 启用,4 条规则 | **实时画像闸门**(见下节) |
| `exit.yield_percentile` | `25` | 卖出阈值:股息率 ≤ 历史 P25 | | `exit.yield_percentile` | `25` | 卖出阈值:股息率 ≤ 历史 P25 |
| `exit.scale_out[]` | 50→50%, 25→0% | 分批减仓阶梯 | | `exit.scale_out[]` | 50→50%, 25→0% | 分批减仓阶梯 |
| `position.max_position` | `0.10` | 单股仓位上限 | | `position.max_position` | `0.10` | 单股仓位上限 |
| `position.sector_max_position` | `0.25` | 单行业仓位上限 | | `position.sector_max_position` | `0.25` | 单行业仓位上限(**尚未实现**,见 §11) |
| `position.max_holdings` | `20` | 最多持仓只数 | | `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 / 中证红利 / 上证指数 | 基准列表 | | `benchmark[]` | 沪深300 / 中证红利 / 上证指数 | 基准列表 |
| `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) | | `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) |
| `fill.price` | `next_open` | 信号次日开盘成交 | | `fill.price` | `next_open` | 信号次日开盘成交 |
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过 | | `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
| `fill.suspended_rule` | `defer` | 停牌时顺延 | | `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
| `dividend.cash_mode` | `reinvest` | 分红处理:`reinvest`/`hold`/`cash_out` | | `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
--- ---
@@ -452,6 +842,9 @@ period: { start: 2015-01-01, end: latest }
| `charts.*` | 全部 `true` | 各图表开关 | | `charts.*` | 全部 `true` | 各图表开关 |
| `naming.*` | 见文件 | 输出文件命名模板 | | `naming.*` | 见文件 | 输出文件命名模板 |
| `layout.max_width` | `1440` | 页面最大宽度(px) | | `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-报告部署)。 > **`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 index [--no-weight] [--start YYYYMMDD]
hdiv sync price --which daily|adj_factor|daily_basic --start --end [--no-resume] 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 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 分钟 | | `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
| `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 | | `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 |
| `trading` | 停牌与涨跌停 | ~20 分钟 | | `trading` | 停牌与涨跌停 | ~20 分钟 |
| `backfill` | 2015–2018 历史回补(写入 qlib 原有表,`INSERT IGNORE` 不覆盖既有行) | ~15 分钟 | | `backfill` | 历史回补(写入 qlib 原有表,`INSERT IGNORE` **不覆盖既有行**) | 视区间 |
> **`--only-missing`**:跳过已同步的标的,支持断点续传 > **`--only-missing`**:跳过已同步的标的,支持断点续传
> **`--interleaved`**:按股票一次性拉齐四张报表,使策略相关的大市值股票优先就绪 > **`--interleaved`**:按股票一次性拉齐四张报表,使策略相关的大市值股票优先就绪
> **回补需要显式授权**:`HDIV_ALLOW_BACKFILL=1` 或改 `datasource.yml` > **回补需要显式授权**:`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` — 数据审计 ## 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] hdiv audit [--no-persist] [--html]
``` ```
执行 18 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、代码有效性), 执行 15 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、量价单位一致性、代码有效性),
结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。 结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。
退出码:总体 FAIL 时为 1。 退出码:总体 FAIL 时为 1。
> 与单位有关的两项:`UNIT`(`daily_basic` 的市值恒等式 + 量级)与
> `UNIT-OHLCV`(`stock_daily` 逐年抽样的量价单位一致性)。
> 后者当前会报 **WARN**:2015-2019 的存量行仍是 Tushare 原始单位,
> 读取层已兜底换算(结果正确),但存量数据本身仍应择机重刷。
> 详见 §9.1。
## 5.4 `universe` — 股票池筛选 ## 5.4 `universe` — 股票池筛选
```bash ```bash
@@ -554,11 +958,21 @@ hdiv universe [-c config/universe.yml] [--asof 2024-06-28 | latest] [--no-persis
## 5.5 `profile` — 个股画像 ## 5.5 `profile` — 个股画像
```bash ```bash
hdiv profile --universe-run <run_id> # 对股票池全部股票 hdiv profile --universe-run <run_id> # 对股票池全部股票(asof 取该股票池的时点)
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票 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 只的静态报告 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` — 策略管理 ## 5.6 `strategy` — 策略管理
```bash ```bash
@@ -572,19 +986,41 @@ hdiv strategy diff -f A.yml --other B.yml # 比较两版差异
```bash ```bash
hdiv backtest [--start 2015-01-01] [--end 2026-09-30] [--no-persist] [--html] 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> # 用指定股票池(冻结)并建立关联 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 笔 回测 HD_MR_V1 v1.0:2015-01-05 ~ 2026-09-30(2846 个交易日)
累计现金分红 558,797(已扣红利税 13,676) | 对账残差 -0.0000 ✓ 实时画像闸门已启用:窗口 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 说明成本或分红入账有遗漏,**此时不要采信绩效指标**。 > 应为 0;若不为 0 说明成本或分红入账有遗漏,**此时不要采信绩效指标**。
>
> 「画像剔除 1027 次」= 有多少个买入信号被实时画像拦下。它们全部以
> `REJECT` 记录在库,可在前端「未成交信号」里逐条查看每条规则的实际值。
## 5.8 `sensitivity` — 参数敏感性 ## 5.8 `sensitivity` — 参数敏感性
@@ -692,6 +1128,87 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
> ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §7.5)。 > ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §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. 报告解读 # 7. 报告解读
@@ -754,6 +1271,11 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
## 7.5 Walk-forward 报告 ★ 最重要 ## 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 且稳定性 > 1 | 策略可能真的有效 |
| 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 | | 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 |
| 样本外超额 < 0 | **策略未通过样本外检验** | | 样本外超额 < 0 | 看是否集中在牛市(见下) |
| 全期回测远好于样本外均值 | **单路径回测高估了策略** | | 全期回测远好于样本外均值 | **单路径回测高估了策略** |
> **本系统的实测结论**:全期回测 +114.80%,而 7 窗口样本外收益均值 **−0.95%** > **务必用年化口径比较样本内/外**:训练段 5 年、测试段 1 年,
> (基准 +2.29%,超额 **−3.24pp**)。即**当前策略没有稳定的样本外超额收益**。 > 直接比累计收益会得出「样本内远高于样本外」的错误印象(本项目实测踩过这个坑,
> 它的价值在于**回撤控制**(样本外最差 −24.62%,基准同期 −46%~−52%), > 详见 implementation-status.md §7.3)。页面上统一使用年化。
> **务必按牛熊分开看超额**。高股息/低估值策略的典型形态是
> **牛市跑输、熊市跑赢**(用上涨弹性换取下跌保护)。
> 此时只看「超额均值」会被样本里牛熊比例误导 ——
> 本项目实测:4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。
> 判断这类策略要问的是「我用上涨弹性换下跌保护,值不值」,
> 而不是「它有没有 alpha」。
> **本系统的实测结论**:全期回测 +92.73%,而 7 窗口样本外收益均值 **+1.16%**
> (基准 +2.29%,超额 **−1.13pp**)。即**当前策略没有稳定的样本外超额收益**。
> 它的价值在于**回撤控制**(样本外最差 −22.97%,基准同期 −46%~−52%),
> 更像降低波动的配置工具,而非超额收益来源。 > 更像降低波动的配置工具,而非超额收益来源。
> >
> 这个反差不是 bug,正是 Walk-forward 存在的意义 —— 详见 > 这个反差不是 bug,正是 Walk-forward 存在的意义 —— 详见
> [implementation-status.md §4.6](implementation-status.md)。 > [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 参数敏感性报告 ## 7.6 参数敏感性报告
@@ -977,15 +1516,32 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
| `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** | | `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** |
| 财务报表金额 | 元 | 元(不变) | | 财务报表金额 | 元 | 元(不变) |
| `dividend.base_share` | 万股 | **股** | | `dividend.base_share` | 万股 | **股** |
| `stock_daily.vol` | 手 | **股**(×100) |
| `stock_daily.amount` | 千元 | **元**(×1000) |
**自检手段**(审计中的 `UNIT` 检查): **自检手段**(审计中的 `UNIT` 与 `UNIT-OHLCV` 检查):
1. **恒等式** `总市值 ≈ 收盘价 × 总股本` —— 能发现「只换算了一个字段」 1. **恒等式** `总市值 ≈ 收盘价 × 总股本` —— 能发现「只换算了一个字段」
2. **绝对量级** 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段 2. **绝对量级** 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段
3. **量价一致性**(`UNIT-OHLCV`):`成交额 / (成交量 × 收盘价)` 应 ≈ 1。
≈ 0.1 说明该行还是 Tushare 原始口径(手 / 千元)。审计会逐年抽样并列出
> 为什么必须两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**, > 为什么必须前两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**,
> 单靠它无法发现「万元当元用」。实测该错误曾导致市值过滤选中 **0 只**股票。 > 单靠它无法发现「万元当元用」。实测该错误曾导致市值过滤选中 **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(无未来函数) ## 9.2 Point-in-Time(无未来函数)
| 数据 | 可见性规则 | | 数据 | 可见性规则 |
@@ -995,11 +1551,25 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
| 行情/指标 | `trade_date <= 评估日` | | 行情/指标 | `trade_date <= 评估日` |
| ST 状态 | 按 `stock_name_history` 的名称生效区间还原,**不看今天的名字** | | ST 状态 | 按 `stock_name_history` 的名称生效区间还原,**不看今天的名字** |
| 股票池 | 包含**此后才退市**的股票(消除生存者偏差) | | 股票池 | 包含**此后才退市**的股票(消除生存者偏差) |
| **实时画像** | 每个决策日按上述规则重算;窗口只覆盖 `(评估日 − N 年, 评估日]` |
| **窗口覆盖率** | `n_obs / 该窗口应有交易日数`;< 1 说明窗口被数据起点截短(见 §4.3、§6.6) |
| **股票池 vs 回测区间** | 股票池的 `asof` 晚于回测起点即**拒绝执行**(可显式放行并留痕) |
另外: 另外:
- **成交在信号次日开盘**,信号日只产生信号 - **成交在信号次日开盘**,信号日只产生信号
- 滚动分位窗口的**右端必须是评估日本身** - 滚动分位窗口的**右端必须是评估日本身**
- 画像的窗口切片是 `(起点, asof]`(左开右闭),与分位参照窗口一致
- Walk-forward 测试段使用**训练段冻结**的分布 - Walk-forward 测试段使用**训练段冻结**的分布
(但实时画像闸门**不需要冻结** —— 它只用当时可见数据做过滤,
不带任何用测试期数据拟合出来的参数)
**三类未来函数,系统的处理方式不同**:
| 类型 | 处理 |
|---|---|
| 用未来数据算**当日因子** | 代码层杜绝(`Repo` 是唯一取数出口,有负例测试) |
| 用未来时点选出的**股票池**跑更早区间 | **拒绝执行**(`--universe-run` 的 asof 校验) |
| 用未来数据给人看的**研究快照**(画像/筛选页) | 允许,但**不进入回测**;回测每天自己重算 |
## 9.3 分红口径 ## 9.3 分红口径
@@ -1043,11 +1613,18 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
| 过户费 | 双边 | | 过户费 | 双边 |
| 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 | | 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 |
| 整手 | 买入按 100 股取整 | | 整手 | 买入按 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>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** | | 股票清单 | `#/universes/<run_id>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** |
| 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 | | 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 |
| 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** | | 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** |
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、绩效指标、**逐笔成交与理由** | | 回测详情 | `#/backtests/<run_id>` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 |
| 个股买卖点 | `#/backtests/<run_id>/stocks/<symbol>` | 股价/股息率/PE/ROE 趋势图 + 买卖点标注 |
| **样本外** | `#/walkforwards` | Walk-forward 记录;**样本内 vs 样本外**逐窗口对比 |
| 归档 | `#/archive` | 已归档与已删除的记录,可恢复 | | 归档 | `#/archive` | 已归档与已删除的记录,可恢复 |
### 记录管理 ### 记录管理
- **重跑覆盖**:同一份配置 + 同一个 `asof` 时点 → 同一个 `run_id`,重跑会**原地覆盖**
旧记录(含成员清单与因子快照),不会累积重复条目。
你在界面上的**命名与备注会被保留**,不会被重跑清掉。
改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。
- **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上 - **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上
- **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档 - **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档
- **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。 - **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。
@@ -1363,6 +1946,45 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
./deploy/install-service.sh status # 本项目(后端侧) ./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 股票池为空或很少 ## 11.3 股票池为空或很少
**按顺序排查**: **按顺序排查**:
@@ -1396,6 +2018,10 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
**最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。 **最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。
若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。 若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。
注意:**用 `--universe-run` 冻结股票池时没有预热期** —— 引擎不再自筛选,
历史约束不作用于早期,2015 年起即可能满仓。这也是为什么冻结模式的回撤
显著大于重新筛选模式。若你看到早期就满仓,那是冻结模式的正常表现,不是 bug。
**确认方法**: **确认方法**:
```sql ```sql
@@ -1448,8 +2074,14 @@ market.min_market_capp
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 | | 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 | | 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 | | 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
| 7 | 回测 2015–2018 为预热期 | 100% 现金,无信号(见 §10.4) | | 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 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 | | 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 使用前请务必知道 ## 12.1 使用前请务必知道
@@ -1504,6 +2136,6 @@ export PYTHONPATH=src
| 个股画像口径(窗口/分位/权重) | `config/profile.yml` | | 个股画像口径(窗口/分位/权重) | `config/profile.yml` |
| 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` | | 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` |
| 手续费/滑点/红利税 | `config/cost.yml` | | 手续费/滑点/红利税 | `config/cost.yml` |
| 回测区间/Walk-forward/基准 | `config/backtest.yml` | | 回测区间/Walk-forward/基准/分位最小样本量 | `config/backtest.yml` |
| 报告外观与部署方式 | `config/report.yml` | | 报告外观与部署方式 | `config/report.yml` |
| 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) | | 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) |
+256 -11
View File
@@ -31,11 +31,11 @@ from hdiv.core.config import (
config_hash, config_hash,
load_config, load_config,
) )
from hdiv.core.errors import DataGapError from hdiv.core.errors import DataGapError, HdivError
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo, data_version from hdiv.data.repo import Repo, data_version
from hdiv.data.sync.base import stable_id from hdiv.data.sync.base import stable_id
from hdiv.factor.dividend_yield import build_dps_events, ttm_dps_series from hdiv.factor.dividend_yield import build_dps_events, ttm_dps_series, ttm_params
from hdiv.strategy.registry import StrategyRegistry from hdiv.strategy.registry import StrategyRegistry
@@ -160,6 +160,7 @@ class BacktestEngine:
backtest: BacktestConfig | None = None, backtest: BacktestConfig | None = None,
frozen_reference: tuple[date, date] | None = None, frozen_reference: tuple[date, date] | None = None,
universe_run_id: str | None = None, universe_run_id: str | None = None,
allow_lookahead_universe: bool = False,
) -> None: ) -> None:
db.load_dotenv_once() db.load_dotenv_once()
self.strategy = strategy self.strategy = strategy
@@ -173,7 +174,14 @@ class BacktestEngine:
# 指定 universe_run_id 时,使用该次筛选的成员作为**冻结股票池** # 指定 universe_run_id 时,使用该次筛选的成员作为**冻结股票池**
# (不再按周期重新筛选)。这同时建立了「股票池记录 ↔ 回测记录」的显式关联。 # (不再按周期重新筛选)。这同时建立了「股票池记录 ↔ 回测记录」的显式关联。
self.universe_run_id = universe_run_id self.universe_run_id = universe_run_id
# 股票池自带 asof:若它晚于回测起点,就等于用未来信息选股。
# 默认拒绝;只有显式放行才执行,且必须把「含未来信息」写进 run 记录。
self.allow_lookahead_universe = allow_lookahead_universe
self.lookahead_universe_note: str | None = None
self.universe_cfg = self.registry.resolved_universe(strategy) self.universe_cfg = self.registry.resolved_universe(strategy)
# 实时画像闸门(PIT):关闭时整条链路不参与,回测行为与启用前一致
self.gate_cfg = strategy.entry.profile_gate
self.pit: Any = None
@classmethod @classmethod
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine: def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
@@ -262,7 +270,25 @@ class BacktestEngine:
bt.capital.initial, bt.capital.initial,
), ),
"unimplemented": state["unimplemented"], "unimplemented": state["unimplemented"],
"profile_gate": self.pit.stats() if self.pit is not None else None,
} }
if self.pit is not None:
gate_stats = self.pit.stats()
result["profile_gate_verdicts"] = {
"reject": sum(
1 for x in state["signals"] if x.kind == "REJECT"
),
}
if verbose:
print(
f" 实时画像:计算 {gate_stats['snapshots_computed']} 次"
f"(缓存命中 {gate_stats['snapshots_cached']})"
f",涉及 {gate_stats['distinct_asof']} 个决策时点"
f",财报面板载入 {gate_stats['financial_loads']} 次"
f",流动性查询 {gate_stats['liquidity_loads']} 次"
f" | 画像剔除 {result['profile_gate_verdicts']['reject']} 次",
flush=True,
)
if verbose: if verbose:
rc = result["reconciliation"] rc = result["reconciliation"]
print( print(
@@ -286,6 +312,55 @@ class BacktestEngine:
) )
return result return result
# ------------------------------------------------------------------
# 股票池未来函数守卫
# ------------------------------------------------------------------
def _universe_asof(self) -> date | None:
"""读股票池记录的 asof 日期(同时校验 run_id 是否存在)。"""
df = db.read_sql(
"SELECT asof_date FROM hd_universe_run WHERE run_id = :r",
{"r": self.universe_run_id}, cfg=load_config("datasource"),
)
if df.empty:
raise HdivError(
f"股票池 {self.universe_run_id} 不存在(hd_universe_run 无此 run_id)。\n"
f" 可执行 `python -m hdiv universe` 重新筛选,或在 Web 前端「股票池」页复制正确的 run_id。"
)
return pd.to_datetime(df["asof_date"].iloc[0]).date()
def _check_universe_asof(self, backtest_start: date) -> None:
"""拒绝「用未来时点选出的股票池去跑更早的区间」。
这是本项目自己已经在 walk-forward 上认定的纪律(见
``docs/implementation-status.md`` §7.4):股票池带 asof,把它套到更早的
区间就是用未来信息选股。单次回测没有理由例外。
"""
uasof = self._universe_asof()
if uasof is None or uasof <= backtest_start:
return
note = (
f"股票池含未来信息:{self.universe_run_id} 的 asof={uasof} "
f"晚于回测起点 {backtest_start},各调仓日复用了同一份事后名单"
)
if not self.allow_lookahead_universe:
raise HdivError(
f"拒绝执行:股票池的 asof({uasof})晚于回测起点({backtest_start})。\n"
f" 股票池 {self.universe_run_id} 是用 {uasof} 当天可见的数据选出来的,\n"
f" 名单里含有回测起点时不可能知道的信息(哪些公司此后仍满足连续分红、\n"
f" 5 年 ROE、自由现金流覆盖等条件)。把它套到更早的年份即未来函数。\n"
f" 正确做法(推荐第 1 种):\n"
f" 1) 去掉 --universe-run,让引擎在每个调仓日按当时可见数据重新筛选:\n"
f" python -m hdiv backtest --start {backtest_start} --end <end>\n"
f" 2) 若只想检验某个固定股票池,把回测起点改到该股票池 asof 之后:\n"
f" python -m hdiv backtest --universe-run {self.universe_run_id} "
f"--start {uasof}\n"
f" 确实需要复现「带未来信息」的历史结果(例如与修复前的记录对比)时,\n"
f" 显式放行:--allow-lookahead-universe\n"
f" 届时本次运行会在 hd_backtest_run.unimplemented_json 中如实声明该偏差。"
)
self.lookahead_universe_note = note
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 数据准备 # 数据准备
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -310,6 +385,11 @@ class BacktestEngine:
del cur del cur
universe_by_refresh: dict[date, set[str]] = {} universe_by_refresh: dict[date, set[str]] = {}
if self.universe_run_id: if self.universe_run_id:
# 未来函数守卫:股票池自带 asof。若它晚于回测起点,名单里就含有
# 「当时不可能知道」的信息(哪些公司此后仍满足分红/质量条件),
# 把它套到更早的年份上就是用未来信息选股 —— 与 walk-forward
# 拒绝 --universe-run 是同一条理由,这里必须同样拒绝。
self._check_universe_asof(days[0])
# 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。 # 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。
# 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。 # 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。
dfu = db.read_sql( dfu = db.read_sql(
@@ -366,6 +446,24 @@ class BacktestEngine:
suspend = self._load_suspend(all_syms, days[0], days[-1]) suspend = self._load_suspend(all_syms, days[0], days[-1])
limits = self._load_limits(all_syms, days[0], days[-1]) limits = self._load_limits(all_syms, days[0], days[-1])
# --- 实时画像闸门:预载跨决策日共享的面板(仅在启用时)---
if self.gate_cfg.enabled:
from hdiv.profile.pit import PitProfileService
self.pit = PitProfileService(window_years=self.gate_cfg.window_years)
# 画像取数起点必须覆盖最长窗口(profile.yml 的 windows_years),
# 与分位参照窗口(backtest.yml 的 lookback_years)是两个独立的量。
pit_start = date(max(days[0].year - self.pit.max_years - 1, 2000), 1, 1)
self.pit.prepare(all_syms, pit_start, days[-1])
self.pit.configure({r.metric for r in self.gate_cfg.rules})
if verbose:
print(
f" 实时画像闸门已启用:窗口 {self.gate_cfg.window_years} 年,"
f"{len(self.gate_cfg.rules)} 条规则,"
f"面板自 {pit_start} 起载入({len(all_syms)} 只)",
flush=True,
)
return { return {
"days": days, "days": days,
"refresh_dates": refresh_dates, "refresh_dates": refresh_dates,
@@ -421,6 +519,16 @@ class BacktestEngine:
) )
return out return out
def _has_constraint_rows(self, table: str, start: date, end: date) -> bool:
"""该约束表在回测区间内**是否有任何数据**(表级判定,与股票池无关)。"""
if not db.table_exists(table, self.cfg_db()):
return False
df = db.read_sql(
f"SELECT COUNT(*) AS n FROM {table} WHERE trade_date BETWEEN :s AND :e",
{"s": start, "e": end}, cfg=self.cfg_db(),
)
return bool(df["n"].iloc[0])
def cfg_db(self) -> Any: def cfg_db(self) -> Any:
return load_config("datasource") return load_config("datasource")
@@ -527,13 +635,54 @@ class BacktestEngine:
peak = equity["total_value"].cummax() peak = equity["total_value"].cummax()
equity["drawdown"] = equity["total_value"] / peak - 1.0 equity["drawdown"] = equity["total_value"] / peak - 1.0
for name in ("涨跌停近似", "停牌顺延", "成交量占比约束"): # 「约束没生效」只能由**表里有没有数据**判定,不能由「过滤后集合为空」判定 ——
if name == "涨跌停近似" and not ctx["limits"]: # 后者会把「这批股票这段时间恰好没停牌/没涨跌停」误报成「数据缺失」。
unimplemented.add("涨跌停约束未生效(hd_limit 无数据,成交按可达价格近似)") # 实测:2026-08~09 的 hd_suspend 覆盖到 2026-09-30,却因该区间无停牌
if name == "停牌顺延" and not ctx["suspend"]: # 而被声明成「hd_suspend 无数据」,属于把自己的建模正常状态说成数据缺陷。
unimplemented.add("停牌约束未生效(hd_suspend 无数据)") if not self._has_constraint_rows("hd_limit", days[0], days[-1]):
unimplemented.add("涨跌停约束未生效(hd_limit 在回测区间内无数据,成交按可达价格近似)")
if not self._has_constraint_rows("hd_suspend", days[0], days[-1]):
unimplemented.add("停牌约束未生效(hd_suspend 在回测区间内无数据)")
# 以下三项**配置写了但引擎没实现**,必须如实声明 —— 否则 run 记录看起来
# 「一切正常」,而使用者以为 backtest.yml 的 defer / reinvest 生效了。
# (配置承诺与实际行为不一致,是本项目反复记录的一类缺陷。)
# 注意字段归属:fill/dividend 在 backtest.yml;execution/risk 在策略 yml。
if str(bt.fill.suspended_rule) != "skip" or str(bt.fill.limit_up_down_rule) != "skip" \
or str(s.execution.suspended_rule) != "skip" \
or str(s.execution.limit_up_down_rule) != "skip":
unimplemented.add(
"未实现停牌/涨跌停顺延(suspended_rule / limit_up_down_rule 的 "
"defer 分支):未成交信号在当日被**丢弃**,不会顺延到下一个可成交日"
)
if str(bt.dividend.cash_mode) != "hold" or bt.dividend.reinvest_rule:
unimplemented.add(
"未实现分红再投资规则(cash_mode=reinvest / reinvest_rule):"
"现金分红按除权日入账后**留存为现金**,在下次调仓时按目标权重重新配置"
)
if s.execution.signal_to_execution != "next_open" or str(bt.fill.price) != "next_open":
unimplemented.add(
f"未实现 signal_to_execution/fill.price 的 "
f"{s.execution.signal_to_execution}/{bt.fill.price} 分支:"
f"成交固定按信号次日开盘价"
)
if bt.dividend.handle_rights_issue:
unimplemented.add(
"未实现配股处理(handle_rights_issue):配股缴款/股数变动不入账"
)
if not bt.fill.partial_fill: if not bt.fill.partial_fill:
unimplemented.add("未启用部分成交(按信号全额成交,但受资金与权重上限约束)") unimplemented.add("未启用部分成交(按信号全额成交,但受资金与权重上限约束)")
if bt.fill.max_volume_pct is not None:
unimplemented.add(
"未实现成交量占比约束(fill.max_volume_pct 未被使用)"
)
if s.risk.liquidity_limit_pct_adv is not None:
unimplemented.add(
"未实现 risk.liquidity_limit_pct_adv(单笔成交不超过当日成交额的比例)"
)
# 显式放行的未来函数必须留在 run 记录里 —— 否则事后无法分辨
# 「这条收益曲线是干净的」还是「这条用了事后名单」。
if self.lookahead_universe_note:
unimplemented.add(self.lookahead_universe_note)
div_df = pd.DataFrame(dividend_ledger) div_df = pd.DataFrame(dividend_ledger)
return { return {
@@ -574,9 +723,12 @@ class BacktestEngine:
if close_hist.empty: if close_hist.empty:
continue continue
idx = pd.DatetimeIndex(close_hist.index) idx = pd.DatetimeIndex(close_hist.index)
# 参数从因子层的统一来源取,不再硬编码 ——
# 否则改了 profile.yml 的回测也不会变(曾如此)。
_w, _g, _sm = ttm_params()
dps = ttm_dps_series( dps = ttm_dps_series(
idx, ctx["events"].get(sym, pd.DataFrame()), idx, ctx["events"].get(sym, pd.DataFrame()),
ttm_days=365, grace_days=45, ttm_days=_w, grace_days=_g, smooth_spikes=_sm,
) )
with np.errstate(divide="ignore", invalid="ignore"): with np.errstate(divide="ignore", invalid="ignore"):
y = np.where(close_hist.to_numpy(dtype="float64") > 0, y = np.where(close_hist.to_numpy(dtype="float64") > 0,
@@ -589,6 +741,14 @@ class BacktestEngine:
ref_ser = ser.loc[pd.Timestamp(ref[0]): pd.Timestamp(ref[1])] if ref else ser ref_ser = ser.loc[pd.Timestamp(ref[0]): pd.Timestamp(ref[1])] if ref else ser
if ref_ser.empty: if ref_ser.empty:
ref_ser = ser ref_ser = ser
# 样本不足则不作判断 —— 保持现有仓位,既不买也不卖。
#
# 分位 = 「≤当前值的观测占比」。窗口只有 1 个观测且恰好等于当前值时
# 占比 100%,会击穿任何买入阈值。这是统计假象而非「股息率处于高位」:
# 实测 2015-01-06(行情数据首日)窗口 2010-2015 只有 1 个观测,
# 8 只股票因此被「100% 分位」买入。
if ref_ser.size < self.bt_cfg.percentile_reference.min_observations:
continue
pct = float((ref_ser <= current).sum() / ref_ser.size * 100.0) pct = float((ref_ser <= current).sum() / ref_ser.size * 100.0)
held = sym in positions held = sym in positions
@@ -601,6 +761,8 @@ class BacktestEngine:
"reference_window": [str(ref[0]), str(ref[1])] if ref else None, "reference_window": [str(ref[0]), str(ref[1])] if ref else None,
"reference_mode": self._reference_mode(), "reference_mode": self._reference_mode(),
"observation_count": int(ref_ser.size), "observation_count": int(ref_ser.size),
"min_observations": int(
self.bt_cfg.percentile_reference.min_observations),
"close": price, "close": price,
} }
@@ -611,12 +773,19 @@ class BacktestEngine:
if not held: if not held:
if target is not None and target > 0 and pct >= s.entry.yield_percentile: if target is not None and target > 0 and pct >= s.entry.yield_percentile:
gate = self._gate(sym, day)
if gate is not None and gate["verdict"] != "PASS":
out.append(self._reject_signal(
sym, day, current, pct, price, common, gate,
))
continue
out.append(Signal( out.append(Signal(
sym, day, "BUY", target, current, pct, price, sym, day, "BUY", target, current, pct, price,
{**common, {**common,
"rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g}," "rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g},"
f"目标仓位 {target:.0%}", f"目标仓位 {target:.0%}",
"reason_cn": "股息率进入历史高位区间,达到买入阈值"}, "reason_cn": "股息率进入历史高位区间,达到买入阈值",
**({"profile_gate": gate} if gate else {})},
)) ))
else: else:
if target is None: if target is None:
@@ -628,15 +797,91 @@ class BacktestEngine:
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}", "rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"}, "reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
)) ))
else: elif target < 1.0:
out.append(Signal( out.append(Signal(
sym, day, "TRIM" if target < 1.0 else "ADD", target, current, pct, price, sym, day, "TRIM", target, current, pct, price,
{**common, {**common,
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}", "rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
"reason_cn": "股息率分位变动,按阶梯规则调整仓位"}, "reason_cn": "股息率分位变动,按阶梯规则调整仓位"},
)) ))
else:
# ADD 也是买入 —— 同样要过实时画像闸门。
# 被拒时**不动已有仓位**(REJECT 不进入待成交队列),
# 因为闸门的语义是「不值得买」,不是「该卖」。
gate = self._gate(sym, day)
if gate is not None and gate["verdict"] != "PASS":
out.append(self._reject_signal(
sym, day, current, pct, price, common, gate,
))
continue
out.append(Signal(
sym, day, "ADD", target, current, pct, price,
{**common,
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
"reason_cn": "股息率分位变动,按阶梯规则调整仓位",
**({"profile_gate": gate} if gate else {})},
))
return out return out
# ------------------------------------------------------------------
# 实时画像闸门
# ------------------------------------------------------------------
def _gate(self, sym: str, day: date) -> dict[str, Any] | None:
"""惰性计算该股在 ``day`` 的实时画像并判定闸门。
只在「买入条件已触发」时调用 —— 这是「在触发条件的时候计算」的落点。
未启用闸门时返回 ``None``,调用方不产生任何额外行为。
**启用但未初始化必须报错,不得静默放行**:静默放行等于
「配置了一个风险控制但它不生效」,属于最难发现的一类失效 ——
回测照跑,拿到的却是没有闸门的版本。
"""
if not self.gate_cfg.enabled:
return None
if self.pit is None:
raise HdivError(
"实时画像闸门已启用,但画像服务未初始化。\n"
" 正常路径由 BacktestEngine.run() → _prepare() 负责初始化;\n"
" 若你直接调用 _evaluate/_simulate,请先调用 _prepare(days)。"
)
from hdiv.profile.pit import evaluate_gate
snap = self.pit.snapshot(sym, day)
rules = [r.model_dump() for r in self.gate_cfg.rules]
return evaluate_gate(
rules, snap,
on_unverifiable=self.gate_cfg.on_unverifiable,
min_window_coverage=self.gate_cfg.min_window_coverage,
)
def _reject_signal(
self, sym: str, day: date, current: float, pct: float, price: float,
common: dict[str, Any], gate: dict[str, Any],
) -> Signal:
"""把「为什么不买」写成可追溯的信号记录(plan.md §32 的同一条原则)。"""
failed = gate.get("failed") or []
unver = gate.get("unverifiable") or []
if failed:
why = "实时画像未通过:" + "、".join(failed)
cn = "按当日可见数据重算画像后判定不值得买,剔除"
else:
why = "实时画像无法验证:" + "、".join(unver)
cn = "按当日可见数据重算画像,样本不足/数据缺失,保守不买"
checks = {
c["metric"]: {"stat": c["stat"], "actual": c["actual"],
"status": c["status"], "threshold": c["threshold"],
"op": c["op"], "passed": c["passed"]}
for c in gate.get("checks", [])
}
return Signal(
sym, day, "REJECT", 0.0, current, pct, price,
{**common, "rule": why, "reason_cn": cn,
"profile_gate": gate, "profile_checks": checks,
# executed=False + skip_reason 让它在「未成交信号」列表里可读
"skip_reason": "PROFILE_GATE", "executed": False},
)
def _reference_window(self, day: date) -> tuple[date, date] | None: def _reference_window(self, day: date) -> tuple[date, date] | None:
if self.frozen_reference is not None: if self.frozen_reference is not None:
return self.frozen_reference return self.frozen_reference
+7 -2
View File
@@ -27,6 +27,8 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import BacktestConfig, load_config from hdiv.core.config import BacktestConfig, load_config
from hdiv.report.format import NumFmt
from hdiv.core.errors import DataGapError, SchemaValidationError from hdiv.core.errors import DataGapError, SchemaValidationError
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo, data_version from hdiv.data.repo import Repo, data_version
@@ -222,6 +224,7 @@ class WalkForwardRunner:
from hdiv.factor.dividend_yield import ( from hdiv.factor.dividend_yield import (
build_dps_events, build_dps_events,
dividend_yield_series, dividend_yield_series,
ttm_params,
) )
sel = self.registry.selector(self.strategy) sel = self.registry.selector(self.strategy)
@@ -236,8 +239,10 @@ class WalkForwardRunner:
for sym, g in px.groupby("symbol"): for sym, g in px.groupby("symbol"):
g = g.sort_values("trade_date").copy() g = g.sort_values("trade_date").copy()
g["trade_date"] = pd.to_datetime(g["trade_date"]) g["trade_date"] = pd.to_datetime(g["trade_date"])
_w, _g, _sm = ttm_params()
ser = dividend_yield_series( ser = dividend_yield_series(
g.set_index("trade_date")["close"], events.get(sym, pd.DataFrame()) g.set_index("trade_date")["close"], events.get(sym, pd.DataFrame()),
ttm_days=_w, grace_days=_g, smooth_spikes=_sm,
) )
if ser.empty: if ser.empty:
continue continue
@@ -315,7 +320,7 @@ class WalkForwardRunner:
def pct(k: str) -> str: def pct(k: str) -> str:
v = s.get(k) v = s.get(k)
return "—" if v is None else f"{v * 100:,.2f}%" return "—" if v is None else NumFmt.from_config().pct(v)
def num(k: str, d: int = 2) -> str: def num(k: str, d: int = 2) -> str:
v = s.get(k) v = s.get(k)
+46 -4
View File
@@ -104,10 +104,15 @@ def cmd_sync(args: argparse.Namespace) -> int:
elif target == "backfill": elif target == "backfill":
from hdiv.data.sync import price from hdiv.data.sync import price
# basic_start 必须可传入:run_backfill 的默认值是 2015-01-01,
# 若要回补 2015 年之前的 daily_basic,只给 --basic-end 是不够的 ——
# 早期 CLI 没有这个参数,于是「回补 2010-2014」实际只补了行情,
# daily_basic 仍停在 2015,画像里的 PE/PB 依旧拿不到早年数据。
r = price.run_backfill( r = price.run_backfill(
price_start=args.start, price_start=args.start,
price_end=args.end, price_end=args.end,
basic_end=args.basic_end, basic_start=args.basic_start or args.start,
basic_end=args.basic_end or args.end,
resume=not args.no_resume, resume=not args.no_resume,
) )
return 0 if r.get("monotonic_ok", True) else 1 return 0 if r.get("monotonic_ok", True) else 1
@@ -234,6 +239,13 @@ def cmd_profile(args: argparse.Namespace) -> int:
pb = ProfileBuilder.from_config(args.config) pb = ProfileBuilder.from_config(args.config)
result = pb.run(universe_run_id=args.universe_run, symbols=args.symbols, asof=args.asof) result = pb.run(universe_run_id=args.universe_run, symbols=args.symbols, asof=args.asof)
print(f"画像 {result['run_id']}:{result['symbol_count']} 只") print(f"画像 {result['run_id']}:{result['symbol_count']} 只")
# 覆盖率必须显眼:名义「5 年窗口」可能只有 3 年多数据(数据起点截断)
for w in result.get("warnings", []):
print(f" ⚠ {w}")
worst = (result.get("window_coverage") or {}).get("worst")
if worst:
wy, cov = worst
print(f" 窗口覆盖率:最差 {wy} 年窗口 {cov:.1%}(1.0 = 名义窗口被完整覆盖)")
if args.html: if args.html:
from hdiv.report.build import build_profile_report from hdiv.report.build import build_profile_report
@@ -278,6 +290,23 @@ def cmd_backtest(args: argparse.Namespace) -> int:
from hdiv.backtest.walk_forward import WalkForwardRunner from hdiv.backtest.walk_forward import WalkForwardRunner
if args.mode == "walkforward": if args.mode == "walkforward":
# 冻结股票池与 walk-forward 在时序上不兼容:
# 股票池有其自身的 asof(如 2025-01-21),而 walk-forward 的窗口从
# 2015 年就开始训练 —— 用未来时点选出的股票池去跑过去的窗口,
# 就是典型的未来函数,恰好破坏 walk-forward 要守护的纪律。
#
# 早期实现没有这个参数,于是 --universe-run 被**静默忽略**,
# 用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
if args.universe_run:
raise HdivError(
"walk-forward 模式不支持 --universe-run。\n"
" 原因:股票池带有自己的时点(asof),把它套到更早的训练窗口上\n"
" 等于用未来信息选股,会破坏 walk-forward 的无未来函数纪律。\n"
" 正确做法:walk-forward 会在每个窗口内按各自时点重新筛选,\n"
" 这正是它要检验的「策略能否在未知未来上复现」。\n"
" 若确实想检验某个固定股票池,请用普通回测:\n"
" python -m hdiv backtest --universe-run <run_id>"
)
wf = WalkForwardRunner.from_strategy(args.strategy) wf = WalkForwardRunner.from_strategy(args.strategy)
res = wf.run() res = wf.run()
print(f"Walk-forward {res['wf_id']}:{res['window_count']} 个窗口") print(f"Walk-forward {res['wf_id']}:{res['window_count']} 个窗口")
@@ -289,7 +318,9 @@ def cmd_backtest(args: argparse.Namespace) -> int:
_reject_no_persist_with_html(args, "hdiv backtest") _reject_no_persist_with_html(args, "hdiv backtest")
engine = BacktestEngine.from_strategy( engine = BacktestEngine.from_strategy(
args.strategy, universe_run_id=args.universe_run args.strategy,
universe_run_id=args.universe_run,
allow_lookahead_universe=args.allow_lookahead_universe,
) )
res = engine.run(start=args.start, end=args.end, persist=not args.no_persist) res = engine.run(start=args.start, end=args.end, persist=not args.no_persist)
if args.no_persist: if args.no_persist:
@@ -366,7 +397,12 @@ def build_parser() -> argparse.ArgumentParser:
s.add_argument("--which", choices=["daily", "adj_factor", "daily_basic"]) s.add_argument("--which", choices=["daily", "adj_factor", "daily_basic"])
s.add_argument("--start", default="2015-01-01") s.add_argument("--start", default="2015-01-01")
s.add_argument("--end", default="2018-12-31") s.add_argument("--end", default="2018-12-31")
s.add_argument("--basic-end", default="2019-12-31") s.add_argument(
"--basic-start", default=None,
help="daily_basic 回补起点(默认与 --start 相同)。"
"回补 2015 年之前的数据时必须显式给出,否则 daily_basic 不会被回补",
)
s.add_argument("--basic-end", default="2019-12-31", help="daily_basic 回补终点")
s.add_argument("--no-resume", action="store_true") s.add_argument("--no-resume", action="store_true")
s.add_argument("--no-weight", action="store_true") s.add_argument("--no-weight", action="store_true")
s.set_defaults(func=cmd_sync) s.set_defaults(func=cmd_sync)
@@ -477,7 +513,13 @@ def build_parser() -> argparse.ArgumentParser:
b.add_argument("--end", default=None) b.add_argument("--end", default=None)
b.add_argument( b.add_argument(
"--universe-run", default=None, "--universe-run", default=None,
help="使用指定股票池筛选记录的成员作为冻结股票池(并建立关联)", help="使用指定股票池筛选记录的成员作为冻结股票池(并建立关联);"
"若该股票池的 asof 晚于回测起点则拒绝执行(未来函数)",
)
b.add_argument(
"--allow-lookahead-universe", action="store_true",
help="显式放行「股票池 asof 晚于回测起点」的组合(含未来信息,"
"会如实写入 hd_backtest_run.unimplemented_json)",
) )
b.add_argument("--no-persist", action="store_true") b.add_argument("--no-persist", action="store_true")
# HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。 # HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。
+101
View File
@@ -96,11 +96,36 @@ class PathsConfig(StrictModel):
log_dir: str = "logs" log_dir: str = "logs"
class SyncConfig(StrictModel):
"""行情同步的「完整性」判定口径(决定断点续传会不会重拉整段历史)。
一个交易日被视为**已完整同步**,要求当日股票数 ≥
``max(min_symbols_floor, min_symbols_ratio × 当年应有上市股票数)``。
**为什么不能只用一个绝对阈值**:A 股 2005 年只有约 1,350 只股票,
2010 年约 1,700 只。若固定要求 1,500 只,2005-2009 的**每一个交易日**
都会被判成「未完成」,于是断点续传完全失效 —— 回补中断一次就要从
第一天重新拉,且每次重跑都会把整段早年历史再拉一遍。
"""
min_symbols_floor: int = 200
min_symbols_ratio: float = 0.6
@model_validator(mode="after")
def _check(self) -> SyncConfig:
if self.min_symbols_floor < 1:
raise SchemaValidationError("sync.min_symbols_floor 必须为正")
if not 0.0 < self.min_symbols_ratio <= 1.0:
raise SchemaValidationError("sync.min_symbols_ratio 必须落在 (0, 1]")
return self
class DataSourceConfig(StrictModel): class DataSourceConfig(StrictModel):
version: int = 1 version: int = 1
database: DatabaseConfig database: DatabaseConfig
tushare: TushareConfig = Field(default_factory=TushareConfig) tushare: TushareConfig = Field(default_factory=TushareConfig)
paths: PathsConfig = Field(default_factory=PathsConfig) paths: PathsConfig = Field(default_factory=PathsConfig)
sync: SyncConfig = Field(default_factory=SyncConfig)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -292,6 +317,8 @@ class SufficiencyConfig(StrictModel):
class TtmDividendConfig(StrictModel): class TtmDividendConfig(StrictModel):
window_days: int = 365 window_days: int = 365
grace_days: int = 45 grace_days: int = 45
# 是否消除除权间隔不规整造成的毛刺(重叠虚高 / 断档虚低)
smooth_spikes: bool = True
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> TtmDividendConfig: def _check(self) -> TtmDividendConfig:
@@ -479,6 +506,7 @@ class ScheduleConfig(StrictModel):
class PercentileReferenceConfig(StrictModel): class PercentileReferenceConfig(StrictModel):
mode: Literal["rolling", "frozen"] = "rolling" mode: Literal["rolling", "frozen"] = "rolling"
lookback_years: int = 5 lookback_years: int = 5
min_observations: int = 250
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> PercentileReferenceConfig: def _check(self) -> PercentileReferenceConfig:
@@ -602,11 +630,84 @@ class ScaleStep(StrictModel):
return self return self
class ProfileGateRule(StrictModel):
"""一条实时画像闸门规则。
语义:``<metric> 的 <stat> <op> <value>`` 必须成立,否则不买。
指标名与分位可用性在**配置期**校验 —— 写错一个指标名若拖到运行时,
只会得到「无法验证 → 保守不买」,表现为策略再也不交易,极难定位。
"""
metric: str
stat: Literal["current_value", "current_percentile"] = "current_value"
op: Literal[">=", "<=", ">", "<"] = ">="
value: float
@model_validator(mode="after")
def _check(self) -> ProfileGateRule:
from hdiv.core.metrics import GATE_METRICS, PERCENTILE_METRICS
if self.metric not in GATE_METRICS:
head = self.metric.split("_")[0]
near = sorted(m for m in GATE_METRICS if head and head in m)
raise SchemaValidationError(
f"profile_gate 规则引用了未知指标 {self.metric!r}。"
f"可选指标见 hdiv/core/metrics.py;相近的有 {near[:6]}"
)
if self.stat == "current_percentile" and self.metric not in PERCENTILE_METRICS:
raise SchemaValidationError(
f"{self.metric} 是标量指标,没有历史分位,不能用 "
f"stat=current_percentile。有分位的指标:{sorted(PERCENTILE_METRICS)}"
)
return self
class ProfileGateConfig(StrictModel):
"""实时(PIT)个股画像闸门。
在每个决策日、**买入条件已经触发之后**,用「当时可见的数据」重算画像,
不通过的票直接剔除。跨股票共享的面板按时点缓存,代价与「触发次数」成正比,
而不是与「回测区间 × 股票数」成正比。
"""
#: 是否启用。关闭时回测行为与启用前完全一致(可用于复现历史结果)
enabled: bool = False
#: 画像统计窗口(年)。0 = 全历史;其余必须是 config/profile.yml 的 windows_years 之一
window_years: int = 5
#: 数据缺失/样本不足(无法验证)时:reject = 保守不买,pass = 放行
on_unverifiable: Literal["reject", "pass"] = "reject"
#: 窗口**实际覆盖率**下限(1.0 = 名义 5 年就必须真有 5 年数据)。
#: 0 = 不因覆盖率淘汰(默认,保持改造前行为)。
#:
#: 为什么需要它:`window_slice(asof, 5)` 只是「把已有数据切成最近 5 年」,
#: 数据起点晚于窗口左端时窗口会被静默截短 —— 实测 2018-05-18 的「5 年」
#: 窗口只有 3.4 年(817/1215 个交易日,67%),而画像仍报 OK。
min_window_coverage: float = 0.0
rules: list[ProfileGateRule] = Field(default_factory=list)
@model_validator(mode="after")
def _check(self) -> ProfileGateConfig:
if self.window_years < 0:
raise SchemaValidationError("profile_gate.window_years 不能为负")
if not 0.0 <= self.min_window_coverage <= 1.0:
raise SchemaValidationError(
f"profile_gate.min_window_coverage 必须落在 [0, 1],"
f"当前 {self.min_window_coverage}"
)
if self.enabled and not self.rules:
raise SchemaValidationError(
"profile_gate.enabled=true 但 rules 为空 —— 空闸门等于每次都要"
"算一遍画像再无条件放行。请补齐规则,或把 enabled 设为 false。"
)
return self
class EntryConfig(StrictModel): class EntryConfig(StrictModel):
yield_percentile: float yield_percentile: float
require_universe_pass: bool = True require_universe_pass: bool = True
require_risk_pass: bool = True require_risk_pass: bool = True
scale_in: list[ScaleStep] = Field(default_factory=list) scale_in: list[ScaleStep] = Field(default_factory=list)
profile_gate: ProfileGateConfig = Field(default_factory=ProfileGateConfig)
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> EntryConfig: def _check(self) -> EntryConfig:
+62
View File
@@ -0,0 +1,62 @@
"""画像指标代码清单(闸门配置的唯一校验来源)。
放在 ``core`` 而不是 ``profile`` 里,是为了让 ``core.config``(策略配置校验)
可以引用它,而 ``profile.pit`` 也能引用同一份定义 —— 避免出现
「配置允许的指标」与「画像实际能算的指标」两个清单。
**为什么必须显式列清单**:闸门规则里写错一个指标名,若不在配置期拒绝,
运行时就只会得到「该指标缺失 → 无法验证」,在保守策略下等于**永久不交易**。
这类静默失效必须挡在配置校验里。
"""
from __future__ import annotations
#: 只需价格/每日指标即可计算的指标
VALUATION_METRICS: frozenset[str] = frozenset(
{"dv_yield", "ttm_dps", "close", "drawdown", "pe_ttm", "pb", "ps_ttm",
"dv_vol_daily", "dv_vol_monthly", "dv_vol_quarterly", "dv_vol_annual"}
)
#: 需要风险/收益统计(仍只需价格 + 指数)
RETURN_METRICS: frozenset[str] = frozenset(
{"vol_250d", "max_drawdown_3y", "ret_1y", "ret_3y", "ret_5y", "cagr_5y", "beta_300"}
)
#: 需要分红明细
DIVIDEND_METRICS: frozenset[str] = frozenset(
{"dividend_continuity_years", "dividend_years_in_window", "dps",
"dps_cagr_5y", "dps_volatility", "total_cash_dividend",
"dividend_fy_free_cashflow"}
)
#: 需要财报(最重的查询:财务表各约 30 万行)
FINANCIAL_METRICS: frozenset[str] = frozenset(
{"roe", "roic", "gross_margin", "net_margin", "ocf_to_profit", "ocf_to_profit_calc",
"roe_avg", "roic_avg", "gross_margin_avg", "net_margin_avg", "ocf_to_profit_avg",
"debt_ratio", "free_cashflow", "n_income_attr_p", "total_assets",
"payout_ratio", "fcf_dividend_cover"}
)
#: 需要流动性(20 日均成交额)
LIQUIDITY_METRICS: frozenset[str] = frozenset({"avg_amount_20d"})
#: 有「历史分位」的指标 —— 即按窗口输出分布统计的那些。
#: 其余是标量(只有最新值),对其做分位判断无意义,配置校验直接拒绝。
PERCENTILE_METRICS: frozenset[str] = VALUATION_METRICS - {
"dv_vol_daily", "dv_vol_monthly", "dv_vol_quarterly", "dv_vol_annual"
}
#: 实时画像能产出的全部指标代码
ALL_METRICS: frozenset[str] = (
VALUATION_METRICS | RETURN_METRICS | DIVIDEND_METRICS
| FINANCIAL_METRICS | LIQUIDITY_METRICS
)
#: 观测单位是**交易日**的指标 —— 只有这些能用交易日历当覆盖率分母。
#:
#: 其余指标的 ``n_obs`` 不是交易日数:
#: - 年报均值类(``roe_avg`` / ``ocf_to_profit_avg`` …)的 ``n_obs`` 是**财年数**(常为 3~5),
#: 拿它比「5 年窗口应有 1219 个交易日」会算出 0.4% 这种荒谬覆盖率;
#: - 标量快照类(``payout_ratio`` / ``debt_ratio`` …)的 ``n_obs`` 是 1。
#:
#: 量纲对不上就不能比 —— 这是本项目反复踩到的同一类错误。
DAILY_OBSERVATION_METRICS: frozenset[str] = VALUATION_METRICS | RETURN_METRICS
#: 闸门规则允许引用的指标(与 ALL_METRICS 相同,单独命名以表达「对外契约」)
GATE_METRICS: frozenset[str] = ALL_METRICS
+99 -10
View File
@@ -123,12 +123,16 @@ def _coverage_check(
col: str, col: str,
want_start: date, want_start: date,
cfg: DataSourceConfig, cfg: DataSourceConfig,
min_symbols_per_day: int, min_symbols_per_day: int | None = None,
sample_days: int = 6, sample_days: int = 6,
) -> CheckResult: ) -> CheckResult:
"""覆盖度检查。 """覆盖度检查。
性能说明:``stock_daily`` 有 770 万行,对全部交易日做 ``want_start`` 是「数据应至少覆盖到哪一天」的目标;``min_symbols_per_day``
是**绝对下限的覆盖**(一般不用传,留空即按 ``datasource.yml: sync`` 的
「按年份成比例」口径判定 —— 早年市场只有一千多只股票,固定阈值会误报)。
性能说明:``stock_daily`` 有 1,600 万行,对全部交易日做
``GROUP BY trade_date`` + ``COUNT(DISTINCT symbol)`` 会触发全表聚合(数分钟)。 ``GROUP BY trade_date`` + ``COUNT(DISTINCT symbol)`` 会触发全表聚合(数分钟)。
因此改为**采样若干交易日**再取最小股票数 —— 足以发现「某段区间数据稀疏」的问题, 因此改为**采样若干交易日**再取最小股票数 —— 足以发现「某段区间数据稀疏」的问题,
成本从 O(全部行) 降到 O(采样日)。 成本从 O(全部行) 降到 O(采样日)。
@@ -168,9 +172,15 @@ def _coverage_check(
) )
counts.append((d, n)) counts.append((d, n))
min_day, min_per_day = min(counts, key=lambda x: x[1]) min_day, min_per_day = min(counts, key=lambda x: x[1])
# 阈值必须**随年份变化**:2005 年 A 股只有约 1,350 只股票,
# 用固定 2,000 只会把早年正常数据误报成「疑似数据稀疏」——
# 与同步器断点续传曾经踩到的是同一个坑(见 sync.price.fetched_days)。
floor, ratio, by_year = _expected_thresholds(cfg)
need_min = max(floor, int(ratio * by_year.get(min_day.year, 0)))
metrics["sampled_days"] = {str(d): n for d, n in counts} metrics["sampled_days"] = {str(d): n for d, n in counts}
metrics["min_symbols_per_day"] = min_per_day metrics["min_symbols_per_day"] = min_per_day
metrics["min_symbols_date"] = str(min_day) metrics["min_symbols_date"] = str(min_day)
metrics["min_symbols_expected"] = need_min
metrics["total_trading_days"] = len(all_days) metrics["total_trading_days"] = len(all_days)
if not enough_years: if not enough_years:
@@ -183,13 +193,13 @@ def _coverage_check(
f"当前覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日)", f"当前覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日)",
metrics, metrics,
) )
if min_per_day < min_symbols_per_day: if min_per_day < need_min:
return CheckResult( return CheckResult(
code, code,
name, name,
WARN, WARN,
table, table,
f"{min_day} 仅有 {min_per_day} 只股票(低于 {min_symbols_per_day}),疑似数据稀疏", f"{min_day} 仅有 {min_per_day} 只股票(按当年市场规模应 ≥ {need_min}),疑似数据稀疏",
metrics, metrics,
) )
return CheckResult( return CheckResult(
@@ -197,28 +207,38 @@ def _coverage_check(
name, name,
OK, OK,
table, table,
f"覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日),采样最少 {min_per_day} 只({min_day})", f"覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日),"
f"采样最少 {min_per_day} 只({min_day},当年应 ≥ {need_min})",
metrics, metrics,
) )
def _expected_thresholds(cfg: DataSourceConfig) -> tuple[int, float, dict[int, int]]:
"""复用同步器的「按年份的规模阈值」,避免两处各写一套判定。"""
from hdiv.data.sync.price import _day_thresholds
return _day_thresholds(cfg)
def check_g2_price(cfg: DataSourceConfig) -> CheckResult: def check_g2_price(cfg: DataSourceConfig) -> CheckResult:
return _coverage_check( return _coverage_check(
# 目标起点 = 2005:windows_years 最长 10 年,回测自 2015-01-05 起,
# 要补满 10 年窗口就必须有 2005 年的数据
"G2", "日线行情(含复权因子)", "stock_daily", "trade_date", "G2", "日线行情(含复权因子)", "stock_daily", "trade_date",
date(2015, 1, 31), cfg, 2000, date(2005, 1, 31), cfg,
) )
def check_g2b_adjust(cfg: DataSourceConfig) -> CheckResult: def check_g2b_adjust(cfg: DataSourceConfig) -> CheckResult:
return _coverage_check( return _coverage_check(
"G2b", "复权因子", "adjust_factor", "trade_date", date(2015, 1, 31), cfg, 2000 "G2b", "复权因子", "adjust_factor", "trade_date", date(2005, 1, 31), cfg
) )
def check_g3_daily_basic(cfg: DataSourceConfig) -> CheckResult: def check_g3_daily_basic(cfg: DataSourceConfig) -> CheckResult:
r = _coverage_check( r = _coverage_check(
"G3", "每日指标(PE/PB/股息率/市值)", "daily_basic", "trade_date", "G3", "每日指标(PE/PB/股息率/市值)", "daily_basic", "trade_date",
date(2015, 1, 31), cfg, 2000, date(2005, 1, 31), cfg,
) )
if r.severity == OK: if r.severity == OK:
dv = _scalar( dv = _scalar(
@@ -415,8 +435,8 @@ def check_g5b_index_weight(cfg: DataSourceConfig) -> CheckResult:
def check_g6_trading(cfg: DataSourceConfig) -> list[CheckResult]: def check_g6_trading(cfg: DataSourceConfig) -> list[CheckResult]:
out: list[CheckResult] = [] out: list[CheckResult] = []
for code, table, label, want in [ for code, table, label, want in [
("G6", "hd_suspend", "停牌记录", date(2015, 1, 31)), ("G6", "hd_suspend", "停牌记录", date(2010, 1, 31)),
("G6b", "hd_limit", "涨跌停价", date(2015, 1, 31)), ("G6b", "hd_limit", "涨跌停价", date(2010, 1, 31)),
]: ]:
st = _table_stats(table, cfg) st = _table_stats(table, cfg)
if not st.get("exists") or st.get("rows", 0) == 0: if not st.get("exists") or st.get("rows", 0) == 0:
@@ -536,6 +556,74 @@ def check_units(cfg: DataSourceConfig) -> CheckResult:
) )
def check_ohlcv_units(cfg: DataSourceConfig) -> CheckResult:
"""``stock_daily`` 量价单位一致性(成交量/成交额)。
``stock_daily`` 是「追加进既有 qlib 库」的表:2015-01~2019 的行由本项目
从 Tushare 回补(原始单位:手 / 千元),2020 起沿用 qlib 存量(股 / 元),
2019 年同日混着两种。``min_avg_amount_20d`` 之类阈值是按「元」写的,
所以这种混用会**静默**把早年流动性低估 1000 倍、把股票池清空 ——
必须由审计主动发现(单位错误不会抛异常,只会让结果全错)。
判据:``成交额 / (成交量 × 收盘价)``,≈1 = 已换算,≈0.1 = 原始口径。
取多个年份的样本日,任何一天存在原始口径行即判 WARN(不是 FAIL:
读取层 :func:`hdiv.data.units.normalize_ohlcv_units` 已做兜底换算,
但存量数据仍应择机修复,且新增写入不得再引入原始单位)。
"""
from hdiv.data.units import OHLCV_RAW, OHLCV_UNKNOWN, detect_ohlcv_units
df_max = db.read_sql("SELECT MAX(trade_date) AS d FROM stock_daily", cfg=cfg)
if df_max.empty or df_max["d"].iloc[0] is None:
return CheckResult("UNIT-OHLCV", "量价单位自检", WARN, "stock_daily", "stock_daily 无数据")
last = pd.to_datetime(df_max["d"].iloc[0]).date()
# 每年取一个样本日:覆盖回补区间与 qlib 存量区间
sample_days: list[date] = []
for y in range(last.year, max(last.year - 12, 2009), -1):
row = db.read_sql(
"SELECT MAX(trade_date) AS d FROM stock_daily WHERE YEAR(trade_date) = :y",
{"y": y}, cfg=cfg,
)
if not row.empty and row["d"].iloc[0] is not None:
sample_days.append(pd.to_datetime(row["d"].iloc[0]).date())
if not sample_days:
return CheckResult("UNIT-OHLCV", "量价单位自检", WARN, "stock_daily", "无法取样")
per_day: list[dict[str, Any]] = []
raw_days: list[str] = []
for d in sample_days:
s = db.read_sql(
"SELECT symbol, close, volume, amount FROM stock_daily WHERE trade_date = :d",
{"d": d}, cfg=cfg,
)
if s.empty:
continue
for c in ("close", "volume", "amount"):
s[c] = pd.to_numeric(s[c], errors="coerce")
unit = detect_ohlcv_units(s)
n_raw = int((unit == OHLCV_RAW).sum())
n_conv = int((unit != OHLCV_RAW).sum() - (unit == OHLCV_UNKNOWN).sum())
per_day.append({"date": str(d), "rows": len(s), "raw": n_raw,
"converted": n_conv,
"unknown": int((unit == OHLCV_UNKNOWN).sum())})
if n_raw:
raw_days.append(f"{d}({n_raw}/{len(s)} 行为原始单位)")
detail = {"sampled_days": per_day}
if raw_days:
return CheckResult(
"UNIT-OHLCV", "量价单位自检", WARN, "stock_daily",
"存在 Tushare 原始单位(手/千元)的行:" + ";".join(raw_days[:6])
+ "。读取层已兜底换算为「股/元」,但存量数据应择机重刷,"
"且新增写入必须走 sync.price.daily_frame 的换算路径。",
detail,
)
return CheckResult(
"UNIT-OHLCV", "量价单位自检", OK, "stock_daily",
f"{len(per_day)} 个抽样年份的量价单位一致(股/元)", detail,
)
def check_universe_ready(cfg: DataSourceConfig) -> CheckResult: def check_universe_ready(cfg: DataSourceConfig) -> CheckResult:
"""第一版策略所需的最小数据条件是否齐备。""" """第一版策略所需的最小数据条件是否齐备。"""
needed = { needed = {
@@ -575,6 +663,7 @@ ALL_CHECKS = [
check_g5b_index_weight, check_g5b_index_weight,
check_g6_trading, check_g6_trading,
check_units, check_units,
check_ohlcv_units,
check_duplicates, check_duplicates,
check_symbol_orphans, check_symbol_orphans,
check_universe_ready, check_universe_ready,
+110 -29
View File
@@ -29,6 +29,7 @@ from hdiv.data import db
from hdiv.data.units import ( from hdiv.data.units import (
normalize_financial_panel, normalize_financial_panel,
normalize_market_panel, normalize_market_panel,
normalize_ohlcv_units,
) )
@@ -47,6 +48,21 @@ class PanelSpec:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _symbol_filter(
symbols: list[str] | None, params: dict, col: str = "symbol"
) -> str:
"""构造 ``AND <col> IN (...)`` 片段并把值绑进 ``params``。
``symbols`` 为空(或 None)时返回空串 —— 与旧行为完全一致(全市场)。
这是**纯性能开关**:不改变任何 PIT 语义,只把全表扫描缩到目标股票。
"""
if not symbols:
return ""
ph = ", ".join(f":sym{i}" for i in range(len(symbols)))
params.update({f"sym{i}": s for i, s in enumerate(symbols)})
return f" AND {col} IN ({ph})"
def data_version(cfg: DataSourceConfig | None = None) -> str: def data_version(cfg: DataSourceConfig | None = None) -> str:
"""数据版本指纹 = 关键表的最大日期摘要,写入每次 run 以支持复现。 """数据版本指纹 = 关键表的最大日期摘要,写入每次 run 以支持复现。
@@ -231,6 +247,11 @@ class Repo:
none —— 不复权原始价 none —— 不复权原始价
qfq —— 前复权(以区间末为基准) qfq —— 前复权(以区间末为基准)
hfq —— 后复权(以区间首为基准) hfq —— 后复权(以区间首为基准)
成交量/成交额**一律归一化为「股 / 元」**再返回:``stock_daily`` 里
2015-2019 的行是 Tushare 原始单位(手 / 千元),2020 起是「股 / 元」,
直接使用会让按「元」写的流动性阈值低估 1000 倍。见
:func:`hdiv.data.units.normalize_ohlcv_units`。
""" """
if not symbols: if not symbols:
return pd.DataFrame(columns=["symbol", "trade_date", "close", "open", "high", "low"]) return pd.DataFrame(columns=["symbol", "trade_date", "close", "open", "high", "low"])
@@ -252,6 +273,7 @@ class Repo:
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date
for c in ("open", "high", "low", "close", "volume", "amount", "factor"): for c in ("open", "high", "low", "close", "volume", "amount", "factor"):
df[c] = pd.to_numeric(df[c], errors="coerce") df[c] = pd.to_numeric(df[c], errors="coerce")
df, _diag = normalize_ohlcv_units(df)
if adjust != "none": if adjust != "none":
f = df["factor"].fillna(1.0) f = df["factor"].fillna(1.0)
if adjust == "hfq": if adjust == "hfq":
@@ -276,20 +298,42 @@ class Repo:
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date
return df return df
def avg_amount(self, asof: date, window: int = 20) -> pd.DataFrame: def avg_amount(
"""asof 前 window 个交易日的日均成交额(流动性过滤)。""" self, asof: date, window: int = 20, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""asof 前 window 个交易日的日均成交额(流动性过滤)。
返回的 ``avg_amount`` 单位是**元**。为此必须逐行归一化后再取均值 ——
早期实现直接 ``AVG(amount)``,把 2015-2019 的「千元」与 2020 起的「元」
混在一起平均,且与按「元」配置的阈值比较,低估 1000 倍。
实测后果:``min_avg_amount_20d: 20000000`` 在 2015-2019 实际等价于
「日均成交额 ≥ 200 亿元」,股票池被整体清空。
``symbols`` 为纯性能开关(全市场约 10 万行;限定几十只股票后降到毫秒级)。
"""
d0 = self.trading_day(asof) d0 = self.trading_day(asof)
days = self.trading_days(d0 - timedelta(days=window * 3), d0) days = self.trading_days(d0 - timedelta(days=window * 3), d0)
days = days[-window:] days = days[-window:]
if not days: if not days:
return pd.DataFrame(columns=["symbol", "avg_amount"]) return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
params: dict = {"s": days[0], "e": days[-1]}
sym_in = _symbol_filter(symbols, params)
df = db.read_sql( df = db.read_sql(
"SELECT symbol, AVG(amount) AS avg_amount, COUNT(*) AS n " "SELECT symbol, close, volume, amount "
"FROM stock_daily WHERE trade_date BETWEEN :s AND :e GROUP BY symbol", "FROM stock_daily WHERE trade_date BETWEEN :s AND :e" + sym_in,
{"s": days[0], "e": days[-1]}, params,
cfg=self.cfg, cfg=self.cfg,
) )
return df if df.empty:
return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
for c in ("close", "volume", "amount"):
df[c] = pd.to_numeric(df[c], errors="coerce")
df, _diag = normalize_ohlcv_units(df)
# n 沿用 COUNT(*) 语义(该窗口内有行情的交易日数),与归一化无关
g = df.groupby("symbol", as_index=False).agg(
avg_amount=("amount", "mean"), n=("amount", "size")
)
return g
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 停牌 / 涨跌停 # 停牌 / 涨跌停
@@ -320,24 +364,31 @@ class Repo:
# 财务数据(PIT) # 财务数据(PIT)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def financial_panel(self, asof: date) -> pd.DataFrame: def financial_panel(
self, asof: date, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""asof 时点可见的最新一期财务数据(``ann_date <= asof``)。 """asof 时点可见的最新一期财务数据(``ann_date <= asof``)。
实现要点:用窗口函数取「报告期最新」的那条; 实现要点:用窗口函数取「报告期最新」的那条;
``ann_date <= asof`` 保证不使用未公告数据。 ``ann_date <= asof`` 保证不使用未公告数据。
``symbols`` 非空时只取这些股票 —— 纯粹的性能开关(财务表各约 30 万行,
全表扫描约 1.7 秒;限定几十只股票后降到毫秒级)。**不改变 PIT 语义**:
它只是在原有 WHERE 上追加一个 ``symbol IN (...)``,不会改变任何
(symbol, end_date) 分区内的 ``ROW_NUMBER`` 结果。
""" """
fin = self._latest_financial(asof, "hd_fina_indicator", ["roe", "roic", "debt_to_assets", fin = self._latest_financial(asof, "hd_fina_indicator", ["roe", "roic", "debt_to_assets",
"grossprofit_margin", "netprofit_margin", "grossprofit_margin", "netprofit_margin",
"ocf_to_profit"]) "ocf_to_profit"], symbols=symbols)
if fin.empty: if fin.empty:
return fin return fin
cf = self._latest_financial(asof, "hd_cashflow", ["n_cashflow_act", "free_cashflow", cf = self._latest_financial(asof, "hd_cashflow", ["n_cashflow_act", "free_cashflow",
"c_pay_dist_dpcp_int_exp"]) "c_pay_dist_dpcp_int_exp"], symbols=symbols)
bs = self._latest_financial(asof, "hd_balancesheet", ["total_assets", "total_liab", bs = self._latest_financial(asof, "hd_balancesheet", ["total_assets", "total_liab",
"total_hldr_eqy_exc_min_int", "total_hldr_eqy_exc_min_int",
"money_cap", "goodwill"]) "money_cap", "goodwill"], symbols=symbols)
inc = self._latest_financial(asof, "hd_income", ["total_revenue", "revenue", "n_income", inc = self._latest_financial(asof, "hd_income", ["total_revenue", "revenue", "n_income",
"n_income_attr_p"]) "n_income_attr_p"], symbols=symbols)
out = fin out = fin
for other in (cf, bs, inc): for other in (cf, bs, inc):
if other.empty: if other.empty:
@@ -352,7 +403,10 @@ class Repo:
out = self._derive_financial(out) out = self._derive_financial(out)
return out return out
def _latest_financial(self, asof: date, table: str, cols: list[str]) -> pd.DataFrame: def _latest_financial(
self, asof: date, table: str, cols: list[str],
*, symbols: list[str] | None = None,
) -> pd.DataFrame:
if not db.table_exists(table, self.cfg): if not db.table_exists(table, self.cfg):
return pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols]) return pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols])
rt = ( rt = (
@@ -361,6 +415,8 @@ class Repo:
else "" else ""
) )
sel = ", ".join(cols) sel = ", ".join(cols)
params: dict = {"asof": asof}
sym_cond = _symbol_filter(symbols, params)
sql = f""" sql = f"""
SELECT symbol, end_date, ann_date, {sel} FROM ( SELECT symbol, end_date, ann_date, {sel} FROM (
SELECT symbol, end_date, ann_date, {sel}, SELECT symbol, end_date, ann_date, {sel},
@@ -368,13 +424,13 @@ class Repo:
PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC
) AS rn ) AS rn
FROM {table} FROM {table}
WHERE ann_date <= :asof {rt} WHERE ann_date <= :asof {rt} {sym_cond}
-- 公告日不可能早于报告期;这类行是数据源错误(实测 920185.BJ 有 2 条), -- 公告日不可能早于报告期;这类行是数据源错误(实测 920185.BJ 有 2 条),
-- 若不过滤会构成未来函数。审计中仍会如实报告其数量。 -- 若不过滤会构成未来函数。审计中仍会如实报告其数量。
AND ann_date >= end_date AND ann_date >= end_date
) t WHERE rn = 1 ) t WHERE rn = 1
""" """
df = db.read_sql(sql, {"asof": asof}, cfg=self.cfg) df = db.read_sql(sql, params, cfg=self.cfg)
for c in ("end_date", "ann_date"): for c in ("end_date", "ann_date"):
if c in df.columns and not df.empty: if c in df.columns and not df.empty:
df[c] = pd.to_datetime(df[c]).dt.date df[c] = pd.to_datetime(df[c]).dt.date
@@ -449,7 +505,9 @@ class Repo:
df[c] = pd.to_numeric(df[c], errors="coerce") df[c] = pd.to_numeric(df[c], errors="coerce")
return df return df
def annual_financial_history(self, asof: date, *, years: int = 6) -> pd.DataFrame: def annual_financial_history(
self, asof: date, *, years: int = 6, symbols: list[str] | None = None
) -> pd.DataFrame:
"""asof 时点可见的**年度**财务指标历史(用于 5 年平均等长期口径)。 """asof 时点可见的**年度**财务指标历史(用于 5 年平均等长期口径)。
为什么必须用年报而不是最新季报: 为什么必须用年报而不是最新季报:
@@ -458,22 +516,29 @@ class Repo:
会把几乎所有好公司误杀(实测:600036.SH 的 2024Q1 ROE 仅 3.47%)。 会把几乎所有好公司误杀(实测:600036.SH 的 2024Q1 ROE 仅 3.47%)。
因此这里只取 ``end_date`` 为 12-31 的年报,且 ``ann_date <= asof``(PIT)。 因此这里只取 ``end_date`` 为 12-31 的年报,且 ``ann_date <= asof``(PIT)。
``symbols`` 是纯性能开关(见 :func:`_symbol_filter`):内层子查询与外层
用同一个 symbol 过滤条件,因此不会改变任何 ``(symbol, end_date)`` 分组
的 ``MAX(ann_date)``,结果与全市场口径逐行一致。
""" """
if not db.table_exists("hd_fina_indicator", self.cfg): if not db.table_exists("hd_fina_indicator", self.cfg):
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"]) return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
since = date(asof.year - years - 1, 12, 31) since = date(asof.year - years - 1, 12, 31)
params: dict = {"asof": asof, "since": since}
sym_in = _symbol_filter(symbols, params)
sym_in_f = _symbol_filter(symbols, params, col="f.symbol")
fin = db.read_sql( fin = db.read_sql(
"SELECT f.symbol, f.end_date, f.ann_date, f.roe, f.roic, f.grossprofit_margin, " "SELECT f.symbol, f.end_date, f.ann_date, f.roe, f.roic, f.grossprofit_margin, "
" f.netprofit_margin, f.ocf_to_profit " " f.netprofit_margin, f.ocf_to_profit "
"FROM hd_fina_indicator f " "FROM hd_fina_indicator f "
"JOIN (SELECT symbol, end_date, MAX(ann_date) AS a FROM hd_fina_indicator " "JOIN (SELECT symbol, end_date, MAX(ann_date) AS a FROM hd_fina_indicator "
" WHERE ann_date <= :asof AND MONTH(end_date) = 12 AND end_date >= :since " " WHERE ann_date <= :asof AND MONTH(end_date) = 12 AND end_date >= :since "
" AND ann_date >= end_date " " AND ann_date >= end_date" + sym_in + " "
" GROUP BY symbol, end_date) m " " GROUP BY symbol, end_date) m "
" ON m.symbol = f.symbol AND m.end_date = f.end_date AND m.a = f.ann_date " " ON m.symbol = f.symbol AND m.end_date = f.end_date AND m.a = f.ann_date "
"WHERE MONTH(f.end_date) = 12 AND f.end_date >= :since", "WHERE MONTH(f.end_date) = 12 AND f.end_date >= :since" + sym_in_f,
{"asof": asof, "since": since}, params,
cfg=self.cfg, cfg=self.cfg,
) )
if fin.empty: if fin.empty:
@@ -484,23 +549,27 @@ class Repo:
fin[c] = pd.to_numeric(fin[c], errors="coerce") / 100.0 fin[c] = pd.to_numeric(fin[c], errors="coerce") / 100.0
# 经营现金流/净利润:用现金流量表与利润表年报口径补算(比 fina_indicator 更可靠) # 经营现金流/净利润:用现金流量表与利润表年报口径补算(比 fina_indicator 更可靠)
ocf = self._annual_ocf_ratio(asof, since) ocf = self._annual_ocf_ratio(asof, since, symbols=symbols)
if not ocf.empty: if not ocf.empty:
fin = fin.merge(ocf, on=["symbol", "year"], how="left") fin = fin.merge(ocf, on=["symbol", "year"], how="left")
return fin return fin
def _annual_ocf_ratio(self, asof: date, since: date) -> pd.DataFrame: def _annual_ocf_ratio(
self, asof: date, since: date, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""年报口径的 经营现金流 / 归母净利润。""" """年报口径的 经营现金流 / 归母净利润。"""
if not (db.table_exists("hd_cashflow", self.cfg) and db.table_exists("hd_income", self.cfg)): if not (db.table_exists("hd_cashflow", self.cfg) and db.table_exists("hd_income", self.cfg)):
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"]) return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
params: dict = {"asof": asof, "since": since}
sym_in = _symbol_filter(symbols, params, col="c.symbol")
df = db.read_sql( df = db.read_sql(
"SELECT c.symbol, c.end_date, c.n_cashflow_act, i.n_income_attr_p " "SELECT c.symbol, c.end_date, c.n_cashflow_act, i.n_income_attr_p "
"FROM hd_cashflow c " "FROM hd_cashflow c "
"JOIN hd_income i ON i.symbol = c.symbol AND i.end_date = c.end_date " "JOIN hd_income i ON i.symbol = c.symbol AND i.end_date = c.end_date "
" AND i.ann_date = c.ann_date AND i.report_type = c.report_type " " AND i.ann_date = c.ann_date AND i.report_type = c.report_type "
"WHERE c.report_type = '1' AND MONTH(c.end_date) = 12 " "WHERE c.report_type = '1' AND MONTH(c.end_date) = 12 "
" AND c.end_date >= :since AND c.ann_date <= :asof", " AND c.end_date >= :since AND c.ann_date <= :asof" + sym_in,
{"asof": asof, "since": since}, params,
cfg=self.cfg, cfg=self.cfg,
) )
if df.empty: if df.empty:
@@ -512,7 +581,8 @@ class Repo:
return df[["symbol", "year", "ocf_to_netprofit_calc"]] return df[["symbol", "year", "ocf_to_netprofit_calc"]]
def annual_financial_averages( def annual_financial_averages(
self, asof: date, *, years: int = 5, min_years: int = 3 self, asof: date, *, years: int = 5, min_years: int = 3,
symbols: list[str] | None = None, hist: pd.DataFrame | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""把年报历史聚合成「N 年平均」指标。 """把年报历史聚合成「N 年平均」指标。
@@ -520,8 +590,14 @@ class Repo:
``net_margin_avg`` / ``ocf_to_profit_avg`` / ``fin_years_count`` / ``net_margin_avg`` / ``ocf_to_profit_avg`` / ``fin_years_count`` /
``fin_latest_year``。样本年数不足 ``min_years`` 时对应平均值为 NaN ``fin_latest_year``。样本年数不足 ``min_years`` 时对应平均值为 NaN
(宁可标注「不可得」,也不要用不足的样本猜)。 (宁可标注「不可得」,也不要用不足的样本猜)。
``hist`` 允许调用方传入已取好的 :meth:`annual_financial_history` 结果,
避免同一时点重复查询(PIT 画像逐日调用时这是 2 倍开销)。
""" """
hist = self.annual_financial_history(asof, years=years) hist = (
self.annual_financial_history(asof, years=years, symbols=symbols)
if hist is None else hist
)
if hist.empty: if hist.empty:
return pd.DataFrame( return pd.DataFrame(
columns=["symbol", "roe_avg", "roic_avg", "gross_margin_avg", columns=["symbol", "roe_avg", "roic_avg", "gross_margin_avg",
@@ -552,7 +628,9 @@ class Repo:
out.loc[thin, c] = pd.NA out.loc[thin, c] = pd.NA
return out return out
def annual_financials(self, asof: date, *, years: int = 12) -> pd.DataFrame: def annual_financials(
self, asof: date, *, years: int = 12, symbols: list[str] | None = None
) -> pd.DataFrame:
"""按**财年**对齐的年度财务数据(PIT)。 """按**财年**对齐的年度财务数据(PIT)。
为什么必须单独提供:``financial_panel`` 返回的是**最新一期**财报 为什么必须单独提供:``financial_panel`` 返回的是**最新一期**财报
@@ -562,7 +640,7 @@ class Repo:
返回列:``symbol / year / n_income_attr_p / n_cashflow_act / 返回列:``symbol / year / n_income_attr_p / n_cashflow_act /
free_cashflow / c_pay_dist_dpcp_int_exp``,仅取年报(``MONTH(end_date)=12``), free_cashflow / c_pay_dist_dpcp_int_exp``,仅取年报(``MONTH(end_date)=12``),
且 ``ann_date <= asof``。 且 ``ann_date <= asof``。``symbols`` 为纯性能开关。
""" """
if not (db.table_exists("hd_income", self.cfg) and db.table_exists("hd_cashflow", self.cfg)): if not (db.table_exists("hd_income", self.cfg) and db.table_exists("hd_cashflow", self.cfg)):
return pd.DataFrame( return pd.DataFrame(
@@ -570,6 +648,8 @@ class Repo:
"free_cashflow", "c_pay_dist_dpcp_int_exp"] "free_cashflow", "c_pay_dist_dpcp_int_exp"]
) )
since = date(asof.year - years - 1, 12, 31) since = date(asof.year - years - 1, 12, 31)
params: dict = {"asof": asof, "since": since}
sym_in = _symbol_filter(symbols, params, col="i.symbol")
df = db.read_sql( df = db.read_sql(
"SELECT i.symbol, i.end_date, i.n_income, i.n_income_attr_p, " "SELECT i.symbol, i.end_date, i.n_income, i.n_income_attr_p, "
" c.n_cashflow_act, c.free_cashflow, c.c_pay_dist_dpcp_int_exp " " c.n_cashflow_act, c.free_cashflow, c.c_pay_dist_dpcp_int_exp "
@@ -577,8 +657,9 @@ class Repo:
"LEFT JOIN hd_cashflow c ON c.symbol = i.symbol AND c.end_date = i.end_date " "LEFT JOIN hd_cashflow c ON c.symbol = i.symbol AND c.end_date = i.end_date "
" AND c.ann_date = i.ann_date AND c.report_type = i.report_type " " AND c.ann_date = i.ann_date AND c.report_type = i.report_type "
"WHERE i.report_type = '1' AND MONTH(i.end_date) = 12 " "WHERE i.report_type = '1' AND MONTH(i.end_date) = 12 "
" AND i.end_date >= :since AND i.ann_date <= :asof AND i.ann_date >= i.end_date", " AND i.end_date >= :since AND i.ann_date <= :asof AND i.ann_date >= i.end_date"
{"asof": asof, "since": since}, + sym_in,
params,
cfg=self.cfg, cfg=self.cfg,
) )
if df.empty: if df.empty:
+73 -10
View File
@@ -21,6 +21,7 @@ from hdiv.core.config import DataSourceConfig, load_config
from hdiv.data import db from hdiv.data import db
from hdiv.data.sync.base import sync_job, to_date, to_float, upsert from hdiv.data.sync.base import sync_job, to_date, to_float, upsert
from hdiv.data.tushare_client import TushareClient from hdiv.data.tushare_client import TushareClient
from hdiv.data.units import amount_qian_to_yuan, vol_shou_to_shares
EXCHANGES = ("SSE", "SZSE", "BSE") EXCHANGES = ("SSE", "SZSE", "BSE")
EXCHANGE_CODE = {"SSE": "SH", "SZSE": "SZ", "BSE": "BJ"} EXCHANGE_CODE = {"SSE": "SH", "SZSE": "SZ", "BSE": "BJ"}
@@ -60,19 +61,59 @@ def open_days(start: date, end: date, cfg: DataSourceConfig | None = None) -> li
#: 一个交易日被视为「已同步完成」所需的最少股票数。 #: 一个交易日被视为「已同步完成」所需的最少股票数。
#: A 股自 2015 年起每个交易日都有 2,000 只以上在交易。 #: 保留为**绝对下限**:A 股自 2015 年起每个交易日都有 2,000 只以上在交易。
#: 早期实现只看「该日期是否存在」,会把**只填了几百只**的半成品日当成已完成 #: 早期实现只看「该日期是否存在」,会把**只填了几百只**的半成品日当成已完成
#: (实测:原 qlib 数据在 2019 年仅 243 只/日,被误判为已同步,形成整年数据空洞)。 #: (实测:原 qlib 数据在 2019 年仅 243 只/日,被误判为已同步,形成整年数据空洞)。
#: 但现在**不再单独使用它** —— 2005 年 A 股只有约 1,350 只,固定 1,500 会让
#: 2005-2009 的每一天都判为「未完成」,断点续传失效。实际阈值见
#: :func:`_day_threshold`:``max(绝对下限, 比例 × 当年应有上市股票数)``。
MIN_SYMBOLS_PER_DAY = 1500 MIN_SYMBOLS_PER_DAY = 1500
def _expected_symbols_by_year(cfg: DataSourceConfig) -> dict[int, int]:
"""``{年份: 该年末累计上市股票数}``(来自 ``stock.list_date``,独立于行情表)。
用独立的 ``stock`` 表当参照,而不是行情表自身的观测数 —— 后者是循环论证:
整段缺失的年份根本没有行,无从判断它「应该有」多少。
忽略退市会让这个数偏大(是上界),0.6 的比例留了足够余量。
"""
try:
df = db.read_sql(
"SELECT YEAR(list_date) AS y, COUNT(*) AS n FROM stock "
"WHERE list_date IS NOT NULL AND YEAR(list_date) > 1990 GROUP BY y",
cfg=cfg,
)
except Exception:
return {}
if df.empty:
return {}
counts = {int(r["y"]): int(r["n"]) for _, r in df.iterrows()}
cum, out = 0, {}
for y in range(min(counts), max(counts) + 1):
cum += counts.get(y, 0)
out[y] = cum
return out
def _day_thresholds(cfg: DataSourceConfig) -> tuple[int, float, dict[int, int]]:
scfg = getattr(cfg, "sync", None)
floor = int(getattr(scfg, "min_symbols_floor", 200))
ratio = float(getattr(scfg, "min_symbols_ratio", 0.6))
return floor, ratio, _expected_symbols_by_year(cfg)
def fetched_days( def fetched_days(
table: str, start: date, end: date, cfg: DataSourceConfig, *, min_symbols: int = MIN_SYMBOLS_PER_DAY table: str, start: date, end: date, cfg: DataSourceConfig,
*, min_symbols: int | None = None,
) -> set[date]: ) -> set[date]:
"""**数据完整**的交易日集合(用于断点续传)。 """**数据完整**的交易日集合(用于断点续传)。
判定标准是「当日股票数 >= min_symbols」,而不是「当日是否存在行」—— 判定标准是「当日股票数 >= 该日应有的规模」,而不是「当日是否存在行」——
否则半成品日期会被跳过,留下难以察觉的数据空洞。 否则半成品日期会被跳过,留下难以察觉的数据空洞。
阈值随年份变化(见 ``SyncConfig``):早年 A 股只有一千多只股票,
固定阈值会把 2005-2009 的每一天都判成「未完成」,断点续传失效。
``min_symbols`` 显式给定时按旧口径(固定阈值)判定,保持向后兼容。
""" """
df = db.read_sql( df = db.read_sql(
f"SELECT trade_date AS d, COUNT(DISTINCT symbol) AS n FROM `{table}` " f"SELECT trade_date AS d, COUNT(DISTINCT symbol) AS n FROM `{table}` "
@@ -82,11 +123,19 @@ def fetched_days(
) )
if df.empty: if df.empty:
return set() return set()
return { floor, ratio, by_year = _day_thresholds(cfg)
to_date(r["d"]) # type: ignore[misc] out: set[date] = set()
for _, r in df.iterrows() for _, r in df.iterrows():
if int(r["n"]) >= min_symbols d = to_date(r["d"])
} if d is None:
continue
if min_symbols is not None:
need = int(min_symbols)
else:
need = max(floor, int(ratio * by_year.get(d.year, 0)))
if int(r["n"]) >= need:
out.add(d)
return out
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -121,6 +170,14 @@ def _tp(rows: list[dict]) -> tuple[list[date], list[str]]:
def daily_frame(rows: list[dict]) -> pd.DataFrame: def daily_frame(rows: list[dict]) -> pd.DataFrame:
"""Tushare ``daily`` → ``stock_daily`` 行,**并统一量价单位**。
Tushare 的 ``vol`` 是「手」、``amount`` 是「千元」,而既有的 ``stock_daily``
(qlib 存量数据)是「股 / 元」。早期实现原样写入,于是同一列在 2015-2019 与
2020 起是两套单位,按「元」写的流动性阈值在早年低估 1000 倍。
这里在写入端就换算,读取端的 :func:`hdiv.data.units.normalize_ohlcv_units`
作为存量数据的兜底(对已换算行幂等)。
"""
if not rows: if not rows:
return pd.DataFrame() return pd.DataFrame()
td, sym = _tp(rows) td, sym = _tp(rows)
@@ -133,8 +190,14 @@ def daily_frame(rows: list[dict]) -> pd.DataFrame:
"high": [to_float(r.get("high")) for r in rows], "high": [to_float(r.get("high")) for r in rows],
"low": [to_float(r.get("low")) for r in rows], "low": [to_float(r.get("low")) for r in rows],
"close": [to_float(r.get("close")) for r in rows], "close": [to_float(r.get("close")) for r in rows],
"volume": [to_float(r.get("vol")) for r in rows], # 手 → 股
"amount": [to_float(r.get("amount")) for r in rows], "volume": vol_shou_to_shares(
pd.Series([to_float(r.get("vol")) for r in rows], dtype="float64")
),
# 千元 → 元
"amount": amount_qian_to_yuan(
pd.Series([to_float(r.get("amount")) for r in rows], dtype="float64")
),
"source": "tushare", "source": "tushare",
"adjust": "none", "adjust": "none",
} }
+8 -1
View File
@@ -81,7 +81,14 @@ def sync_trading_constraints(
cfg = load_config("datasource") cfg = load_config("datasource")
days = open_days(start, end, cfg) days = open_days(start, end, cfg)
if resume: if resume:
done_s = fetched_days(SUSPEND_TABLE, start, end, cfg) # 两张表的「完整性」判定不能用同一把尺子:
# hd_limit —— 每个交易日有全市场约 2,600~3,500 行,可用按年份的规模阈值;
# hd_suspend —— 每天只有**当天停牌的那几十只**(实测 18~260 行),
# 永远达不到「市场规模的 60%」,于是每一天都被判成「未完成」,
# 断点续传彻底失效(每次重跑都重新拉全部停牌日)。
# 停牌表只能退化为「有行即视为已同步」;真正无停牌的交易日会被重复拉取,
# 代价极小(每天 1 次调用且返回空)。
done_s = fetched_days(SUSPEND_TABLE, start, end, cfg, min_symbols=1)
done_l = fetched_days(LIMIT_TABLE, start, end, cfg) done_l = fetched_days(LIMIT_TABLE, start, end, cfg)
days_s = [d for d in days if d not in done_s] days_s = [d for d in days if d not in done_s]
days_l = [d for d in days if d not in done_l] days_l = [d for d in days if d not in done_l]
+105
View File
@@ -66,6 +66,111 @@ def vol_shou_to_shares(s: pd.Series) -> pd.Series:
return pd.to_numeric(s, errors="coerce") * SHOU return pd.to_numeric(s, errors="coerce") * SHOU
# ---------------------------------------------------------------------------
# stock_daily 的量价单位(历史遗留:同一列混着两种单位)
# ---------------------------------------------------------------------------
#: ``stock_daily`` 中一行已经处于本项目统一口径(成交量=股,成交额=元)。
OHLCV_CONVERTED = "converted"
#: ``stock_daily`` 中一行仍是 Tushare 原始口径(成交量=手,成交额=千元)。
OHLCV_RAW = "raw"
#: 无法判定(缺 close/volume/amount,或非正值)。
OHLCV_UNKNOWN = "unknown"
#: raw 与 converted 的比值相差正好 10 倍(见 :func:`ohlcv_unit_ratio`),
#: 而 A 股有 ±10% 涨跌幅限制使 VWAP/收盘价落在 0.9~1.1,所以 0.3 这个阈值
#: 有 ~3 倍的安全边际 —— 不会把正常行情误判成另一种单位。
_RAW_MAX_RATIO = 0.3
def ohlcv_unit_ratio(df: pd.DataFrame) -> pd.Series:
"""``成交额 / (成交量 × 收盘价)``:≈1 表示已换算,≈0.1 表示 Tushare 原始单位。
这是唯一可靠的判据:列名完全看不出单位,而量级会。推导:
- 统一口径(股 / 元):``amount / (volume × close) = 1``
- 原始口径(手 / 千元):``amount / (volume × close) = 100 / 1000 = 0.1``
``VWAP = amount / volume`` 与 ``close`` 的比值受涨跌幅限制约束,
因此该比值只有 1 或 0.1 两个可能,不存在中间态。
"""
need = {"amount", "volume", "close"}
if df.empty or not need <= set(df.columns):
return pd.Series(dtype="float64", index=df.index)
amt = pd.to_numeric(df["amount"], errors="coerce")
vol = pd.to_numeric(df["volume"], errors="coerce")
close = pd.to_numeric(df["close"], errors="coerce")
denom = vol * close
ok = (denom > 0) & amt.notna()
out = pd.Series(float("nan"), index=df.index, dtype="float64")
out[ok] = amt[ok] / denom[ok]
return out
def detect_ohlcv_units(df: pd.DataFrame) -> pd.Series:
"""逐行判定 ``stock_daily`` 的量价单位,返回 ``raw`` / ``converted`` / ``unknown``。"""
if df.empty:
return pd.Series(dtype="object", index=df.index)
r = ohlcv_unit_ratio(df)
out = pd.Series(OHLCV_UNKNOWN, index=df.index, dtype="object")
out[r.notna() & (r > _RAW_MAX_RATIO)] = OHLCV_CONVERTED
out[r.notna() & (r <= _RAW_MAX_RATIO)] = OHLCV_RAW
return out
def normalize_ohlcv_units(
df: pd.DataFrame,
*,
volume_col: str = "volume",
amount_col: str = "amount",
close_col: str = "close",
) -> tuple[pd.DataFrame, dict[str, Any]]:
"""把 ``stock_daily`` 的成交量/成交额统一到「股 / 元」。
**为什么必须在读取时做**:``stock_daily`` 是「追加进既有 qlib 库」的表 ——
2015-01~2019 的行由本项目从 Tushare 回补,写的是原始单位(手 / 千元);
2020 起的行沿用 qlib 既有数据(股 / 元);2019 年同日混着两种。
而 ``min_avg_amount_20d`` 这类阈值是按「元」写的,于是 2015-2019 的
20 日均额被低估 1000 倍 —— 流动性门槛实际变成「日均成交额 ≥ 200 亿元」,
把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各筛选出 0 只)。
该函数是**幂等**的:已换算的行比值 ≈1,不会被二次换算。
返回 ``(新 DataFrame, 诊断信息)``,不修改入参。
"""
if df.empty:
return df, {"total": 0, "raw": 0, "converted": 0, "unknown": 0, "fixed": 0}
for col in (volume_col, amount_col, close_col):
if col not in df.columns:
# 缺少任一列都无法判定单位,只能原样返回(并在诊断里体现)
return df, {
"total": int(len(df)), "raw": 0, "converted": 0,
"unknown": int(len(df)), "fixed": 0,
"error": f"缺少列 {col},无法判定单位",
}
unit = detect_ohlcv_units(
df.rename(columns={volume_col: "volume", amount_col: "amount",
close_col: "close"})
)
out = df.copy()
raw_mask = unit.to_numpy() == OHLCV_RAW
if raw_mask.any():
out.loc[raw_mask, volume_col] = (
pd.to_numeric(out.loc[raw_mask, volume_col], errors="coerce") * SHOU
)
out.loc[raw_mask, amount_col] = (
pd.to_numeric(out.loc[raw_mask, amount_col], errors="coerce") * QIAN
)
counts = unit.value_counts()
diag = {
"total": int(len(df)),
"raw": int(counts.get(OHLCV_RAW, 0)),
"converted": int(counts.get(OHLCV_CONVERTED, 0)),
"unknown": int(counts.get(OHLCV_UNKNOWN, 0)),
"fixed": int(raw_mask.sum()),
}
return out, diag
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 面板归一化 # 面板归一化
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+89 -12
View File
@@ -37,6 +37,11 @@ from hdiv.factor.dividend_yield import (
rolling_volatility, rolling_volatility,
window_slice, window_slice,
) )
from hdiv.profile.coverage import (
expected_by_window,
format_warning,
summarise,
)
# 指标展示名与单位(呈现层用) # 指标展示名与单位(呈现层用)
METRIC_META: dict[str, dict[str, str]] = { METRIC_META: dict[str, dict[str, str]] = {
@@ -55,6 +60,13 @@ METRIC_META: dict[str, dict[str, str]] = {
"dps": {"label": "每股分红", "unit": "price"}, "dps": {"label": "每股分红", "unit": "price"},
"payout_ratio": {"label": "分红支付率", "unit": "pct"}, "payout_ratio": {"label": "分红支付率", "unit": "pct"},
"fcf_dividend_cover": {"label": "FCF 对分红覆盖", "unit": "ratio"}, "fcf_dividend_cover": {"label": "FCF 对分红覆盖", "unit": "ratio"},
# 两个「自由现金流」是不同的口径,必须分开:
# free_cashflow —— 最近一期已公告财报的自由现金流
# dividend_fy_free_cashflow —— 与最近一次分红**同一财年**的自由现金流
# 后者才是计算 FCF 覆盖倍数的分子;混用一个代码会让同一指标在不同报告里
# 显示两个不同的数(实测格力电器 2018-05-18:67.1 亿 vs 70.5 亿)。
"free_cashflow": {"label": "自由现金流(最近一期)", "unit": "money"},
"dividend_fy_free_cashflow": {"label": "自由现金流(分红同财年)", "unit": "money"},
"dividend_continuity_years": {"label": "连续分红年数", "unit": "years"}, "dividend_continuity_years": {"label": "连续分红年数", "unit": "years"},
"dps_cagr_5y": {"label": "DPS 5年复合增速", "unit": "pct"}, "dps_cagr_5y": {"label": "DPS 5年复合增速", "unit": "pct"},
"dps_volatility": {"label": "DPS 波动率", "unit": "ratio"}, "dps_volatility": {"label": "DPS 波动率", "unit": "ratio"},
@@ -102,7 +114,15 @@ class ProfileBuilder:
asof: str | date | None = None, asof: str | date | None = None,
persist: bool = True, persist: bool = True,
verbose: bool = True, verbose: bool = True,
return_rows: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""计算画像。
``return_rows=True`` 时在结果里附带 ``stat_rows`` / ``series_rows`` /
``score_rows`` 原始行(不落库也能检查逐指标取值)。这是给**等价性测试**
用的:实时画像(``profile.pit``)与批量画像必须逐值一致,而验证这一点
需要一个不写库也能读到逐指标结果的入口。
"""
cfg = load_config("datasource") cfg = load_config("datasource")
c = self.config c = self.config
@@ -202,6 +222,20 @@ class ProfileBuilder:
if verbose and i % 50 == 0: if verbose and i % 50 == 0:
print(f" [{i}/{len(syms)}] 已画像", flush=True) print(f" [{i}/{len(syms)}] 已画像", flush=True)
# 窗口覆盖率自检:名义窗口与实际可用数据是两件事。
# 数据起点晚于窗口左端时窗口会被**静默截短**(实测 2018-05-18 的
# 「5 年」窗口只有 3.4 年),而 n_obs 门槛低到 20 就放行 ——
# 这里至少把它说出来,避免把「3.4 年」当成「5 年」用。
cov_summary = summarise(
stat_rows,
expected_by_window(
self.repo, asof_d,
# 除配置窗口外,收益/回撤类指标还会产出 1/3 年窗口
sorted({int(w) for w in c.windows_years} | {1, 3}),
),
)
cov_warn = format_warning(cov_summary)
result = { result = {
"run_id": run_id, "run_id": run_id,
"asof_date": asof_d, "asof_date": asof_d,
@@ -211,7 +245,15 @@ class ProfileBuilder:
"score_count": len(score_rows), "score_count": len(score_rows),
"skipped": skipped, "skipped": skipped,
"symbols": sorted({r["symbol"] for r in stat_rows}), "symbols": sorted({r["symbol"] for r in stat_rows}),
"window_coverage": cov_summary,
} }
if cov_warn:
result["warnings"] = [cov_warn]
if return_rows:
# 不落库也能逐指标核对(等价性测试用)
result["stat_rows"] = stat_rows
result["series_rows"] = series_rows
result["score_rows"] = score_rows
if persist: if persist:
result["written"] = self._persist( result["written"] = self._persist(
@@ -244,11 +286,25 @@ class ProfileBuilder:
fin_latest: pd.DataFrame, fin_latest: pd.DataFrame,
div_by_symbol: dict[str, list[dict]] | None = None, div_by_symbol: dict[str, list[dict]] | None = None,
fy_table: dict[tuple[str, int], Any] | None = None, fy_table: dict[tuple[str, int], Any] | None = None,
build_series: bool = True,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""单股画像。
``build_series=False`` 跳过**仅供绘图**的降采样序列,统计量完全不受影响。
实时画像(``profile.pit``)只需要 ``current_value`` / ``current_percentile``,
不需要画图 —— 而构造那几条最多 1500 点的序列占掉本函数约一半耗时
(实测每个快照 0.86s → 0.37s)。
**本函数强制按 ``trade_date`` 排序**,不依赖调用方给有序帧:所有序列的
「当日值」都是取最后一个观测(``current = sub.iloc[-1]``),行序错了就会
取到任意一天的值,而且**不会报错**。2026-10-04 回补数据时就踩到过
(详见 :meth:`_load_daily_basic`)。
"""
c = self.config c = self.config
px = price[price["symbol"] == sym] px = price[price["symbol"] == sym]
if px.empty: if px.empty:
return None return None
px = px.sort_values("trade_date") # 见 docstring:行序即语义
close = px.set_index("trade_date")["close"] close = px.set_index("trade_date")["close"]
close.index = pd.to_datetime(close.index) close.index = pd.to_datetime(close.index)
@@ -258,6 +314,7 @@ class ProfileBuilder:
events.get(sym, pd.DataFrame()), events.get(sym, pd.DataFrame()),
ttm_days=c.ttm_dividend.window_days, ttm_days=c.ttm_dividend.window_days,
grace_days=c.ttm_dividend.grace_days, grace_days=c.ttm_dividend.grace_days,
smooth_spikes=c.ttm_dividend.smooth_spikes,
) )
if yser.empty: if yser.empty:
return None return None
@@ -271,6 +328,7 @@ class ProfileBuilder:
} }
bs = basics[basics["symbol"] == sym] bs = basics[basics["symbol"] == sym]
if not bs.empty: if not bs.empty:
bs = bs.sort_values("trade_date") # 同上:行序即「当日值」的语义
b = bs.set_index(pd.to_datetime(bs["trade_date"])) b = bs.set_index(pd.to_datetime(bs["trade_date"]))
for col in ("pe_ttm", "pb", "ps_ttm"): for col in ("pe_ttm", "pb", "ps_ttm"):
if col in b.columns: if col in b.columns:
@@ -309,8 +367,8 @@ class ProfileBuilder:
"OK" if st.get("n_obs", 0) >= min(c.min_obs_days, 20) else "INSUFFICIENT", "OK" if st.get("n_obs", 0) >= min(c.min_obs_days, 20) else "INSUFFICIENT",
) )
) )
# 展示序列只落配置指定的指标 # 展示序列只落配置指定的指标(且仅在需要时构造 —— 见 build_series)
if metric in c.series_metrics: if build_series and metric in c.series_metrics:
disp = resample_for_storage( disp = resample_for_storage(
pd.DataFrame({"trade_date": s.index, "value": s.to_numpy()}), pd.DataFrame({"trade_date": s.index, "value": s.to_numpy()}),
c.series_max_points, c.series_max_points,
@@ -452,17 +510,25 @@ class ProfileBuilder:
st.update(DividendFilter._payout_and_cover(recs, fy_row, asof, c)) st.update(DividendFilter._payout_and_cover(recs, fy_row, asof, c))
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
# 标量型质量指标 # 标量型质量指标。
for code in ( # 刻意**不含 ttm_dps** —— 它已由上面的序列循环按窗口产出(带完整分布统计),
"dividend_continuity_years", "dividend_years_in_window", # 这里再产一次会写出两条 (ttm_dps, window=0) 行:落库时互相覆盖,
"ttm_dps", "dps_cagr_5y", "dps_volatility", # 而「哪条胜出」取决于写入顺序,属于不确定行为。
"payout_ratio", "fcf_dividend_cover", "free_cashflow", #
"total_cash_dividend", # 同理 **不含 free_cashflow**:``fin_latest`` 已按「最近一期公告」产出该代码,
# 而这里是**与分红同一财年**的自由现金流 —— 两个不同口径不能共用一个代码。
# 后者改用 ``dividend_fy_free_cashflow``,两者都保留、都唯一。
for code, emit_code in (
("dividend_continuity_years", None), ("dividend_years_in_window", None),
("dps_cagr_5y", None), ("dps_volatility", None),
("payout_ratio", None), ("fcf_dividend_cover", None),
("free_cashflow", "dividend_fy_free_cashflow"),
("total_cash_dividend", None),
): ):
v = _f(st.get(code)) v = _f(st.get(code))
if v is None: if v is None:
continue continue
out.append(self._stat_row(sym, code, None, {"n_obs": 1}, v, None, "OK")) out.append(self._stat_row(sym, emit_code or code, None, {"n_obs": 1}, v, None, "OK"))
# DPS 年度序列(用于趋势展示与分位) # DPS 年度序列(用于趋势展示与分位)
dps_by_year = st.get("dps_by_year") or {} dps_by_year = st.get("dps_by_year") or {}
if dps_by_year: if dps_by_year:
@@ -663,7 +729,16 @@ class ProfileBuilder:
} }
def _load_daily_basic(self, syms: list[str], start: date, end: date) -> pd.DataFrame: def _load_daily_basic(self, syms: list[str], start: date, end: date) -> pd.DataFrame:
"""批量取每日指标(估值序列),按 symbol 分片以控制单条 SQL 的规模。""" """批量取每日指标(估值序列),按 symbol 分片以控制单条 SQL 的规模。
**必须 ``ORDER BY symbol, trade_date``**:下游按「最后一个观测」取当日值
(``current = sub.iloc[-1]``),若行序不是日期序就会取到任意一天的值。
早期实现漏了排序 —— 在「行按日期顺序插入」时侥幸正确,
但 2026-10-04 回补 2005-2014 时新行是**追加**进去的,
于是同一股票的行序变成「2015-2026 在前、2005-2014 在后」,
格力电器 2018-05-18 的 PE(TTM) 被取成 2014 年的 8.51(真值 12.12)。
等价性测试(实时画像 vs 批量画像)当场抓到该分歧。
"""
out: list[pd.DataFrame] = [] out: list[pd.DataFrame] = []
cfg = load_config("datasource") cfg = load_config("datasource")
for i in range(0, len(syms), 500): for i in range(0, len(syms), 500):
@@ -673,7 +748,8 @@ class ProfileBuilder:
params.update({f"s{j}": s for j, s in enumerate(batch)}) params.update({f"s{j}": s for j, s in enumerate(batch)})
df = db.read_sql( df = db.read_sql(
"SELECT symbol, trade_date, pe_ttm, pb, ps_ttm, dv_ttm " "SELECT symbol, trade_date, pe_ttm, pb, ps_ttm, dv_ttm "
f"FROM daily_basic WHERE symbol IN ({ph}) AND trade_date BETWEEN :start AND :end", f"FROM daily_basic WHERE symbol IN ({ph}) AND trade_date BETWEEN :start AND :end "
"ORDER BY symbol, trade_date",
params, cfg=cfg, params, cfg=cfg,
) )
if not df.empty: if not df.empty:
@@ -682,7 +758,8 @@ class ProfileBuilder:
return pd.DataFrame(columns=["symbol", "trade_date", "pe_ttm", "pb", "ps_ttm"]) return pd.DataFrame(columns=["symbol", "trade_date", "pe_ttm", "pb", "ps_ttm"])
df = pd.concat(out, ignore_index=True) df = pd.concat(out, ignore_index=True)
df["trade_date"] = pd.to_datetime(df["trade_date"]) df["trade_date"] = pd.to_datetime(df["trade_date"])
return df # 分片拼接后仍需全局有序(各分片内部有序 ≠ 整体有序的日期序)
return df.sort_values(["symbol", "trade_date"], ignore_index=True)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 落库 # 落库
+119
View File
@@ -0,0 +1,119 @@
"""画像窗口的**实际覆盖度**(不是「报告了 N 年」,而是「真的有 N 年数据」)。
**为什么需要它**:``window_slice(asof, 5)`` 的语义是「把已有数据切成最近 5 年」,
不是「保证有 5 年数据」。若数据起点晚于窗口左端,窗口会被**静默截短**,
而 ``_stat_row`` 只看 ``n_obs >= min(min_obs_days, 20)`` 就标 ``OK`` ——
20 个观测(约 1 个月)也算通过。
实测(600036.SH,``dv_yield``,5 年窗口):
| asof | 窗口内实际观测 | 应有权重 | 覆盖率 |
|---|---:|---:|---:|
| 2015-12-31 | 239 | 1212 | 19.7% |
| 2016-12-30 | 483 | 1212 | 39.9% |
| 2017-12-29 | 727 | 1212 | 60.0% |
| 2018-12-28 | 970 | 1212 | 80.0% |
| 2019-12-31 | 1214 | 1215 | 99.9% |
| 2020 起 | ≈1215 | ≈1215 | 100% |
而 ``n_obs=817`` 的 2018-05-18、四个窗口(0/5/8/10)**报出完全相同的 n_obs**
—— 这正是「被数据起点截断」的指纹。
本模块提供统一的分母(**交易日历的真实开市天数**,不是 243 这种近似),
供实时画像(``profile.pit``)与批量画像(``profile.builder``)共用。
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
#: 覆盖率低于该值时,画像页与 CLI 打印警告(不改变任何判定,只是不让人误以为有 5 年)
WARN_COVERAGE = 0.95
def window_start(asof: date, years: int) -> date:
"""窗口左端(与 ``factor.dividend_yield.window_slice`` 完全一致的口径)。"""
return asof - timedelta(days=int(years * 365.25))
def expected_trading_days(repo: Any, asof: date, years: int) -> int:
"""``(asof - N 年, asof]`` 内**应有**的交易日数(按交易日历)。
``years <= 0`` 表示全历史:没有可比的「应有」天数,返回 0 让调用方跳过覆盖率判定。
区间必须是**左开右闭** —— 与 ``factor.dividend_yield.window_slice`` 的
``trade_date > start`` 完全一致。``repo.trading_days`` 取的是闭区间,
所以左端点恰为交易日时要减 1;否则覆盖率永远差一天、达不到 100%,
会让 ``min_window_coverage = 1.0`` 变成「永远拒绝」。
"""
if years <= 0:
return 0
lo = window_start(asof, years)
days = repo.trading_days(lo, asof)
n = len(days)
if n and days[0] == lo:
n -= 1
return n
def coverage_ratio(n_obs: int, expected: int) -> float | None:
"""实际观测数 / 应有交易日数,封顶 1.0。``expected<=0`` 时返回 None(不适用)。"""
if expected <= 0:
return None
if n_obs >= expected:
return 1.0
return max(0.0, float(n_obs) / float(expected))
def expected_by_window(repo: Any, asof: date, windows: list[int]) -> dict[int, int]:
"""``{窗口年数: 应有交易日数}``(含 0 → 0)。"""
return {int(w): expected_trading_days(repo, asof, int(w)) for w in windows}
def summarise(stat_rows: list[dict[str, Any]], expected: dict[int, int]) -> dict[str, Any]:
"""把一批 stat 行按窗口汇总覆盖率(供 CLI/报告打印警告)。
只统计 :data:`DAILY_OBSERVATION_METRICS` —— 其余指标的 ``n_obs`` 是财年数或 1,
用交易日当分母会算出「0.4%」这种量纲错误的覆盖率。
返回 ``{"windows": {年数: {"min": 最低覆盖率, "n": 行数}}, "worst": (年数, 比率)}``。
"""
from hdiv.core.metrics import DAILY_OBSERVATION_METRICS
agg: dict[int, list[float]] = {}
for r in stat_rows:
if r.get("metric_code") not in DAILY_OBSERVATION_METRICS:
continue
wy = int(r.get("window_years") or 0)
exp = expected.get(wy, 0)
c = coverage_ratio(int(r.get("n_obs") or 0), exp)
if c is None:
continue
agg.setdefault(wy, []).append(c)
windows = {
wy: {"min": min(vals), "n": len(vals)} for wy, vals in sorted(agg.items())
}
worst: tuple[int, float] | None = None
for wy, info in windows.items():
if worst is None or info["min"] < worst[1]:
worst = (wy, info["min"])
return {"windows": windows, "worst": worst}
def format_warning(summary: dict[str, Any]) -> str | None:
"""覆盖率不足时的一句话说明(否则 None)。
逐个列出**所有**不足的窗口,而不是只报最差的那个 ——
否则「5 年已齐、只有 10 年不足」也会被说成「数据不足」,容易误导。
"""
windows = (summary or {}).get("windows") or {}
short = [(wy, info["min"]) for wy, info in sorted(windows.items())
if info["min"] < WARN_COVERAGE]
if not short:
return None
detail = "、".join(f"{wy} 年窗口 {cov:.1%}" for wy, cov in short)
return (
f"窗口数据不足:{detail}"
f"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)"
)
+555
View File
@@ -0,0 +1,555 @@
"""Point-in-Time(实时)个股画像服务。
**为什么需要它**:``ProfileBuilder`` 是**批量 + 单一时点**的研究工具 ——
它的 ``run()`` 一次算完整个股票池、落库一份快照,供人看。回测需要的却是
「每个决策日按当时可见的数据重新画像」。两者共用同一套指标定义,但调用形态不同。
本模块提供回测侧的形态:
1. **PIT 语义与 ProfileBuilder 完全一致**:价格、每日指标、分红、财报一律只在
``<= asof`` 的范围内取数;每个窗口用同一把 ``window_slice`` 切分。
等价性有回归测试(``tests/test_profile_pit.py``),而不是靠注释保证。
2. **惰性**:只在「买入条件已触发」时才计算 —— 绝大多数股票日根本不需要画像。
3. **可复用**:跨决策日共享的面板(价格、每日指标、指数)只载入一次;
与时点强相关的面板(分红、财报)按 asof 缓存,同一 asof 内多只股票复用。
这正是「长期数据可以沿用、在触发条件时计算」的落地方式。
4. **成本可观测**:``stats()`` 汇报载入次数/查询次数,避免「悄悄变慢」。
一次完整回测(12 年、每月评估、49 只候选)的画像计算量取决于触发次数,
而不是 12 年 × 49 只 —— 这是惰性的核心收益。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
import pandas as pd
from hdiv.core.errors import HdivError
from hdiv.core.metrics import (
ALL_METRICS,
DAILY_OBSERVATION_METRICS,
DIVIDEND_METRICS,
FINANCIAL_METRICS,
GATE_METRICS,
LIQUIDITY_METRICS,
PERCENTILE_METRICS,
RETURN_METRICS,
VALUATION_METRICS,
)
from hdiv.data.repo import Repo
from hdiv.factor.dividend_yield import build_dps_events
from hdiv.profile.builder import METRIC_META, ProfileBuilder
from hdiv.profile.coverage import coverage_ratio, expected_by_window
__all__ = [
"ALL_METRICS", "DIVIDEND_METRICS", "FINANCIAL_METRICS", "GATE_METRICS",
"LIQUIDITY_METRICS", "METRIC_META", "PERCENTILE_METRICS", "RETURN_METRICS",
"VALUATION_METRICS", "PitProfileService", "ProfileSnapshot",
"evaluate_gate", "metrics_needing_dividends", "metrics_needing_financials",
]
# 指标分组定义在 hdiv.core.metrics(叶子模块),此处引用同一份 ——
# 配置校验(core.config)与画像实现(profile.pit)不允许出现两个清单。
def metrics_needing_financials(metrics: set[str]) -> bool:
"""是否需要财报面板。
**分红质量指标也算「需要财报」**:``ProfileBuilder._dividend_quality_stats``
用「最近一个已公告年报」的 ``fin_end_date`` 反推考核财年(``_target_years``),
再用同一财年的 ``annual_financials`` 算支付率/FCF 覆盖。缺了 ``fin_latest``
会静默退回 ``asof.year - 1`` 这个猜测值 —— 实测会让格力电器
2018-05-18 的 ``dividend_continuity_years`` 从 10 变成 4。
宁可多付一次查询,也不接受两套口径。
"""
return bool(metrics & (FINANCIAL_METRICS | DIVIDEND_METRICS))
def metrics_needing_dividends(metrics: set[str]) -> bool:
return bool(metrics & (DIVIDEND_METRICS | VALUATION_METRICS))
# 指标分组(VALUATION/RETURN/DIVIDEND/FINANCIAL/LIQUIDITY_METRICS、
# PERCENTILE_METRICS、ALL_METRICS)定义在 ``hdiv.core.metrics`` 这个叶子模块里,
# 此处已 import —— 配置校验(core.config)与画像实现共用同一份清单,
# 不允许出现两个版本的「允许哪些指标」。
#: ``ProfileBuilder._profile_one`` 会直接对 ``fin_*`` 面板做 ``["symbol"]`` 取列,
#: 所以「不需要财报」时也必须给出**带列名的空表**,而不是无列的 ``DataFrame()``
#: (否则会 KeyError,而不是安静地跳过财务指标)。
_EMPTY_FIN_HIST = (
"symbol", "year", "roe", "roic", "grossprofit_margin", "netprofit_margin",
"ocf_to_profit", "ocf_to_netprofit_calc",
)
_EMPTY_FIN_AVG = (
"symbol", "roe_avg", "roic_avg", "gross_margin_avg", "net_margin_avg",
"ocf_to_profit_avg", "fin_years_count", "fin_latest_year",
)
_EMPTY_FIN_LATEST = (
"symbol", "end_date", "ann_date", "debt_ratio", "free_cashflow",
"n_income_attr_p", "total_assets",
)
_EMPTY_BASICS = ("symbol", "trade_date", "pe_ttm", "pb", "ps_ttm")
def _with_columns(df: pd.DataFrame, cols: tuple[str, ...]) -> pd.DataFrame:
"""空表 → 带列名的空表;非空表原样返回。"""
if df.empty and "symbol" not in df.columns:
return pd.DataFrame(columns=list(cols))
return df
# ---------------------------------------------------------------------------
# 单只股票的画像快照
# ---------------------------------------------------------------------------
@dataclass
class ProfileSnapshot:
"""某只股票在某个 asof 的实时画像(只保留决策需要的形态)。"""
symbol: str
asof: date
window_years: int
#: 指标 → 当前值(``current_value``)
values: dict[str, float] = field(default_factory=dict)
#: 指标 → 当前在窗口分布中的分位(仅 PERCENTILE_METRICS)
percentiles: dict[str, float] = field(default_factory=dict)
#: 指标 → ``OK`` / ``INSUFFICIENT``(样本不足时不得当作可用)
status: dict[str, str] = field(default_factory=dict)
#: 指标 → 实际取数的窗口年数(0 = 全历史)
windows: dict[str, int] = field(default_factory=dict)
#: 指标 → 窗口内的实际观测数
n_obs: dict[str, int] = field(default_factory=dict)
#: 指标 → 窗口**实际覆盖率**(1.0 = 名义窗口被完整覆盖;窗口 0 不适用)
coverage: dict[str, float] = field(default_factory=dict)
#: 安全边际分项得分(供留痕,不参与闸门判定)
scores: dict[str, float] = field(default_factory=dict)
def get(self, metric: str) -> tuple[float | None, str, int]:
"""返回 ``(值, 状态, 窗口)``。缺失指标的状态为 ``MISSING``。"""
return (
self.values.get(metric),
self.status.get(metric, "MISSING"),
self.windows.get(metric, -1),
)
def get_percentile(self, metric: str) -> float | None:
return self.percentiles.get(metric)
# ---------------------------------------------------------------------------
# 按 asof 缓存的时点上下文
# ---------------------------------------------------------------------------
@dataclass
class _AsOfContext:
"""同一 asof 上所有股票共用的 PIT 面板(惰性装载)。"""
asof: date
symbols: list[str]
dividends: pd.DataFrame
div_by_symbol: dict[str, list[dict[str, Any]]]
fin_hist: pd.DataFrame = field(default_factory=pd.DataFrame)
fin_avg: pd.DataFrame = field(default_factory=pd.DataFrame)
fin_latest: pd.DataFrame = field(default_factory=pd.DataFrame)
fy_table: dict[tuple[str, int], Any] = field(default_factory=dict)
needs_financial: bool = False
#: ``{窗口年数: 该窗口应有的交易日数}`` —— 覆盖率的分母(按交易日历,非近似)
expected_obs: dict[int, int] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# 服务
# ---------------------------------------------------------------------------
class PitProfileService:
"""实时画像服务(PIT,惰性,按 asof 缓存)。
用法::
svc = PitProfileService(window_years=5)
svc.prepare(symbols, data_start, end)
snap = svc.snapshot("600036.SH", date(2018, 5, 18))
"""
def __init__(
self,
*,
window_years: int = 5,
repo: Repo | None = None,
builder: ProfileBuilder | None = None,
load_index: bool = True,
load_basics: bool = True,
) -> None:
self.window_years = int(window_years)
self.repo = repo or Repo()
self.builder = builder or ProfileBuilder.from_config()
self.max_years = max(self.builder.config.windows_years)
if self.window_years != 0 and self.window_years not in self.builder.config.windows_years:
raise HdivError(
f"实时画像窗口 {self.window_years} 年不可用:config/profile.yml 的 "
f"windows_years={self.builder.config.windows_years} 里没有它。\n"
f" 请把它加进 windows_years(例如 [3, 5, 8, 10]),"
f"或把策略的 profile_gate.window_years 改成其中一个值。"
)
self.load_index = load_index
self.load_basics = load_basics
self._prepared = False
self._symbols: list[str] = []
self._price = pd.DataFrame()
self._basics = pd.DataFrame()
self._index = pd.DataFrame()
self._all_dividends = pd.DataFrame()
self._ctx: dict[date, _AsOfContext] = {}
self._snapshots: dict[tuple[str, date], ProfileSnapshot] = {}
#: 闸门规则用到的指标集合;None = 未知,按「全都可能需要」处理(保守)
self._needed: set[str] | None = None
self._financial_required: bool | None = None
self._counters: dict[str, int] = {
"price_loaded": 0, "basics_loaded": 0, "asof_contexts": 0,
"financial_loads": 0, "liquidity_loads": 0,
"snapshots_computed": 0, "snapshots_cached": 0,
}
# ------------------------------------------------------------------
# 成本控制:只载入规则真正需要的面板
# ------------------------------------------------------------------
def configure(self, metrics: set[str]) -> None:
"""声明闸门用到的指标集合。
这一步是**成本控制的关键**:若规则里没有任何财务指标,就不必付财报全表
查询的代价(各约 30 万行)。未调用时按「全都可能需要」保守处理。
"""
self._needed = set(metrics)
self._financial_required = metrics_needing_financials(self._needed)
def _needs(self, metric: str) -> bool:
return self._needed is None or metric in self._needed
def _needs_financials(self) -> bool:
"""未 ``configure`` 时按「需要」处理 —— 宁可多查,不可少算。"""
return self._financial_required is not False
def require_financials(self, required: bool) -> None:
"""显式声明是否需要财报面板(覆盖 ``configure`` 的推断)。"""
self._financial_required = bool(required)
# ------------------------------------------------------------------
# 批量预载(跨越整个回测区间、与 asof 无关的部分)
# ------------------------------------------------------------------
def prepare(self, symbols: list[str], start: date, end: date) -> None:
"""载入跨决策日共享的面板。
只有 ``trade_date`` 范围过滤,没有 PIT 语义 —— 真正的 PIT 剪裁发生在
:meth:`snapshot` 里逐 asof 进行(与 ``ProfileBuilder.run`` 的取数起点
规则完全一致:``asof.year - max_years - 1`` 的 1 月 1 日)。
"""
self._symbols = sorted(set(symbols))
if not self._symbols:
self._prepared = True
return
self._price = self.repo.price_history(self._symbols, start, end, adjust="none")
self._counters["price_loaded"] += 1
if self.load_basics:
self._basics = self.builder._load_daily_basic(self._symbols, start, end)
self._counters["basics_loaded"] += 1
if self.load_index:
self._index = self.repo.index_history("000300.SH", start, end)
# 分红:为**整个回测区间**取一次超集,逐 asof 再用与 repo.dividend_records
# 完全相同的三重 PIT 条件(imp_ann_date / ex_date / 回看窗口)在 pandas 里剪裁。
#
# 超集下界必须覆盖**最早**的 asof 所需的回看窗口,而不是最后一个 asof ——
# 早期实现按 end 回看 13 年,于是 2018 年的画像拿不到 2006-2012 的分红,
# 格力电器的 dividend_continuity_years 被算成 4(真值 10)。
span_start = start - timedelta(days=int((self.max_years + 2) * 365.25))
span_years = int((end - span_start).days / 365.25) + 2
self._all_dividends = self.repo.dividend_records(end, years_back=span_years)
if not self._all_dividends.empty:
self._all_dividends = self._all_dividends[
self._all_dividends["symbol"].isin(set(self._symbols))
]
self._prepared = True
# ------------------------------------------------------------------
# 单股快照
# ------------------------------------------------------------------
def snapshot(self, symbol: str, asof: date) -> ProfileSnapshot | None:
"""计算(或取缓存)``symbol`` 在 ``asof`` 的实时画像。"""
if not self._prepared:
raise HdivError("PitProfileService 必须先 prepare(symbols, start, end)")
key = (symbol, asof)
hit = self._snapshots.get(key)
if hit is not None:
self._counters["snapshots_cached"] += 1
return hit
ctx = self._context(asof, needs_financial=self._needs_financials())
snap = self._compute(symbol, asof, ctx)
if snap is not None:
self._snapshots[key] = snap
self._counters["snapshots_computed"] += 1
return snap
def stats(self) -> dict[str, int]:
out = dict(self._counters)
out["distinct_asof"] = len(self._ctx)
out["cached_symbols"] = len(self._snapshots)
return out
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _context(self, asof: date, *, needs_financial: bool | None) -> _AsOfContext:
hit = self._ctx.get(asof)
if hit is not None and (hit.needs_financial or not needs_financial):
return hit
# ---- 分红:与 repo.dividend_records 完全相同的 PIT 三重条件 ----
d = self._all_dividends
if d.empty:
div = d
else:
since = asof - timedelta(days=int((self.max_years + 2) * 365.25))
imp = pd.to_datetime(d["imp_ann_date"]).dt.date
ex = pd.to_datetime(d["ex_date"]).dt.date
div = d[(imp <= asof) & (ex <= asof) & (ex >= since)]
div_by_symbol: dict[str, list[dict[str, Any]]] = {}
if not div.empty:
for rec in div.to_dict("records"):
div_by_symbol.setdefault(rec["symbol"], []).append(rec)
ctx = _AsOfContext(
asof=asof, symbols=self._symbols, dividends=div,
div_by_symbol=div_by_symbol, needs_financial=bool(needs_financial),
expected_obs=expected_by_window(
self.repo, asof,
# 窗口不止 profile.yml 的 [5,8,10]:收益类指标会产出 1/3 年窗口
# (ret_1y / ret_3y / max_drawdown_3y),分母必须一并备好
sorted({int(w) for w in self.builder.config.windows_years} | {1, 3}),
),
)
if needs_financial:
syms = self._symbols
ctx.fin_hist = self.repo.annual_financial_history(
asof, years=self.max_years + 1, symbols=syms
)
ctx.fin_avg = self.repo.annual_financial_averages(
asof, years=5, symbols=syms, hist=ctx.fin_hist
)
ctx.fin_latest = self.repo.financial_panel(asof, symbols=syms)
annual = self.repo.annual_financials(asof, years=self.max_years + 2, symbols=syms)
if not annual.empty:
ctx.fy_table = {
(r["symbol"], int(r["year"])): r for _, r in annual.iterrows()
}
self._counters["financial_loads"] += 1
self._ctx[asof] = ctx
self._counters["asof_contexts"] += 1
return ctx
def _compute(
self, symbol: str, asof: date, ctx: _AsOfContext
) -> ProfileSnapshot | None:
"""调用 ``ProfileBuilder._profile_one`` —— **指标定义的单一口径来源**。"""
start = date(asof.year - self.max_years - 1, 1, 1)
def _slice(df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return df
td = pd.to_datetime(df["trade_date"]).dt.date
return df[(td >= start) & (td <= asof)]
price = _slice(self._price) if not self._price.empty else self._price
price = price[price["symbol"] == symbol] if not price.empty else price
if price.empty:
return None
basics = _slice(self._basics) if not self._basics.empty else self._basics
if not basics.empty:
basics = basics[basics["symbol"] == symbol]
basics = _with_columns(basics, _EMPTY_BASICS)
index = _slice(self._index) if not self._index.empty else self._index
sym_div = ctx.div_by_symbol.get(symbol, [])
# ProfileBuilder 期望的 events 是 {symbol: DataFrame}
events = build_dps_events(pd.DataFrame(sym_div)) if sym_div else {}
# 流动性是逐 (股票, 时点) 的查询:只有在规则真的用到时才付出这个代价
if self._needs("avg_amount_20d"):
avg = self.repo.avg_amount(asof, window=20, symbols=[symbol])
self._counters["liquidity_loads"] += 1
else:
avg = pd.DataFrame(columns=["symbol", "avg_amount", "n"])
res = self.builder._profile_one(
symbol, asof, price, events, basics, index, avg,
_with_columns(ctx.fin_hist, _EMPTY_FIN_HIST),
_with_columns(ctx.fin_avg, _EMPTY_FIN_AVG),
_with_columns(ctx.fin_latest, _EMPTY_FIN_LATEST),
ctx.div_by_symbol, ctx.fy_table,
# 闸门只需要当日值与分位,不需要绘图序列(省掉约一半耗时)
build_series=False,
)
if res is None:
return None
snap = ProfileSnapshot(symbol=symbol, asof=asof, window_years=self.window_years)
by_code: dict[str, dict[str, Any]] = {}
for row in res["stats"]:
code = row["metric_code"]
wy = int(row["window_years"])
# 序列型指标同时有「全历史(0)」与各窗口行 —— 按配置的窗口优先,
# 找不到则退回全历史,并把实际窗口如实记录(windows[code])。
if code not in by_code or self._prefer(wy, by_code[code]["window_years"]):
by_code[code] = row
for code, row in by_code.items():
cur = row.get("current_value")
if cur is not None:
snap.values[code] = float(cur)
pct = row.get("current_percentile")
if pct is not None:
snap.percentiles[code] = float(pct)
snap.status[code] = str(row.get("status") or "MISSING")
snap.windows[code] = int(row["window_years"])
n_obs = int(row.get("n_obs") or 0)
snap.n_obs[code] = n_obs
# 覆盖率:实际观测数 / 该窗口应有的交易日数。
# **这是「名义 5 年」与「真的有 5 年数据」的区别所在**。
# 只对**观测单位是交易日**的指标计算 —— 年报均值类的 n_obs 是财年数、
# 标量类的 n_obs 是 1,拿交易日当分母是量纲错误。
# 窗口 0(全历史)没有「应有」天数,记为 1.0(不参与判定)。
if code in DAILY_OBSERVATION_METRICS:
cov = coverage_ratio(
n_obs, ctx.expected_obs.get(int(row["window_years"]), 0)
)
else:
cov = None
snap.coverage[code] = 1.0 if cov is None else cov
for row in res["scores"]:
if row.get("score") is not None:
snap.scores[row["score_code"]] = float(row["score"])
return snap
def _prefer(self, new_wy: int, old_wy: int) -> bool:
"""窗口优先级:配置窗口 > 全历史 > 其它;**同窗口时后来者胜出**。
「同窗口后来者胜出」是刻意与落库语义对齐:``hd_profile_stat`` 对
``(run_id, symbol, metric_code, window_years)`` 唯一,若画像产出了重复行,
数据库里留下的是**最后写入**的那条。实时画像必须与页面上看到的数是同一个。
(重复行本身正在被逐一消除,这里只是兜底,不允许出现两套口径。)
"""
rank = {self.window_years: 0, 0: 1}
rn, ro = rank.get(new_wy, 2), rank.get(old_wy, 2)
return rn <= ro
# ---------------------------------------------------------------------------
# 闸门判定
# ---------------------------------------------------------------------------
def evaluate_gate(
rules: list[dict[str, Any]],
snapshot: ProfileSnapshot | None,
*,
on_unverifiable: str = "reject",
min_window_coverage: float = 0.0,
) -> dict[str, Any]:
"""按规则逐条判定;返回可直接写入 ``reason_json`` 的可追溯结果。
三种结局:
- ``PASS`` —— 全部规则成立
- ``REJECT`` —— 至少一条规则不成立
- ``UNVERIFIABLE`` —— 指标缺失、样本不足,**或窗口数据覆盖不足**;
按 ``on_unverifiable`` 决定是保守淘汰(``reject``)还是放行(``pass``)
**不猜**:指标缺失(MISSING)、样本不足(INSUFFICIENT)绝不当作 0 或当作通过。
``min_window_coverage``:窗口实际覆盖率下限(1.0 = 必须完整覆盖名义窗口)。
默认 0 表示**不因覆盖率淘汰**(保持改造前行为);设成 1.0 时,
「名义 5 年但实际只有 3.4 年数据」会被判为无法验证。
"""
checks: list[dict[str, Any]] = []
failed: list[str] = []
unverifiable: list[str] = []
for r in rules:
metric = str(r["metric"])
stat = str(r.get("stat", "current_value"))
op = str(r.get("op", ">="))
threshold = float(r["value"])
actual: float | None = None
status = "MISSING"
window = -1
coverage = 1.0
n_obs = 0
if snapshot is not None:
if stat == "current_percentile":
actual = snapshot.get_percentile(metric)
status = snapshot.status.get(metric, "MISSING")
window = snapshot.windows.get(metric, -1)
else:
actual, status, window = snapshot.get(metric)
coverage = snapshot.coverage.get(metric, 1.0)
n_obs = snapshot.n_obs.get(metric, 0)
# 覆盖率不足 = 用**不完整**的窗口算出来的统计量,不能当作已验证
short_window = window > 0 and coverage < min_window_coverage - 1e-9
ok: bool | None
if actual is None or status != "OK":
ok = None
unverifiable.append(f"{metric}.{stat}")
elif short_window:
ok = None
unverifiable.append(f"{metric}.window_coverage={coverage:.0%}")
else:
ok = _compare(actual, op, threshold)
if not ok:
failed.append(f"{metric}.{stat}{op}{threshold:g}")
checks.append({
"metric": metric, "stat": stat, "op": op, "threshold": threshold,
"actual": actual, "status": status, "window_years": window,
"n_obs": n_obs, "window_coverage": round(coverage, 4),
"passed": ok,
})
if failed:
verdict = "REJECT"
elif unverifiable:
verdict = "REJECT" if on_unverifiable == "reject" else "PASS"
else:
verdict = "PASS"
detail: dict[str, Any] = {
"verdict": verdict,
"checks": checks,
"failed": failed,
"unverifiable": unverifiable,
"on_unverifiable": on_unverifiable,
"min_window_coverage": min_window_coverage,
}
if snapshot is not None:
detail["asof"] = str(snapshot.asof)
detail["window_years"] = snapshot.window_years
if snapshot.scores:
detail["safety_margin_scores"] = snapshot.scores
return detail
_OPS = {
">=": lambda a, b: a >= b,
"<=": lambda a, b: a <= b,
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
}
def _compare(actual: float, op: str, threshold: float) -> bool:
fn = _OPS.get(op)
if fn is None: # pragma: no cover - 配置层已校验
raise HdivError(f"不支持的比较符:{op}")
return bool(fn(actual, threshold))
+251 -5
View File
@@ -21,6 +21,18 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import load_config from hdiv.core.config import load_config
from hdiv.core.errors import HdivError
from hdiv.report.format import NumFmt
#: 净值曲线默认叠加的指数(沪深300)
DEFAULT_INDEX_CODE = "000300.SH"
def _fmt() -> NumFmt:
"""当前配置的格式化器(每次读取,保证改配置立即生效)。"""
return NumFmt.from_config()
from hdiv.data import db from hdiv.data import db
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -151,7 +163,7 @@ def _yi(v: Any) -> str:
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
n = _num(v) n = _num(v)
return "—" if n is None else f"{n * 100:.2f}%" return "—" if n is None else _fmt().pct(n)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -550,6 +562,175 @@ def get_backtest(run_id: str) -> dict[str, Any] | None:
return r return r
# ---------------------------------------------------------------------------
# Walk-forward(样本外验证)
# ---------------------------------------------------------------------------
def list_walkforwards() -> list[dict[str, Any]]:
"""Walk-forward 运行列表。每个 wf_id 是一条独立记录。"""
cfg = load_config("datasource")
df = db.read_sql(
"""
SELECT w.wf_id, w.strategy_id, w.strategy_version, w.scheme,
w.train_years, w.test_years, w.step_months, w.window_count,
w.config_hash, w.data_version, w.code_version, w.status,
w.created_at, w.config_json,
(SELECT COUNT(*) FROM hd_walkforward_window k
WHERE k.wf_id = w.wf_id) AS windows_actual
FROM hd_walkforward_run w
ORDER BY w.created_at DESC
""",
cfg=cfg,
)
out: list[dict[str, Any]] = []
for _, row in df.iterrows():
r = _rec(row)
r["strategy"] = describe_strategy(_json_field(r.pop("config_json", None)) or {})
r["title"] = (
f"{r['strategy_id']} v{r['strategy_version']} · "
f"{r['scheme']} 训练{r['train_years']}年/测试{r['test_years']}年 · "
f"{r['window_count']} 窗口"
)
# 汇总样本外表现。逐窗口取「该窗口 test_run 的 total_return」,
# 每个窗口只贡献一个样本 —— 早期版本靠 metric 行数反推窗口数,
# 一旦某个指标缺失就会算错胜率。
agg = db.read_sql(
"""
SELECT k.window_index,
MAX(CASE WHEN m.metric_code='total_return'
THEN m.metric_value END) AS ret,
MAX(CASE WHEN m.metric_code='max_drawdown'
THEN m.metric_value END) AS dd
FROM hd_walkforward_window k
LEFT JOIN hd_backtest_metric m
ON m.run_id = k.test_run_id AND m.scope = 'all'
AND (m.benchmark_code IS NULL OR m.benchmark_code = '')
WHERE k.wf_id = :w
GROUP BY k.window_index
""",
{"w": r["wf_id"]}, cfg=cfg,
)
rets = [x for x in agg["ret"].tolist() if x is not None]
dds = [x for x in agg["dd"].tolist() if x is not None]
r["oos"] = {
"window_count": len(agg),
"sample_count": len(rets),
"mean_return": (sum(rets) / len(rets)) if rets else None,
"win_rate": (sum(1 for x in rets if x > 0) / len(rets)) if rets else None,
"worst_drawdown": min(dds) if dds else None,
}
out.append(r)
return out
def get_walkforward(wf_id: str) -> dict[str, Any] | None:
"""Walk-forward 详情:逐窗口的样本内/外表现与冻结参数。"""
cfg = load_config("datasource")
head = db.read_sql(
"SELECT * FROM hd_walkforward_run WHERE wf_id = :w", {"w": wf_id}, cfg=cfg
)
if head.empty:
return None
r = _rec(head.iloc[0])
r["strategy"] = describe_strategy(_json_field(r.pop("config_json", None)) or {})
r["config"] = _json_field(r.pop("config_json", None))
r["title"] = (
f"{r['strategy_id']} v{r['strategy_version']} · "
f"{r['window_count']} 窗口({r['scheme']})"
)
win = db.read_sql(
"SELECT window_index, train_start, train_end, test_start, test_end, "
" frozen_params_json, train_run_id, test_run_id "
"FROM hd_walkforward_window WHERE wf_id = :w ORDER BY window_index",
{"w": wf_id}, cfg=cfg,
)
run_ids = [x for x in win["train_run_id"].tolist() + win["test_run_id"].tolist() if x]
mets: dict[str, dict[str, Any]] = {}
if run_ids:
placeholders = ",".join(f":r{i}" for i in range(len(run_ids)))
params = {f"r{i}": v for i, v in enumerate(run_ids)}
md = db.read_sql(
f"SELECT run_id, metric_code, metric_value FROM hd_backtest_metric "
f"WHERE run_id IN ({placeholders}) AND scope='all' "
f" AND (benchmark_code IS NULL OR benchmark_code = '') "
f" AND metric_code IN ('total_return','cagr','max_drawdown','sharpe',"
f" 'trade_count','annual_volatility')",
params, cfg=cfg,
)
for _, x in md.iterrows():
mets.setdefault(x["run_id"], {})[x["metric_code"]] = _num(x["metric_value"])
# 基准指标存成 benchmark_<code>(如 benchmark_000300.SH),
# 上面的查询用 benchmark_code='' 过滤掉了它 —— 于是页面上看不到
# **超额收益**,而那恰恰是判读样本外最该看的数字。这里单独取回。
bm = db.read_sql(
f"SELECT run_id, metric_code, metric_value FROM hd_backtest_metric "
f"WHERE run_id IN ({placeholders}) AND category = 'benchmark'",
params, cfg=cfg,
)
for _, x in bm.iterrows():
code = str(x["metric_code"]).replace("benchmark_", "")
mets.setdefault(x["run_id"], {})[f"benchmark::{code}"] = _num(x["metric_value"])
# 基准:从 equity 表取被比较基准区间收益(回测落库时已算入 metric)
windows = []
for _, w in win.iterrows():
tr, te = w["train_run_id"], w["test_run_id"]
oos = {k: v for k, v in mets.get(te, {}).items() if not k.startswith("benchmark::")}
bench = {k.split("::", 1)[1]: v
for k, v in mets.get(te, {}).items() if k.startswith("benchmark::")}
# 超额 = 策略样本外收益 − 基准同期收益(取第一个基准,与 backtest.yml 顺序一致)
bench_code = next(iter(bench), None)
bench_ret = bench.get(bench_code) if bench_code else None
strat_ret = oos.get("total_return")
windows.append({
"window_index": int(w["window_index"]),
"train_start": _v(w["train_start"]), "train_end": _v(w["train_end"]),
"test_start": _v(w["test_start"]), "test_end": _v(w["test_end"]),
"frozen_params": _json_field(w["frozen_params_json"]) or {},
"train_run_id": tr, "test_run_id": te,
"in_sample": {k: v for k, v in mets.get(tr, {}).items()
if not k.startswith("benchmark::")},
"out_of_sample": oos,
"benchmark_code": bench_code,
"benchmark_return": bench_ret,
"excess_return": (strat_ret - bench_ret)
if (strat_ret is not None and bench_ret is not None) else None,
})
oos = [x["out_of_sample"].get("total_return") for x in windows
if x["out_of_sample"].get("total_return") is not None]
dds = [x["out_of_sample"].get("max_drawdown") for x in windows
if x["out_of_sample"].get("max_drawdown") is not None]
bench = [x["benchmark_return"] for x in windows if x["benchmark_return"] is not None]
excess = [x["excess_return"] for x in windows if x["excess_return"] is not None]
import statistics as _st
summary = {
"window_count": len(windows),
"oos_returns": oos,
"benchmark_returns": bench,
"excess_returns": excess,
"benchmark_mean": (_st.fmean(bench) if bench else None),
"excess_mean": (_st.fmean(excess) if excess else None),
"excess_win_rate": (sum(1 for x in excess if x > 0) / len(excess)) if excess else None,
"oos_mean": (_st.fmean(oos) if oos else None),
"oos_median": (_st.median(oos) if oos else None),
"oos_win_rate": (sum(1 for x in oos if x > 0) / len(oos)) if oos else None,
"oos_worst_drawdown": (min(dds) if dds else None),
# 稳定性 = 均值 / 标准差:<1 说明窗口间差异大于均值本身,结论不稳
"oos_stability": (
_st.fmean(oos) / _st.pstdev(oos)
if len(oos) > 1 and _st.pstdev(oos) > 0 else None
),
}
r["windows"] = windows
r["summary"] = summary
return r
def get_backtest_metrics(run_id: str) -> list[dict[str, Any]]: def get_backtest_metrics(run_id: str) -> list[dict[str, Any]]:
cfg = load_config("datasource") cfg = load_config("datasource")
return _records(db.read_sql( return _records(db.read_sql(
@@ -559,7 +740,30 @@ def get_backtest_metrics(run_id: str) -> list[dict[str, Any]]:
)) ))
def get_backtest_equity(run_id: str) -> dict[str, Any]: def list_indices() -> list[dict[str, Any]]:
"""可叠加到净值曲线上的基准指数(库里有多少列多少)。
一并返回各自的行情的起止日期与点数:前端据此提示
「该指数在本次回测区间内没有行情」,而不是画一条空线让人猜。
"""
cfg = load_config("datasource")
df = db.read_sql(
"SELECT index_code, MAX(index_name) AS index_name, COUNT(*) AS points, "
" MIN(trade_date) AS start, MAX(trade_date) AS end "
"FROM hd_index_daily GROUP BY index_code ORDER BY index_code",
cfg=cfg,
)
return [{
"code": r["index_code"],
"name": _v(r["index_name"]) or r["index_code"],
"points": int(r["points"]),
"start": _v(r["start"]),
"end": _v(r["end"]),
"is_default": r["index_code"] == DEFAULT_INDEX_CODE,
} for _, r in df.iterrows()]
def get_backtest_equity(run_id: str, *, index_code: str | None = None) -> dict[str, Any]:
cfg = load_config("datasource") cfg = load_config("datasource")
df = db.read_sql( df = db.read_sql(
"SELECT trade_date, nav, total_value, cash, position_value, drawdown, " "SELECT trade_date, nav, total_value, cash, position_value, drawdown, "
@@ -568,13 +772,52 @@ def get_backtest_equity(run_id: str) -> dict[str, Any]:
{"r": run_id}, cfg=cfg, {"r": run_id}, cfg=cfg,
) )
if df.empty: if df.empty:
return {"dates": [], "nav": [], "bench": [], "drawdown": [], "holding_count": []} return {"dates": [], "nav": [], "bench": [], "drawdown": [],
"holding_count": [], "benchmark_code": None, "index": None}
dates = [str(pd.Timestamp(x).date()) for x in df["trade_date"]]
return { return {
"dates": [str(pd.Timestamp(x).date()) for x in df["trade_date"]], "dates": dates,
"nav": [_num(x) for x in df["nav"]], "nav": [_num(x) for x in df["nav"]],
"bench": [_num(x) for x in df["benchmark_nav"]], "bench": [_num(x) for x in df["benchmark_nav"]],
"drawdown": [_num(x) for x in df["drawdown"]], "drawdown": [_num(x) for x in df["drawdown"]],
"holding_count": [int(x) if x is not None else 0 for x in df["holding_count"]], "holding_count": [int(x) if x is not None else 0 for x in df["holding_count"]],
"benchmark_code": _v(df["benchmark_code"].iloc[-1]),
# 可选叠加指数(右轴);不传就是纯净值曲线
"index": _index_overlay(index_code, dates, cfg=cfg) if index_code else None,
}
def _index_overlay(index_code: str, dates: list[str], *, cfg: Any) -> dict[str, Any]:
"""把指数收盘价对齐到净值曲线的日期上,供右轴叠加。
类目轴上每个类目一个点,序列必须**逐点对齐**(缺的补 None),
否则整条指数线会相对净值曲线整体错位。
"""
df = db.read_sql(
"SELECT trade_date, close, index_name FROM hd_index_daily "
"WHERE index_code = :c AND trade_date BETWEEN :s AND :e "
"ORDER BY trade_date",
{"c": index_code, "s": dates[0], "e": dates[-1]}, cfg=cfg,
)
if df.empty:
# 区分「库里没这个指数」(用户传错,应当报错)
# 与「这个指数在该区间没有行情」(创业板指对更早的回测),后者只提示。
n = int(db.read_sql(
"SELECT COUNT(*) AS n FROM hd_index_daily WHERE index_code = :c",
{"c": index_code}, cfg=cfg)["n"].iloc[0])
if not n:
raise HdivError(f"未知指数:{index_code}")
return {"code": index_code, "name": index_code, "close": [None] * len(dates),
"points": 0, "covered": False}
close_by_date = {str(pd.Timestamp(d).date()): _num(c)
for d, c in zip(df["trade_date"], df["close"])}
close = [close_by_date.get(d) for d in dates]
return {
"code": index_code,
"name": _v(df["index_name"].iloc[0]) or index_code,
"close": close,
"points": sum(1 for x in close if x is not None),
"covered": True,
} }
@@ -612,7 +855,7 @@ def _reason_text(d: dict[str, Any]) -> str:
y = _num(d.get("dividend_yield")) y = _num(d.get("dividend_yield"))
p = _num(d.get("yield_percentile")) p = _num(d.get("yield_percentile"))
if y is not None: if y is not None:
parts.append(f"股息率 {y * 100:.2f}%") parts.append(f"股息率 {_fmt().pct(y)}")
if p is not None: if p is not None:
parts.append(f"历史分位 {p:.1f}%") parts.append(f"历史分位 {p:.1f}%")
if d.get("rule"): if d.get("rule"):
@@ -652,6 +895,9 @@ _SKIP_LABELS = {
"ALREADY_AT_TARGET": "已达目标仓位", "ALREADY_AT_TARGET": "已达目标仓位",
"NO_POSITION": "无持仓", "NO_POSITION": "无持仓",
"BELOW_MIN_TRADE": "低于最小交易量", "BELOW_MIN_TRADE": "低于最小交易量",
# 实时画像闸门剔除(信号类型 REJECT):不是撮合失败,而是「按当日可见
# 数据重算画像后判定不值得买」。详情在 reason_json.profile_gate.checks。
"PROFILE_GATE": "实时画像未通过,主动放弃买入",
} }
+233
View File
@@ -458,3 +458,236 @@ def test_metrics_do_not_invent_values() -> None:
}) })
m = compute_metrics(eq, [], load_config("backtest"), "r3") m = compute_metrics(eq, [], load_config("backtest"), "r3")
assert m["sharpe"] is None, "1 个观测算不出波动率,Sharpe 必须是 None" assert m["sharpe"] is None, "1 个观测算不出波动率,Sharpe 必须是 None"
# ---------------------------------------------------------------------------
# 分位参照的最小样本量保护
# ---------------------------------------------------------------------------
def test_min_observations_config_exists_with_sane_default() -> None:
"""回归:分位参考必须设最小样本量,否则退化分布会伪造 100% 分位。
分位 = 「≤当前值的观测占比」。窗口里只有 1 个观测且恰好等于当前值时
占比 100%,击穿任何买入阈值 —— 实测 2015-01-06(行情数据首日)
8 只股票因此被「100% 分位」买入。
"""
from hdiv.core.config import load_config
ref = load_config("backtest").percentile_reference
assert hasattr(ref, "min_observations"), "缺少 min_observations 配置"
assert ref.min_observations >= 60, \
f"最小样本量过低({ref.min_observations}),至少应约一个季度"
def test_engine_guards_against_insufficient_reference_sample() -> None:
"""引擎必须在样本不足时跳过信号,而不是照常算分位。"""
import inspect
from hdiv.backtest import engine as eng
src = inspect.getsource(eng)
assert "min_observations" in src, "引擎未使用 min_observations"
# 保护必须在计算 pct 之前,且以 continue 跳过该股当日
i_guard = src.find("ref_ser.size < self.bt_cfg.percentile_reference.min_observations")
i_pct = src.find("pct = float((ref_ser <= current)")
assert i_guard != -1, "未找到最小样本量判断"
assert i_pct != -1 and i_guard < i_pct, "样本量判断必须早于分位计算"
assert "continue" in src[i_guard:i_pct], "样本不足应跳过(continue)而非降级计算"
@pytest.mark.db
def test_recent_backtests_have_no_weak_sample_trades() -> None:
"""按新配置跑出的回测不应存在弱样本成交(样本 < min_observations)。"""
from hdiv.core.config import load_config
from hdiv.data import db
db.load_dotenv_once()
cfg = load_config("datasource")
min_obs = load_config("backtest").percentile_reference.min_observations
df = db.read_sql(
"SELECT run_id, COUNT(*) AS n FROM hd_backtest_trade "
"WHERE JSON_EXTRACT(reason_json, '$.observation_count') IS NOT NULL "
" AND JSON_EXTRACT(reason_json, '$.observation_count') < :m "
" AND run_id IN (SELECT run_id FROM hd_backtest_run "
" WHERE created_at > '2026-10-03 14:30:00') "
"GROUP BY run_id",
{"m": min_obs}, cfg=cfg,
)
assert df.empty, (
f"存在弱样本成交的回测(应为 0):"
f"{[(r['run_id'][:10], int(r['n'])) for _, r in df.iterrows()]}"
)
# ---------------------------------------------------------------------------
# 实时画像闸门(entry.profile_gate)
# ---------------------------------------------------------------------------
def _trigger_ctx(sym: str = "000001.SZ", n: int = 280) -> dict:
"""构造一个「股息率处于历史最高分位」的最小上下文。
每股分红恒定 1 元、股价从 20 元跌到 10 元 → 股息率从 5% 升到 10%,
当前值即窗口最大值,分位 = 100% ≥ P75,必然触发买入条件。
``n`` 必须 **同时** 满足两个约束:
- ≥ ``backtest.yml: percentile_reference.min_observations``(250,否则引擎跳过);
- ≈ ≤ 410 个自然日(TTM 分红窗口 365 + 宽限 45),否则序列尾部 TTM 分红归零、
股息率变成 0、分位塌到 30% 以下,触发不了买入。280 个交易日 ≈ 392 天,两者都满足。
"""
days = pd.bdate_range("2015-01-05", periods=n)
close = np.concatenate([np.full(n - 50, 20.0), np.linspace(20.0, 10.0, 50)])
px = pd.DataFrame({"open": close, "close": close}, index=days)
ev = pd.DataFrame([{
"ex_date": days[0], "imp_ann_date": days[0], "cash_div_tax": 1.0,
}])
return {"px_by_sym": {sym: px}, "events": {sym: ev}, "_last_day": days[-1].date()}
def _pass_gate(*_a, **_k) -> dict:
return {"verdict": "PASS", "checks": [], "failed": [], "unverifiable": []}
def _reject_gate(*_a, **_k) -> dict:
return {
"verdict": "REJECT",
"checks": [{"metric": "payout_ratio", "stat": "current_value", "op": "<=",
"threshold": 1.0, "actual": 1.4, "status": "OK",
"window_years": 0, "passed": False}],
"failed": ["payout_ratio.current_value<=1"],
"unverifiable": [],
}
def test_gate_rejects_buy(engine, monkeypatch) -> None:
"""闸门不通过时必须改为 REJECT,且不产生 BUY。"""
ctx = _trigger_ctx()
day = ctx["_last_day"]
monkeypatch.setattr(engine, "_gate", _reject_gate)
sigs = engine._evaluate(day, 1e6, {}, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert "BUY" not in kinds, f"闸门未拦住买入:{kinds}"
assert kinds == ["REJECT"]
rej = sigs[0]
assert rej.reason["skip_reason"] == "PROFILE_GATE"
assert rej.reason["executed"] is False
# 「为什么不买」必须可追溯:逐条规则的实际值与阈值都要留下
chk = rej.reason["profile_checks"]["payout_ratio"]
assert chk["actual"] == pytest.approx(1.4) and chk["threshold"] == 1.0
assert chk["passed"] is False
def test_gate_pass_keeps_buy(engine, monkeypatch) -> None:
"""闸门通过时买入必须照常发生(不能误杀)。"""
ctx = _trigger_ctx()
monkeypatch.setattr(engine, "_gate", _pass_gate)
sigs = engine._evaluate(ctx["_last_day"], 1e6, {}, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert kinds == ["BUY"], kinds
def test_gate_reject_leaves_existing_position_untouched(engine, monkeypatch) -> None:
"""闸门语义是「不值得买」,不是「该卖」—— 被拒时不得动已有仓位。"""
ctx = _trigger_ctx()
pos = {"000001.SZ": Position(symbol="000001.SZ", quantity=1000.0, avg_cost=15.0,
first_buy_date=date(2015, 1, 5), last_buy_date=date(2015, 1, 5),
cost_basis=15000.0)}
monkeypatch.setattr(engine, "_gate", _reject_gate)
sigs = engine._evaluate(ctx["_last_day"], 1e6, pos, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert "TRIM" not in kinds and "SELL" not in kinds and "ADD" not in kinds, kinds
assert kinds == ["REJECT"], "高仓位侧被拒时应只留痕,不调仓"
def test_gate_handles_unverifiable_conservatively(engine, monkeypatch) -> None:
"""无法验证(数据缺失/样本不足)时按配置保守处理,且理由要能区分。"""
def _unver(*_a, **_k):
return {"verdict": "REJECT", "checks": [
{"metric": "roe_avg", "stat": "current_value", "op": ">=", "threshold": 0.08,
"actual": None, "status": "INSUFFICIENT", "window_years": 0, "passed": None}],
"failed": [], "unverifiable": ["roe_avg.current_value"]}
ctx = _trigger_ctx()
monkeypatch.setattr(engine, "_gate", _unver)
sigs = engine._evaluate(ctx["_last_day"], 1e6, {}, {"000001.SZ"}, ctx)
assert [s.kind for s in sigs] == ["REJECT"]
assert "无法验证" in sigs[0].reason["rule"]
assert "样本不足" in sigs[0].reason["reason_cn"]
def test_gate_disabled_returns_none(engine) -> None:
"""闸门关闭时 _gate 必须返回 None —— 调用方不产生任何额外行为。"""
from hdiv.backtest.engine import BacktestEngine
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
eng.gate_cfg.enabled = False
eng.pit = object() # 即使被注入也不得被使用
assert eng._gate("000001.SZ", date(2018, 5, 18)) is None
def test_gate_enabled_but_uninitialized_fails_loudly() -> None:
"""启用但未初始化必须报错,不得静默放行(否则等于风控悄悄失效)。"""
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.errors import HdivError
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
eng.gate_cfg.enabled = True
eng.pit = None
with pytest.raises(HdivError) as ei:
eng._gate("000001.SZ", date(2018, 5, 18))
assert "_prepare" in str(ei.value)
@pytest.mark.db
def test_gate_enabled_backtest_records_rejections() -> None:
"""端到端:启用闸门的短区间回测必须留下可追溯的 REJECT 记录且资金对账平衡。"""
from hdiv.backtest.engine import BacktestEngine
try:
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
assert eng.gate_cfg.enabled is True, "默认策略应已启用实时画像闸门"
res = eng.run(start=date(2016, 1, 1), end=date(2016, 12, 31),
persist=False, verbose=False)
except Exception as exc: # 数据不可用
pytest.skip(f"数据库不可用:{exc}")
assert res["reconciliation"]["balanced"] is True
stats = res["profile_gate"]
assert stats["asof_contexts"] > 0, "应产生实时画像时点"
rejects = [s for s in res["signals"] if s.kind == "REJECT"]
assert rejects, "该区间应存在被画像剔除的买入信号"
for s in rejects:
assert s.reason["skip_reason"] == "PROFILE_GATE"
gate = s.reason["profile_gate"]
assert gate["verdict"] in {"REJECT", "UNVERIFIABLE"}
assert gate["checks"], "每条 REJECT 都必须带逐规则留痕"
assert gate["failed"] or gate["unverifiable"]
for c in gate["checks"]:
assert set(c) >= {"metric", "op", "threshold", "actual", "status", "passed"}
@pytest.mark.db
def test_unimplemented_declarations_are_honest() -> None:
"""自我声明必须两头都准:既不能漏报「配置写了但没实现」,
也不能把「这批股票恰好没停牌」误报成「数据缺失」。
背景:2026-10-04 之前,``unimplemented`` 用「过滤后集合为空」判定约束失效,
于是 2026-08~09(hd_suspend 明明覆盖到 2026-09-30,只是这批股票没停牌)
被声明成「hd_suspend 无数据」—— 把自己的建模正常状态说成数据缺陷。
"""
from hdiv.backtest.engine import BacktestEngine
try:
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
res = eng.run(start=date(2026, 8, 3), end=date(2026, 9, 30),
persist=False, verbose=False)
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
decl = " ".join(res["unimplemented"])
# ① 不得把「无停牌」误报成「无数据」(约束表在 2010 起有数据)
assert "无数据" not in decl, f"误报数据缺失:{decl}"
# ② 必须如实声明「配置承诺但未实现」的项
for must in ("defer", "分红再投资", "配股", "成交量占比"):
assert must in decl, f"漏报未实现项 {must}:{decl}"
+125 -4
View File
@@ -11,6 +11,7 @@
from __future__ import annotations from __future__ import annotations
import pandas as pd
import pytest import pytest
from hdiv.cli import build_parser from hdiv.cli import build_parser
@@ -107,10 +108,12 @@ def test_documented_actions_exist(parser, cmd: str, expected: set[str]) -> None:
"cmd,flags", "cmd,flags",
[ [
("sync", {"--only-missing", "--limit", "--symbols", "--apis", ("sync", {"--only-missing", "--limit", "--symbols", "--apis",
"--interleaved", "--start", "--end", "--no-resume", "--no-weight"}), "--interleaved", "--start", "--end", "--no-resume", "--no-weight",
"--basic-start", "--basic-end"}),
("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}), ("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}),
("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}), ("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}),
("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run"}), ("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run",
"--allow-lookahead-universe"}),
("sensitivity", {"-s", "--strategy", "--sweep"}), ("sensitivity", {"-s", "--strategy", "--sweep"}),
("strategy", {"-f", "--file", "--other"}), ("strategy", {"-f", "--file", "--other"}),
("audit", {"--no-persist", "--no-html"}), ("audit", {"--no-persist", "--no-html"}),
@@ -140,13 +143,22 @@ def test_backtest_universe_run_flag(parser) -> None:
"""股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。""" """股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。"""
args = parser.parse_args(["backtest", "--universe-run", "abc123"]) args = parser.parse_args(["backtest", "--universe-run", "abc123"])
assert args.universe_run == "abc123" assert args.universe_run == "abc123"
assert args.allow_lookahead_universe is False, "默认不得放行未来函数"
args2 = parser.parse_args(["backtest"]) args2 = parser.parse_args(["backtest"])
assert args2.universe_run is None assert args2.universe_run is None
def test_backtest_lookahead_override_flag(parser) -> None:
"""放行未来函数必须是一个显式开关,不能是默认行为。"""
a = parser.parse_args(
["backtest", "--universe-run", "abc123", "--allow-lookahead-universe"]
)
assert a.allow_lookahead_universe is True
@pytest.mark.db @pytest.mark.db
def test_frozen_universe_is_used_when_run_id_given() -> None: def test_frozen_universe_is_used_when_run_id_given() -> None:
"""指定 --universe-run 时引擎必须使用该股票池,而不是重新筛选。""" """显式放行时,冻结股票池在各调仓日必须完全相同。"""
from datetime import date from datetime import date
from hdiv.backtest.engine import BacktestEngine from hdiv.backtest.engine import BacktestEngine
@@ -167,7 +179,8 @@ def test_frozen_universe_is_used_when_run_id_given() -> None:
pytest.skip(f"数据库不可用:{exc}") pytest.skip(f"数据库不可用:{exc}")
eng = BacktestEngine.from_strategy( eng = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid "config/strategy/high_dividend_v1.yml", universe_run_id=rid,
allow_lookahead_universe=True,
) )
assert eng.universe_run_id == rid assert eng.universe_run_id == rid
ctx = eng._prepare(eng.repo.trading_days(date(2024, 1, 1), date(2024, 6, 28)), ctx = eng._prepare(eng.repo.trading_days(date(2024, 1, 1), date(2024, 6, 28)),
@@ -177,6 +190,56 @@ def test_frozen_universe_is_used_when_run_id_given() -> None:
assert all(x == sets[0] for x in sets), "冻结股票池在各调仓日必须完全相同" assert all(x == sets[0] for x in sets), "冻结股票池在各调仓日必须完全相同"
@pytest.mark.db
def test_future_universe_is_rejected_by_default() -> None:
"""回归:股票池 asof 晚于回测起点 = 未来函数,必须默认拒绝。
早期实现允许用 2025 年选出的股票池跑 2015 起的单次回测,2015-01-06
就按那份事后名单成交 —— 与 walk-forward 已明确拒绝的做法自相矛盾。
"""
from datetime import date
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.config import load_config
from hdiv.core.errors import HdivError
from hdiv.data import db
db.load_dotenv_once()
try:
cfg = load_config("datasource")
df = db.read_sql(
"SELECT run_id, asof_date FROM hd_universe_run WHERE deleted_at IS NULL "
"ORDER BY asof_date DESC LIMIT 1", cfg=cfg,
)
if df.empty:
pytest.skip("没有筛选记录")
rid = df["run_id"].iloc[0]
uasof = pd.to_datetime(df["asof_date"].iloc[0]).date()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
eng = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
)
early = date(uasof.year - 5, 1, 1)
with pytest.raises(HdivError) as ei:
eng._prepare(eng.repo.trading_days(early, date(uasof.year - 5, 3, 31)), verbose=False)
msg = str(ei.value)
assert "晚于回测起点" in msg
assert "--allow-lookahead-universe" in msg, "错误信息必须给出放行开关"
# 起点晚于股票池 asof 时是合法的:此时股票池属于「事前信息」
later = date(uasof.year + 1, 1, 2)
eng2 = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
)
days = eng2.repo.trading_days(later, date(later.year, 3, 31))
if len(days) >= 2:
ctx = eng2._prepare(days, verbose=False)
assert ctx["universe_by_refresh"], "asof 不晚于起点时应正常使用该股票池"
assert eng2.lookahead_universe_note is None
def test_backtest_mode_choices(parser) -> None: def test_backtest_mode_choices(parser) -> None:
"""手册只承诺 single / walkforward 两种模式。""" """手册只承诺 single / walkforward 两种模式。"""
sub = _subparsers(parser)["backtest"] sub = _subparsers(parser)["backtest"]
@@ -301,3 +364,61 @@ def test_html_flag_exists_on_all_report_producing_commands() -> None:
for cmd in ("universe", "profile", "backtest", "audit", "sensitivity"): for cmd in ("universe", "profile", "backtest", "audit", "sensitivity"):
args = p.parse_args([cmd]) args = p.parse_args([cmd])
assert getattr(args, "html", None) is False, f"hdiv {cmd} --html 默认应为 False" assert getattr(args, "html", None) is False, f"hdiv {cmd} --html 默认应为 False"
def test_walkforward_rejects_universe_run() -> None:
"""回归:--universe-run 与 walk-forward 时序不兼容,必须明确拒绝。
股票池自带 asof(如 2025-01-21),而 walk-forward 窗口从 2015 年就开始训练;
把未来时点选出的股票池套到更早的窗口上等于用未来信息选股。
早期实现没有该参数,于是 --universe-run 被**静默忽略** ——
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
"""
import os
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-m", "hdiv", "backtest",
"--universe-run", "whatever", "--mode", "walkforward"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": "src"},
)
assert r.returncode == 1
out = r.stdout + r.stderr
assert "不支持 --universe-run" in out, out[:400]
assert "未来" in out, "应说明原因(未来函数)"
assert "Traceback" not in out
def test_walkforward_runner_has_no_universe_param() -> None:
"""再确认一层:runner 本身不接受冻结股票池,避免日后被误加回去。"""
import inspect
from hdiv.backtest.walk_forward import WalkForwardRunner
sig = inspect.signature(WalkForwardRunner.__init__)
assert "universe_run_id" not in sig.parameters, (
"WalkForwardRunner 不应接受 universe_run_id —— "
"冻结股票池与 walk-forward 的时序纪律冲突"
)
def test_no_future_warning_on_selector() -> None:
"""回归:object 列的 fillna 曾触发 pandas Downcasting FutureWarning。
用 -W error::FutureWarning 跑一遍筛选路径,确保不再产生该警告
(pandas 未来版本会改变行为,届时结果可能静默变化)。
"""
import os
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-W", "error::FutureWarning", "-m", "hdiv",
"universe", "--asof", "2025-03-21", "--no-persist", "--no-html"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": "src"}, timeout=600,
)
assert "FutureWarning" not in (r.stdout + r.stderr), \
f"仍存在 FutureWarning:{(r.stdout + r.stderr)[-400:]}"
+71
View File
@@ -229,6 +229,77 @@ def test_strategy_bad_status() -> None:
_load_strategy_mutated(lambda r: r["strategy"].update({"status": "RUNNING"})) _load_strategy_mutated(lambda r: r["strategy"].update({"status": "RUNNING"}))
# ---------------------------------------------------------------------------
# 实时画像闸门(profile_gate)配置校验
#
# 这些必须挡在配置期:写错指标名若拖到运行时,只会表现为
# 「无法验证 → 保守不买」,即策略悄悄再也不交易,极难定位。
# ---------------------------------------------------------------------------
def test_profile_gate_unknown_metric_rejected() -> None:
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["rules"] = [
{"metric": "not_a_metric", "op": ">=", "value": 1.0}
]
_load_strategy_mutated(mutate)
def test_profile_gate_percentile_on_scalar_metric_rejected() -> None:
"""标量指标没有历史分位,不能用 current_percentile。"""
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["rules"] = [
{"metric": "payout_ratio", "stat": "current_percentile",
"op": "<=", "value": 1.0}
]
_load_strategy_mutated(mutate)
def test_profile_gate_enabled_without_rules_rejected() -> None:
def mutate(r: dict) -> None:
r["entry"]["profile_gate"] = {"enabled": True, "rules": []}
_load_strategy_mutated(mutate)
def test_profile_gate_bad_operator_rejected() -> None:
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["rules"] = [
{"metric": "dv_yield", "op": "~=", "value": 1.0}
]
_load_strategy_mutated(mutate)
def test_profile_gate_coverage_bounds() -> None:
"""min_window_coverage 必须落在 [0,1]:1.0 = 必须完整覆盖名义窗口。"""
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["min_window_coverage"] = 1.5
_load_strategy_mutated(mutate)
def test_profile_gate_disabled_without_rules_is_allowed() -> None:
"""默认(未启用、无规则)必须能正常加载 —— 否则所有历史配置都会失效。"""
raw = yaml.safe_load(
(config_dir() / "strategy" / "high_dividend_v1.yml").read_text(encoding="utf-8")
)
raw["entry"]["profile_gate"] = {"enabled": False, "rules": []}
import tempfile
from pathlib import Path
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh:
yaml.safe_dump(raw, fh, allow_unicode=True)
tmp = Path(fh.name)
try:
cfg = load_config(f"strategy:{tmp}")
assert cfg.entry.profile_gate.enabled is False
finally:
tmp.unlink(missing_ok=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 可复现性:config_hash 稳定性 # 可复现性:config_hash 稳定性
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+590
View File
@@ -0,0 +1,590 @@
"""实时(Point-in-Time)个股画像测试。
三条必须被锁定的性质:
1. **与批量画像同一定义** —— ``PitProfileService`` 在某个 asof 上算出的指标,
必须与 ``ProfileBuilder.run(asof=...)`` 逐值一致。否则「回测用的画像」和
「页面上看的画像」是两个东西,这正是最难发现的一类错误。
2. **PIT 纪律** —— 未公告的财报、未实施/未除权的分红一律不得影响当日画像。
用一个「公告日前一天 vs 公告日当天」的对照来证明,而不是靠注释。
3. **不猜** —— 指标缺失/样本不足必须报 ``MISSING`` / ``INSUFFICIENT``,
闸门据此判定为「无法验证」并按配置保守处理。
"""
from __future__ import annotations
from datetime import date, timedelta
import pytest
from hdiv.profile.builder import METRIC_META
from hdiv.profile.pit import (
ALL_METRICS,
FINANCIAL_METRICS,
PERCENTILE_METRICS,
ProfileSnapshot,
evaluate_gate,
metrics_needing_financials,
)
# ---------------------------------------------------------------------------
# 非 DB:指标集合与闸门语义
# ---------------------------------------------------------------------------
class TestMetricGroups:
def test_declared_metrics_cover_display_names(self) -> None:
"""有中文展示名的指标必须都能作为闸门条件(否则页面能看、回测不能用)。"""
assert frozenset(METRIC_META) <= ALL_METRICS
def test_groups_are_disjoint_and_complete(self) -> None:
from hdiv.profile.pit import (
DIVIDEND_METRICS,
LIQUIDITY_METRICS,
RETURN_METRICS,
VALUATION_METRICS,
)
groups = [VALUATION_METRICS, RETURN_METRICS, DIVIDEND_METRICS,
FINANCIAL_METRICS, LIQUIDITY_METRICS]
union: set[str] = set()
for g in groups:
assert not (union & g), f"指标分组重叠:{union & g}"
union |= g
assert frozenset(union) == ALL_METRICS
def test_percentile_metrics_are_subset_of_valuation(self) -> None:
"""只有按窗口输出分布统计的指标才有历史分位。"""
assert PERCENTILE_METRICS <= ALL_METRICS
assert "dv_vol_daily" not in PERCENTILE_METRICS, "波动率是标量,没有历史分位"
def test_financial_detection(self) -> None:
assert metrics_needing_financials({"roe_avg"}) is True
# 分红质量指标依赖「最近已公告年报」反推考核财年,因此也算需要财报
assert metrics_needing_financials({"dividend_continuity_years"}) is True
assert metrics_needing_financials({"dv_yield", "pe_ttm"}) is False
class TestGateEvaluation:
def _snap(self, **kw) -> ProfileSnapshot:
s = ProfileSnapshot(symbol="X", asof=date(2018, 5, 18), window_years=5)
s.values.update(kw.get("values", {}))
s.percentiles.update(kw.get("percentiles", {}))
s.status.update(kw.get("status", {}))
s.windows.update({
k: kw.get("window", 5)
# 窗口要对**值指标与分位指标**都设上,否则 window=-1 会让覆盖率检查失效
for k in list(kw.get("values", {})) + list(kw.get("percentiles", {}))
})
s.coverage.update(kw.get("coverage", {}))
return s
def test_short_window_is_unverifiable_when_coverage_enforced(self) -> None:
"""名义 5 年但实际只有 67% 数据时:默认放行,强制覆盖率则拦下。
这是「声称 5 年」与「真有 5 年」的分界。实测 600036.SH 在 2018-05-18
的 5 年窗口只有 817/1219 个交易日(67%),而画像仍报 status=OK。
"""
s = self._snap(
percentiles={"dv_yield": 90.0},
status={"dv_yield": "OK"},
coverage={"dv_yield": 0.67},
)
rules = [{"metric": "dv_yield", "stat": "current_percentile",
"op": ">=", "value": 75}]
assert evaluate_gate(rules, s)["verdict"] == "PASS", "默认不因覆盖率淘汰"
g = evaluate_gate(rules, s, min_window_coverage=1.0)
assert g["verdict"] == "REJECT"
assert g["unverifiable"] == ["dv_yield.window_coverage=67%"]
assert g["checks"][0]["window_coverage"] == pytest.approx(0.67)
def test_full_window_passes_coverage_check(self) -> None:
s = self._snap(
percentiles={"dv_yield": 90.0},
status={"dv_yield": "OK"},
coverage={"dv_yield": 1.0},
)
rules = [{"metric": "dv_yield", "stat": "current_percentile",
"op": ">=", "value": 75}]
g = evaluate_gate(rules, s, min_window_coverage=1.0)
assert g["verdict"] == "PASS" and not g["unverifiable"]
def test_full_history_window_is_exempt_from_coverage(self) -> None:
"""窗口 0(全历史)没有「应有天数」,不得因覆盖率被拦。"""
s = ProfileSnapshot(symbol="X", asof=date(2018, 5, 18), window_years=5)
s.values["roe"] = 0.12
s.status["roe"] = "OK"
s.windows["roe"] = 0
s.coverage["roe"] = 1.0
g = evaluate_gate([{"metric": "roe", "op": ">=", "value": 0.08}], s,
min_window_coverage=1.0)
assert g["verdict"] == "PASS"
def test_all_rules_pass(self) -> None:
s = self._snap(
values={"payout_ratio": 0.4},
percentiles={"dv_yield": 80.0},
status={"payout_ratio": "OK", "dv_yield": "OK"},
)
r = evaluate_gate([
{"metric": "dv_yield", "stat": "current_percentile", "op": ">=", "value": 75},
{"metric": "payout_ratio", "op": "<=", "value": 1.0},
], s)
assert r["verdict"] == "PASS" and not r["failed"]
def test_one_rule_fails_is_reject(self) -> None:
s = self._snap(
values={"payout_ratio": 1.4},
percentiles={"dv_yield": 90.0},
status={"payout_ratio": "OK", "dv_yield": "OK"},
)
r = evaluate_gate([
{"metric": "dv_yield", "stat": "current_percentile", "op": ">=", "value": 75},
{"metric": "payout_ratio", "op": "<=", "value": 1.0},
], s)
assert r["verdict"] == "REJECT"
assert r["failed"] == ["payout_ratio.current_value<=1"]
def test_missing_metric_is_not_treated_as_zero(self) -> None:
"""缺失指标绝不能当作 0 —— 否则 `<= 1.0` 这类规则会永远通过。"""
s = self._snap(values={}, status={})
r = evaluate_gate([{"metric": "payout_ratio", "op": "<=", "value": 1.0}], s)
assert r["verdict"] == "REJECT", "默认必须保守(无法验证即不买)"
assert r["unverifiable"] == ["payout_ratio.current_value"]
assert r["checks"][0]["actual"] is None
def test_unverifiable_can_be_configured_to_pass(self) -> None:
s = self._snap(values={}, status={})
r = evaluate_gate(
[{"metric": "payout_ratio", "op": "<=", "value": 1.0}], s,
on_unverifiable="pass",
)
assert r["verdict"] == "PASS" and r["unverifiable"]
def test_insufficient_sample_is_unverifiable(self) -> None:
s = self._snap(values={"roe": 0.1}, status={"roe": "INSUFFICIENT"})
r = evaluate_gate([{"metric": "roe", "op": ">=", "value": 0.08}], s)
assert r["verdict"] == "REJECT"
assert r["checks"][0]["status"] == "INSUFFICIENT"
def test_none_snapshot_is_unverifiable(self) -> None:
r = evaluate_gate([{"metric": "roe", "op": ">=", "value": 0.08}], None)
assert r["verdict"] == "REJECT" and r["checks"][0]["status"] == "MISSING"
def test_all_comparison_operators(self) -> None:
s = self._snap(values={"x": 5.0}, status={"x": "OK"})
for op, thr, ok in ((">=", 5.0, True), (">", 5.0, False),
("<=", 5.0, True), ("<", 5.0, False)):
r = evaluate_gate([{"metric": "x", "op": op, "value": thr}], s)
assert (r["verdict"] == "PASS") is ok, f"{op} {thr}"
# ---------------------------------------------------------------------------
# DB:与批量画像等价 + PIT 纪律
# ---------------------------------------------------------------------------
def _db_ready():
from hdiv.data import db
db.load_dotenv_once()
return db
@pytest.mark.db
def test_pit_profile_matches_batch_builder() -> None:
"""实时画像必须与 `hdiv profile --asof` 的结果逐值一致(同一定义)。
这是「回测里用的画像」与「页面上看到的画像」不会分叉的唯一保证。
"""
db = _db_ready()
from hdiv.profile.builder import ProfileBuilder
from hdiv.profile.pit import PitProfileService
try:
cfg = db.read_sql(
"SELECT symbol FROM stock WHERE symbol IN ('600036.SH','601398.SH','000651.SZ') "
"ORDER BY symbol LIMIT 3", cfg=__import__("hdiv.core.config", fromlist=["load_config"]).load_config("datasource"),
)
if cfg.empty:
pytest.skip("数据库无样本股票")
syms = cfg["symbol"].tolist()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
asof = date(2018, 5, 18)
batch = ProfileBuilder.from_config()
svc = PitProfileService(window_years=5)
svc.prepare(syms, date(2004, 1, 1), date(2026, 9, 30))
for sym in syms:
res = batch.run(symbols=[sym], asof=asof, persist=False, verbose=False,
return_rows=True)
want = {
(r["metric_code"], int(r["window_years"])): r["current_value"]
for r in res["stat_rows"] if r["current_value"] is not None
}
snap = svc.snapshot(sym, asof)
assert snap is not None, f"{sym} 应能算出画像"
# 反向守护:实时画像不得产出未声明的指标代码(否则闸门配置无从校验)
assert set(snap.status) <= ALL_METRICS, (
f"未声明的指标:{set(snap.status) - ALL_METRICS}"
)
for (code, wy), v in want.items():
if wy not in (0, 5):
continue
# 实时画像每个指标只保留一个窗口(配置窗口优先)
got, status, got_wy = snap.get(code)
if got is None:
continue
if got_wy != wy:
continue
assert got == pytest.approx(v, rel=1e-9), (
f"{sym} {code} window={wy}: 实时画像 {got} != 批量画像 {v}"
)
@pytest.mark.db
def test_pit_profile_excludes_unannounced_report() -> None:
"""PIT 纪律:公告日前一天不得看到该年报的 ROE。
反例证明:若实现漏了 `ann_date <= asof`,公告日前后两个快照
会给出同一个 ROE(都用了新财报),测试即失败。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
sql = (
"SELECT symbol, end_date, ann_date, roe FROM hd_fina_indicator "
"WHERE MONTH(end_date) = 12 AND roe IS NOT NULL AND ann_date >= end_date "
" AND ann_date >= '2016-01-01' AND ann_date <= '2022-12-31' "
"ORDER BY symbol, ann_date"
)
df = db.read_sql(sql, cfg=load_config("datasource"))
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
if df.empty:
pytest.skip("没有可用的年报样本")
import pandas as pd
df = df.sort_values(["symbol", "ann_date"])
sym = None
row = None
for s, g in df.groupby("symbol"):
g = g.sort_values("ann_date")
if len(g) >= 2:
sym, row = s, g.iloc[1]
break
if sym is None:
pytest.skip("没有「至少两期年报」的样本")
ann = pd.to_datetime(row["ann_date"]).date()
svc = PitProfileService(window_years=5)
svc.prepare([sym], date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"roe"})
before = svc.snapshot(sym, ann - timedelta(days=1))
after = svc.snapshot(sym, ann)
if before is None or after is None:
pytest.skip(f"{sym} 在 {ann} 前后无行情")
roe_before, st_before, _ = before.get("roe")
roe_after, st_after, _ = after.get("roe")
if roe_before is None or roe_after is None:
pytest.skip(f"{sym} 缺少 ROE 数据")
assert roe_after == pytest.approx(float(row["roe"]) / 100.0, rel=1e-6), (
"公告日当天应已能看到该年报"
)
assert roe_before != pytest.approx(roe_after, rel=1e-12), (
f"公告日({ann})前一天不得看到该年报的 ROE —— 否则是未来函数"
)
@pytest.mark.db
def test_pit_profile_excludes_future_dividend() -> None:
"""PIT 纪律:未除权的分红不得进入当日 TTM 股息率。"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
df = db.read_sql(
"SELECT symbol, ex_date, cash_div_tax FROM hd_dividend "
"WHERE div_proc='实施' AND cash_div_tax > 0.2 AND ex_date >= '2016-01-01' "
" AND ex_date <= '2022-12-31' ORDER BY cash_div_tax DESC LIMIT 5",
cfg=load_config("datasource"),
)
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
if df.empty:
pytest.skip("没有分红样本")
import pandas as pd
sym = df.iloc[0]["symbol"]
ex = pd.to_datetime(df.iloc[0]["ex_date"]).date()
svc = PitProfileService(window_years=5)
svc.prepare([sym], date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"ttm_dps"})
before = svc.snapshot(sym, ex - timedelta(days=1))
after = svc.snapshot(sym, ex)
if before is None or after is None:
pytest.skip(f"{sym} 在除权日 {ex} 前后无行情")
v_before, _, _ = before.get("ttm_dps")
v_after, _, _ = after.get("ttm_dps")
if v_before is None or v_after is None:
pytest.skip(f"{sym} 缺少 TTM DPS")
assert v_after >= v_before, "除权日当天 TTM 分红应把新分红计入"
assert v_after != pytest.approx(v_before, rel=1e-12), (
f"除权日({ex})前一天不得包含该笔分红 —— 否则是未来函数"
)
@pytest.mark.db
def test_pit_profile_rejects_unavailable_window() -> None:
"""请求 profile.yml 未定义的窗口必须报错,而不是悄悄退回全历史。"""
_db_ready()
from hdiv.core.errors import HdivError
from hdiv.profile.pit import PitProfileService
with pytest.raises(HdivError) as ei:
PitProfileService(window_years=3)
assert "windows_years" in str(ei.value)
@pytest.mark.db
def test_pit_profile_is_lazy_and_reuses_panels() -> None:
"""惰性 + 面板复用:这是「长周期数据沿用、触发时才计算」的落地证据。
- 只配估值类规则时,绝不触碰财报表(各约 30 万行);
- 同一 asof 上多只股票复用同一份时点面板(而不是每股查一次库);
- 同一 (股票, asof) 第二次调用直接命中缓存。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol IN "
"('600036.SH','601398.SH','000651.SZ','600519.SH') ORDER BY symbol",
cfg=load_config("datasource"),
)
if rows.empty:
pytest.skip("数据库无样本股票")
syms = rows["symbol"].tolist()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
asof = date(2020, 6, 30)
svc = PitProfileService(window_years=5)
svc.prepare(syms, date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"dv_yield", "pe_ttm", "pb"}) # 纯估值:不需要任何财报
snaps = [svc.snapshot(s, asof) for s in syms]
assert all(x is not None for x in snaps)
st = svc.stats()
assert st["financial_loads"] == 0, "纯估值规则不得载入财报面板"
assert st["liquidity_loads"] == 0, "未用到成交额时不得查询流动性"
assert st["asof_contexts"] == 1, "同一 asof 只应构建一次时点面板"
assert st["snapshots_computed"] == len(syms)
before = st["snapshots_cached"]
svc.snapshot(syms[0], asof)
assert svc.stats()["snapshots_cached"] == before + 1, "重复调用必须命中缓存"
# 需要财报的指标才会付出那次查询(并且同一 asof 只付一次)
svc2 = PitProfileService(window_years=5)
svc2.prepare(syms, date(2004, 1, 1), date(2026, 9, 30))
svc2.configure({"roe_avg"})
for s in syms:
svc2.snapshot(s, asof)
assert svc2.stats()["financial_loads"] == 1, "同一 asof 的财报面板只应载入一次"
# ---------------------------------------------------------------------------
# 窗口覆盖率:分母必须与 window_slice 的左开右闭口径一致
# ---------------------------------------------------------------------------
class _StubRepo:
"""只提供 trading_days 的最小替身(纯函数测试,不碰数据库)。"""
def __init__(self, days: list[date]) -> None:
self._days = days
def trading_days(self, start: date, end: date) -> list[date]:
return [d for d in self._days if start <= d <= end]
class TestWindowCoverage:
def test_left_endpoint_is_excluded_like_window_slice(self) -> None:
"""交易日历取闭区间,而 window_slice 是 `> start` —— 左端点要减掉。
不减会让覆盖率永远差一天,`min_window_coverage=1.0` 就变成「永远拒绝」。
"""
from hdiv.profile.coverage import expected_trading_days, window_start
asof = date(2021, 1, 4)
lo = window_start(asof, 1) # 2020-01-03
repo = _StubRepo([lo, date(2020, 1, 6), date(2020, 12, 31), asof])
# 闭区间 4 天,去掉左端点 → 3 天
assert expected_trading_days(repo, asof, 1) == 3
def test_left_endpoint_not_a_trading_day(self) -> None:
from hdiv.profile.coverage import expected_trading_days
asof = date(2021, 1, 4)
repo = _StubRepo([date(2020, 1, 6), date(2020, 12, 31), asof])
assert expected_trading_days(repo, asof, 1) == 3
def test_full_history_has_no_denominator(self) -> None:
from hdiv.profile.coverage import expected_trading_days
repo = _StubRepo([date(2020, 1, 6)])
assert expected_trading_days(repo, date(2021, 1, 4), 0) == 0
def test_coverage_ratio_is_capped_at_one(self) -> None:
from hdiv.profile.coverage import coverage_ratio
assert coverage_ratio(100, 200) == pytest.approx(0.5)
assert coverage_ratio(250, 200) == 1.0, "多出来的观测不放大覆盖率"
assert coverage_ratio(10, 0) is None, "没有分母时返回 None(不适用)"
def test_format_warning_only_when_short(self) -> None:
"""只列**不足**的窗口,不要因为 10 年窗口不足就说成「5 年数据不足」。"""
from hdiv.profile.coverage import format_warning
assert format_warning({}) is None
assert format_warning({"windows": {5: {"min": 1.0, "n": 9}}}) is None
msg = format_warning({"windows": {5: {"min": 0.67, "n": 9}}})
assert msg is not None and "67.0%" in msg and "5 年窗口" in msg
mixed = format_warning({
"windows": {5: {"min": 1.0, "n": 9}, 10: {"min": 0.34, "n": 7}},
})
assert mixed is not None
assert "10 年窗口" in mixed
assert "5 年窗口" not in mixed, "已达标的窗口不该出现在警告里"
@pytest.mark.db
def test_real_window_coverage_grows_with_asof() -> None:
"""真实数据:5 年窗口的覆盖率随数据积累而上升,2020 起才满覆盖。
行情/每日指标自 2015-01-05 才有,因此任何早于 2020-01 的 asof,
其「5 年窗口」都是被截短的 —— 这是**数据事实**,不是代码问题,
但必须能被看见。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol = '600036.SH'", cfg=load_config("datasource")
)
if rows.empty:
pytest.skip("无样本股票")
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
svc = PitProfileService(window_years=5)
svc.prepare(["600036.SH"], date(2015, 1, 1), date(2026, 9, 30))
svc.configure({"dv_yield"})
vals = {}
for a in ("2016-12-30", "2018-05-18", "2021-06-30"):
s = svc.snapshot("600036.SH", date.fromisoformat(a))
if s is None:
pytest.skip(f"{a} 无行情")
vals[a] = s.coverage["dv_yield"]
assert vals["2016-12-30"] < 0.5, f"2016 年 5 年窗口应严重不足:{vals}"
assert vals["2018-05-18"] == pytest.approx(0.67, abs=0.02)
assert vals["2021-06-30"] >= 0.999, f"2021 年应已满覆盖:{vals}"
# n_obs 必须一并暴露 —— 它是判断「窗口是否被截断」的原始依据
s = svc.snapshot("600036.SH", date(2018, 5, 18))
assert s.n_obs["dv_yield"] == 817, "实测 2018-05-18 的 5 年窗口为 817 个观测"
@pytest.mark.db
def test_snapshot_ignores_input_row_order() -> None:
"""回归:画像的「当日值」必须由**日期**决定,不能受输入行序影响。
2026-10-04 回补 2005-2014 时,新行是**追加**进 ``daily_basic`` 的,
同一股票的物理行序变成「2015-2026 在前、2005-2014 在后」;
而当时 ``_load_daily_basic`` 没有 ``ORDER BY``、``_profile_one`` 用
``iloc[-1]`` 取当日值 —— 于是格力电器 2018-05-18 的 PE(TTM)
被取成 2014 年的 8.51(真值 12.12)。这个测试把该不变量钉死:
**随机打乱输入面板的行序,结果必须逐值不变。**
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol = '000651.SZ'",
cfg=load_config("datasource"),
)
if rows.empty:
pytest.skip("无样本股票")
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
asof = date(2018, 5, 18)
sym = "000651.SZ"
def _snapshot(shuffle_seed: int | None) -> ProfileSnapshot:
svc = PitProfileService(window_years=5)
svc.prepare([sym], date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"pe_ttm", "pb", "dv_yield"})
if shuffle_seed is not None:
# 直接打乱内部面板:模拟「行序不是日期序」
svc._basics = svc._basics.sample(frac=1.0, random_state=shuffle_seed)
svc._price = svc._price.sample(frac=1.0, random_state=shuffle_seed + 1)
s = svc.snapshot(sym, asof)
assert s is not None
return s
ref = _snapshot(None)
for seed in (1, 7, 42):
got = _snapshot(seed)
for metric in ("pe_ttm", "pb", "dv_yield"):
if metric in ref.values and metric in got.values:
assert got.values[metric] == pytest.approx(ref.values[metric], rel=1e-9), (
f"打乱输入行序后 {metric} 变了:{got.values[metric]} != {ref.values[metric]}"
)
# 顺带断言该日 PE(TTM) 就是 12.12 那个量级(防止排序修好后取到错窗口)
assert ref.values["pe_ttm"] == pytest.approx(12.115, rel=1e-3), ref.values["pe_ttm"]
@pytest.mark.db
def test_daily_basic_loader_is_date_sorted() -> None:
"""``_load_daily_basic`` 必须返回按 (symbol, trade_date) 有序的帧。
下游把「最后一个观测」当作当日值 —— 有序性是**语义前提**,不是可选优化。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.builder import ProfileBuilder
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol IN ('000651.SZ','600036.SH') ORDER BY symbol",
cfg=load_config("datasource"),
)
if rows.empty:
pytest.skip("无样本股票")
syms = rows["symbol"].tolist()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
df = ProfileBuilder.from_config()._load_daily_basic(syms, date(2005, 1, 1), date(2026, 9, 30))
assert not df.empty
for sym, g in df.groupby("symbol", sort=False):
assert g["trade_date"].is_monotonic_increasing, f"{sym} 的行序不是日期序"
assert df["trade_date"].min().date() <= date(2005, 1, 10), "回补后应包含 2005 年数据"
+6 -2
View File
@@ -188,7 +188,8 @@ def test_price_frames_map_tushare_columns() -> None:
assert list(d.columns) == ["symbol", "trade_date", "open", "high", "low", assert list(d.columns) == ["symbol", "trade_date", "open", "high", "low",
"close", "volume", "amount", "source", "adjust"] "close", "volume", "amount", "source", "adjust"]
assert d.iloc[0]["symbol"] == "000001.SZ" assert d.iloc[0]["symbol"] == "000001.SZ"
assert d.iloc[0]["volume"] == 100, "Tushare 的 vol 列映射为 volume" assert d.iloc[0]["volume"] == 10000, "Tushare 的 vol(手)必须换算为股(×100)"
assert d.iloc[0]["amount"] == 1000000, "Tushare 的 amount(千元)必须换算为元(×1000)"
a = adj_frame([{"ts_code": "000001.SZ", "trade_date": "20150105", "adj_factor": 1.2}]) a = adj_frame([{"ts_code": "000001.SZ", "trade_date": "20150105", "adj_factor": 1.2}])
assert a.iloc[0]["factor"] == 1.2 assert a.iloc[0]["factor"] == 1.2
@@ -243,7 +244,10 @@ def test_per_api_limits_are_independent() -> None:
cfg = __import__("hdiv.core.config", fromlist=["load_config"]).load_config("datasource") cfg = __import__("hdiv.core.config", fromlist=["load_config"]).load_config("datasource")
ts = cfg.tushare ts = cfg.tushare
assert ts.limit_for("dividend") == 180 assert ts.limit_for("dividend") == 180
assert ts.limit_for("daily") == 480 # daily 系列的额度必须**低于实测上限**:以 ~196 次/分钟跑 daily 会被 Tushare
# 拒绝,触发限频后要冷却 62 秒,逐日回补时远慢于平滑配速(见 datasource.yml 注释)
assert 100 <= ts.limit_for("daily") <= 200, ts.limit_for("daily")
assert ts.limit_for("daily") == ts.limit_for("adj_factor") == ts.limit_for("daily_basic")
assert ts.limit_for("未知接口") == ts.rate_limit_default assert ts.limit_for("未知接口") == ts.rate_limit_default
# 构造客户端需要 token;此处仅验证配置层 # 构造客户端需要 token;此处仅验证配置层
assert ts.rate_limit_cooldown_sec >= 60, "冷却必须覆盖 Tushare 的 60 秒滑动窗口" assert ts.rate_limit_cooldown_sec >= 60, "冷却必须覆盖 Tushare 的 60 秒滑动窗口"
+138
View File
@@ -0,0 +1,138 @@
"""量价单位归一化(``stock_daily`` 的历史遗留混用)测试。
背景:``stock_daily`` 里 2015-01~2019 的行是 Tushare 原始单位(手 / 千元),
2020 起沿用既有 qlib 存量(股 / 元),2019 年同日混着两种。
按「元」配置的流动性阈值因此把早年低估 1000 倍,会把股票池整体清空。
这些测试锁定「读取层必须幂等地归一化」这一契约。
"""
from __future__ import annotations
import pandas as pd
import pytest
from hdiv.data.units import (
OHLCV_CONVERTED,
OHLCV_UNKNOWN,
amount_qian_to_yuan,
detect_ohlcv_units,
normalize_ohlcv_units,
ohlcv_unit_ratio,
vol_shou_to_shares,
)
def _row(symbol: str, close: float, shares: float, **kw) -> dict:
"""按「股 / 元」口径构造一行(即换算后的目标形态)。"""
return {
"symbol": symbol,
"close": close,
"volume": shares,
"amount": shares * close,
**kw,
}
def _raw_row(symbol: str, close: float, shares: float, **kw) -> dict:
"""按 Tushare 原始口径构造一行:成交量为手、成交额为千元。
``shares`` 是真实股数。手 = 股/100;千元 = (股 × 价)/1000。
"""
return {
"symbol": symbol,
"close": close,
"volume": shares / 100.0,
"amount": shares * close / 1000.0,
**kw,
}
class TestScalarConversions:
def test_vol_shou_to_shares(self) -> None:
assert vol_shou_to_shares(pd.Series([100.0])).iloc[0] == 10000.0
def test_amount_qian_to_yuan(self) -> None:
assert amount_qian_to_yuan(pd.Series([1000.0])).iloc[0] == 1_000_000.0
class TestUnitRatio:
def test_converted_rows_ratio_is_one(self) -> None:
df = pd.DataFrame([_row("000001.SZ", 10.0, 1e6)])
assert ohlcv_unit_ratio(df).iloc[0] == pytest.approx(1.0)
def test_raw_rows_ratio_is_point_one(self) -> None:
# 手 / 千元:volume 是股数/100,amount 是元/1000 → 比值 1/10
df = pd.DataFrame([_raw_row("000001.SZ", 10.0, 1e6)])
assert ohlcv_unit_ratio(df).iloc[0] == pytest.approx(0.1)
def test_ratio_tolerates_intraday_move(self) -> None:
"""VWAP 与收盘价相差 ±10%(涨跌停)时不得误判单位。"""
df = pd.DataFrame([
_row("A", 10.0, 1e6, amount=1e6 * 11.0), # VWAP 高于收盘 10%
_row("B", 10.0, 1e6, amount=1e6 * 9.0), # VWAP 低于收盘 10%
])
assert list(detect_ohlcv_units(df)) == [OHLCV_CONVERTED, OHLCV_CONVERTED]
def test_degenerate_rows_are_unknown(self) -> None:
df = pd.DataFrame([
{"symbol": "A", "close": 0.0, "volume": 100.0, "amount": 1000.0},
{"symbol": "B", "close": 10.0, "volume": 0.0, "amount": 1000.0},
{"symbol": "C", "close": 10.0, "volume": 100.0, "amount": float("nan")},
])
assert list(detect_ohlcv_units(df)) == [OHLCV_UNKNOWN] * 3
class TestNormalizeOhlcvUnits:
def test_raw_rows_are_converted(self) -> None:
raw = pd.DataFrame([{"symbol": "000001.SZ", "close": 9.80,
"volume": 417732.0, "amount": 412636.0}])
out, diag = normalize_ohlcv_units(raw)
assert diag["raw"] == 1 and diag["fixed"] == 1
# 41,773,200 股 × 9.8784 ≈ 4.126 亿元
assert out.iloc[0]["volume"] == pytest.approx(41_773_200.0)
assert out.iloc[0]["amount"] == pytest.approx(412_636_000.0)
assert out.iloc[0]["amount"] / out.iloc[0]["volume"] == pytest.approx(9.878, abs=0.01)
def test_is_idempotent(self) -> None:
raw = pd.DataFrame([_raw_row("A", 10.0, 1e6)])
once, diag1 = normalize_ohlcv_units(raw)
assert diag1["fixed"] == 1
twice, diag2 = normalize_ohlcv_units(once)
pd.testing.assert_frame_equal(once, twice)
assert diag2["raw"] == 0, "已换算的行不得被二次换算"
def test_mixed_units_within_one_date(self) -> None:
"""2019 年同日两种单位并存(实测 3596 行里 237 行已换算)。"""
df = pd.DataFrame([
_raw_row("RAW", 10.0, 1e6),
_row("CONV", 10.0, 1e6),
])
out, diag = normalize_ohlcv_units(df)
assert diag["raw"] == 1 and diag["converted"] == 1
for i in out.index:
assert out.at[i, "amount"] / (out.at[i, "volume"] * out.at[i, "close"]) == pytest.approx(1.0)
def test_does_not_mutate_input(self) -> None:
raw = pd.DataFrame([_raw_row("A", 10.0, 1e6)])
before = raw.copy()
normalize_ohlcv_units(raw)
pd.testing.assert_frame_equal(raw, before)
def test_empty_frame(self) -> None:
out, diag = normalize_ohlcv_units(pd.DataFrame())
assert out.empty and diag["total"] == 0
def test_missing_column_is_reported_not_guessed(self) -> None:
"""缺 close 时无法判定单位 —— 必须原样返回并说明,不得瞎猜。"""
df = pd.DataFrame([{"symbol": "A", "volume": 1e4, "amount": 1e5}])
out, diag = normalize_ohlcv_units(df)
pd.testing.assert_frame_equal(out, df)
assert "error" in diag and diag["fixed"] == 0
def test_custom_column_names(self) -> None:
df = pd.DataFrame([_raw_row("A", 10.0, 1e6)].copy())
df = df.rename(columns={"volume": "vol", "amount": "amt", "close": "px"})
out, diag = normalize_ohlcv_units(df, volume_col="vol", amount_col="amt", close_col="px")
assert diag["fixed"] == 1
assert out.iloc[0]["vol"] == pytest.approx(1e6)
assert out.iloc[0]["amt"] == pytest.approx(1e7)