修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 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:
@@ -74,12 +74,38 @@ docs/ 文档
|
||||
|
||||
**本系统的实测结论是:策略没有稳定的样本外超额收益。**
|
||||
|
||||
全期回测(2015–2026)显示 +114.80% / CAGR 6.73%,
|
||||
但 7 窗口 Walk-forward 的样本外收益均值仅 **−0.95%**(基准 +2.29%,超额 **−3.24pp**)。
|
||||
全期回测(2015–2026)显示 +92.73% / CAGR 5.75%,
|
||||
但 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 的样本外结果为主要依据,不要采信单条路径的全期数字。**
|
||||
详见[实施状态 §4.6](docs/implementation-status.md)。
|
||||
|
||||
|
||||
+24
-3
@@ -67,9 +67,14 @@ tushare:
|
||||
index_weight: 180
|
||||
suspend_d: 180
|
||||
stk_limit: 180
|
||||
daily: 480
|
||||
adj_factor: 480
|
||||
daily_basic: 480
|
||||
# daily/adj_factor/daily_basic:**不要设成 480**。
|
||||
# 2026-10-04 回补 2005-2014 时实测:以约 196 次/分钟跑 `daily`,
|
||||
# 300 次调用后即被 Tushare 拒绝(此 token 的实际上限 ≤ 200/min)。
|
||||
# 一旦触发限频,客户端要冷却 62 秒再重试 —— 逐日回补 2,400+ 天时,
|
||||
# 这种「撞墙再等」远比「按额度平滑配速」慢,而且有耗尽 4 次重试的风险。
|
||||
daily: 170
|
||||
adj_factor: 170
|
||||
daily_basic: 170
|
||||
index_daily: 180
|
||||
retry: 4
|
||||
retry_backoff_sec: 2
|
||||
@@ -84,3 +89,19 @@ paths:
|
||||
templates_dir: templates
|
||||
assets_dir: assets
|
||||
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
|
||||
|
||||
@@ -38,6 +38,38 @@ entry:
|
||||
# 必须未进入风险状态
|
||||
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)。留空 => 一次性建满。
|
||||
scale_in:
|
||||
- { percentile: 75, weight: 0.25 }
|
||||
|
||||
+741
-72
@@ -9,10 +9,18 @@
|
||||
|
||||
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
|
||||
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
|
||||
全链路打通并通过 262 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
|
||||
数据同步仍在后台补齐最后 ~15%(分红 99.6%、财报 ~87%),
|
||||
但这不影响系统功能 —— 它只影响股票池的绝对规模。
|
||||
**2026-10-03 完成三项正确性改造**(详见 §9):修复 `stock_daily` 量价单位不一致、
|
||||
拒绝「用未来时点的股票池跑更早区间」、新增**实时(PIT)个股画像闸门**。
|
||||
三项都让「交易依据更符合实际」,且在 walk-forward 样本外口径下**都改善了绩效**
|
||||
(样本外均值 −0.95% → **+1.16%**,最差回撤 −24.62% → **−22.97%**;
|
||||
2026-10-04 又把行情回补到 2005,样本外逐窗口未变、单路径则从 +117.36% 降到 +92.73%),
|
||||
但**仍未取得正超额**(−1.12pp)。
|
||||
|
||||
数据层已补齐:行情/每日指标/复权因子覆盖 **2005-01-04 起**,
|
||||
停牌与涨跌停价覆盖 **2010-01-04 起**,分红 1990 起、四张财报表 1990/2001 起。
|
||||
审计 `FAIL=0`(14 OK / 5 WARN,WARN 均为已知且已声明)。
|
||||
|
||||
---
|
||||
|
||||
@@ -22,13 +30,13 @@
|
||||
|
||||
| 缺口 | 状态 | 实测结果 |
|
||||
|---|---|---|
|
||||
| **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 253,879 行(其中 56,513 条实施且有现金分红),覆盖 1990–2026 |
|
||||
| **G2 日线行情** | ✅ 完成 | `stock_daily` 起点 **2015-01-05**(2015 年首个交易日),2,838 个交易日;**2019 年空洞已补齐**(+89 万行) |
|
||||
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,2,840 个交易日 |
|
||||
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 2015-01-05,2,286 个交易日 |
|
||||
| **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 267,295 行,覆盖 1990–2026 |
|
||||
| **G2 日线行情** | ✅ 完成 | `stock_daily` **起点 2005-01-04**(2026-10-04 回补,+3,927,792 行),16,007,169 行 / 5,265 个交易日 |
|
||||
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,16,789,137 行 / 5,267 个交易日 |
|
||||
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 **2005-01-04**(+4,309,461 行),15,972,821 行 / 5,275 个交易日 |
|
||||
| **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
|
||||
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
|
||||
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` 67,765 行;`hd_limit` **669 万行** |
|
||||
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` **468,388 行**、`hd_limit` **14,837,155 行**,均覆盖 **2010-01-04** 起(2026-10-04 回补,各 +400,623 / +5,787,253 行) |
|
||||
|
||||
### 1.2 数据库(30 张 `hd_*` 表)
|
||||
|
||||
@@ -44,7 +52,7 @@
|
||||
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
|
||||
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
|
||||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
|
||||
| 数据审计 | `data/audit.py`(18 项检查) | ✅ |
|
||||
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
|
||||
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
|
||||
| 因子 | `factor/dividend_yield.py` | ✅ |
|
||||
| 个股画像 | `profile/builder.py` | ✅ |
|
||||
@@ -115,46 +123,116 @@
|
||||
> 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、
|
||||
> 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、
|
||||
> 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。
|
||||
>
|
||||
> ⚠️ **口径变更史**:2026-10-03 三项改造(量价单位修复、`--universe-run`
|
||||
> 未来函数守卫、实时画像闸门默认启用)→ 2026-10-04 **行情回补到 2005**
|
||||
> (见 §9.3c,让 5/8/10 年窗口首次真正完整)。
|
||||
>
|
||||
> **结论先行 —— 两个口径给出相反答案,以 walk-forward 为准**:
|
||||
>
|
||||
> | 口径 | 闸门开 | 闸门关 |
|
||||
> |---|---:|---:|
|
||||
> | 全期单路径总收益 | +92.73% | **+101.20%** ← 单路径说闸门有害 |
|
||||
> | Walk-forward 样本外均值 | **+1.16%** | −0.00% ← 样本外说闸门有益 |
|
||||
> | 样本外最差回撤 | **−22.97%** | −24.22% |
|
||||
>
|
||||
> **而回补数据只改变了单条路径,没有改变任何样本外结果** ——
|
||||
> 单路径从 +117.36% 掉到 +92.73%,样本外 7 个窗口**逐窗口一个数字都没变**。
|
||||
> 原因见 §4.6d:样本外的测试窗口是 2020–2026,其参照窗口本就落在
|
||||
> 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
|
||||
> 过去被数据缺口「挡住」的 2015 年(大牛市 + 股灾)现在会被真实交易。
|
||||
|
||||
### 4.1 与基准对比
|
||||
### 4.1 与基准对比(回补后重跑)
|
||||
|
||||
| 指标 | **策略** | 沪深300 | 中证红利 | 上证指数 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 总收益 | **+114.80%** | +19.66% | +53.35% | +14.67% |
|
||||
| 年化 CAGR | **+6.73%** | +1.54% | +3.71% | +1.17% |
|
||||
| 最大回撤 | **−21.05%** | −46.70% | −46.51% | −52.30% |
|
||||
| Sharpe | 0.31 | — | — | — |
|
||||
| 指标 | **策略(闸门开)** | 策略(闸门关) | 沪深300 | 中证红利 | 上证指数 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 总收益 | **+92.73%** | +101.20% | +19.66% | +53.35% | +14.67% |
|
||||
| 年化 CAGR | **+5.75%** | +6.14% | +1.54% | +3.71% | +1.17% |
|
||||
| 最大回撤 | **−25.38%** | −25.54% | −46.70% | −46.51% | −52.30% |
|
||||
| Sharpe | 0.22 | 0.23 | — | — | — |
|
||||
| Calmar | 0.23 | 0.24 | — | — | — |
|
||||
| 成交笔数 | 197 | 225 | — | — | — |
|
||||
|
||||
**策略跑赢天然基准「中证红利」61 个百分点,而回撤不到其一半。**
|
||||
`run_id`:闸门开 `fa916050ffb647b6ab4cfd90ba1adf36`,
|
||||
闸门关 `5347fd13dd8fc0b0a512156489c615bc`(两次资金对账残差均为 0)。
|
||||
|
||||
> ⚠ **但这个数字有严重误导性,请看 §4.6 的 Walk-forward 结果。**
|
||||
> 单条路径的全期回测会把「持有可能穿越熊市并在后期回本」的效应放大,
|
||||
> 而逐年样本外检验给出的是一幅**完全不同的图景**。
|
||||
**策略仍跑赢天然基准「中证红利」,回撤不到其一半** —— 但见 §4.6:
|
||||
单条路径的全期数字有严重误导性。
|
||||
|
||||
> **回补数据让全期收益*下降*了 24.6pp(+117.36% → +92.73%)。**
|
||||
> 这不是 bug,而是把「假象」修掉了:回补前 2015 年因缺少历史分布
|
||||
> 而 100% 现金(收益 0.00%),回补后 2015 年从第一个交易日起就被
|
||||
> 真实交易,而那年**亏了 3.18%**(大牛市顶部建仓 + 股灾)。
|
||||
> 靠数据缺口「躲过股灾」不是策略能力 —— 详情见 §4.4。
|
||||
|
||||
> **闸门到底拦掉了什么**:全期 141 个决策时点、2428 次画像计算、
|
||||
> **1027 次买入信号被剔除**(`REJECT`,可在「未成交信号」里逐条查看原因)。
|
||||
> 剔除使成交从 225 笔降到 197 笔,收益下降 8.5pp,回撤基本不变。
|
||||
|
||||
> 该结果是在**修正了支付率与 FCF 覆盖两个筛选条件之后**取得的
|
||||
> (此前这两个条件因 `base_share` 漏选而静默失效)。修正后股票池由 49 只
|
||||
> 收紧到 28 只,收益反而提高 —— 说明「分红可持续性」这一安全边际条件
|
||||
> 确实在筛选优质标的。
|
||||
> (此前这两个条件因 `base_share` 漏选而静默失效)。
|
||||
|
||||
### 4.2 交易与分红
|
||||
### 4.2 交易与分红(闸门开)
|
||||
|
||||
| 项目 | 数值 |
|
||||
|---|---:|
|
||||
| 成交笔数 | 180 |
|
||||
| 累计现金分红 | 558,797 元(占期初资金 55.9%) |
|
||||
| 已扣红利税 | 13,676 元 |
|
||||
| 期末资金 | 2,148,010 元 |
|
||||
| 成交笔数 | 197 |
|
||||
| 期末资金 | 1,927,312 元 |
|
||||
| **资金对账残差** | **0.0000** ✅ |
|
||||
| 画像剔除的买入信号 | 1027 |
|
||||
| 画像计算次数 / 决策时点 | 2428 / 141 |
|
||||
|
||||
### 4.3 逐年收益
|
||||
### 4.3 逐年收益(回补后重跑,闸门开)
|
||||
|
||||
| 年 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 策略 | +18.56% | +6.64% | +11.85% | **−3.99%** | +1.25% | +22.22% | +14.67% | +7.32% |
|
||||
| 年 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|
||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
||||
| 策略(闸门开) | **−3.18%** | +5.91% | +28.25% | **−10.86%** | +39.52% | −1.97% | +3.05% | **−14.13%** | +4.37% | +13.37% | +11.23% | +2.43% |
|
||||
| 闸门关 | −1.26% | +7.04% | +26.83% | −9.95% | +38.22% | +0.14% | +2.99% | −14.35% | +4.56% | +11.06% | +12.00% | +4.30% |
|
||||
|
||||
11.7 年中仅 2022 一年为负,且跌幅远小于同期市场。
|
||||
**2015 年不再是 0,而是 −3.18%(闸门开)/ −1.26%(闸门关)。**
|
||||
这正是全期收益下降 24.6pp 的来源:回补前 2015 年因数据缺口无法产生信号
|
||||
(100% 现金、收益 0.00%),回补后它被真实交易,而在牛市顶部建仓
|
||||
随后遭遇股灾是亏钱的。**「靠数据缺口躲过股灾」不是策略能力。**
|
||||
|
||||
### 4.4 预热期说明(重要)
|
||||
### 4.4 预热期与「数据缺口假象」(重要)
|
||||
|
||||
> **⚠️ 2026-10-04 定论(见 §9.3b/§9.3c)**:本节历史上曾把
|
||||
> 「2015–2018 组合 100% 现金」解释为「不做未来函数的代价与证明」。
|
||||
> 追查后确认那是**两个数据缺陷叠加出的假象**,不是纪律的胜利:
|
||||
>
|
||||
> 1. `stock_daily` 量价单位不一致 → 流动性门槛在早年低估 1000 倍,
|
||||
> **把 2015-2019 的股票池整体清空**(见 §9.1);
|
||||
> 2. 行情只到 2015-01-05 → 滚动参照窗口填不满,即便有候选也算不出分位
|
||||
> (见 §9.3b)。
|
||||
>
|
||||
> 两者都在 2026-10-03/04 修好。现在:
|
||||
>
|
||||
> - **2015-01-05 的 PIT 股票池 = 3 只**(此前 0 只)
|
||||
> - **5 年窗口覆盖率 98.68%**(此前无法计算)
|
||||
> - **2015 年从第一个交易日起就被真实交易**
|
||||
>
|
||||
> 代价是全期收益从 +117.36% 降到 +92.73% —— **修数据让结果变差,
|
||||
> 但变真实了**。下面保留历史记录以对照。
|
||||
|
||||
> **补充(2026-10-03):预热期只在「按周期重新筛选」时成立。**
|
||||
>
|
||||
> 用 `--universe-run` 指定**冻结股票池**时,引擎不解自筛选,历史约束(连续分红 5 年、
|
||||
> 5 年 ROE 等)不再作用于早期,于是**没有预热期**。实测同一段区间:
|
||||
>
|
||||
> | 模式 | 总收益 | CAGR | 最大回撤 | Sharpe |
|
||||
> |---|---:|---:|---:|---:|
|
||||
> | 重新筛选(无 `--universe-run`) | 114.80% | 6.73% | **−21.05%** | 0.31 |
|
||||
> | 冻结股票池(`--universe-run`) | 374.61% | 14.19% | **−53.06%** | 0.57 |
|
||||
>
|
||||
> **冻结模式的回撤是两倍以上**,因为它在 2015 年满仓吃到了股灾,而重新筛选模式
|
||||
> 因预热期空仓躲过。预热期不只是技术细节,它**实质影响风险特征**。
|
||||
>
|
||||
> **而且冻结模式本身是未来函数**(股票池 asof 晚于回测起点):现已默认拒绝执行,
|
||||
> 只能加 `--allow-lookahead-universe` 复现,且该偏差会写入 `unimplemented_json`。
|
||||
> 详见 §9.2。
|
||||
>
|
||||
> 另外,冻结模式曾暴露一个真实缺陷(已修复,见 §4.7):
|
||||
> 行情首日的滚动窗口只有 1 个观测,分位被算成 100%,8 只股票被误买入。
|
||||
|
||||
**2015–2018 年组合保持 100% 现金、无任何成交**,这是**预期行为**而非缺陷:
|
||||
|
||||
@@ -188,28 +266,46 @@
|
||||
### 4.6 Walk-forward 样本外验证(plan.md §23/§25)
|
||||
|
||||
7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布:
|
||||
(下表为 2026-10-04 回补后重跑,`wf_id = e855fdf268827e1e4c31510c6736eea3`,
|
||||
**实时画像闸门启用**)
|
||||
|
||||
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 |
|
||||
|---|---|---|---:|---:|
|
||||
| #0 | 2015–2019 | 2020 | **+4.01%** | −11.94% |
|
||||
| #1 | 2016–2020 | 2021 | **−3.07%** | −15.79% |
|
||||
| #2 | 2017–2021 | 2022 | **−9.17%** | −24.22% |
|
||||
| #3 | 2018–2022 | 2023 | **−8.63%** | −18.00% |
|
||||
| #4 | 2019–2023 | 2024 | **+2.61%** | −24.62% |
|
||||
| #5 | 2020–2024 | 2025 | **+3.78%** | −10.50% |
|
||||
| #6 | 2021–2025 | 2026(部分) | **+3.83%** | −14.88% |
|
||||
> **⚠️ 重要:这 7 个数字与「回补前」逐窗口完全相同。**
|
||||
> 也就是说,把行情从 2015 补到 2005 **没有改变任何样本外结果**,
|
||||
> 却让单条路径的全期收益从 +117.36% 变成 +92.73%。
|
||||
> 原因:样本外的测试窗口是 2020–2026,其参照窗口(冻结在训练段)
|
||||
> 本就落在 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
|
||||
> 2015 年是否可交易会改变整条路径。**这正是「不要采信单条路径」的又一例证**
|
||||
> —— 见 §4.6d。
|
||||
|
||||
**样本外汇总:**
|
||||
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | Sharpe |
|
||||
|---|---|---|---:|---:|---:|
|
||||
| #0 | 2015–2019 | 2020 | **+12.27%** | −12.39% | 0.52 |
|
||||
| #1 | 2016–2020 | 2021 | **−2.47%** | −15.89% | −0.29 |
|
||||
| #2 | 2017–2021 | 2022 | **−3.05%** | −16.68% | −0.32 |
|
||||
| #3 | 2018–2022 | 2023 | **−11.32%** | −22.97% | −0.95 |
|
||||
| #4 | 2019–2023 | 2024 | **+12.16%** | −15.63% | 0.50 |
|
||||
| #5 | 2020–2024 | 2025 | **+6.65%** | −11.58% | 0.33 |
|
||||
| #6 | 2021–2025 | 2026(部分) | **−6.09%** | −18.39% | −0.73 |
|
||||
|
||||
| 指标 | 数值 |
|
||||
|---|---:|
|
||||
| 盈利窗口 | 4 / 7(胜率 57.14%) |
|
||||
| 样本外收益 **均值** | **−0.95%** |
|
||||
| 样本外收益 中位数 | +2.61% |
|
||||
| 样本外 CAGR 均值 | −0.78% |
|
||||
| 最差窗口回撤 | −24.62% |
|
||||
| **基准收益均值** | **+2.29%** |
|
||||
| **超额收益均值** | **−3.24%** |
|
||||
**样本外汇总(闸门开):**
|
||||
|
||||
| 指标 | 数值 | 改造前(旧口径) |
|
||||
|---|---:|---:|
|
||||
| 盈利窗口 | 3 / 7(胜率 42.86%) | 4 / 7(57.14%) |
|
||||
| 样本外收益 **均值** | **+1.16%** | −0.95% |
|
||||
| 样本外收益 中位数 | −2.47% | +2.61% |
|
||||
| 样本外 CAGR 均值 | +0.85% | −0.78% |
|
||||
| 最差窗口回撤 | −22.97% | −24.62% |
|
||||
| **基准收益均值** | **+2.29%** | +2.29% |
|
||||
| **超额收益均值** | **−1.12pp** | −3.24pp |
|
||||
|
||||
> **结论没有变,但程度变轻了**:样本外均值由 −0.95% 转为 **+1.16%**,
|
||||
> 超额仍为负(**−1.12pp**)。胜率反而从 4/7 降到 3/7 —— 均值改善主要来自
|
||||
> 2020(+4.01% → +12.27%)与 2022(−9.17% → −3.05%),
|
||||
> 而 2026 部分年份由 +3.83% 转为 −6.09%。
|
||||
>
|
||||
> 这轮数字受**三项**改动影响(量价单位修复、行情回补到 2005、画像闸门);
|
||||
> 其中**回补的贡献为 0**(逐窗口未变),两者已用对照 run 分离 —— 见 §4.6d。
|
||||
|
||||
#### 结论:单路径回测显著高估了策略
|
||||
|
||||
@@ -217,25 +313,113 @@
|
||||
|
||||
| | 全期单路径回测 | Walk-forward 样本外均值 |
|
||||
|---|---:|---:|
|
||||
| 收益 | **+114.80%**(11.7 年) | **−0.95%/年** |
|
||||
| 相对基准 | +95pp | **−3.24pp** |
|
||||
| 收益 | **+117.36%**(11.7 年) | **+1.16%/年** |
|
||||
| 相对基准 | +97.7pp | **−1.13pp** |
|
||||
|
||||
**这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug:
|
||||
|
||||
1. **全期回测允许仓位穿越牛熊**:2019–2021 建的仓在 2022–2023 的下跌中继续持有,
|
||||
1. **全期回测允许仓位穿越牛熊**:2016–2019 建的仓在 2022–2023 的下跌中继续持有,
|
||||
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
|
||||
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
|
||||
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
|
||||
训练期校准的阈值在测试期就失灵了。
|
||||
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大
|
||||
(稳定性指标 −0.16,说明窗口间差异大于均值本身)。
|
||||
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大。
|
||||
|
||||
**因此:不要采信 §4.1 的 +114.80%。** 更接近真实的表述是
|
||||
「该策略在 2020/2024/2025/2026 的样本外为正,在 2021/2022/2023 为负,
|
||||
长期看与基准相比没有稳定的超额收益,且回撤更小(防御性成立、进攻性不足)」。
|
||||
**因此:不要采信 §4.1 的 +117.36%。** 更接近真实的表述是
|
||||
「该策略在 2020/2024/2025 的样本外为正,在 2021/2022/2023/2026 为负,
|
||||
长期看与基准相比没有稳定的超额收益(−1.13pp),且回撤更小
|
||||
(最差 −22.97%,而基准同期回撤 −46% ~ −52%)」。
|
||||
|
||||
### 4.6c 实时画像闸门的净影响(两个口径结论相反)
|
||||
|
||||
同一份代码、同一份数据,只切换 `entry.profile_gate.enabled`
|
||||
(下表为**回补后**的数字):
|
||||
|
||||
**① 全期单路径(2015-01-05 ~ 2026-09-30)**
|
||||
|
||||
| | 闸门开 | 闸门关 | 差 |
|
||||
|---|---:|---:|---:|
|
||||
| 总收益 | +92.73% | +101.20% | **−8.47pp** |
|
||||
| CAGR | +5.75% | +6.14% | −0.39pp |
|
||||
| 最大回撤 | −25.38% | −25.54% | +0.16pp(略好) |
|
||||
| Sharpe | 0.22 | 0.23 | −0.01 |
|
||||
| 成交笔数 | 197 | 225 | −28 |
|
||||
| 买入信号被画像剔除 | 1027 | 0 | — |
|
||||
| `run_id` | `fa916050…` | `5347fd13…` | |
|
||||
|
||||
**② Walk-forward 7 窗口样本外**(`wf_id`:开 `e855fdf2…` / 关 `231d0b29…`)
|
||||
—— **回补前后逐窗口完全相同**
|
||||
|
||||
| | 闸门开 | 闸门关 | 差 |
|
||||
|---|---:|---:|---:|
|
||||
| 样本外收益 **均值** | **+1.16%** | −0.00% | **+1.16pp** |
|
||||
| 样本外收益 中位数 | −2.47% | +3.78% | −6.25pp |
|
||||
| 盈利窗口 | 3 / 7 | 4 / 7 | −1 |
|
||||
| 样本外 CAGR 均值 | +0.85% | +0.17% | +0.68pp |
|
||||
| **最差窗口回撤** | **−22.97%** | −24.22% | **+1.25pp** |
|
||||
| **超额收益均值** | **−1.12pp** | −2.29pp | **+1.17pp** |
|
||||
|
||||
逐窗口(超额 = 策略 − 沪深300):
|
||||
|
||||
| 测试年 | 闸门开 | 闸门关 | 基准 | 闸门开超额 | 闸门关超额 |
|
||||
|---|---:|---:|---:|---:|---:|
|
||||
| 2020 | +12.27% | +10.17% | +25.51% | −13.23pp | −15.34pp |
|
||||
| 2021 | −2.47% | −2.84% | −6.21% | +3.74pp | +3.37pp |
|
||||
| 2022 | −3.05% | −9.17% | −21.27% | **+18.23pp** | +12.11pp |
|
||||
| 2023 | −11.32% | −9.58% | −11.75% | +0.43pp | +2.17pp |
|
||||
| 2024 | +12.16% | +3.80% | +16.20% | −4.04pp | −12.40pp |
|
||||
| 2025 | +6.65% | +3.78% | +21.19% | −14.54pp | −17.41pp |
|
||||
| 2026 | −6.09% | +3.84% | −7.63% | +1.54pp | +11.48pp |
|
||||
|
||||
**两个口径给出相反结论。按本项目的一贯立场 —— 以样本外为准 —— 闸门是改善**
|
||||
(样本外均值 +1.16pp、最差回撤 +1.25pp、超额 +1.17pp),
|
||||
虽然它**降低了盈利窗口数**(4→3)与中位数,也就是说改善集中在少数年份。
|
||||
|
||||
单条路径之所以给出相反答案:它被「2016–2019 一次建仓 + 2024–2026 回本」这段
|
||||
**穿越牛熊的持有**主导,而闸门剔除的那些买入恰好在单路径上是赚的。
|
||||
这正是 §4.6 标题那句话的又一个例证 —— **不要采信单条路径**。
|
||||
|
||||
### 4.6d 三维归因:三项改动各自贡献多少
|
||||
|
||||
因为每一项都有「开关式」的对照 run,可以逐项分离。**结论是样本外只认前两项,
|
||||
而回补的贡献为 0。**
|
||||
|
||||
**样本外(walk-forward 均值 / 超额 / 最差回撤)**
|
||||
|
||||
| 版本 | 样本外收益均值 | 样本外超额均值 | 最差回撤 | 盈利窗口 |
|
||||
|---|---:|---:|---:|---:|
|
||||
| 改造前(量价单位错误 + 无闸门 + 数据缺 2015 前) | −0.95% | −3.24pp | −24.62% | 4 / 7 |
|
||||
| 仅修量价单位(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
|
||||
| + 数据回补到 2005(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
|
||||
| + 实时画像闸门(**当前**) | **+1.16%** | **−1.12pp** | **−22.97%** | 3 / 7 |
|
||||
|
||||
**单条路径(全期总收益)**
|
||||
|
||||
| 版本 | 总收益 |
|
||||
|---|---:|
|
||||
| 数据缺 2015 前(改造前口径) | +114.80% |
|
||||
| + 量价单位修复(闸门关) | +134.06% |
|
||||
| + 数据回补到 2005(闸门关) | **+101.20%** |
|
||||
| + 实时画像闸门(**当前**) | **+92.73%** |
|
||||
|
||||
**读法**:
|
||||
|
||||
1. **回补在样本外贡献 0、在单路径贡献 −32.9pp。** 样本外的测试窗口是
|
||||
2020–2026,参照窗口本就落在 2015 年之后,不依赖 2005–2014;
|
||||
而单路径从 2015 年起,回补让 2015 年(牛市顶 + 股灾)从「被数据缺口
|
||||
挡住」变成「被真实交易」(−3.18%),整条路径随之改变。
|
||||
**同一项数据修正,在两个口径下的"效果"相差 32.9pp —— 这就是为什么
|
||||
本项目坚持只认样本外。**
|
||||
2. **闸门在两个口径下依然相反**(样本外 +1.16pp、单路径 −8.47pp),
|
||||
与回补前的结论一致。
|
||||
3. **结论没有变**:超额仍为负(−1.12pp),当前策略依然没有稳定的
|
||||
样本外超额收益。
|
||||
|
||||
> 需要提醒的是:+1.16% 的样本外均值建立在 **7 个观测**上,
|
||||
> 标准误很大。不要把「从 −0.95% 到 +1.16%」读成「策略变好了」。
|
||||
|
||||
> 值得注意的是,策略的**回撤控制**在样本外依然稳定成立
|
||||
> (最差 −24.62%,而基准同期回撤 −46% ~ −52%),
|
||||
> (最差 −22.97%,而基准同期回撤 −46% ~ −52%),
|
||||
> 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**,
|
||||
> 而非超额收益来源。
|
||||
|
||||
@@ -243,19 +427,29 @@
|
||||
|
||||
1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
|
||||
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
|
||||
2. **涨跌停与停牌约束覆盖 2019 年起**;2015-2018 区间为近似建模,
|
||||
引擎会在 `hd_backtest_run.unimplemented_json` 中如实声明。
|
||||
(停牌同步器起点为 2019-01-01,如需 2015-2018 可改 `--start` 重跑。)
|
||||
2. **涨跌停与停牌约束覆盖 2010 年起**(2026-10-04 回补,原为 2019 起);
|
||||
回测区间 2015-01-05 起已**全部**有真实约束,不再是近似建模。
|
||||
2010 年之前的涨跌停价 Tushare 无数据(实测 2005 年 `stk_limit` 返回 0 行)。
|
||||
3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。
|
||||
4. **`index_weight` 为空**:不影响基准收益计算(用指数点位),
|
||||
仅影响成分股分析。
|
||||
5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。
|
||||
6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。
|
||||
7. **2015-2018 为预热期**(无历史分布可用),见 §4.4 说明。
|
||||
7. **不再有「预热期空仓」**:2026-10-04 把行情回补到 2005 后,滚动 5 年分位
|
||||
在 2015-01-05 起即可计算,**2015 年(大牛市 + 股灾)从第一个交易日起
|
||||
就被真实交易**。此前的「2015 年 100% 现金」不是设计,而是数据缺口造成的
|
||||
假象(见 §9.3b/§9.3c)。这也使全期收益下降 —— 见 §4.4。
|
||||
8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。
|
||||
9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026),
|
||||
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻;
|
||||
单次完整跑完约需 25 分钟,用 `hdiv backtest --mode walkforward` 执行。
|
||||
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻。
|
||||
10. **幸存者偏差尚有 3 只的缺口**:`stock_daily` 里有 3 个代码
|
||||
(`000022.SZ`、`000043.SZ`、`300114.SZ`,均因吸收合并/重组退市)
|
||||
不在 `stock` 表中,因此永远不会进入候选集。占 5,903 只的 0.05%。
|
||||
根因是 `stock` 表只收录在市股票,补它需要扩展 `sync` 的
|
||||
`backfill_tables` 白名单,属独立的数据补全工作。
|
||||
11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码**
|
||||
(其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除;
|
||||
SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。
|
||||
|
||||
---
|
||||
|
||||
@@ -363,19 +557,494 @@ hdiv report validate # 或:python -m hdiv.report.validate output
|
||||
|
||||
部署:`deploy/nginx.conf.example`(两种布局)+ `deploy/serve.sh`(启停脚本)。
|
||||
|
||||
## 4.6b 样本外超额的真相:牛市跑输、熊市跑赢(2026-10-03 补充,同日重跑更新)
|
||||
|
||||
补上基准对比后(此前接口把 `benchmark_<code>` 行过滤掉了,页面上看不到超额),
|
||||
逐窗口的超额呈现**高度规律**的形态:
|
||||
|
||||
| 测试年 | 策略 | 沪深300 | 超额 | 市场 |
|
||||
|---|---:|---:|---:|---|
|
||||
| 2020 | +12.27% | +25.51% | **−13.24pp** | 牛市 |
|
||||
| 2021 | −2.47% | −6.21% | **+3.74pp** | 熊市 |
|
||||
| 2022 | −3.05% | −21.27% | **+18.22pp** | 熊市 |
|
||||
| 2023 | −11.32% | −11.75% | **+0.43pp** | 熊市 |
|
||||
| 2024 | +12.16% | +16.20% | **−4.04pp** | 牛市 |
|
||||
| 2025 | +6.65% | +21.19% | **−14.54pp** | 牛市 |
|
||||
| 2026 | −6.09% | −7.63% | **+1.54pp** | 熊市 |
|
||||
|
||||
**4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。** 超额胜率 57.1%。
|
||||
|
||||
因此更准确的表述不是「没有超额收益」,而是:
|
||||
**它是一份低 beta 的防御型配置 —— 用牛市的大幅跑输换取熊市的相对抗跌**。
|
||||
超额均值 −1.13pp 是样本内牛熊比例的结果,而非策略「无效」;
|
||||
在熊市占比更高的样本里,这个均值会转正。
|
||||
|
||||
**判读时必须分开看牛熊**,只看均值会得出误导性结论。
|
||||
|
||||
> **重跑后这个形态反而更清晰**:牛市跑输的幅度收窄
|
||||
> (−21.50 → −13.23、−13.58 → −4.04、−17.41 → −14.54),
|
||||
> 熊市跑赢的幅度扩大(2022:+12.11 → +18.23)。
|
||||
> 归因已用「闸门关闭」的对照 run 分离(§4.6d):两项改造在样本外都是正向的,
|
||||
> 其中闸门的贡献集中在 2022/2024/2025 三个年份。
|
||||
|
||||
## 4.7 已修复:退化分布伪造 100% 分位(2026-10-03)
|
||||
|
||||
**现象**:明细里出现「股息率 0.00%;历史分位 100.0%」并触发买入。
|
||||
|
||||
**根因**:分位定义为「≤当前值的观测占比」。当参考窗口只剩 1 个观测、
|
||||
且恰好等于当前值时,占比恒为 100%,足以击穿任何买入阈值。
|
||||
|
||||
触发条件:回测起点早于行情数据起点时,滚动窗口伸进空区间。
|
||||
实测 2015-01-06(行情数据首日)窗口 2010-2015 只有 **1 个观测**,
|
||||
**8 只股票**因此被买入 —— 且买在 2015 年股灾前的高点。
|
||||
|
||||
**影响面**:该次回测 217 笔成交中有 **22 笔(10.1%)** 参考样本不足 250 天。
|
||||
|
||||
**修复**:新增 `backtest.yml: percentile_reference.min_observations`(默认 250 ≈ 一年),
|
||||
窗口样本不足则**当日对该股不产生任何信号**(保持现状,不买不卖),
|
||||
并把 `min_observations` 一并写入 `reason_json` 便于事后核查。
|
||||
|
||||
**修复效果**:弱样本成交 22 → 0;同区间总收益 289.73% → 374.61%
|
||||
(去掉的是股灾前的错误买入,因此反而更好)。
|
||||
|
||||
> 这个缺陷说明:**分位类指标必须带最小样本量**。
|
||||
> 与 §9.6 绩效指标的 `MIN_OBS_FOR_RISK` 是同一类问题 —— 样本不足时
|
||||
> 正确做法是「不判断」,而不是照常输出一个看似合理的数字。
|
||||
|
||||
## 7.3 Walk-forward 前端与重跑覆盖(2026-10-03)
|
||||
|
||||
**补上 Walk-forward 前端入口**:此前 `hd_walkforward_run` 有数据但**没有任何接口或页面**,
|
||||
跑完 25 分钟在界面上看不到任何东西。现新增 `/api/walkforwards`(列表)、
|
||||
`/api/walkforwards/{wf_id}`(详情)与 `#/walkforwards` 页面,
|
||||
逐窗口展示样本内/样本外收益、CAGR、Sharpe、最大回撤、成交数与**冻结阈值**。
|
||||
|
||||
**⚠ 判读更正(同日)**:初版页面把「训练段累计收益」与「测试段累计收益」并排比较,
|
||||
得出「样本内 7.0%→60.7%、样本外仅 −0.95%,明显过拟合」的结论 —— **这是错的**。
|
||||
训练段是 **1825 天(5 年)**、测试段是 **364 天(1 年)**,两者累计收益不可比。
|
||||
|
||||
换算年化后:
|
||||
|
||||
| 窗口 | 训练年化 | 测试年化 |
|
||||
|---|---:|---:|
|
||||
| #0 | 1.37% | **4.02%** |
|
||||
| #2 | 5.01% | −9.29% |
|
||||
| #6 | 9.52% | 5.25% |
|
||||
|
||||
窗口 #0 的样本外年化**高于**样本内。7 个窗口里 6 个是样本外年化低于样本内,
|
||||
方向存在但幅度远没有累计口径显示的那么夸张。
|
||||
|
||||
页面与表格已改用**年化**口径,并在图下注明区间长度差异。
|
||||
|
||||
**筛选记录改为重跑覆盖**:`run_id` 原先含 `datetime.now()`,
|
||||
导致同一 asof 反复重跑不断累积(2025-01-21 累积了 11 条内容相同的记录)。
|
||||
现改为 `stable_id(名称, 时点, config_hash)` —— 不含时间,同输入即同 id,
|
||||
重跑原地覆盖运行头、成员清单与因子快照。
|
||||
|
||||
刻意**不含 `data_version`**:用户要的是「这一天的筛选结果」,
|
||||
而不是「每次数据快照各存一份」;每次运行实际使用的 data_version 仍完整记录可追溯。
|
||||
|
||||
候选集缩小时(新股上市/退市),上次存在而本次不再出现的成员被标记为
|
||||
`fail_stage='stale'`(UPDATE,非 DELETE),以符合「禁止物理删除」的约束。
|
||||
界面上的命名/备注/归档状态不在更新列中,重跑不会清掉用户标注。
|
||||
|
||||
## 7.4 已修复:`--universe-run` 在 walk-forward 下被静默忽略(2026-10-03)
|
||||
|
||||
`hdiv backtest --universe-run X --mode walkforward` 中,`--universe-run` **完全没有生效** ——
|
||||
`WalkForwardRunner` 根本没有这个参数,CLI 也没校验,于是参数被丢掉且无任何提示。
|
||||
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
|
||||
|
||||
**但正确做法不是「支持」它,而是拒绝**:
|
||||
|
||||
| | 时点 |
|
||||
|---|---|
|
||||
| 股票池 `b8dd742f…` | asof = **2025-01-21** |
|
||||
| walk-forward 最早训练区间 | **2015-01-01** 起 |
|
||||
|
||||
把 2025 年选出的股票池套到 2015 年的训练窗口上,就是用未来信息选股 ——
|
||||
恰好破坏了 walk-forward 要守护的无未来函数纪律。
|
||||
|
||||
现在该组合会明确报错并说明原因与替代方案。同时新增两层回归测试:
|
||||
CLI 必须拒绝,且 `WalkForwardRunner` 的签名里不得出现 `universe_run_id`(防止日后被误加回去)。
|
||||
|
||||
同类问题:`selector.py` 中 `df["is_fresh"].fillna(False)` 触发 pandas
|
||||
Downcasting `FutureWarning`(pandas 未来版本会改变行为,可能让筛选结果静默变化)。
|
||||
已改为 `astype("boolean").fillna(False).astype(bool)`,语义不变;
|
||||
并新增测试用 `-W error::FutureWarning` 跑筛选路径。
|
||||
|
||||
## 7.5 已修复:显示精度配置是死的(2026-10-03)
|
||||
|
||||
**现象**:改了 `config/report.yml: layout.decimals.ratio` 对股息率等百分比**毫无影响**。
|
||||
|
||||
**根因**:`layout.decimals` 三个设置里,**只有 `price` 曾被读取过一次**
|
||||
(renderer 的价格格式化)。`ratio` 与 `money` 从未被任何代码引用 —— 纯摆设。
|
||||
而百分比显示散落在 **20 余处硬编码**:
|
||||
|
||||
| 位置 | 原实现 |
|
||||
|---|---|
|
||||
| `profile_report._pct` | `f"{x*100:.2f}%"` |
|
||||
| `backtest_report._pct` / `_fmt_metric` | `f"{x*100:,.2f}%"` |
|
||||
| `universe_report._pct` | `dec: int = 2`(默认写死) |
|
||||
| `sensitivity_report._pct` / `walkforward_report._pct` | `f"{x*100:,.2f}%"` |
|
||||
| `web/service._reason_text`、`web/analysis._reason_text` | 成交理由里的股息率写死 2 位 |
|
||||
| `analysis/sensitivity.py`、`analysis/performance.py` | CLI 输出写死 |
|
||||
| `web/app.js` 的 `pct()` | 前端写死 `toFixed(2)` |
|
||||
|
||||
**修复**:新增 `src/hdiv/report/format.py`(`NumFmt`),所有格式化统一走它,
|
||||
精度由配置驱动;前端通过 `/api/config/display` 获取精度,也由配置驱动。
|
||||
|
||||
**语义**(与用户确认):`decimals.ratio` 指**原始比率**的小数位。
|
||||
比率保留 ratio 位后乘 100,恰好少两位 —— 即 **百分比小数位 = ratio - 2**:
|
||||
|
||||
| ratio | 原始比率 | 股息率显示 |
|
||||
|---:|---|---|
|
||||
| 2 | 0.06 | 6% |
|
||||
| 4 | 0.0617 | 6.17% |
|
||||
| 6 | 0.061715 | 6.1715% |
|
||||
|
||||
并加了两条硬断言:`src/` 全域不得再出现 `:.2f}%`,前端必须存在 `FMT.percent`。
|
||||
|
||||
## 7.6 已修复:股息率毛刺与口径分裂(2026-10-03)
|
||||
|
||||
### 问题一:除权间隔不规整造成的毛刺
|
||||
|
||||
A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每年除权日附近
|
||||
制造两种**日历假象**:
|
||||
|
||||
| 类型 | 成因 | 实测 |
|
||||
|---|---|---|
|
||||
| **重叠虚高** | 间隔 < 365,新旧分红同时在窗口内 | 招商银行 2015-07-03:0.620 → **1.290**(+108%),10 天后回落 0.670 |
|
||||
| **断档虚低** | 间隔 > 365,旧的已到期新的未入场 | 中国神华 2016-07-04:0.740 → **0.320**(−57%) |
|
||||
|
||||
旧实现只在**结果恰好为 0** 时用 `grace_days` 兜底,而实际跌落是**部分跌落**
|
||||
(1.290→0.670 不是 0),所以完全没兜住。实测:
|
||||
|
||||
| 股票 | 修复前 >20% 跳变 | 修复后 | 修复前虚低归零天数 |
|
||||
|---|---:|---:|---:|
|
||||
| 招商银行 | 21 | **5** | — |
|
||||
| 工商银行 | 25 | **7** | — |
|
||||
| 中国银行 | 25 | **7** | **77 天** |
|
||||
| 伊利股份 | 24 | **13** | **68 天** |
|
||||
|
||||
**修法**:把「硬窗口」换成「按后继接管」。对每次分红 i,若与下一次的间隔
|
||||
落在 `window_days ± grace_days`(即 320~410 天),视为同一档年度分红,
|
||||
计入区间延到 `min(下一次除权日, 除权日 + 365 + grace)`:
|
||||
|
||||
- 间隔略小于一年 → 后继提前接管,**消除重叠虚高**
|
||||
- 间隔略大于一年 → 旧的计到新的入场,**填补断档虚低**
|
||||
- 超过 `365 + grace` 仍无后继(真停发)→ 封顶,**如实归零**
|
||||
- 间隔 < 320 天视为**年内多次分红**(中期+年度),互不取代 ——
|
||||
否则会把中期分红误删,人为制造新的低点
|
||||
|
||||
### 问题二:同一个「股息率」在四处口径不同
|
||||
|
||||
修复过程中发现更严重的问题:TTM 参数在四个调用点来源不一。
|
||||
|
||||
| 调用点 | 修复前 |
|
||||
|---|---|
|
||||
| `profile/builder.py` | ✓ 读 `profile.yml` |
|
||||
| `backtest/engine.py` | ✗ **硬编码 365 / 45** |
|
||||
| `backtest/walk_forward.py` | ✗ 用函数默认值 |
|
||||
| `web/analysis.py` | ✗ 用函数默认值 |
|
||||
| `universe/filters/dividend.py` | ✗ **另写了一遍** trailing-12月求和 |
|
||||
|
||||
后果:改 `profile.yml` 只有画像会变;更糟的是**筛选器用的股息率与画像/回测不一致**,
|
||||
而这是**直接决定选股**的数字。
|
||||
|
||||
**修法**:新增 `ttm_params()`(单一事实来源)与 `ttm_dps_at()`(单点求值),
|
||||
五处全部改用同一实现,参数统一来自 `profile.yml: ttm_dividend`。
|
||||
|
||||
### 影响与后续
|
||||
|
||||
因子值变化约 **1.2% 的交易日**(每只股票 11 年约 30~64 天,即原先的毛刺日),
|
||||
最大单日差异达 60%+。由于股息率是买卖信号的直接输入:
|
||||
|
||||
- **已有的画像与回测结果已过期**,需重跑
|
||||
- 筛选结果需重新生成(`hd_universe_run` 会原地覆盖)
|
||||
|
||||
## 8. 测试覆盖
|
||||
|
||||
```
|
||||
262 passed
|
||||
403 passed(pytest 退出码 0)
|
||||
```
|
||||
|
||||
| 测试文件 | 覆盖 |
|
||||
|---|---|
|
||||
| `test_config.py` | 配置正向加载 + 18 类非法配置必须被拒 |
|
||||
| `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
|
||||
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
|
||||
| `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
|
||||
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
|
||||
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
|
||||
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
|
||||
| `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数 |
|
||||
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run`) |
|
||||
| `test_profile_pit.py` | **实时画像与批量画像逐值等价**、公告日/除权日 PIT 负例、惰性与面板复用、窗口校验、**窗口覆盖率(含左开右闭分母)**、**输入行序无关性** |
|
||||
| `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数、**画像闸门(剔除/放行/不动仓位/无法验证)**、**未实现声明的诚实性** |
|
||||
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run` 的未来函数守卫) |
|
||||
| `test_web.py` | 前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 |
|
||||
|
||||
---
|
||||
|
||||
# 9. 三项正确性改造(2026-10-03)
|
||||
|
||||
三项都是「不报错、只让结果悄悄错」的类型,因此都配了负例测试与如实声明。
|
||||
|
||||
## 9.1 已修复:`stock_daily` 量价单位前后不一致
|
||||
|
||||
**现象**:2015-01~2019 的股票池被流动性门槛整体清空 —— 实测按当时可见数据筛选,
|
||||
**2016/2017/2018 各得到 0 只**,2019 只有 3 只;而 `market` 过滤本应留下几十只。
|
||||
|
||||
**根因**:`stock_daily` 是「追加进既有 qlib 库」的表。
|
||||
|
||||
| 区间 | 来源 | `volume` 单位 | `amount` 单位 |
|
||||
|---|---|---|---|
|
||||
| 2015-01 ~ 2019 | 本项目从 Tushare 回补 | 手 | 千元 |
|
||||
| 2019(同日混合) | 两者重叠 | 3596 行里 337 行已换算 | — |
|
||||
| 2020 ~ | qlib 存量 | 股 | 元 |
|
||||
|
||||
而 `universe.market.min_avg_amount_20d: 20000000` 是按「元」写的,
|
||||
`Repo.avg_amount` 又直接 `AVG(amount)` —— 于是早年门槛实际变成
|
||||
**「日均成交额 ≥ 200 亿元」**。`units.py` 里的 `amount_qian_to_yuan`
|
||||
写了但**从未被调用**,`sync.price.daily_frame` 也是原样落库;
|
||||
审计的单位自检只看 `daily_basic.total_mv`,所以一直没报警。
|
||||
|
||||
**修复(两层)**:
|
||||
|
||||
1. 写入端 `sync.price.daily_frame`:`vol ×100`、`amount ×1000`
|
||||
2. 读取端 `units.normalize_ohlcv_units`:按行判据
|
||||
`成交额 / (成交量 × 收盘价)`(≈1 已换算 / ≈0.1 原始)判定并换算,**幂等**;
|
||||
`Repo.price_history` 与 `Repo.avg_amount` 都走它
|
||||
3. 审计新增 `UNIT-OHLCV`:逐年抽样列出仍为原始单位的行数(防回归)
|
||||
|
||||
**效果**(同一份配置、同一批数据):
|
||||
|
||||
| asof | 修复前入选 | 修复后入选 |
|
||||
|---|---:|---:|
|
||||
| 2016-02-01 | 0 | **7** |
|
||||
| 2017-02-01 | 0 | **11** |
|
||||
| 2018-02-01 | 0 | **13** |
|
||||
| 2019-02-01 | 3 | **14** |
|
||||
| 2024-12-31 | 46 | 46 |
|
||||
|
||||
> **这更正了 §4.4 的一条叙事。** 原文把「2015–2018 组合 100% 现金」
|
||||
> 归因为「不做未来函数的代价与证明」。真实原因至少有一部分是**单位 bug 把股票池清空了**。
|
||||
> 预热期(滚动 5 年分位参照需要 250 个观测)确实存在,但它不是唯一原因。
|
||||
|
||||
**未做**:没有重刷 2015-2019 的存量数据(写入是 `INSERT IGNORE`,
|
||||
不动既有行是硬约束)。读取层兜底已使结果正确,存量数据的清理留给一次显式的数据维护。
|
||||
|
||||
## 9.2 已修复:单次回测里 `--universe-run` 的未来函数
|
||||
|
||||
**现象**:`hdiv backtest --universe-run <股票池>` 会把该股票池的成员
|
||||
**冻结**到所有调仓日。若股票池的 `asof` 晚于回测起点,2015 年的选股就用了
|
||||
2025 年的信息。实测库中 `247724798ec2…`:引用股票池 `b8dd742f…`(asof **2025-01-21**),
|
||||
回测 2015-01-05 起,**2015-01-06 就有 8 笔成交**。
|
||||
|
||||
**为什么必须改**:项目自己在 §7.4 已经认定「把 2025 年选出的股票池套到 2015 年
|
||||
的训练窗口上就是用未来信息选股」,并因此在 walk-forward 下**拒绝**该组合 ——
|
||||
同一条理由对单次回测同样成立,此前却没有拦。
|
||||
|
||||
**修复**:引擎在执行前校验股票池 `asof` 与回测起点:
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| `asof <= 回测起点` | 正常执行(此时股票池属于事前信息) |
|
||||
| `asof > 回测起点` | **拒绝执行**,错误信息给出三种正确做法 |
|
||||
| 显式 `--allow-lookahead-universe` | 放行,并把偏差写入 `hd_backtest_run.unimplemented_json` |
|
||||
|
||||
**测试**:`test_future_universe_is_rejected_by_default` 用**库中最晚 asof** 的股票池
|
||||
跑更早区间,断言必须抛错且错误信息含放行开关;同时断言 `asof <= 起点` 时正常工作。
|
||||
|
||||
## 9.3 新增:实时(PIT)个股画像闸门
|
||||
|
||||
**需求**:交易依据要与实际情况相符 —— 2018-05-18 的决策依据应当是
|
||||
**2013-05-18 ~ 2018-05-17 的画像**,即按当时可见的数据实时重算画像,
|
||||
剔除「不值得买」的票。
|
||||
|
||||
**改造前的真实状态**(这一点必须先说清楚):
|
||||
|
||||
| 层 | 是否 PIT |
|
||||
|---|---|
|
||||
| 股息率分位(唯一的交易依据) | ✅ 已经是滚动 5 年、只用 `<= 当日` 的数据 |
|
||||
| 股票池(逐 12 个月重建) | ✅ PIT;但 `--universe-run` 冻结时 ❌(见 §9.2) |
|
||||
| `hd_profile_stat`(个股画像) | ⚠️ 是 asof 的**快照**,且**回测从不读取它** |
|
||||
|
||||
也就是说:改造前画像既不参与交易,也不存在「按每个决策日重算」的形态。
|
||||
|
||||
**新增 `src/hdiv/profile/pit.py`**:
|
||||
|
||||
- `PitProfileService.snapshot(symbol, asof)` —— 按当时可见数据重算画像。
|
||||
**指标定义复用 `ProfileBuilder._profile_one`**(同一定义来源),
|
||||
有**逐值等价测试**保证「回测里的画像」==「页面上的画像」
|
||||
- `evaluate_gate(rules, snapshot, on_unverifiable)` —— 逐条判定,
|
||||
三种结局:`PASS` / `REJECT` / 无法验证(按配置保守或放行)。
|
||||
指标缺失与样本不足**绝不当作 0**
|
||||
|
||||
**引擎接入**(`entry.profile_gate`):只在**买入条件已触发之后**才计算 ——
|
||||
这是「在触发条件的时候计算」的落点。被剔除时产出信号类型 `REJECT`、
|
||||
`skip_reason = PROFILE_GATE`,前端「未成交信号」里可直接看到
|
||||
**每个指标的实际值、阈值、状态与是否通过**。
|
||||
|
||||
**成本控制**(对应「长周期数据可以沿用」):
|
||||
|
||||
| 手段 | 效果 |
|
||||
|---|---|
|
||||
| 只在触发时计算 | 成本 ∝ 触发次数,而非「区间长度 × 股票数」 |
|
||||
| 跨股票共享面板按时点缓存 | 同一 asof 的财报面板只载入一次 |
|
||||
| 按规则声明所需指标 | 规则里没有财务指标时**完全不查财报表**(各约 30 万行) |
|
||||
| 财务查询加 symbol 过滤 | 单次 1076ms → 41ms(语义不变,仅追加 `symbol IN (...)`) |
|
||||
|
||||
实测一段 2016 全年回测:20 个决策时点、46 次画像计算、
|
||||
20 次财报面板载入、**0 次流动性查询**(规则未用到)、剔除 18 次。
|
||||
|
||||
**等价性测试抓到的两个真实缺陷**(都是「同一指标两个值」):
|
||||
|
||||
1. `ttm_dps` 被产出两行 `(ttm_dps, 0)`(序列循环 + 分红质量各一次),
|
||||
落库后谁胜出取决于写入顺序 → 已删除重复来源
|
||||
2. `free_cashflow` 同名不同义:`fin_latest`(最近一期)与
|
||||
`_payout_and_cover`(**与分红同一财年**)。实测格力电器 2018-05-18
|
||||
两个值分别是 67.1 亿与 70.5 亿 → 后者改名 `dividend_fy_free_cashflow`
|
||||
3. 实时侧分红超集的下界算错:按 `end` 回看 13 年 →
|
||||
2018 年的画像拿不到 2006-2012 的分红,格力 `dividend_continuity_years`
|
||||
被算成 4(真值 10)→ 改为按**回测起点**计算超集下界
|
||||
|
||||
**默认策略已启用**(`config/strategy/high_dividend_v1.yml` 的
|
||||
`entry.profile_gate.enabled: true`),因此**此前所有回测数字都已重跑**。
|
||||
重跑后的净影响见 §4.6c/§4.6d:**样本外是改善,单条路径是变差。**
|
||||
|
||||
**一个必须知道的取舍**:`on_unverifiable: reject` 是默认值,它比股票池筛选
|
||||
(`universe.dividend.on_missing_data: pass`)更严格。实测 2016 年初的中石化:
|
||||
最新可见分红属于 FY2015,而 FY2015 年报要到 3 月才公告 ——
|
||||
**支付率在当时根本无法验证**。`reject` 会放弃买入,`pass` 会照买。
|
||||
这是策略取舍得由使用者决定,不是 bug。
|
||||
|
||||
## 9.3b 窗口覆盖率:「名义 5 年」vs「真有 5 年」(2026-10-03 补充)
|
||||
|
||||
**问题**:用户要求「滚动计算过去 5 年的个股画像」。机制在 §9.3 已实现,
|
||||
但 `window_slice(asof, 5)` 的语义只是「把**已有**数据切成最近 5 年」——
|
||||
数据起点晚于窗口左端时,窗口会被**静默截短**,而 `status` 仍报 `OK`
|
||||
(`_stat_row` 的门槛是 `n_obs >= min(min_obs_days, 20)`,即 20 个观测就放行)。
|
||||
|
||||
**实测(600036.SH,`dv_yield`,5 年窗口)**:
|
||||
|
||||
| asof | 窗口内实际观测 | 应有交易日 | 覆盖率 |
|
||||
|---|---:|---:|---:|
|
||||
| 2015-12-31 | 239 | 1214 | **19.7%** |
|
||||
| 2016-12-30 | 483 | 1214 | **39.8%** |
|
||||
| 2018-05-18 | 817 | 1219 | **67.0%** |
|
||||
| 2019-12-31 | 1214 | 1219 | 99.6% |
|
||||
| 2020-12-31 起 | ≈1218 | ≈1218 | **100%** |
|
||||
|
||||
截断的指纹很明显:**2018-05-18 的四个窗口(0/5/8/10)报出完全相同的 `n_obs=817`**。
|
||||
|
||||
**根因是数据缺口,不是代码**:`stock_daily` / `daily_basic` / `adjust_factor`
|
||||
都只从 **2015-01-05** 起(分红、财报、指数、ST 历史都覆盖到 1990 年代)。
|
||||
因此任何早于 2020-01 的 asof,其 5 年窗口都不完整。
|
||||
|
||||
**这次补上的三件事**:
|
||||
|
||||
1. **量化**:新增 `profile/coverage.py`,用**交易日历的真实开市天数**作为分母
|
||||
(不是 243 这种近似),区间口径与 `window_slice` 严格一致(左开右闭 ——
|
||||
否则覆盖率永远差一天、`min_window_coverage=1.0` 会变成「永远拒绝」)
|
||||
2. **暴露**:`ProfileSnapshot` 新增 `n_obs` / `coverage`;
|
||||
gate 的 `checks[]` 记录 `n_obs` 与 `window_coverage`;
|
||||
`hdiv profile` 打印覆盖率警告
|
||||
3. **可强制**:策略新增 `entry.profile_gate.min_window_coverage`
|
||||
(0 = 不因覆盖率淘汰,保持改造前行为;1.0 = 名义 5 年必须真有 5 年数据)
|
||||
|
||||
**顺带修正的两个缺陷**:
|
||||
|
||||
- `hdiv sync backfill` 的 `basic_start` 未暴露给 CLI(函数默认 2015-01-01),
|
||||
于是「回补 2010–2014」实际只补了行情与复权因子、**`daily_basic` 仍停在 2015**。
|
||||
已新增 `--basic-start`,缺省跟随 `--start`。
|
||||
- `config/profile.yml: sufficiency` 的三个阈值
|
||||
(`min_history_years_dividend/price`、`min_dividend_records`)
|
||||
**从未被任何代码使用** —— 与 §7.5 记录的「显示精度配置是死的」同类问题。
|
||||
现已在已知限制中如实声明;真正的充分性判定由
|
||||
`min_window_coverage` + `on_unverifiable` 承担。
|
||||
|
||||
**回补方案**见 [user-guide §6.6](user-guide.md)。回补是 `INSERT IGNORE`(只追加)。
|
||||
|
||||
## 9.3c 回补已执行:行情补到 2005,5/8/10 年窗口全部补齐(2026-10-04)
|
||||
|
||||
按 §9.3b 的方案执行完毕。**Tushare 探针实测三个接口在 2005 年都有数据**
|
||||
(此前担心早年无数据,实际有),因此一次性补到 2005,让
|
||||
`profile.yml: windows_years = [5, 8, 10]` **三个窗口**都完整。
|
||||
|
||||
### 数据量变化
|
||||
|
||||
| 表 | 回补前 | 回补后 | 新增 | 新起点 |
|
||||
|---|---:|---:|---:|---|
|
||||
| `stock_daily` | 12,079,377 | 16,007,169 | +3,927,792 | **2005-01-04** |
|
||||
| `adjust_factor` | 12,205,794 | 16,789,137 | +4,583,343 | **2005-01-04** |
|
||||
| `daily_basic` | 11,663,360 | 15,972,821 | +4,309,461 | **2005-01-04** |
|
||||
| `hd_suspend` | 67,765 | 468,388 | +400,623 | **2010-01-04** |
|
||||
| `hd_limit` | 9,049,902 | 14,837,155 | +5,787,253 | **2010-01-04** |
|
||||
|
||||
- 命令:`hdiv sync backfill --start 2005-01-01 --end 2014-12-31 --basic-start 2005-01-01 …`
|
||||
与 `hdiv sync trading --start 2010-01-01 --end 2018-12-31`
|
||||
- 11,655 次 API 调用,全程 **0 次限频**(把 `daily/adj_factor/daily_basic`
|
||||
的限频从 480 下调到 170 —— 实测该 token 在约 196 次/分钟即被拒,
|
||||
「撞墙后冷却 62 秒」远慢于平滑配速)
|
||||
- 校验:三张表均「只增不减」✅;新写入行已是**正确的「股/元」单位**(实测比值 1.005~1.018)
|
||||
|
||||
### 覆盖率:目标达成
|
||||
|
||||
| asof | 回补前 5 年覆盖 | 回补后 5 年覆盖 |
|
||||
|---|---:|---:|
|
||||
| 2015-01-05 | —(数据起点之外) | **98.68%** |
|
||||
| 2016-12-30 | 39.79% | **99.01%** |
|
||||
| 2018-05-18 | 67.02% | **99.10%** |
|
||||
| 2021-06-30 | 100% | 100% |
|
||||
|
||||
**残下的约 1% 已逐日核实为真实停牌,不是数据洞**:600036.SH 在
|
||||
2010-01-05~2015-01-05 的 5 年窗口内缺 16 个交易日,把它们与
|
||||
`hd_suspend` 交叉比对,**16/16 全部命中停牌记录**
|
||||
(2010-03-05~03-12、2013-08-28~09-04 两个整周正是招行的配股停牌)。
|
||||
|
||||
### 顺带修掉的两个「同一类」缺陷
|
||||
|
||||
回补过程暴露了两处与 §9.1 同源的**固定阈值**问题(早年市场只有一千多只股票,
|
||||
固定阈值会把正常数据判成异常):
|
||||
|
||||
1. **断点续传失效**:`fetched_days` 用固定 `MIN_SYMBOLS_PER_DAY = 1500` 判定
|
||||
「某日是否已完整同步」。2005-2009 每天只有 ~1,350 只 → **每一天都被判成未完成**,
|
||||
回补一旦中断就要从第一天重来。已改为
|
||||
`max(绝对下限, 比例 × 当年应有上市股票数)`(新增 `datasource.yml: sync` 段配置),
|
||||
实测阈值 2005 年 829、2015 年 1,732、2024 年 3,397。
|
||||
2. **审计误报**:`G2/G2b/G3` 同样用固定 2,000 只判定「疑似数据稀疏」,
|
||||
回补后把 2005 年正常数据报成 WARN。已改为复用同一套按年份阈值。
|
||||
审计结果由 **OK=11 / WARN=8** 改善为 **OK=14 / WARN=5 / FAIL=0**。
|
||||
|
||||
### 效果
|
||||
|
||||
| 项 | 回补前 | 回补后 |
|
||||
|---|---|---|
|
||||
| 2015-01-05 的 PIT 股票池 | 0 只 | **3 只** |
|
||||
| 2015 年能否交易 | 不能(无历史分布) | **能,从第一个交易日起** |
|
||||
| 涨跌停/停牌约束覆盖 | 2019 起 | **2010 起**(2015-2018 不再是近似建模) |
|
||||
|
||||
> 这也意味着 **§4 的全部回测数字都需要再次重跑** —— 2015 年(大牛市 + 股灾)
|
||||
> 现在会被真实交易,而此前该年是 100% 现金。
|
||||
> `min_window_coverage` 保持默认 `0.0`:覆盖率现在已足够高(≥98.7%),
|
||||
> 强制 1.0 只会因个别停牌日误杀。
|
||||
|
||||
## 9.4 这三项改造带来的口径变化(重跑时必须知道)
|
||||
|
||||
| 变化 | 影响 |
|
||||
|---|---|
|
||||
| 量价单位修复 | 2015-2019 的股票池从 0~3 只变为 7~14 只,早期不再是纯预热期;样本外均值 +0.95pp |
|
||||
| `--universe-run` 守卫 | 历史「冻结股票池」回测无法再原样复现,需加 `--allow-lookahead-universe` 且结果被标注为含未来信息 |
|
||||
| 闸门默认启用 | 买入被进一步过滤(全期剔除 998 次);样本外均值再 +1.16pp、最差回撤 +1.25pp,但单路径收益 −16.7pp。`enabled: false` 可关闭以对比 |
|
||||
| `ttm_dps` / `free_cashflow` 去重改名 | `hd_profile_stat` 的既有画像快照需重跑;`dividend_fy_free_cashflow` 是新指标代码 |
|
||||
|
||||
**回补后(当前数据状态)的四个基准 run(可直接复核)**:
|
||||
|
||||
| 用途 | run_id |
|
||||
|---|---|
|
||||
| 单次回测,闸门开 | `fa916050ffb647b6ab4cfd90ba1adf36` |
|
||||
| 单次回测,闸门关 | `5347fd13dd8fc0b0a512156489c615bc` |
|
||||
| Walk-forward,闸门开 | `e855fdf268827e1e4c31510c6736eea3` |
|
||||
| Walk-forward,闸门关 | `231d0b298a007e1fb68f2e20a6cc42a9` |
|
||||
|
||||
(回补前的四个 run 为 `1a7e5b72…` / `e6e65382…` / `697a2ecd…` / `ae296c0f…`,
|
||||
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
|
||||
|
||||
|
||||
|
||||
+673
-41
@@ -7,6 +7,7 @@
|
||||
|
||||
# 目录
|
||||
|
||||
0. [全流程操作](#0-全流程操作) ★ **先读这一节(选股 → 画像 → 回测)**
|
||||
1. [系统是什么](#1-系统是什么)
|
||||
2. [快速开始](#2-快速开始)
|
||||
3. [目录与架构](#3-目录与架构)
|
||||
@@ -22,6 +23,288 @@
|
||||
|
||||
---
|
||||
|
||||
# 0. 全流程操作
|
||||
|
||||
> **选股 → 画像 → 回测**,三步主路径。
|
||||
|
||||
本节是**主操作路径**:从「选出一批股票」到「跑出一份可信的回测」,
|
||||
每一步都说明**命令做了什么、数据从哪来、落了哪些库、有哪些坑**。
|
||||
|
||||
## 0.0 五分钟全流程(可直接复制)
|
||||
|
||||
```bash
|
||||
cd ~/project/高股息回测
|
||||
export PYTHONPATH=src
|
||||
|
||||
# ① 选股:按 2025-01-01 当时可见的数据筛选(实际落到交易日 2024-12-31)
|
||||
.venv/bin/python -m hdiv universe --asof 2025-01-01
|
||||
# → 记下打印的 run_id,例如 02485801b2805cbae0e02db66c7bc946
|
||||
|
||||
# ② 画像:对这 46 只看它们在**该时点**的 5/8/10 年画像
|
||||
.venv/bin/python -m hdiv profile --universe-run 02485801b2805cbae0e02db66c7bc946
|
||||
|
||||
# ③ 登记策略(首次需要;之后改 YAML 再 register 即可)
|
||||
.venv/bin/python -m hdiv strategy register
|
||||
|
||||
# ④ 回测:用这个池子,**起点不得早于股票池 asof**
|
||||
.venv/bin/python -m hdiv backtest \
|
||||
--universe-run 02485801b2805cbae0e02db66c7bc946 \
|
||||
--start 2025-01-01
|
||||
|
||||
# ⑤ Walk-forward 样本外验证(★ 判断策略好坏的唯一依据)
|
||||
.venv/bin/python -m hdiv backtest --mode walkforward
|
||||
|
||||
# ⑥ 打开前端看结果
|
||||
.venv/bin/python -m hdiv web # → http://127.0.0.1:8099/
|
||||
```
|
||||
|
||||
> **③④ 的顺序无所谓,②和④互不依赖**(见 §0.3 的「关键认知」)。
|
||||
> **④ 与 ⑤ 的区别是本质性的**:④ 是「一条路径」,⑤ 是「逐年样本外」。
|
||||
> 本项目只认 ⑤ —— 详见 §7.5。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 步骤①:选股 `hdiv universe`
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m hdiv universe --asof 2025-01-01 [--no-persist] [--html]
|
||||
```
|
||||
|
||||
### 发生了什么
|
||||
|
||||
```
|
||||
① asof 归一化 → 2025-01-01 是元旦休市,落到「≤ asof 的最近交易日」= 2024-12-31
|
||||
② 取候选集 → stock 表中 list_date <= asof 且 (delist_date 为空或 > asof)
|
||||
即「当时已上市、当时未退市」——**包含此后才退市的股票**(消除生存者偏差)
|
||||
③ 挂 PIT 面板 → daily_basic(当日或最近 5 个交易日内)、20 日均成交额、
|
||||
最新已公告财报(ann_date <= asof)、已实施且已除权的分红
|
||||
④ 依次过 4 个滤网 → market → risk → dividend → quality(先便宜的、淘汰率高的)
|
||||
每只股票记录:每个滤网的通过位 + **首个未通过的滤网 + 原因 + 取值快照**
|
||||
⑤ 截断 → 按股息率降序取前 output.max_members(默认 200)只
|
||||
⑥ 落库 → hd_universe_run(头)+ hd_universe_member(逐股留痕)
|
||||
```
|
||||
|
||||
各滤网的判据(全部来自 `config/universe.yml`,改 YAML 即改行为):
|
||||
|
||||
| 顺序 | 滤网 | 主要判据 |
|
||||
|---|---|---|
|
||||
| 1 | `market` | 交易所 ∈ [SSE, SZSE]、板块 ∈ [主板/创业板/科创板]、上市 ≥ 10 年、总市值 ≥ 500 亿、20 日均成交额 ≥ 2,000 万元、当日有行情 |
|
||||
| 2 | `risk` | 非 ST(按 `stock_name_history` 还原**当时**的名字)、未退市、未停牌、净资产为正、资产负债率 ≤ 80%(银行/保险/证券/信托豁免) |
|
||||
| 3 | `dividend` | 股息率 ≥ 3%(**自算 PIT-TTM**,非 `dv_ttm`)、连续分红 ≥ 5 年、6 年窗口内至少 5 个分红年、支付率 ≤ 100%、自由现金流为正、FCF 覆盖分红 ≥ 1 倍 |
|
||||
| 4 | `quality` | 5 年平均 ROE ≥ 8%、经营现金流/净利润 ≥ 0.6(用**年报**而非季报,否则累计值会误杀) |
|
||||
|
||||
### 关键性质
|
||||
|
||||
1. **无未来函数**:每一条判据都带 `<= asof` 约束(行情 `trade_date`、财报 `ann_date`、
|
||||
分红 `imp_ann_date` 与 `ex_date` 双重)。ST 状态按历史名称还原,不看今天的名字。
|
||||
2. **run_id 是确定性的**:由「配置哈希 + asof」决定,**不含时间戳**。
|
||||
所以同配置同时点重跑会**原地覆盖同一条记录**,不会积累重复。
|
||||
3. **`asof` 会被归一化到交易日**。`--asof 2025-01-01` 与 `--asof 2024-12-31`
|
||||
得到**同一个 run_id**。看到打印的 `asof=2024-12-31` 不是 bug。
|
||||
4. **`--no-persist` 的后果**:结果不落库 → 前端看不到,**也无法被回测引用**。
|
||||
CLI 会显式提醒。
|
||||
|
||||
### 落库与追溯
|
||||
|
||||
| 表 | 内容 |
|
||||
|---|---|
|
||||
| `hd_universe_run` | run_id / asof_date / 候选数 / 入选数 / 配置指纹 / 数据版本 |
|
||||
| `hd_universe_member` | 逐股:每个滤网通过位、`fail_stage`(首个未通过滤网)、`fail_reason`(中文原因)、取值快照 |
|
||||
|
||||
> **「为什么没选上」是这个模块最重要的产出。** 前端「股票池」页可逐股下钻。
|
||||
|
||||
---
|
||||
|
||||
## 0.2 步骤②:画像 `hdiv profile`
|
||||
|
||||
```bash
|
||||
# 对某个股票池的全部成员,按**该股票池的 asof** 画像
|
||||
.venv/bin/python -m hdiv profile --universe-run <run_id>
|
||||
|
||||
# 指定股票 + 指定时点(任意历史时点,PIT)
|
||||
.venv/bin/python -m hdiv profile --symbols 600036.SH 000651.SZ --asof 2018-05-18
|
||||
```
|
||||
|
||||
### 发生了什么
|
||||
|
||||
```
|
||||
① 确定对象与时点 → --universe-run 时 asof 取该池的 asof_date;--asof 可显式覆盖
|
||||
② 取数 → 起点 = asof.year − max(windows_years) − 1 的 1 月 1 日,终点 = asof
|
||||
价格(不复权)、daily_basic(PE/PB/PS)、分红、年报财务、指数
|
||||
③ 逐股逐指标统计 → 每个指标 × 每个窗口 [全历史, 5, 8, 10] 年:
|
||||
n_obs、min/max/mean/median/std、P10/P25/P50/P75/P90、
|
||||
当前值、**当前值在该窗口分布中的分位**
|
||||
④ 分红质量 → 连续分红年数、DPS 增速与波动、支付率、FCF 覆盖
|
||||
(与「股票池筛选」共用同一套口径函数)
|
||||
⑤ 安全边际评分 → 5 个分项(股息率/估值/财务质量/资产负债表/分红质量)
|
||||
+ 全项齐备时才给 composite 综合分
|
||||
⑥ 覆盖率自检 → 窗口实际观测数 ÷ 该窗口应有交易日数;不足则打印警告
|
||||
⑦ 落库 → hd_profile_run / hd_profile_stat / hd_profile_series / hd_profile_score
|
||||
```
|
||||
|
||||
### 输出解读
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `n_obs` | 该窗口内的实际观测数(**注意分位就是在这 n 个观测上算的**) |
|
||||
| `current_value` | asof 当天的值(取窗口内**最后一个观测**) |
|
||||
| `current_percentile` | 当前值在窗口分布中的分位(`≤ 当前值的观测占比`) |
|
||||
| `status` | `OK` / `INSUFFICIENT`(样本不足,**不猜、不用 0 填充**) |
|
||||
| 窗口覆盖率 | 1.0 = 名义 5 年真有 5 年数据;< 1 说明被数据起点截短 |
|
||||
|
||||
CLI 会打印覆盖率,例如:
|
||||
|
||||
```
|
||||
画像 <run_id>:46 只
|
||||
窗口覆盖率:最差 10 年窗口 94.6%(1.0 = 名义窗口被完整覆盖)
|
||||
```
|
||||
|
||||
### 关键认知(最容易误解的一点)
|
||||
|
||||
> **画像是一次「快照」,回测并不读它。**
|
||||
>
|
||||
> 回测里每次买入前用的画像,是引擎**在该决策日实时重算**的
|
||||
> (见 §0.3 第④步),与这里落库的快照是**两条独立路径**。
|
||||
> 两者由「逐值等价测试」约束,不会给出两个不同的数。
|
||||
>
|
||||
> 所以:**画像页是给你看的,不是给回测用的。** 回测每天自己算。
|
||||
|
||||
---
|
||||
|
||||
## 0.3 步骤③:回测 `hdiv backtest`
|
||||
|
||||
```bash
|
||||
# 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 asof
|
||||
.venv/bin/python -m hdiv backtest --universe-run <run_id> --start 2025-01-01
|
||||
|
||||
# 不冻结:引擎在每个调仓日按当时可见数据重新筛选(判断策略本身用这个)
|
||||
.venv/bin/python -m hdiv backtest --start 2015-01-01
|
||||
```
|
||||
|
||||
### 发生了什么(逐日事件循环)
|
||||
|
||||
```
|
||||
① 确定区间 → 交易日历取 [start, end],默认取 config/backtest.yml 的 period
|
||||
② 重建股票池 → --universe-run:守卫校验 asof ≤ 回测首个交易日,然后**冻结**该清单,
|
||||
所有调仓日复用(每 12 个月不再重筛)
|
||||
否则:每 universe_refresh_months=12 个月的调仓日,调 selector.run(asof=当日)
|
||||
—— 用的是**当时可见**的数据(PIT)
|
||||
③ 预载面板 → 价格(不复权)、分红事件、停牌、涨跌停、指数
|
||||
若闸门启用:另按最长窗口预载画像面板
|
||||
④ 逐日循环 ↓
|
||||
④a 开盘 → 执行**昨日**收盘产生的信号,成交价 = 次日开盘价 ± 滑点
|
||||
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送转股增加股数
|
||||
④c 收盘 → 每月一次评估信号(signal_frequency_months=1):
|
||||
· 算当日股息率 = TTM 每股分红 ÷ 不复权收盘价
|
||||
· 算历史分位 = 当前值在「(当日−5年, 当日]」分布中的占比
|
||||
· 分位 ≥ entry.yield_percentile(75) → 买入候选(阶梯定目标仓位)
|
||||
· 分位 ≤ exit.yield_percentile(25) → 卖出候选
|
||||
· **买入候选再过「实时画像闸门」**(见下)
|
||||
④d 收盘 → 盯市:总市值 = 现金 + 持仓 × 不复权收盘价 → 净值曲线
|
||||
⑤ 绩效与对账 → 收益/CAGR/回撤/Sharpe/Sortino/Calmar、逐年收益、
|
||||
**资金恒等式残差**(应为 0)
|
||||
⑥ 落库 → hd_backtest_run / _equity / _position / _trade / _signal / _metric
|
||||
```
|
||||
|
||||
### ④c 的「实时画像闸门」(★ 你关心的那件事)
|
||||
|
||||
| 问题 | 答案 |
|
||||
|---|---|
|
||||
| 会实时算画像吗? | **会**。每个决策日、对**已触发买入条件**的标的,按当时可见数据重算过去 5 年画像 |
|
||||
| 用哪一天的画像? | **该决策日自己的**。2025-03-31 用 (2020-03-31, 2025-03-31],不是「2025-01-01 那天」的 |
|
||||
| 每只股票每天都算吗? | **不是**。惰性:只有股息率分位 ≥ P75 已触发的才去算 |
|
||||
| 算全量指标吗? | 算 `_profile_one` 的全部指标,但**只加载规则声明用到的面板**(纯估值规则不查财报表) |
|
||||
| 管卖出吗? | **不管**。卖出/减仓只看股息率分位 |
|
||||
| 决定仓位大小吗? | **不决定**。`weight_scheme: score` 未实现,仓位由分位阶梯决定 |
|
||||
| 不通过会怎样? | 产出信号类型 `REJECT` + `skip_reason=PROFILE_GATE`,**不成交**;前端「未成交信号」可逐条看每条规则的实际值/阈值/是否通过 |
|
||||
| 数据缺失/样本不足? | 按 `on_unverifiable`(默认 `reject`)保守处理 —— **不猜** |
|
||||
| 关掉它? | `entry.profile_gate.enabled: false`,整条链路不参与,行为回到改造前 |
|
||||
|
||||
配置与规则详见 §4.3「实时画像闸门」。
|
||||
|
||||
### 未来函数守卫(会拒绝执行的情况)
|
||||
|
||||
| 情形 | 行为 |
|
||||
|---|---|
|
||||
| `--universe-run` 且**股票池 asof > 回测首个交易日** | **拒绝执行**,退出码 1,错误信息给出三种正确做法 |
|
||||
| `--universe-run` 用在 `--mode walkforward` | **拒绝执行**(训练窗口比股票池时点更早) |
|
||||
| `--universe-run` 且 asof ≤ 起点 | 正常执行(股票池属于**事前信息**) |
|
||||
| 确需复现带未来信息的旧结果 | 显式加 `--allow-lookahead-universe`;偏差会写入 `unimplemented_json` |
|
||||
|
||||
> **例**:`--universe-run b8dd742f…`(asof=2025-01-21)+ 默认起点 2015-01-01
|
||||
> → **直接报错,不会跑出任何结果**。
|
||||
> 想用这个池子,必须 `--start 2025-01-21`(或更晚)。
|
||||
|
||||
### 回测里的成交与成本口径
|
||||
|
||||
| 项 | 口径 |
|
||||
|---|---|
|
||||
| 成交价 | 信号**次日开盘价** ± 滑点(固定此行为,`same_close` 等分支未实现) |
|
||||
| 佣金 / 印花税 / 过户费 | 佣金双边取 `max(额×费率, 最低佣金)`;印花税**仅卖出**;过户费双边 |
|
||||
| 红利税 | 按持股期限:≤1 月 20%、≤1 年 10%、>1 年免征 |
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板 → 该信号**跳过**(记 `skip_reason`) |
|
||||
| 停牌 | 该信号**跳过**(`backtest.yml` 写的 `defer` **未实现**,不会顺延) |
|
||||
| 分红 | 除权日入账,**留存为现金**(`cash_mode: reinvest` 未实现),下次调仓按目标权重再配置 |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
|
||||
> 你可以直接从库里读到该 run 到底哪些约束没生效 —— 不会静默。
|
||||
|
||||
### 验证这次回测「干净」的两个动作
|
||||
|
||||
```sql
|
||||
-- ① 有没有被声明为「含未来信息」(应为 0)
|
||||
SELECT run_id, start_date, end_date,
|
||||
(unimplemented_json LIKE '%含未来信息%') AS bias_flag
|
||||
FROM hd_backtest_run
|
||||
WHERE universe_run_id = '<你的 run_id>'
|
||||
ORDER BY created_at DESC;
|
||||
|
||||
-- ② 资金对账残差必须为 0(不为 0 时不要采信任何绩效指标)
|
||||
SELECT run_id, status, unimplemented_json FROM hd_backtest_run
|
||||
ORDER BY created_at DESC LIMIT 1;
|
||||
```
|
||||
|
||||
CLI 也会直接打印对账残差与 `✓`。
|
||||
|
||||
---
|
||||
|
||||
## 0.4 步骤④:看结果与「能不能信」
|
||||
|
||||
| 入口 | 看什么 |
|
||||
|---|---|
|
||||
| `hdiv web` → 首页 | 数据审计、股票池、画像、回测、Walk-forward 的汇总入口 |
|
||||
| 回测详情 | 净值曲线、逐笔成交(含 `reason_json`:为什么买)、未成交信号(含 `REJECT` 原因)、持仓 |
|
||||
| **Walk-forward 详情** | **逐窗口样本内/样本外对比** —— 判断策略好坏的唯一依据 |
|
||||
| `output/reports/` | 静态 HTML 快照(需 `--html` 显式导出) |
|
||||
|
||||
**读结论的三条纪律**:
|
||||
|
||||
1. **只认 Walk-forward 的样本外结果**,不要采信单路径全期数字。
|
||||
本项目的实测就是反例:同一份数据,回补前后单路径从 +117% 掉到 +93%,
|
||||
而样本外 7 个窗口**一个数字都没变**。
|
||||
2. **对账残差不为 0 就不要看绩效**。
|
||||
3. **样本太短不要下结论**。用 2025 起的池子跑不到 2 年(约 21 个调仓月),
|
||||
统计上说明不了任何问题,更**不能拿来调参**。
|
||||
|
||||
---
|
||||
|
||||
## 0.5 一页速查:每步的命令、产出、坑
|
||||
|
||||
| 步骤 | 命令 | 落库 | 最容易踩的坑 |
|
||||
|---|---|---|---|
|
||||
| ① 选股 | `hdiv universe --asof <日期>` | `hd_universe_run` / `hd_universe_member` | `asof` 会归一化到交易日;`--no-persist` 后无法被回测引用 |
|
||||
| ② 画像 | `hdiv profile --universe-run <id>` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) |
|
||||
| ③ 回测 | `hdiv backtest [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`/`reinvest` 等未实现项在 `unimplemented_json` 里 |
|
||||
| ④ 验证 | `hdiv backtest --mode walkforward` | `hd_walkforward_run` / `_window` | 必须与 `--universe-run` 分开用;约 25~80 分钟 |
|
||||
| ⑤ 调参 | `hdiv sensitivity --sweep "..."` | `hd_sensitivity_run` / `_point` | 样本不足时噪声会被误读为过拟合 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
# 1. 系统是什么
|
||||
|
||||
一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线:
|
||||
@@ -120,26 +403,34 @@ export PYTHONPATH=src
|
||||
|
||||
## 2.4 完整跑一遍策略研究
|
||||
|
||||
> **完整的分步讲解见 §0(先读那一节)。** 这里只给最短的命令序列。
|
||||
|
||||
```bash
|
||||
export PYTHONPATH=src
|
||||
|
||||
# 股票池(时点:2024-06-28)
|
||||
# ① 股票池(时点:2024-06-28)
|
||||
.venv/bin/python -m hdiv universe --asof 2024-06-28
|
||||
# → 记下输出的 run_id
|
||||
|
||||
# 个股画像(对股票池内全部股票)
|
||||
.venv/bin/python -m hdiv profile --universe-run <上一步的 run_id>
|
||||
# ② 个股画像(对股票池内全部股票,按该池的时点)
|
||||
.venv/bin/python -m hdiv profile --universe-run <①的 run_id>
|
||||
|
||||
# 登记策略
|
||||
# ③ 登记策略
|
||||
.venv/bin/python -m hdiv strategy register
|
||||
|
||||
# 回测(默认区间取 config/backtest.yml 的 period)
|
||||
.venv/bin/python -m hdiv backtest
|
||||
# ④ 回测
|
||||
# - 若要用①这个池子:必须带 --universe-run,且 --start 不得早于该池 asof
|
||||
# (2024-06-28 的池子 → 起点最早 2024-06-28;否则会被未来函数守卫拒绝)
|
||||
# - 若要看**策略本身**的历史表现:去掉 --universe-run,让引擎逐调仓日重筛
|
||||
.venv/bin/python -m hdiv backtest --universe-run <①的 run_id> --start 2024-06-28
|
||||
.venv/bin/python -m hdiv backtest --start 2015-01-01 # 策略本身
|
||||
# 注意:不传 --start 时取 config/backtest.yml 的 period.start(2015-01-01),
|
||||
# 这与 --universe-run 组合会被**拒绝**,详见 §0.3 的守卫表。
|
||||
|
||||
# Walk-forward 样本外验证(约 25 分钟)
|
||||
# ⑤ Walk-forward 样本外验证(★ 判断策略的唯一依据,约 25~80 分钟)
|
||||
.venv/bin/python -m hdiv backtest --mode walkforward
|
||||
|
||||
# 参数敏感性
|
||||
# ⑥ 参数敏感性
|
||||
.venv/bin/python -m hdiv sensitivity
|
||||
```
|
||||
|
||||
@@ -310,17 +601,26 @@ industry_exemptions:
|
||||
| `series_max_points` | `1500` | 落库序列点数上限(等间隔降采样,仅影响画图) |
|
||||
| `series_metrics` | `[dv_yield, pe_ttm, pb, close, drawdown]` | 哪些指标需要落时间序列 |
|
||||
| `ttm_dividend.window_days` | `365` | TTM 每股分红回看天数 |
|
||||
| `ttm_dividend.grace_days` | `45` | 宽限期(见下方说明) |
|
||||
| `ttm_dividend.grace_days` | `45` | 宽限期/容差(见下方说明) |
|
||||
| `ttm_dividend.smooth_spikes` | `true` | 消除除权间隔不规整造成的毛刺 |
|
||||
| `percentiles` | `[10,25,50,75,90]` | 需计算的分位数 |
|
||||
| `dividend_yield_volatility.*` | 250/60/20/10 | 日/月/季/年频波动窗口 |
|
||||
| `safety_margin.mode` | `separate` | `separate`=分项展示 / `composite`=加权综合 |
|
||||
| `safety_margin.weights` | 见文件 | 综合分权重(`composite` 模式下必须和为 1.0) |
|
||||
| `safety_margin.score_anchors` | 见文件 | 各分项的满分/零分锚点 |
|
||||
|
||||
> **`grace_days` 为什么必需**:A 股年度分红的除权间隔中位数约 **366 天**
|
||||
> (实测招商银行 5 次间隔 > 365 天,最长 393 天)。严格 365 天窗口会制造
|
||||
> 1~3 天的「空窗期」,把股息率算成 0 —— 这是统计假象,会污染历史分位。
|
||||
> 仅当严格窗口结果为零时,才回退到 `365 + grace_days`。
|
||||
> **`grace_days` 的作用**:A 股相邻两次除权间隔经常 ≠ 365 天,
|
||||
> 硬窗口会在每年除权日附近制造两种日历假象 ——
|
||||
> **重叠虚高**(间隔 < 365,新旧同时在窗口内,实测招商银行 +108%)
|
||||
> 与**断档虚低**(间隔 > 365,旧的已到期新的未入场,实测中国神华 −57%)。
|
||||
>
|
||||
> `smooth_spikes: true` 时按「同一档年度分红由后继接管」处理:
|
||||
> 间隔落在 `365 ± grace_days` 内即视为同一次分红的正常漂移,
|
||||
> 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红
|
||||
> (中期+年度),彼此都保留;超过 `365 + grace_days` 仍无后继则如实归零。
|
||||
>
|
||||
> 实测效果:招商银行 >20% 跳变 21 → 5 次,中国银行虚低归零 77 天 → 0 天。
|
||||
> 若个别股票仍有断档,把 `grace_days` 调大(如 90)。
|
||||
|
||||
---
|
||||
|
||||
@@ -339,11 +639,101 @@ strategy:
|
||||
|---|---:|---|
|
||||
| `entry.yield_percentile` | `75` | 买入阈值:股息率 ≥ 历史 P75 |
|
||||
| `entry.scale_in[]` | 75→25%, 80→50%, 85→75%, 90→100% | 分批建仓阶梯 |
|
||||
| `entry.profile_gate` | 启用,4 条规则 | **实时画像闸门**(见下节) |
|
||||
| `exit.yield_percentile` | `25` | 卖出阈值:股息率 ≤ 历史 P25 |
|
||||
| `exit.scale_out[]` | 50→50%, 25→0% | 分批减仓阶梯 |
|
||||
| `position.max_position` | `0.10` | 单股仓位上限 |
|
||||
| `position.sector_max_position` | `0.25` | 单行业仓位上限 |
|
||||
| `position.max_holdings` | `20` | 最多持仓只数 |
|
||||
| `position.sector_max_position` | `0.25` | 单行业仓位上限(**尚未实现**,见 §11) |
|
||||
| `position.max_holdings` | `20` | 最多持仓只数(**尚未实现**,见 §11) |
|
||||
|
||||
### 实时画像闸门 `entry.profile_gate`(新增)
|
||||
|
||||
**它解决的问题**:股票池每 12 个月才重建一次,期间持有的候选可能早已「不值得买」。
|
||||
闸门的语义是 —— **股息率分位触发买入之后,再用「当日可见的数据」重算一次个股画像,
|
||||
不通过的票直接剔除。**
|
||||
|
||||
```yaml
|
||||
entry:
|
||||
profile_gate:
|
||||
enabled: true # false 时整条链路不参与,回测行为与启用前完全一致
|
||||
window_years: 5 # 画像统计窗口;0 = 全历史;其余必须是 profile.yml 的 windows_years 之一
|
||||
on_unverifiable: reject # 数据缺失/样本不足时:reject = 保守不买(默认)/ pass = 放行
|
||||
rules:
|
||||
- { metric: dividend_continuity_years, op: ">=", value: 5 }
|
||||
- { metric: payout_ratio, op: "<=", value: 1.0 }
|
||||
- { metric: fcf_dividend_cover, op: ">=", value: 1.0 }
|
||||
- { metric: roe_avg, op: ">=", value: 0.08 }
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|---|---|
|
||||
| `metric` | 画像指标代码,只能是 `src/hdiv/core/metrics.py` 里声明的那批(写错在**配置期**就报错) |
|
||||
| `stat` | `current_value`(当日值)/ `current_percentile`(当日值在窗口分布中的分位,仅序列型指标可用) |
|
||||
| `op` | `>=` / `<=` / `>` / `<` |
|
||||
| `value` | 阈值 |
|
||||
|
||||
三条关键性质:
|
||||
|
||||
1. **无未来函数**:每个决策日只用「当时可见」的价格、每日指标、分红(`imp_ann_date`
|
||||
与 `ex_date` 双重约束)与财报(`ann_date <= asof`)。有负例测试守护
|
||||
(公告日前一天不得看到该年报;除权日前一天不得包含该笔分红)。
|
||||
2. **与批量画像同一定义**:闸门用的指标与 `hdiv profile` 页面上的指标**逐值一致**
|
||||
(有等价性测试),不会出现「页面一个数、回测另一个数」。
|
||||
3. **惰性与复用**:只在买入条件已触发时才计算;跨股票共享的面板按时点缓存,
|
||||
长周期指标从同一份已载入面板里切窗口。因此成本与**触发次数**成正比,
|
||||
而不是与「区间长度 × 股票数」成正比;规则里不含财务指标时完全不查财报。
|
||||
|
||||
**被剔除的信号怎么看**:信号类型为 `REJECT`,`skip_reason = PROFILE_GATE`,
|
||||
会出现在「回测 → 未成交信号」列表里,标签为
|
||||
**「实时画像未通过,主动放弃买入」**;`reason_json.profile_gate.checks`
|
||||
逐条记录每个指标的**实际值、阈值、状态、是否通过**,可直接回答「为什么没买」。
|
||||
|
||||
**`on_unverifiable` 怎么选**:这是本项目的一贯取舍(同 `universe.dividend.on_missing_data`)。
|
||||
|
||||
| 取值 | 行为 | 适用 |
|
||||
|---|---|---|
|
||||
| `reject`(默认) | 数据缺失或样本不足 → 不买 | 忠于「安全边际」:宁可错过,不可踩雷 |
|
||||
| `pass` | 无法验证 → 放行 | 与股票池筛选口径一致;结果更依赖数据完整度 |
|
||||
|
||||
> 实测差异很大:2016 年初,中石化的**最新可见分红属于 FY2015,而 FY2015 年报尚未公告**
|
||||
> (约 3 月才披露),因此「支付率」在当时**根本无法验证**。
|
||||
> `reject` 会放弃买入,`pass` 会照买 —— 这不是 bug,而是策略取舍得由你决定。
|
||||
|
||||
### 窗口覆盖率 `min_window_coverage`(新增,重要)
|
||||
|
||||
**「名义 5 年」不等于「真有 5 年数据」。** `window_slice(asof, 5)` 的语义是
|
||||
「把已有数据切成最近 5 年」,数据起点晚于窗口左端时窗口会被**静默截短**。
|
||||
本项目行情/每日指标自 **2015-01-05** 才有,所以实测(600036.SH,股息率,5 年窗口):
|
||||
|
||||
| asof | 窗口内实际观测 | 应有交易日 | 覆盖率 |
|
||||
|---|---:|---:|---:|
|
||||
| 2015-12-31 | 239 | 1214 | **19.7%** |
|
||||
| 2016-12-30 | 483 | 1214 | **39.8%** |
|
||||
| 2018-05-18 | 817 | 1219 | **67.0%** |
|
||||
| 2019-12-31 | 1214 | 1219 | 99.6% |
|
||||
| 2020-12-31 起 | ≈1218 | ≈1218 | **100%** |
|
||||
|
||||
也就是说:**2019 年及之前的「过去 5 年画像」实际只有 0.2~4 年数据**,
|
||||
而画像的 `status` 仍报 `OK`(它的门槛低到 20 个观测)。
|
||||
|
||||
```yaml
|
||||
entry:
|
||||
profile_gate:
|
||||
window_years: 5
|
||||
min_window_coverage: 0.0 # 0 = 不因覆盖率淘汰(默认);1.0 = 必须完整覆盖
|
||||
```
|
||||
|
||||
| 取值 | 行为 |
|
||||
|---|---|
|
||||
| `0.0`(默认) | 覆盖率只**记录**在信号的 `reason_json.profile_gate.checks[].window_coverage`,不影响判定 —— 保持改造前行为 |
|
||||
| `1.0` | 名义 5 年必须真有 5 年数据;不足时按 `on_unverifiable` 处理(默认 → 不买) |
|
||||
| 中间值 | 例如 `0.8` = 允许最多缺 20% |
|
||||
|
||||
**建议**:先把数据回补到位(见 §6.6),再考虑启用 `min_window_coverage: 1.0`;
|
||||
否则 2019 年之前会几乎没有买入信号(那是**数据不足**,不是策略判断)。
|
||||
|
||||
> `hdiv profile` 命令也会打印覆盖率,例如:
|
||||
> `⚠ 5 年窗口数据不足:最低覆盖率仅 67.0%(窗口会被数据起点截短)`
|
||||
|
||||
### 目标仓位阶梯(重要)
|
||||
|
||||
@@ -436,9 +826,9 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `benchmark[]` | 沪深300 / 中证红利 / 上证指数 | 基准列表 |
|
||||
| `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) |
|
||||
| `fill.price` | `next_open` | 信号次日开盘成交 |
|
||||
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过 |
|
||||
| `fill.suspended_rule` | `defer` | 停牌时顺延 |
|
||||
| `dividend.cash_mode` | `reinvest` | 分红处理:`reinvest`/`hold`/`cash_out` |
|
||||
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
|
||||
| `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
|
||||
| `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
|
||||
|
||||
---
|
||||
|
||||
@@ -452,6 +842,9 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `charts.*` | 全部 `true` | 各图表开关 |
|
||||
| `naming.*` | 见文件 | 输出文件命名模板 |
|
||||
| `layout.max_width` | `1440` | 页面最大宽度(px) |
|
||||
| `layout.decimals.ratio` | `4` | **比率的小数位**。百分比小数位 = ratio − 2(ratio=4 → 股息率 6.17%;ratio=6 → 6.1715%)。同时作用于静态报告与 Web 前端 |
|
||||
| `layout.decimals.money` | `2` | 金额小数位 |
|
||||
| `layout.decimals.price` | `2` | 价格小数位 |
|
||||
|
||||
> **`asset_mode` 怎么选**:见 [§7.8 报告部署](#78-报告部署)。
|
||||
|
||||
@@ -504,7 +897,8 @@ hdiv sync financial [--interleaved] [--only-missing] [--apis ...] [--limit N]
|
||||
hdiv sync index [--no-weight] [--start YYYYMMDD]
|
||||
hdiv sync price --which daily|adj_factor|daily_basic --start --end [--no-resume]
|
||||
hdiv sync trading [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--no-resume]
|
||||
hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-12-31] [--no-resume]
|
||||
hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
|
||||
[--basic-start <同 --start>] [--basic-end 2019-12-31] [--no-resume]
|
||||
```
|
||||
|
||||
| 目标 | 说明 | 首次耗时 |
|
||||
@@ -514,11 +908,15 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-1
|
||||
| `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
|
||||
| `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 |
|
||||
| `trading` | 停牌与涨跌停 | ~20 分钟 |
|
||||
| `backfill` | 2015–2018 历史回补(写入 qlib 原有表,`INSERT IGNORE` 不覆盖既有行) | ~15 分钟 |
|
||||
| `backfill` | 历史回补(写入 qlib 原有表,`INSERT IGNORE` **不覆盖既有行**) | 视区间 |
|
||||
|
||||
> **`--only-missing`**:跳过已同步的标的,支持断点续传
|
||||
> **`--interleaved`**:按股票一次性拉齐四张报表,使策略相关的大市值股票优先就绪
|
||||
> **回补需要显式授权**:`HDIV_ALLOW_BACKFILL=1` 或改 `datasource.yml`
|
||||
> **`--basic-start` 必须显式给出**:`run_backfill` 的默认 `basic_start=2015-01-01`,
|
||||
> 早期 CLI 没有这个参数,于是「回补 2010–2014」实际只补了行情与复权因子,
|
||||
> **`daily_basic` 仍停在 2015** —— 画像里的 PE/PB/股息率照样拿不到早年数据。
|
||||
> 现在 `--basic-start` 缺省时跟随 `--start`。
|
||||
|
||||
## 5.3 `audit` — 数据审计
|
||||
|
||||
@@ -526,10 +924,16 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-1
|
||||
hdiv audit [--no-persist] [--html]
|
||||
```
|
||||
|
||||
执行 18 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、代码有效性),
|
||||
执行 15 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、量价单位一致性、代码有效性),
|
||||
结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。
|
||||
退出码:总体 FAIL 时为 1。
|
||||
|
||||
> 与单位有关的两项:`UNIT`(`daily_basic` 的市值恒等式 + 量级)与
|
||||
> `UNIT-OHLCV`(`stock_daily` 逐年抽样的量价单位一致性)。
|
||||
> 后者当前会报 **WARN**:2015-2019 的存量行仍是 Tushare 原始单位,
|
||||
> 读取层已兜底换算(结果正确),但存量数据本身仍应择机重刷。
|
||||
> 详见 §9.1。
|
||||
|
||||
## 5.4 `universe` — 股票池筛选
|
||||
|
||||
```bash
|
||||
@@ -554,11 +958,21 @@ hdiv universe [-c config/universe.yml] [--asof 2024-06-28 | latest] [--no-persis
|
||||
## 5.5 `profile` — 个股画像
|
||||
|
||||
```bash
|
||||
hdiv profile --universe-run <run_id> # 对股票池全部股票
|
||||
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票
|
||||
hdiv profile --universe-run <run_id> # 对股票池全部股票(asof 取该股票池的时点)
|
||||
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票与**时点**
|
||||
hdiv profile --symbols 600519.SH --asof 2018-05-18 # 任意历史时点的 PIT 画像
|
||||
hdiv profile --universe-run <run_id> --html --html-limit 20 # 导出前 20 只的静态报告
|
||||
```
|
||||
|
||||
> `--asof` 决定画像用**哪些数据**:只使用 `<= asof` 的行情、每日指标、分红与财报。
|
||||
> 例如 `--asof 2018-05-18` 得到的就是「2018-05-18 当天能算出的画像」,
|
||||
> 5 年窗口覆盖 (2013-05-18, 2018-05-18]。
|
||||
> 不带 `--asof` 且带 `--universe-run` 时,asof 取该股票池的筛选时点。
|
||||
>
|
||||
> **注意**:画像是一次**快照**(每个 asof 一份)。回测并不读取它 ——
|
||||
> 回测的交易依据是引擎在每个决策日**实时重算**的画像,见
|
||||
> §4.3「实时画像闸门」与 §9.2。
|
||||
|
||||
## 5.6 `strategy` — 策略管理
|
||||
|
||||
```bash
|
||||
@@ -572,19 +986,41 @@ hdiv strategy diff -f A.yml --other B.yml # 比较两版差异
|
||||
|
||||
```bash
|
||||
hdiv backtest [--start 2015-01-01] [--end 2026-09-30] [--no-persist] [--html]
|
||||
hdiv backtest --mode walkforward # 7 个滚动窗口,约 25 分钟
|
||||
hdiv backtest --mode walkforward # 7 个滚动窗口
|
||||
hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建立关联
|
||||
|
||||
# 注意 1:--universe-run 与 --mode walkforward 不能同时使用(会报错说明原因)。
|
||||
# 注意 2:单次回测里,若股票池的 asof 晚于回测起点,同样会被**拒绝**
|
||||
# (同一条未来函数纪律)。错误信息会给出三种正确做法:
|
||||
# a) 去掉 --universe-run,让引擎逐调仓日按当时可见数据重新筛选(推荐)
|
||||
# b) 把 --start 改到股票池 asof 之后
|
||||
# c) 确需复现带未来信息的历史结果:显式加 --allow-lookahead-universe
|
||||
# —— 该偏差会写入 hd_backtest_run.unimplemented_json,可被检索出来
|
||||
```
|
||||
|
||||
输出示例:
|
||||
> **为什么单次回测也要拒绝**:股票池带 asof 时点。用 2025-01-21 选出的名单去跑
|
||||
> 2015 年起的区间,名单里含有 2015 年不可能知道的信息(哪些公司此后仍满足
|
||||
> 连续分红、5 年 ROE、自由现金流覆盖等条件)。实测:这样跑出的回测
|
||||
> **在 2015-01-06 就有 8 笔成交**,而按当时可见数据筛选,那段时间的股票池
|
||||
> 只有个位数只股票。
|
||||
|
||||
输出示例(当前配置:实时画像闸门启用):
|
||||
|
||||
```
|
||||
期初 1,000,000 → 期末 2,148,010 | 总收益 114.80% | CAGR 6.73% | 最大回撤 -21.05% | Sharpe 0.31 | 成交 180 笔
|
||||
累计现金分红 558,797(已扣红利税 13,676) | 对账残差 -0.0000 ✓
|
||||
回测 HD_MR_V1 v1.0:2015-01-05 ~ 2026-09-30(2846 个交易日)
|
||||
实时画像闸门已启用:窗口 5 年,4 条规则,面板自 2004-01-01 起载入(86 只)
|
||||
实时画像:计算 2428 次(缓存命中 0),涉及 141 个决策时点,
|
||||
财报面板载入 141 次,流动性查询 0 次 | 画像剔除 1027 次
|
||||
期初 1,000,000 → 期末 1,927,312
|
||||
总收益 92.73% CAGR 5.75% 最大回撤 -25.38% Sharpe 0.22 Calmar 0.23 成交 197 笔
|
||||
累计现金分红 …(已扣红利税 …) | 对账残差 -0.0000 ✓
|
||||
```
|
||||
|
||||
> **「对账残差」是资金恒等式的校验值**(期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红)。
|
||||
> 应为 0;若不为 0 说明成本或分红入账有遗漏,**此时不要采信绩效指标**。
|
||||
>
|
||||
> 「画像剔除 1027 次」= 有多少个买入信号被实时画像拦下。它们全部以
|
||||
> `REJECT` 记录在库,可在前端「未成交信号」里逐条查看每条规则的实际值。
|
||||
|
||||
## 5.8 `sensitivity` — 参数敏感性
|
||||
|
||||
@@ -692,6 +1128,87 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|
||||
|
||||
> ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §7.5)。
|
||||
|
||||
## 6.6 把「过去 5 年」补齐(数据回补)
|
||||
|
||||
> **✅ 本仓库当前的数据状态:已回补完成(2026-10-04)** ——
|
||||
> `stock_daily`/`adjust_factor`/`daily_basic` 均覆盖到 **2005-01-04**,
|
||||
> `hd_suspend`/`hd_limit` 覆盖到 **2010-01-04**。
|
||||
> 5 年窗口覆盖率自 2015-01-05 起为 **98.7%~100%**,残差已逐日与
|
||||
> `hd_suspend` 交叉核实为**真实停牌**(16/16 命中)。
|
||||
> 所以下面这节现在是**方法说明与重做指引**,而不是待办事项。
|
||||
> 完整记录见 [implementation-status §9.3c](implementation-status.md)。
|
||||
|
||||
### 先看缺口:哪些数据支持 5 年回看
|
||||
|
||||
| 数据 | 回补前起点 | 当前 |
|
||||
|---|---|---|
|
||||
| `stock_daily` 行情 | 2015-01-05 | **2005-01-04** ✅ |
|
||||
| `daily_basic` PE/PB/股息率 | 2015-01-05 | **2005-01-04** ✅ |
|
||||
| `adjust_factor` 复权因子 | 2015-01-05 | **2005-01-04** ✅ |
|
||||
| `hd_suspend` / `hd_limit` | 2019-01-02 | **2010-01-04** ✅ |
|
||||
| `hd_dividend` 分红 | 1991 | 不变 ✅ |
|
||||
| `hd_fina_indicator` / `hd_income` / `hd_cashflow` | 1990 / 1990 / 2001 | 不变 ✅ |
|
||||
| `hd_index_daily` 基准 | 1990 | 不变 ✅ |
|
||||
| `stock_name_history` ST 历史 | 1990 | 不变 ✅ |
|
||||
| `trading_calendar` 交易日历 | 2000 | 不变 ✅ |
|
||||
|
||||
**结论:卡住「5 年画像」的只有行情与每日指标,缺口就在 2015-01-05 之前。**
|
||||
|
||||
### 回补到哪一年,取决于你要覆盖多早的决策日
|
||||
|
||||
「asof 的 5 年窗口完整」要求数据起点 ≤ `asof − 5 年`:
|
||||
|
||||
| 想覆盖的决策日 | 必须回补区间 | 新增交易日(约) |
|
||||
|---|---|---:|
|
||||
| 2015-01-05 起 | **2010-01-01 ~ 2014-12-31** | 1,212 |
|
||||
| 2018-01-01 起 | 2013-01-01 ~ 2014-12-31 | 486 |
|
||||
| 同时补齐 8/10 年窗口(`profile.yml: windows_years`) | **2005-01-01 ~ 2014-12-31** | 2,430 |
|
||||
|
||||
### 执行
|
||||
|
||||
```bash
|
||||
cd ~/project/高股息回测
|
||||
export PYTHONPATH=src
|
||||
export HDIV_ALLOW_BACKFILL=1 # 回补写入 qlib 原有表需显式授权
|
||||
|
||||
# ① 行情 + 复权因子 + 每日指标(三者一起补,缺一个画像就残)
|
||||
.venv/bin/python -m hdiv sync backfill \
|
||||
--start 2010-01-01 --end 2014-12-31 \
|
||||
--basic-start 2010-01-01 --basic-end 2014-12-31
|
||||
|
||||
# ② 若还要成交约束(涨跌停/停牌)覆盖到早年
|
||||
.venv/bin/python -m hdiv sync trading --start 2010-01-01 --end 2014-12-31
|
||||
|
||||
# ③ 核对:只增不减,且量价单位一致
|
||||
.venv/bin/python -m hdiv audit
|
||||
```
|
||||
|
||||
**必须知道的四件事**:
|
||||
|
||||
1. **只追加、不改既有行**(`INSERT IGNORE`)。因此 2015-2019 已存在的行不会被
|
||||
修成正确单位 —— 那由读取层兜底(见 §9.1)。回补的新行走的是**正确单位**写入路径。
|
||||
2. **耗时与点数**:`daily`/`adj_factor`/`daily_basic` 的限频是 480 次/分钟,
|
||||
但瓶颈是 HTTP 往返(每日 3 次调用)。1,212 个交易日 ≈ 3,600 次调用,
|
||||
实测量级 **1~3 小时**;补到 2005 年约翻倍。先用 `--limit` 或在测试库试跑。
|
||||
3. **Tushare 权限**:早年数据需要相应积分。若某日返回空,`sync_days` 会记录在
|
||||
`hd_sync_log`,不会中断整体任务。
|
||||
4. **回补后必须重跑**:股票池、画像、回测、Walk-forward 的结论都会变
|
||||
(2015-2019 从「几乎无候选」变成有完整 5 年画像的候选)。
|
||||
|
||||
### 回补后怎么确认「5 年真的齐了」
|
||||
|
||||
```bash
|
||||
# 画像命令会打印覆盖率(不足时给警告)
|
||||
.venv/bin/python -m hdiv profile --symbols 600036.SH --asof 2015-06-30
|
||||
# ⚠ 5 年窗口数据不足:最低覆盖率仅 xx%(窗口会被数据起点截短)
|
||||
|
||||
# 或者在 SQL 里直接量:某股在 (asof-5y, asof] 内的行情观测数
|
||||
```
|
||||
|
||||
也可以把策略的 `entry.profile_gate.min_window_coverage` 设为 `1.0`,
|
||||
让回测**只**在 5 年窗口完整时才允许买入 —— 不完整的决策日会产出
|
||||
`REJECT`(`skip_reason=PROFILE_GATE`),理由写明 `window_coverage=67%`。
|
||||
|
||||
---
|
||||
|
||||
# 7. 报告解读
|
||||
@@ -754,6 +1271,11 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|
||||
|
||||
## 7.5 Walk-forward 报告 ★ 最重要
|
||||
|
||||
> **前端入口**:`#/walkforwards`(导航栏「样本外」)。
|
||||
> 每个 wf_id 是一条独立记录,点进去可看逐窗口的样本内/样本外对比与冻结阈值。
|
||||
> 该页面同时给出**样本外均值、胜率、稳定性**三个核心判据。
|
||||
|
||||
|
||||
**这是判断策略是否真的有效的核心依据。**
|
||||
|
||||
**看什么**:
|
||||
@@ -770,16 +1292,33 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|
||||
|---|---|
|
||||
| 样本外超额 > 0 且稳定性 > 1 | 策略可能真的有效 |
|
||||
| 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 |
|
||||
| 样本外超额 < 0 | **策略未通过样本外检验** |
|
||||
| 样本外超额 < 0 | 看是否集中在牛市(见下) |
|
||||
| 全期回测远好于样本外均值 | **单路径回测高估了策略** |
|
||||
|
||||
> **本系统的实测结论**:全期回测 +114.80%,而 7 窗口样本外收益均值 **−0.95%**
|
||||
> (基准 +2.29%,超额 **−3.24pp**)。即**当前策略没有稳定的样本外超额收益**。
|
||||
> 它的价值在于**回撤控制**(样本外最差 −24.62%,基准同期 −46%~−52%),
|
||||
> **务必用年化口径比较样本内/外**:训练段 5 年、测试段 1 年,
|
||||
> 直接比累计收益会得出「样本内远高于样本外」的错误印象(本项目实测踩过这个坑,
|
||||
> 详见 implementation-status.md §7.3)。页面上统一使用年化。
|
||||
|
||||
> **务必按牛熊分开看超额**。高股息/低估值策略的典型形态是
|
||||
> **牛市跑输、熊市跑赢**(用上涨弹性换取下跌保护)。
|
||||
> 此时只看「超额均值」会被样本里牛熊比例误导 ——
|
||||
> 本项目实测:4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。
|
||||
> 判断这类策略要问的是「我用上涨弹性换下跌保护,值不值」,
|
||||
> 而不是「它有没有 alpha」。
|
||||
|
||||
> **本系统的实测结论**:全期回测 +92.73%,而 7 窗口样本外收益均值 **+1.16%**
|
||||
> (基准 +2.29%,超额 **−1.13pp**)。即**当前策略没有稳定的样本外超额收益**。
|
||||
> 它的价值在于**回撤控制**(样本外最差 −22.97%,基准同期 −46%~−52%),
|
||||
> 更像降低波动的配置工具,而非超额收益来源。
|
||||
>
|
||||
> 这个反差不是 bug,正是 Walk-forward 存在的意义 —— 详见
|
||||
> [implementation-status.md §4.6](implementation-status.md)。
|
||||
>
|
||||
> 另外注意:实时画像闸门在两个口径下结论相反 ——
|
||||
> **全期单路径**收益从 +101.20% 降到 +92.73%(更差),
|
||||
> 但 **walk-forward 样本外**均值从 −0.00% 升到 +1.16%、最差回撤从 −24.22%
|
||||
> 改善到 −22.97%(更好)。按本项目一贯立场以样本外为准,闸门是改善。
|
||||
> 详见 §4.6c。
|
||||
|
||||
## 7.6 参数敏感性报告
|
||||
|
||||
@@ -977,15 +1516,32 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** |
|
||||
| 财务报表金额 | 元 | 元(不变) |
|
||||
| `dividend.base_share` | 万股 | **股** |
|
||||
| `stock_daily.vol` | 手 | **股**(×100) |
|
||||
| `stock_daily.amount` | 千元 | **元**(×1000) |
|
||||
|
||||
**自检手段**(审计中的 `UNIT` 检查):
|
||||
**自检手段**(审计中的 `UNIT` 与 `UNIT-OHLCV` 检查):
|
||||
|
||||
1. **恒等式** `总市值 ≈ 收盘价 × 总股本` —— 能发现「只换算了一个字段」
|
||||
2. **绝对量级** 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段
|
||||
3. **量价一致性**(`UNIT-OHLCV`):`成交额 / (成交量 × 收盘价)` 应 ≈ 1。
|
||||
≈ 0.1 说明该行还是 Tushare 原始口径(手 / 千元)。审计会逐年抽样并列出
|
||||
|
||||
> 为什么必须两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**,
|
||||
> 为什么必须前两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**,
|
||||
> 单靠它无法发现「万元当元用」。实测该错误曾导致市值过滤选中 **0 只**股票。
|
||||
|
||||
> **`stock_daily` 的量价单位曾经不一致(已修复)**:该表是「追加进既有 qlib 库」的,
|
||||
> 2015-01~2019 的行由本项目从 Tushare 回补,写的是**原始单位**(手 / 千元);
|
||||
> 2020 起沿用 qlib 存量(股 / 元);2019 年**同日混着两种**。
|
||||
> 而 `min_avg_amount_20d: 20000000` 是按「元」写的 ——
|
||||
> 于是 2015-2019 的 20 日均额被低估 1000 倍,流动性门槛实际变成
|
||||
> 「日均成交额 ≥ 200 亿元」,**把 2015-2019 的股票池整体清空**
|
||||
> (实测 2016/2017/2018 各筛选出 0 只)。
|
||||
>
|
||||
> 修复方式有两层:写入端(`sync.price.daily_frame`)统一换算;
|
||||
> 读取端(`units.normalize_ohlcv_units`,按行判定、**幂等**)兜住存量数据。
|
||||
> 修好后 2016-02 的股票池是 7 只、2017 是 11 只、2018 是 13 只。
|
||||
> 审计新增 `UNIT-OHLCV` 防止回归。
|
||||
|
||||
## 9.2 Point-in-Time(无未来函数)
|
||||
|
||||
| 数据 | 可见性规则 |
|
||||
@@ -995,11 +1551,25 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| 行情/指标 | `trade_date <= 评估日` |
|
||||
| ST 状态 | 按 `stock_name_history` 的名称生效区间还原,**不看今天的名字** |
|
||||
| 股票池 | 包含**此后才退市**的股票(消除生存者偏差) |
|
||||
| **实时画像** | 每个决策日按上述规则重算;窗口只覆盖 `(评估日 − N 年, 评估日]` |
|
||||
| **窗口覆盖率** | `n_obs / 该窗口应有交易日数`;< 1 说明窗口被数据起点截短(见 §4.3、§6.6) |
|
||||
| **股票池 vs 回测区间** | 股票池的 `asof` 晚于回测起点即**拒绝执行**(可显式放行并留痕) |
|
||||
|
||||
另外:
|
||||
- **成交在信号次日开盘**,信号日只产生信号
|
||||
- 滚动分位窗口的**右端必须是评估日本身**
|
||||
- 画像的窗口切片是 `(起点, asof]`(左开右闭),与分位参照窗口一致
|
||||
- Walk-forward 测试段使用**训练段冻结**的分布
|
||||
(但实时画像闸门**不需要冻结** —— 它只用当时可见数据做过滤,
|
||||
不带任何用测试期数据拟合出来的参数)
|
||||
|
||||
**三类未来函数,系统的处理方式不同**:
|
||||
|
||||
| 类型 | 处理 |
|
||||
|---|---|
|
||||
| 用未来数据算**当日因子** | 代码层杜绝(`Repo` 是唯一取数出口,有负例测试) |
|
||||
| 用未来时点选出的**股票池**跑更早区间 | **拒绝执行**(`--universe-run` 的 asof 校验) |
|
||||
| 用未来数据给人看的**研究快照**(画像/筛选页) | 允许,但**不进入回测**;回测每天自己重算 |
|
||||
|
||||
## 9.3 分红口径
|
||||
|
||||
@@ -1043,11 +1613,18 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| 过户费 | 双边 |
|
||||
| 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 |
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板则该信号不成交,记录 `skip_reason` |
|
||||
| 停牌 | 信号顺延到下一可成交日 |
|
||||
| 涨跌停 | 开盘即封板则该信号**当日跳过**,记录 `skip_reason`(`defer` 未实现) |
|
||||
| 停牌 | **当日跳过**(⚠️ `fill.suspended_rule: defer` **未实现**,不会顺延;见 §0.3) |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**。
|
||||
这样从根上避免了「复权收益 + 分红」的重复计算。
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税),
|
||||
**留存为现金**,在下次调仓时按目标权重重新配置
|
||||
(⚠️ `cash_mode: reinvest` / `reinvest_rule` **未实现**)。
|
||||
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
|
||||
|
||||
> 以上每一项未实现都会逐条写入 `hd_backtest_run.unimplemented_json` —— 可直接查库核对。
|
||||
|
||||
**资金对账**(每次回测都会校验):
|
||||
|
||||
@@ -1233,11 +1810,17 @@ curl -I http://192.168.1.166:8080/ggx/index.html
|
||||
| 股票清单 | `#/universes/<run_id>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** |
|
||||
| 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 |
|
||||
| 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** |
|
||||
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、绩效指标、**逐笔成交与理由** |
|
||||
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 |
|
||||
| 个股买卖点 | `#/backtests/<run_id>/stocks/<symbol>` | 股价/股息率/PE/ROE 趋势图 + 买卖点标注 |
|
||||
| **样本外** | `#/walkforwards` | Walk-forward 记录;**样本内 vs 样本外**逐窗口对比 |
|
||||
| 归档 | `#/archive` | 已归档与已删除的记录,可恢复 |
|
||||
|
||||
### 记录管理
|
||||
|
||||
- **重跑覆盖**:同一份配置 + 同一个 `asof` 时点 → 同一个 `run_id`,重跑会**原地覆盖**
|
||||
旧记录(含成员清单与因子快照),不会累积重复条目。
|
||||
你在界面上的**命名与备注会被保留**,不会被重跑清掉。
|
||||
改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。
|
||||
- **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上
|
||||
- **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档
|
||||
- **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。
|
||||
@@ -1363,6 +1946,45 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
|
||||
./deploy/install-service.sh status # 本项目(后端侧)
|
||||
```
|
||||
|
||||
### 11.2.1 `/api/health` 通,但页面报「配置校验失败」
|
||||
|
||||
现象:状态徽章正常、`api/health` 通,可页面整体报错,形如:
|
||||
|
||||
```text
|
||||
加载失败:配置校验失败:/…/config/datasource.yml
|
||||
1 validation error for DataSourceConfig
|
||||
sync
|
||||
Extra inputs are not permitted [type=extra_forbidden, input_value={'min_symbols_floor': 200…}]
|
||||
```
|
||||
|
||||
**这不是配置写错了,是后端进程太旧**。`hdiv` 在进程启动时把
|
||||
`src/hdiv/core/config.py` 的模型和 `config/*.yml` 一起读进内存(配置还经
|
||||
`lru_cache` 缓存),所以:
|
||||
|
||||
- 你新加了配置字段 + 对应的模型字段,
|
||||
- launchd 里那个进程却还在用**加字段之前**的模型校验文件,
|
||||
- 于是「新文件里的 `sync` 段」成了模型眼里的多余键 → `extra_forbidden`。
|
||||
|
||||
注意 `KeepAlive` 只负责**崩溃后**拉起,正常运行的进程不会因为你改文件而重启。
|
||||
症状最迷惑的地方是:报错看起来像 YAML 写错了,实际 YAML 完全合法。
|
||||
|
||||
**验证与修复**(一条命令即可):
|
||||
|
||||
```bash
|
||||
# 绕过服务,用当前源码直接校验该文件:能通过就说明文件没问题
|
||||
PYTHONPATH=src .venv/bin/python -c \
|
||||
"from hdiv.core.config import load_config; print(load_config('datasource'))"
|
||||
|
||||
# 改完 src/ 或 config/ 之后必须重启后端,让它重新 import 模块、重新读配置
|
||||
./deploy/install-service.sh restart
|
||||
```
|
||||
|
||||
`restart` 用的是 `launchctl kickstart -k`(先杀后拉,PID 会变);
|
||||
手工 nohup 启动的(`deploy/serve.sh start`)对应执行 `./deploy/serve.sh restart`。
|
||||
|
||||
**记住这条规则:改 `src/` 或 `config/` 之后,一律 `restart` 一次。**
|
||||
只改 `web/`、`templates/` 这类静态产物不需要——它们由 nginx 每次请求重新读盘。
|
||||
|
||||
## 11.3 股票池为空或很少
|
||||
|
||||
**按顺序排查**:
|
||||
@@ -1396,6 +2018,10 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
|
||||
**最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。
|
||||
若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。
|
||||
|
||||
注意:**用 `--universe-run` 冻结股票池时没有预热期** —— 引擎不再自筛选,
|
||||
历史约束不作用于早期,2015 年起即可能满仓。这也是为什么冻结模式的回撤
|
||||
显著大于重新筛选模式。若你看到早期就满仓,那是冻结模式的正常表现,不是 bug。
|
||||
|
||||
**确认方法**:
|
||||
|
||||
```sql
|
||||
@@ -1448,8 +2074,14 @@ market.min_market_capp
|
||||
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
|
||||
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
|
||||
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
|
||||
| 7 | 回测 2015–2018 为预热期 | 100% 现金,无信号(见 §10.4) |
|
||||
| 8 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 |
|
||||
| 7 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:<br>`position.max_holdings`、`position.sector_max_position`、`position.weight_scheme`、`risk.max_portfolio_drawdown`、`risk.max_single_drawdown`、`risk.liquidity_limit_pct_adv`、`exit.stop_loss_pct`、`exit.max_holding_days`、`fill.max_volume_pct`、`fill.partial_fill`、<br>**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode=reinvest` / `dividend.reinvest_rule`**(分红留存为现金,在下次调仓再配置)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
|
||||
| 8 | `stock_daily` 2015–2019 的存量行仍是 Tushare 原始单位 | 读取层已兜底换算(结果正确),审计 `UNIT-OHLCV` 报 WARN;重刷数据可消除 |
|
||||
| 9 | ~~行情/每日指标只到 2015-01-05~~ → **已修复(2026-10-04 回补到 2005-01-04)** | 曾使「过去 5 年画像」在 2019 年前只有 0.2~4 年数据(覆盖率 20%~67%);回补后 5 年窗口覆盖率为 **98.7%~100%**,残差经逐日核实为真实停牌。另:**修好数据后全期收益从 +117.36% 降到 +92.73%**,因为 2015 年(牛市顶 + 股灾)从「被数据缺口挡住」变成被真实交易。见 §6.6 与 implementation-status §9.3c |
|
||||
| 10 | `config/profile.yml: sufficiency` 三个阈值**尚未被任何代码使用** | `min_history_years_dividend` / `min_history_years_price` / `min_dividend_records` 目前是死配置。真正的充分性判定由 `profile_gate.min_window_coverage` + `on_unverifiable` 承担 |
|
||||
| 11 | 实时画像闸门默认 `on_unverifiable: reject` | 数据不全时会放弃部分本可买入的标的(刻意保守,可改为 `pass`)。回补后早年数据已基本完整,影响大幅缩小 |
|
||||
| 12 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 |
|
||||
| 13 | 幸存者偏差尚有 **3 只**缺口 | `000022.SZ`/`000043.SZ`/`300114.SZ`(均因吸收合并退市)有行情但不在 `stock` 表,永远不会进入候选集;占 5,903 只的 0.05%。根因是 `stock` 只收录在市股票 |
|
||||
| 14 | `hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录代码 | 其中 356 中 250+106 为北交所(按交易所白名单设计排除);SZ 部分与第 13 条同源。不影响可交易标的 |
|
||||
|
||||
## 12.1 使用前请务必知道
|
||||
|
||||
@@ -1504,6 +2136,6 @@ export PYTHONPATH=src
|
||||
| 个股画像口径(窗口/分位/权重) | `config/profile.yml` |
|
||||
| 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` |
|
||||
| 手续费/滑点/红利税 | `config/cost.yml` |
|
||||
| 回测区间/Walk-forward/基准 | `config/backtest.yml` |
|
||||
| 回测区间/Walk-forward/基准/分位最小样本量 | `config/backtest.yml` |
|
||||
| 报告外观与部署方式 | `config/report.yml` |
|
||||
| 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) |
|
||||
|
||||
+256
-11
@@ -31,11 +31,11 @@ from hdiv.core.config import (
|
||||
config_hash,
|
||||
load_config,
|
||||
)
|
||||
from hdiv.core.errors import DataGapError
|
||||
from hdiv.core.errors import DataGapError, HdivError
|
||||
from hdiv.data import db
|
||||
from hdiv.data.repo import Repo, data_version
|
||||
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
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ class BacktestEngine:
|
||||
backtest: BacktestConfig | None = None,
|
||||
frozen_reference: tuple[date, date] | None = None,
|
||||
universe_run_id: str | None = None,
|
||||
allow_lookahead_universe: bool = False,
|
||||
) -> None:
|
||||
db.load_dotenv_once()
|
||||
self.strategy = strategy
|
||||
@@ -173,7 +174,14 @@ class BacktestEngine:
|
||||
# 指定 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)
|
||||
# 实时画像闸门(PIT):关闭时整条链路不参与,回测行为与启用前一致
|
||||
self.gate_cfg = strategy.entry.profile_gate
|
||||
self.pit: Any = None
|
||||
|
||||
@classmethod
|
||||
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
|
||||
@@ -262,7 +270,25 @@ class BacktestEngine:
|
||||
bt.capital.initial,
|
||||
),
|
||||
"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:
|
||||
rc = result["reconciliation"]
|
||||
print(
|
||||
@@ -286,6 +312,55 @@ class BacktestEngine:
|
||||
)
|
||||
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
|
||||
universe_by_refresh: dict[date, set[str]] = {}
|
||||
if self.universe_run_id:
|
||||
# 未来函数守卫:股票池自带 asof。若它晚于回测起点,名单里就含有
|
||||
# 「当时不可能知道」的信息(哪些公司此后仍满足分红/质量条件),
|
||||
# 把它套到更早的年份上就是用未来信息选股 —— 与 walk-forward
|
||||
# 拒绝 --universe-run 是同一条理由,这里必须同样拒绝。
|
||||
self._check_universe_asof(days[0])
|
||||
# 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。
|
||||
# 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。
|
||||
dfu = db.read_sql(
|
||||
@@ -366,6 +446,24 @@ class BacktestEngine:
|
||||
suspend = self._load_suspend(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 {
|
||||
"days": days,
|
||||
"refresh_dates": refresh_dates,
|
||||
@@ -421,6 +519,16 @@ class BacktestEngine:
|
||||
)
|
||||
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:
|
||||
return load_config("datasource")
|
||||
|
||||
@@ -527,13 +635,54 @@ class BacktestEngine:
|
||||
peak = equity["total_value"].cummax()
|
||||
equity["drawdown"] = equity["total_value"] / peak - 1.0
|
||||
|
||||
for name in ("涨跌停近似", "停牌顺延", "成交量占比约束"):
|
||||
if name == "涨跌停近似" and not ctx["limits"]:
|
||||
unimplemented.add("涨跌停约束未生效(hd_limit 无数据,成交按可达价格近似)")
|
||||
if name == "停牌顺延" and not ctx["suspend"]:
|
||||
unimplemented.add("停牌约束未生效(hd_suspend 无数据)")
|
||||
# 「约束没生效」只能由**表里有没有数据**判定,不能由「过滤后集合为空」判定 ——
|
||||
# 后者会把「这批股票这段时间恰好没停牌/没涨跌停」误报成「数据缺失」。
|
||||
# 实测:2026-08~09 的 hd_suspend 覆盖到 2026-09-30,却因该区间无停牌
|
||||
# 而被声明成「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:
|
||||
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)
|
||||
return {
|
||||
@@ -574,9 +723,12 @@ class BacktestEngine:
|
||||
if close_hist.empty:
|
||||
continue
|
||||
idx = pd.DatetimeIndex(close_hist.index)
|
||||
# 参数从因子层的统一来源取,不再硬编码 ——
|
||||
# 否则改了 profile.yml 的回测也不会变(曾如此)。
|
||||
_w, _g, _sm = ttm_params()
|
||||
dps = ttm_dps_series(
|
||||
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"):
|
||||
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
|
||||
if ref_ser.empty:
|
||||
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)
|
||||
|
||||
held = sym in positions
|
||||
@@ -601,6 +761,8 @@ class BacktestEngine:
|
||||
"reference_window": [str(ref[0]), str(ref[1])] if ref else None,
|
||||
"reference_mode": self._reference_mode(),
|
||||
"observation_count": int(ref_ser.size),
|
||||
"min_observations": int(
|
||||
self.bt_cfg.percentile_reference.min_observations),
|
||||
"close": price,
|
||||
}
|
||||
|
||||
@@ -611,12 +773,19 @@ class BacktestEngine:
|
||||
|
||||
if not held:
|
||||
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(
|
||||
sym, day, "BUY", target, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g},"
|
||||
f"目标仓位 {target:.0%}",
|
||||
"reason_cn": "股息率进入历史高位区间,达到买入阈值"},
|
||||
"reason_cn": "股息率进入历史高位区间,达到买入阈值",
|
||||
**({"profile_gate": gate} if gate else {})},
|
||||
))
|
||||
else:
|
||||
if target is None:
|
||||
@@ -628,15 +797,91 @@ class BacktestEngine:
|
||||
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
|
||||
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
|
||||
))
|
||||
else:
|
||||
elif target < 1.0:
|
||||
out.append(Signal(
|
||||
sym, day, "TRIM" if target < 1.0 else "ADD", target, current, pct, price,
|
||||
sym, day, "TRIM", target, current, pct, price,
|
||||
{**common,
|
||||
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
|
||||
"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
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 实时画像闸门
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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:
|
||||
if self.frozen_reference is not None:
|
||||
return self.frozen_reference
|
||||
|
||||
@@ -27,6 +27,8 @@ import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from hdiv.core.config import BacktestConfig, load_config
|
||||
|
||||
from hdiv.report.format import NumFmt
|
||||
from hdiv.core.errors import DataGapError, SchemaValidationError
|
||||
from hdiv.data import db
|
||||
from hdiv.data.repo import Repo, data_version
|
||||
@@ -222,6 +224,7 @@ class WalkForwardRunner:
|
||||
from hdiv.factor.dividend_yield import (
|
||||
build_dps_events,
|
||||
dividend_yield_series,
|
||||
ttm_params,
|
||||
)
|
||||
|
||||
sel = self.registry.selector(self.strategy)
|
||||
@@ -236,8 +239,10 @@ class WalkForwardRunner:
|
||||
for sym, g in px.groupby("symbol"):
|
||||
g = g.sort_values("trade_date").copy()
|
||||
g["trade_date"] = pd.to_datetime(g["trade_date"])
|
||||
_w, _g, _sm = ttm_params()
|
||||
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:
|
||||
continue
|
||||
@@ -315,7 +320,7 @@ class WalkForwardRunner:
|
||||
|
||||
def pct(k: str) -> str:
|
||||
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:
|
||||
v = s.get(k)
|
||||
|
||||
+46
-4
@@ -104,10 +104,15 @@ def cmd_sync(args: argparse.Namespace) -> int:
|
||||
elif target == "backfill":
|
||||
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(
|
||||
price_start=args.start,
|
||||
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,
|
||||
)
|
||||
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)
|
||||
result = pb.run(universe_run_id=args.universe_run, symbols=args.symbols, asof=args.asof)
|
||||
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:
|
||||
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
|
||||
|
||||
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)
|
||||
res = wf.run()
|
||||
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")
|
||||
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)
|
||||
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("--start", default="2015-01-01")
|
||||
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-weight", action="store_true")
|
||||
s.set_defaults(func=cmd_sync)
|
||||
@@ -477,7 +513,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
b.add_argument("--end", default=None)
|
||||
b.add_argument(
|
||||
"--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")
|
||||
# HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。
|
||||
|
||||
@@ -96,11 +96,36 @@ class PathsConfig(StrictModel):
|
||||
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):
|
||||
version: int = 1
|
||||
database: DatabaseConfig
|
||||
tushare: TushareConfig = Field(default_factory=TushareConfig)
|
||||
paths: PathsConfig = Field(default_factory=PathsConfig)
|
||||
sync: SyncConfig = Field(default_factory=SyncConfig)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -292,6 +317,8 @@ class SufficiencyConfig(StrictModel):
|
||||
class TtmDividendConfig(StrictModel):
|
||||
window_days: int = 365
|
||||
grace_days: int = 45
|
||||
# 是否消除除权间隔不规整造成的毛刺(重叠虚高 / 断档虚低)
|
||||
smooth_spikes: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check(self) -> TtmDividendConfig:
|
||||
@@ -479,6 +506,7 @@ class ScheduleConfig(StrictModel):
|
||||
class PercentileReferenceConfig(StrictModel):
|
||||
mode: Literal["rolling", "frozen"] = "rolling"
|
||||
lookback_years: int = 5
|
||||
min_observations: int = 250
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check(self) -> PercentileReferenceConfig:
|
||||
@@ -602,11 +630,84 @@ class ScaleStep(StrictModel):
|
||||
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):
|
||||
yield_percentile: float
|
||||
require_universe_pass: bool = True
|
||||
require_risk_pass: bool = True
|
||||
scale_in: list[ScaleStep] = Field(default_factory=list)
|
||||
profile_gate: ProfileGateConfig = Field(default_factory=ProfileGateConfig)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check(self) -> EntryConfig:
|
||||
|
||||
@@ -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
@@ -123,12 +123,16 @@ def _coverage_check(
|
||||
col: str,
|
||||
want_start: date,
|
||||
cfg: DataSourceConfig,
|
||||
min_symbols_per_day: int,
|
||||
min_symbols_per_day: int | None = None,
|
||||
sample_days: int = 6,
|
||||
) -> CheckResult:
|
||||
"""覆盖度检查。
|
||||
|
||||
性能说明:``stock_daily`` 有 770 万行,对全部交易日做
|
||||
``want_start`` 是「数据应至少覆盖到哪一天」的目标;``min_symbols_per_day``
|
||||
是**绝对下限的覆盖**(一般不用传,留空即按 ``datasource.yml: sync`` 的
|
||||
「按年份成比例」口径判定 —— 早年市场只有一千多只股票,固定阈值会误报)。
|
||||
|
||||
性能说明:``stock_daily`` 有 1,600 万行,对全部交易日做
|
||||
``GROUP BY trade_date`` + ``COUNT(DISTINCT symbol)`` 会触发全表聚合(数分钟)。
|
||||
因此改为**采样若干交易日**再取最小股票数 —— 足以发现「某段区间数据稀疏」的问题,
|
||||
成本从 O(全部行) 降到 O(采样日)。
|
||||
@@ -168,9 +172,15 @@ def _coverage_check(
|
||||
)
|
||||
counts.append((d, n))
|
||||
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["min_symbols_per_day"] = min_per_day
|
||||
metrics["min_symbols_date"] = str(min_day)
|
||||
metrics["min_symbols_expected"] = need_min
|
||||
metrics["total_trading_days"] = len(all_days)
|
||||
|
||||
if not enough_years:
|
||||
@@ -183,13 +193,13 @@ def _coverage_check(
|
||||
f"当前覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日)",
|
||||
metrics,
|
||||
)
|
||||
if min_per_day < min_symbols_per_day:
|
||||
if min_per_day < need_min:
|
||||
return CheckResult(
|
||||
code,
|
||||
name,
|
||||
WARN,
|
||||
table,
|
||||
f"{min_day} 仅有 {min_per_day} 只股票(低于 {min_symbols_per_day}),疑似数据稀疏",
|
||||
f"{min_day} 仅有 {min_per_day} 只股票(按当年市场规模应 ≥ {need_min}),疑似数据稀疏",
|
||||
metrics,
|
||||
)
|
||||
return CheckResult(
|
||||
@@ -197,28 +207,38 @@ def _coverage_check(
|
||||
name,
|
||||
OK,
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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:
|
||||
return _coverage_check(
|
||||
# 目标起点 = 2005:windows_years 最长 10 年,回测自 2015-01-05 起,
|
||||
# 要补满 10 年窗口就必须有 2005 年的数据
|
||||
"G2", "日线行情(含复权因子)", "stock_daily", "trade_date",
|
||||
date(2015, 1, 31), cfg, 2000,
|
||||
date(2005, 1, 31), cfg,
|
||||
)
|
||||
|
||||
|
||||
def check_g2b_adjust(cfg: DataSourceConfig) -> CheckResult:
|
||||
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:
|
||||
r = _coverage_check(
|
||||
"G3", "每日指标(PE/PB/股息率/市值)", "daily_basic", "trade_date",
|
||||
date(2015, 1, 31), cfg, 2000,
|
||||
date(2005, 1, 31), cfg,
|
||||
)
|
||||
if r.severity == OK:
|
||||
dv = _scalar(
|
||||
@@ -415,8 +435,8 @@ def check_g5b_index_weight(cfg: DataSourceConfig) -> CheckResult:
|
||||
def check_g6_trading(cfg: DataSourceConfig) -> list[CheckResult]:
|
||||
out: list[CheckResult] = []
|
||||
for code, table, label, want in [
|
||||
("G6", "hd_suspend", "停牌记录", date(2015, 1, 31)),
|
||||
("G6b", "hd_limit", "涨跌停价", date(2015, 1, 31)),
|
||||
("G6", "hd_suspend", "停牌记录", date(2010, 1, 31)),
|
||||
("G6b", "hd_limit", "涨跌停价", date(2010, 1, 31)),
|
||||
]:
|
||||
st = _table_stats(table, cfg)
|
||||
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:
|
||||
"""第一版策略所需的最小数据条件是否齐备。"""
|
||||
needed = {
|
||||
@@ -575,6 +663,7 @@ ALL_CHECKS = [
|
||||
check_g5b_index_weight,
|
||||
check_g6_trading,
|
||||
check_units,
|
||||
check_ohlcv_units,
|
||||
check_duplicates,
|
||||
check_symbol_orphans,
|
||||
check_universe_ready,
|
||||
|
||||
+110
-29
@@ -29,6 +29,7 @@ from hdiv.data import db
|
||||
from hdiv.data.units import (
|
||||
normalize_financial_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:
|
||||
"""数据版本指纹 = 关键表的最大日期摘要,写入每次 run 以支持复现。
|
||||
|
||||
@@ -231,6 +247,11 @@ class Repo:
|
||||
none —— 不复权原始价
|
||||
qfq —— 前复权(以区间末为基准)
|
||||
hfq —— 后复权(以区间首为基准)
|
||||
|
||||
成交量/成交额**一律归一化为「股 / 元」**再返回:``stock_daily`` 里
|
||||
2015-2019 的行是 Tushare 原始单位(手 / 千元),2020 起是「股 / 元」,
|
||||
直接使用会让按「元」写的流动性阈值低估 1000 倍。见
|
||||
:func:`hdiv.data.units.normalize_ohlcv_units`。
|
||||
"""
|
||||
if not symbols:
|
||||
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
|
||||
for c in ("open", "high", "low", "close", "volume", "amount", "factor"):
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||
df, _diag = normalize_ohlcv_units(df)
|
||||
if adjust != "none":
|
||||
f = df["factor"].fillna(1.0)
|
||||
if adjust == "hfq":
|
||||
@@ -276,20 +298,42 @@ class Repo:
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date
|
||||
return df
|
||||
|
||||
def avg_amount(self, asof: date, window: int = 20) -> pd.DataFrame:
|
||||
"""asof 前 window 个交易日的日均成交额(流动性过滤)。"""
|
||||
def avg_amount(
|
||||
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)
|
||||
days = self.trading_days(d0 - timedelta(days=window * 3), d0)
|
||||
days = days[-window:]
|
||||
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(
|
||||
"SELECT symbol, AVG(amount) AS avg_amount, COUNT(*) AS n "
|
||||
"FROM stock_daily WHERE trade_date BETWEEN :s AND :e GROUP BY symbol",
|
||||
{"s": days[0], "e": days[-1]},
|
||||
"SELECT symbol, close, volume, amount "
|
||||
"FROM stock_daily WHERE trade_date BETWEEN :s AND :e" + sym_in,
|
||||
params,
|
||||
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)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
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``)。
|
||||
|
||||
实现要点:用窗口函数取「报告期最新」的那条;
|
||||
``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",
|
||||
"grossprofit_margin", "netprofit_margin",
|
||||
"ocf_to_profit"])
|
||||
"ocf_to_profit"], symbols=symbols)
|
||||
if fin.empty:
|
||||
return fin
|
||||
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",
|
||||
"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",
|
||||
"n_income_attr_p"])
|
||||
"n_income_attr_p"], symbols=symbols)
|
||||
out = fin
|
||||
for other in (cf, bs, inc):
|
||||
if other.empty:
|
||||
@@ -352,7 +403,10 @@ class Repo:
|
||||
out = self._derive_financial(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):
|
||||
return pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols])
|
||||
rt = (
|
||||
@@ -361,6 +415,8 @@ class Repo:
|
||||
else ""
|
||||
)
|
||||
sel = ", ".join(cols)
|
||||
params: dict = {"asof": asof}
|
||||
sym_cond = _symbol_filter(symbols, params)
|
||||
sql = f"""
|
||||
SELECT symbol, end_date, ann_date, {sel} FROM (
|
||||
SELECT symbol, end_date, ann_date, {sel},
|
||||
@@ -368,13 +424,13 @@ class Repo:
|
||||
PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC
|
||||
) AS rn
|
||||
FROM {table}
|
||||
WHERE ann_date <= :asof {rt}
|
||||
WHERE ann_date <= :asof {rt} {sym_cond}
|
||||
-- 公告日不可能早于报告期;这类行是数据源错误(实测 920185.BJ 有 2 条),
|
||||
-- 若不过滤会构成未来函数。审计中仍会如实报告其数量。
|
||||
AND ann_date >= end_date
|
||||
) 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"):
|
||||
if c in df.columns and not df.empty:
|
||||
df[c] = pd.to_datetime(df[c]).dt.date
|
||||
@@ -449,7 +505,9 @@ class Repo:
|
||||
df[c] = pd.to_numeric(df[c], errors="coerce")
|
||||
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 年平均等长期口径)。
|
||||
|
||||
为什么必须用年报而不是最新季报:
|
||||
@@ -458,22 +516,29 @@ class Repo:
|
||||
会把几乎所有好公司误杀(实测:600036.SH 的 2024Q1 ROE 仅 3.47%)。
|
||||
|
||||
因此这里只取 ``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):
|
||||
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
|
||||
|
||||
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(
|
||||
"SELECT f.symbol, f.end_date, f.ann_date, f.roe, f.roic, f.grossprofit_margin, "
|
||||
" f.netprofit_margin, f.ocf_to_profit "
|
||||
"FROM hd_fina_indicator f "
|
||||
"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 "
|
||||
" AND ann_date >= end_date "
|
||||
" AND ann_date >= end_date" + sym_in + " "
|
||||
" GROUP BY symbol, end_date) m "
|
||||
" 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",
|
||||
{"asof": asof, "since": since},
|
||||
"WHERE MONTH(f.end_date) = 12 AND f.end_date >= :since" + sym_in_f,
|
||||
params,
|
||||
cfg=self.cfg,
|
||||
)
|
||||
if fin.empty:
|
||||
@@ -484,23 +549,27 @@ class Repo:
|
||||
fin[c] = pd.to_numeric(fin[c], errors="coerce") / 100.0
|
||||
|
||||
# 经营现金流/净利润:用现金流量表与利润表年报口径补算(比 fina_indicator 更可靠)
|
||||
ocf = self._annual_ocf_ratio(asof, since)
|
||||
ocf = self._annual_ocf_ratio(asof, since, symbols=symbols)
|
||||
if not ocf.empty:
|
||||
fin = fin.merge(ocf, on=["symbol", "year"], how="left")
|
||||
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)):
|
||||
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(
|
||||
"SELECT c.symbol, c.end_date, c.n_cashflow_act, i.n_income_attr_p "
|
||||
"FROM hd_cashflow c "
|
||||
"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 "
|
||||
"WHERE c.report_type = '1' AND MONTH(c.end_date) = 12 "
|
||||
" AND c.end_date >= :since AND c.ann_date <= :asof",
|
||||
{"asof": asof, "since": since},
|
||||
" AND c.end_date >= :since AND c.ann_date <= :asof" + sym_in,
|
||||
params,
|
||||
cfg=self.cfg,
|
||||
)
|
||||
if df.empty:
|
||||
@@ -512,7 +581,8 @@ class Repo:
|
||||
return df[["symbol", "year", "ocf_to_netprofit_calc"]]
|
||||
|
||||
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:
|
||||
"""把年报历史聚合成「N 年平均」指标。
|
||||
|
||||
@@ -520,8 +590,14 @@ class Repo:
|
||||
``net_margin_avg`` / ``ocf_to_profit_avg`` / ``fin_years_count`` /
|
||||
``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:
|
||||
return pd.DataFrame(
|
||||
columns=["symbol", "roe_avg", "roic_avg", "gross_margin_avg",
|
||||
@@ -552,7 +628,9 @@ class Repo:
|
||||
out.loc[thin, c] = pd.NA
|
||||
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)。
|
||||
|
||||
为什么必须单独提供:``financial_panel`` 返回的是**最新一期**财报
|
||||
@@ -562,7 +640,7 @@ class Repo:
|
||||
|
||||
返回列:``symbol / year / n_income_attr_p / n_cashflow_act /
|
||||
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)):
|
||||
return pd.DataFrame(
|
||||
@@ -570,6 +648,8 @@ class Repo:
|
||||
"free_cashflow", "c_pay_dist_dpcp_int_exp"]
|
||||
)
|
||||
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(
|
||||
"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 "
|
||||
@@ -577,8 +657,9 @@ class Repo:
|
||||
"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 "
|
||||
"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",
|
||||
{"asof": asof, "since": since},
|
||||
" AND i.end_date >= :since AND i.ann_date <= :asof AND i.ann_date >= i.end_date"
|
||||
+ sym_in,
|
||||
params,
|
||||
cfg=self.cfg,
|
||||
)
|
||||
if df.empty:
|
||||
|
||||
+73
-10
@@ -21,6 +21,7 @@ from hdiv.core.config import DataSourceConfig, load_config
|
||||
from hdiv.data import db
|
||||
from hdiv.data.sync.base import sync_job, to_date, to_float, upsert
|
||||
from hdiv.data.tushare_client import TushareClient
|
||||
from hdiv.data.units import amount_qian_to_yuan, vol_shou_to_shares
|
||||
|
||||
EXCHANGES = ("SSE", "SZSE", "BSE")
|
||||
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 只/日,被误判为已同步,形成整年数据空洞)。
|
||||
#: 但现在**不再单独使用它** —— 2005 年 A 股只有约 1,350 只,固定 1,500 会让
|
||||
#: 2005-2009 的每一天都判为「未完成」,断点续传失效。实际阈值见
|
||||
#: :func:`_day_threshold`:``max(绝对下限, 比例 × 当年应有上市股票数)``。
|
||||
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(
|
||||
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]:
|
||||
"""**数据完整**的交易日集合(用于断点续传)。
|
||||
|
||||
判定标准是「当日股票数 >= min_symbols」,而不是「当日是否存在行」——
|
||||
判定标准是「当日股票数 >= 该日应有的规模」,而不是「当日是否存在行」——
|
||||
否则半成品日期会被跳过,留下难以察觉的数据空洞。
|
||||
|
||||
阈值随年份变化(见 ``SyncConfig``):早年 A 股只有一千多只股票,
|
||||
固定阈值会把 2005-2009 的每一天都判成「未完成」,断点续传失效。
|
||||
``min_symbols`` 显式给定时按旧口径(固定阈值)判定,保持向后兼容。
|
||||
"""
|
||||
df = db.read_sql(
|
||||
f"SELECT trade_date AS d, COUNT(DISTINCT symbol) AS n FROM `{table}` "
|
||||
@@ -82,11 +123,19 @@ def fetched_days(
|
||||
)
|
||||
if df.empty:
|
||||
return set()
|
||||
return {
|
||||
to_date(r["d"]) # type: ignore[misc]
|
||||
for _, r in df.iterrows()
|
||||
if int(r["n"]) >= min_symbols
|
||||
}
|
||||
floor, ratio, by_year = _day_thresholds(cfg)
|
||||
out: set[date] = set()
|
||||
for _, r in df.iterrows():
|
||||
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:
|
||||
"""Tushare ``daily`` → ``stock_daily`` 行,**并统一量价单位**。
|
||||
|
||||
Tushare 的 ``vol`` 是「手」、``amount`` 是「千元」,而既有的 ``stock_daily``
|
||||
(qlib 存量数据)是「股 / 元」。早期实现原样写入,于是同一列在 2015-2019 与
|
||||
2020 起是两套单位,按「元」写的流动性阈值在早年低估 1000 倍。
|
||||
这里在写入端就换算,读取端的 :func:`hdiv.data.units.normalize_ohlcv_units`
|
||||
作为存量数据的兜底(对已换算行幂等)。
|
||||
"""
|
||||
if not rows:
|
||||
return pd.DataFrame()
|
||||
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],
|
||||
"low": [to_float(r.get("low")) 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",
|
||||
"adjust": "none",
|
||||
}
|
||||
|
||||
@@ -81,7 +81,14 @@ def sync_trading_constraints(
|
||||
cfg = load_config("datasource")
|
||||
days = open_days(start, end, cfg)
|
||||
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)
|
||||
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]
|
||||
|
||||
@@ -66,6 +66,111 @@ def vol_shou_to_shares(s: pd.Series) -> pd.Series:
|
||||
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
@@ -37,6 +37,11 @@ from hdiv.factor.dividend_yield import (
|
||||
rolling_volatility,
|
||||
window_slice,
|
||||
)
|
||||
from hdiv.profile.coverage import (
|
||||
expected_by_window,
|
||||
format_warning,
|
||||
summarise,
|
||||
)
|
||||
|
||||
# 指标展示名与单位(呈现层用)
|
||||
METRIC_META: dict[str, dict[str, str]] = {
|
||||
@@ -55,6 +60,13 @@ METRIC_META: dict[str, dict[str, str]] = {
|
||||
"dps": {"label": "每股分红", "unit": "price"},
|
||||
"payout_ratio": {"label": "分红支付率", "unit": "pct"},
|
||||
"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"},
|
||||
"dps_cagr_5y": {"label": "DPS 5年复合增速", "unit": "pct"},
|
||||
"dps_volatility": {"label": "DPS 波动率", "unit": "ratio"},
|
||||
@@ -102,7 +114,15 @@ class ProfileBuilder:
|
||||
asof: str | date | None = None,
|
||||
persist: bool = True,
|
||||
verbose: bool = True,
|
||||
return_rows: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""计算画像。
|
||||
|
||||
``return_rows=True`` 时在结果里附带 ``stat_rows`` / ``series_rows`` /
|
||||
``score_rows`` 原始行(不落库也能检查逐指标取值)。这是给**等价性测试**
|
||||
用的:实时画像(``profile.pit``)与批量画像必须逐值一致,而验证这一点
|
||||
需要一个不写库也能读到逐指标结果的入口。
|
||||
"""
|
||||
cfg = load_config("datasource")
|
||||
c = self.config
|
||||
|
||||
@@ -202,6 +222,20 @@ class ProfileBuilder:
|
||||
if verbose and i % 50 == 0:
|
||||
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 = {
|
||||
"run_id": run_id,
|
||||
"asof_date": asof_d,
|
||||
@@ -211,7 +245,15 @@ class ProfileBuilder:
|
||||
"score_count": len(score_rows),
|
||||
"skipped": skipped,
|
||||
"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:
|
||||
result["written"] = self._persist(
|
||||
@@ -244,11 +286,25 @@ class ProfileBuilder:
|
||||
fin_latest: pd.DataFrame,
|
||||
div_by_symbol: dict[str, list[dict]] | None = None,
|
||||
fy_table: dict[tuple[str, int], Any] | None = None,
|
||||
build_series: bool = True,
|
||||
) -> 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
|
||||
px = price[price["symbol"] == sym]
|
||||
if px.empty:
|
||||
return None
|
||||
px = px.sort_values("trade_date") # 见 docstring:行序即语义
|
||||
close = px.set_index("trade_date")["close"]
|
||||
close.index = pd.to_datetime(close.index)
|
||||
|
||||
@@ -258,6 +314,7 @@ class ProfileBuilder:
|
||||
events.get(sym, pd.DataFrame()),
|
||||
ttm_days=c.ttm_dividend.window_days,
|
||||
grace_days=c.ttm_dividend.grace_days,
|
||||
smooth_spikes=c.ttm_dividend.smooth_spikes,
|
||||
)
|
||||
if yser.empty:
|
||||
return None
|
||||
@@ -271,6 +328,7 @@ class ProfileBuilder:
|
||||
}
|
||||
bs = basics[basics["symbol"] == sym]
|
||||
if not bs.empty:
|
||||
bs = bs.sort_values("trade_date") # 同上:行序即「当日值」的语义
|
||||
b = bs.set_index(pd.to_datetime(bs["trade_date"]))
|
||||
for col in ("pe_ttm", "pb", "ps_ttm"):
|
||||
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",
|
||||
)
|
||||
)
|
||||
# 展示序列只落配置指定的指标
|
||||
if metric in c.series_metrics:
|
||||
# 展示序列只落配置指定的指标(且仅在需要时构造 —— 见 build_series)
|
||||
if build_series and metric in c.series_metrics:
|
||||
disp = resample_for_storage(
|
||||
pd.DataFrame({"trade_date": s.index, "value": s.to_numpy()}),
|
||||
c.series_max_points,
|
||||
@@ -452,17 +510,25 @@ class ProfileBuilder:
|
||||
st.update(DividendFilter._payout_and_cover(recs, fy_row, asof, c))
|
||||
|
||||
out: list[dict[str, Any]] = []
|
||||
# 标量型质量指标
|
||||
for code in (
|
||||
"dividend_continuity_years", "dividend_years_in_window",
|
||||
"ttm_dps", "dps_cagr_5y", "dps_volatility",
|
||||
"payout_ratio", "fcf_dividend_cover", "free_cashflow",
|
||||
"total_cash_dividend",
|
||||
# 标量型质量指标。
|
||||
# 刻意**不含 ttm_dps** —— 它已由上面的序列循环按窗口产出(带完整分布统计),
|
||||
# 这里再产一次会写出两条 (ttm_dps, window=0) 行:落库时互相覆盖,
|
||||
# 而「哪条胜出」取决于写入顺序,属于不确定行为。
|
||||
#
|
||||
# 同理 **不含 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))
|
||||
if v is None:
|
||||
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_by_year = st.get("dps_by_year") or {}
|
||||
if dps_by_year:
|
||||
@@ -663,7 +729,16 @@ class ProfileBuilder:
|
||||
}
|
||||
|
||||
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] = []
|
||||
cfg = load_config("datasource")
|
||||
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)})
|
||||
df = db.read_sql(
|
||||
"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,
|
||||
)
|
||||
if not df.empty:
|
||||
@@ -682,7 +758,8 @@ class ProfileBuilder:
|
||||
return pd.DataFrame(columns=["symbol", "trade_date", "pe_ttm", "pb", "ps_ttm"])
|
||||
df = pd.concat(out, ignore_index=True)
|
||||
df["trade_date"] = pd.to_datetime(df["trade_date"])
|
||||
return df
|
||||
# 分片拼接后仍需全局有序(各分片内部有序 ≠ 整体有序的日期序)
|
||||
return df.sort_values(["symbol", "trade_date"], ignore_index=True)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 落库
|
||||
|
||||
@@ -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"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)"
|
||||
)
|
||||
@@ -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
@@ -21,6 +21,18 @@ import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -151,7 +163,7 @@ def _yi(v: Any) -> str:
|
||||
|
||||
def _pct(v: Any) -> str:
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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]]:
|
||||
cfg = load_config("datasource")
|
||||
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")
|
||||
df = db.read_sql(
|
||||
"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,
|
||||
)
|
||||
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 {
|
||||
"dates": [str(pd.Timestamp(x).date()) for x in df["trade_date"]],
|
||||
"dates": dates,
|
||||
"nav": [_num(x) for x in df["nav"]],
|
||||
"bench": [_num(x) for x in df["benchmark_nav"]],
|
||||
"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"]],
|
||||
"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"))
|
||||
p = _num(d.get("yield_percentile"))
|
||||
if y is not None:
|
||||
parts.append(f"股息率 {y * 100:.2f}%")
|
||||
parts.append(f"股息率 {_fmt().pct(y)}")
|
||||
if p is not None:
|
||||
parts.append(f"历史分位 {p:.1f}%")
|
||||
if d.get("rule"):
|
||||
@@ -652,6 +895,9 @@ _SKIP_LABELS = {
|
||||
"ALREADY_AT_TARGET": "已达目标仓位",
|
||||
"NO_POSITION": "无持仓",
|
||||
"BELOW_MIN_TRADE": "低于最小交易量",
|
||||
# 实时画像闸门剔除(信号类型 REJECT):不是撮合失败,而是「按当日可见
|
||||
# 数据重算画像后判定不值得买」。详情在 reason_json.profile_gate.checks。
|
||||
"PROFILE_GATE": "实时画像未通过,主动放弃买入",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -458,3 +458,236 @@ def test_metrics_do_not_invent_values() -> None:
|
||||
})
|
||||
m = compute_metrics(eq, [], load_config("backtest"), "r3")
|
||||
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
@@ -11,6 +11,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from hdiv.cli import build_parser
|
||||
@@ -107,10 +108,12 @@ def test_documented_actions_exist(parser, cmd: str, expected: set[str]) -> None:
|
||||
"cmd,flags",
|
||||
[
|
||||
("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"}),
|
||||
("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"}),
|
||||
("strategy", {"-f", "--file", "--other"}),
|
||||
("audit", {"--no-persist", "--no-html"}),
|
||||
@@ -140,13 +143,22 @@ def test_backtest_universe_run_flag(parser) -> None:
|
||||
"""股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。"""
|
||||
args = parser.parse_args(["backtest", "--universe-run", "abc123"])
|
||||
assert args.universe_run == "abc123"
|
||||
assert args.allow_lookahead_universe is False, "默认不得放行未来函数"
|
||||
args2 = parser.parse_args(["backtest"])
|
||||
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
|
||||
def test_frozen_universe_is_used_when_run_id_given() -> None:
|
||||
"""指定 --universe-run 时引擎必须使用该股票池,而不是重新筛选。"""
|
||||
"""显式放行时,冻结股票池在各调仓日必须完全相同。"""
|
||||
from datetime import date
|
||||
|
||||
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}")
|
||||
|
||||
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
|
||||
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), "冻结股票池在各调仓日必须完全相同"
|
||||
|
||||
|
||||
@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:
|
||||
"""手册只承诺 single / walkforward 两种模式。"""
|
||||
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"):
|
||||
args = p.parse_args([cmd])
|
||||
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:]}"
|
||||
|
||||
@@ -229,6 +229,77 @@ def test_strategy_bad_status() -> None:
|
||||
_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 稳定性
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -188,7 +188,8 @@ def test_price_frames_map_tushare_columns() -> None:
|
||||
assert list(d.columns) == ["symbol", "trade_date", "open", "high", "low",
|
||||
"close", "volume", "amount", "source", "adjust"]
|
||||
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}])
|
||||
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")
|
||||
ts = cfg.tushare
|
||||
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
|
||||
# 构造客户端需要 token;此处仅验证配置层
|
||||
assert ts.rate_limit_cooldown_sec >= 60, "冷却必须覆盖 Tushare 的 60 秒滑动窗口"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user