From 14ec0c6c866b69ad5449db7c153cdb95949b482d Mon Sep 17 00:00:00 2001 From: Simon Date: Sun, 4 Oct 2026 12:47:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=87=8F=E4=BB=B7?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=20/=20=E6=9C=AA=E6=9D=A5=E5=87=BD=E6=95=B0?= =?UTF-8?q?=E5=AE=88=E5=8D=AB=20/=20=E5=AE=9E=E6=97=B6=E7=94=BB=E5=83=8F?= =?UTF-8?q?=E9=97=B8=E9=97=A8=EF=BC=9B=E8=A1=8C=E6=83=85=E5=9B=9E=E8=A1=A5?= =?UTF-8?q?=E5=88=B0=202005=EF=BC=9B=E6=89=8B=E5=86=8C=E8=A1=A5=E5=85=A8?= =?UTF-8?q?=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型): 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 除本轮修改外, 也含此前遗留的未提交改动(无法按文件切分)。 --- README.md | 32 +- config/datasource.yml | 27 +- config/strategy/high_dividend_v1.yml | 32 ++ docs/implementation-status.md | 813 ++++++++++++++++++++++++--- docs/user-guide.md | 714 +++++++++++++++++++++-- src/hdiv/backtest/engine.py | 267 ++++++++- src/hdiv/backtest/walk_forward.py | 9 +- src/hdiv/cli.py | 50 +- src/hdiv/core/config.py | 101 ++++ src/hdiv/core/metrics.py | 62 ++ src/hdiv/data/audit.py | 109 +++- src/hdiv/data/repo.py | 139 ++++- src/hdiv/data/sync/price.py | 83 ++- src/hdiv/data/sync/trading.py | 9 +- src/hdiv/data/units.py | 105 ++++ src/hdiv/profile/builder.py | 101 +++- src/hdiv/profile/coverage.py | 119 ++++ src/hdiv/profile/pit.py | 555 ++++++++++++++++++ src/hdiv/web/service.py | 256 ++++++++- tests/test_backtest.py | 233 ++++++++ tests/test_cli_contract.py | 129 ++++- tests/test_config.py | 71 +++ tests/test_profile_pit.py | 590 +++++++++++++++++++ tests/test_sync.py | 8 +- tests/test_units.py | 138 +++++ 25 files changed, 4543 insertions(+), 209 deletions(-) create mode 100644 src/hdiv/core/metrics.py create mode 100644 src/hdiv/profile/coverage.py create mode 100644 src/hdiv/profile/pit.py create mode 100644 tests/test_profile_pit.py create mode 100644 tests/test_units.py diff --git a/README.md b/README.md index 4ce41f0..0cfa159 100644 --- a/README.md +++ b/README.md @@ -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)。 diff --git a/config/datasource.yml b/config/datasource.yml index 1f0bbbc..1f5e16d 100644 --- a/config/datasource.yml +++ b/config/datasource.yml @@ -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 diff --git a/config/strategy/high_dividend_v1.yml b/config/strategy/high_dividend_v1.yml index 38406f9..658f311 100644 --- a/config/strategy/high_dividend_v1.yml +++ b/config/strategy/high_dividend_v1.yml @@ -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 } diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 802abe9..195ec7c 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -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_` 行过滤掉了,页面上看不到超额), +逐窗口的超额呈现**高度规律**的形态: + +| 测试年 | 策略 | 沪深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…`, +仍保留在库中,可用于核对「回补是否改变了某项结论」。) + + diff --git a/docs/user-guide.md b/docs/user-guide.md index 2acb417..5abe898 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -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 + +# 指定股票 + 指定时点(任意历史时点,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 会打印覆盖率,例如: + +``` +画像 :46 只 + 窗口覆盖率:最差 10 年窗口 94.6%(1.0 = 名义窗口被完整覆盖) +``` + +### 关键认知(最容易误解的一点) + +> **画像是一次「快照」,回测并不读它。** +> +> 回测里每次买入前用的画像,是引擎**在该决策日实时重算**的 +> (见 §0.3 第④步),与这里落库的快照是**两条独立路径**。 +> 两者由「逐值等价测试」约束,不会给出两个不同的数。 +> +> 所以:**画像页是给你看的,不是给回测用的。** 回测每天自己算。 + +--- + +## 0.3 步骤③:回测 `hdiv backtest` + +```bash +# 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 asof +.venv/bin/python -m hdiv backtest --universe-run --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 ` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) | +| ③ 回测 | `hdiv backtest [--universe-run ] [--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_.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 # 对股票池全部股票 -hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票 +hdiv profile --universe-run # 对股票池全部股票(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 --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 # 用指定股票池(冻结)并建立关联 + +# 注意 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/` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** | | 个股画像 | `#/stocks/` | K线+股息率+PE 四联图、历史分布、安全边际雷达 | | 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** | -| 回测详情 | `#/backtests/` | 净值曲线、绩效指标、**逐笔成交与理由** | +| 回测详情 | `#/backtests/` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 | +| 个股买卖点 | `#/backtests//stocks/` | 股价/股息率/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 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:
`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`、
**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode=reinvest` / `dividend.reinvest_rule`**(分红留存为现金,在下次调仓再配置)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。
**这些都会逐条写入 `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) | diff --git a/src/hdiv/backtest/engine.py b/src/hdiv/backtest/engine.py index 72d6e5c..c5fe5c0 100644 --- a/src/hdiv/backtest/engine.py +++ b/src/hdiv/backtest/engine.py @@ -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 \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 diff --git a/src/hdiv/backtest/walk_forward.py b/src/hdiv/backtest/walk_forward.py index 3e768b0..c3c7679 100644 --- a/src/hdiv/backtest/walk_forward.py +++ b/src/hdiv/backtest/walk_forward.py @@ -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) diff --git a/src/hdiv/cli.py b/src/hdiv/cli.py index 78610c0..74ece71 100644 --- a/src/hdiv/cli.py +++ b/src/hdiv/cli.py @@ -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 " + ) 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。 diff --git a/src/hdiv/core/config.py b/src/hdiv/core/config.py index 920c614..86f2ca8 100644 --- a/src/hdiv/core/config.py +++ b/src/hdiv/core/config.py @@ -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: 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: diff --git a/src/hdiv/core/metrics.py b/src/hdiv/core/metrics.py new file mode 100644 index 0000000..7ad69c4 --- /dev/null +++ b/src/hdiv/core/metrics.py @@ -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 diff --git a/src/hdiv/data/audit.py b/src/hdiv/data/audit.py index 471d039..1a4b46d 100644 --- a/src/hdiv/data/audit.py +++ b/src/hdiv/data/audit.py @@ -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, diff --git a/src/hdiv/data/repo.py b/src/hdiv/data/repo.py index 4254676..23bc13c 100644 --- a/src/hdiv/data/repo.py +++ b/src/hdiv/data/repo.py @@ -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 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: diff --git a/src/hdiv/data/sync/price.py b/src/hdiv/data/sync/price.py index 657a513..86bd331 100644 --- a/src/hdiv/data/sync/price.py +++ b/src/hdiv/data/sync/price.py @@ -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", } diff --git a/src/hdiv/data/sync/trading.py b/src/hdiv/data/sync/trading.py index 82da7bb..3fd0caa 100644 --- a/src/hdiv/data/sync/trading.py +++ b/src/hdiv/data/sync/trading.py @@ -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] diff --git a/src/hdiv/data/units.py b/src/hdiv/data/units.py index aab8958..341b0cf 100644 --- a/src/hdiv/data/units.py +++ b/src/hdiv/data/units.py @@ -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 + + # --------------------------------------------------------------------------- # 面板归一化 # --------------------------------------------------------------------------- diff --git a/src/hdiv/profile/builder.py b/src/hdiv/profile/builder.py index 9f5bfaf..a3ff6a5 100644 --- a/src/hdiv/profile/builder.py +++ b/src/hdiv/profile/builder.py @@ -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) # ------------------------------------------------------------------ # 落库 diff --git a/src/hdiv/profile/coverage.py b/src/hdiv/profile/coverage.py new file mode 100644 index 0000000..cc6c918 --- /dev/null +++ b/src/hdiv/profile/coverage.py @@ -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"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)" + ) diff --git a/src/hdiv/profile/pit.py b/src/hdiv/profile/pit.py new file mode 100644 index 0000000..ff08879 --- /dev/null +++ b/src/hdiv/profile/pit.py @@ -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)) diff --git a/src/hdiv/web/service.py b/src/hdiv/web/service.py index 5544cd3..2a2855f 100644 --- a/src/hdiv/web/service.py +++ b/src/hdiv/web/service.py @@ -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_(如 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": "实时画像未通过,主动放弃买入", } diff --git a/tests/test_backtest.py b/tests/test_backtest.py index c9df02a..e87932f 100644 --- a/tests/test_backtest.py +++ b/tests/test_backtest.py @@ -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}" diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py index b93e951..6bc030a 100644 --- a/tests/test_cli_contract.py +++ b/tests/test_cli_contract.py @@ -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:]}" diff --git a/tests/test_config.py b/tests/test_config.py index 4248cd2..ab19ae8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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 稳定性 # --------------------------------------------------------------------------- diff --git a/tests/test_profile_pit.py b/tests/test_profile_pit.py new file mode 100644 index 0000000..28ebbb5 --- /dev/null +++ b/tests/test_profile_pit.py @@ -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 年数据" diff --git a/tests/test_sync.py b/tests/test_sync.py index f65be2d..8278c73 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -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 秒滑动窗口" diff --git a/tests/test_units.py b/tests/test_units.py new file mode 100644 index 0000000..a734939 --- /dev/null +++ b/tests/test_units.py @@ -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)