Compare commits

...
2 Commits
Author SHA1 Message Date
simon 14ec0c6c86 修复:量价单位 / 未来函数守卫 / 实时画像闸门;行情回补到 2005;手册补全流程
本轮会话的三项正确性改造(均为「不报错、只让结果静默错」的类型):

1) 修复 stock_daily 量价单位前后不一致
   - 现象:2015-2019 存 Tushare 原始单位(手/千元),2020 起存(股/元),2019 同日混合;
     而流动性阈值按「元」配置 → 早年门槛实际是「日均成交额 ≥ 200 亿元」,
     把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各选出 0 只)。
   - 修复:写入端 sync/price.py 统一换算;读取端 units.normalize_ohlcv_units
     按行判定并幂等换算(price_history / avg_amount 都走它);
     审计新增 UNIT-OHLCV 防回归。
   - 效果:2016/2017/2018 的股票池变为 7/11/13 只。

2) 未来函数守卫(单次回测)
   - 股票池自带 asof:若晚于回测起点即**拒绝执行**(原先静默冻结套用),
     与 walk-forward 已有的拒绝理由一致;确需复现加 --allow-lookahead-universe,
     偏差写入 unimplemented_json。

3) 新增实时(PIT)个股画像闸门
   - profile/pit.py:每个决策日按当时可见数据重算过去 5 年画像,
     惰性(仅买入条件已触发的标的)、面板按 asof 缓存、
     规则不含财务指标时不查财报表;被剔除时产出 REJECT + 逐规则留痕。
   - 指标定义复用 ProfileBuilder._profile_one(与批量画像逐值等价的回归测试)。
   - profile/coverage.py:窗口覆盖率(按交易日历的真实开市天数),
     策略新增 entry.profile_gate.min_window_coverage(默认 0,不改变既有行为)。
   - core/metrics.py:闸门可用指标的唯一定义(配置期即校验,避免写错指标名静默失效)。

4) 行情回补到 2005(使 5/8/10 年窗口真正完整)
   - stock_daily / adjust_factor / daily_basic 补到 2005-01-04;
     hd_suspend / hd_limit 补到 2010-01-04。
   - 5 年窗口覆盖率:2018-05-18 由 67.0% → 99.1%,2016-12-30 由 39.8% → 99.0%;
     残差经逐日与 hd_suspend 交叉核实为真实停牌(16/16 命中)。
   - 审计 G2/G3 与断点续传原先用固定阈值(2000 / 1500 只),
     会把 2005-2009 的正常数据误判为异常 —— 改为按「当年应有上市股票数」成比例判定。
   - 节流修正:daily/adj_factor/daily_basic 限频 480 → 170(实测该 token 约 196/min 即被拒)。

5) 自我声明如实化
   - 原先「约束未生效」由「过滤后集合为空」判定,会把「这批股票恰好没停牌」
     误报成「hd_suspend 无数据」;改为按表级判定。
   - 补齐此前静默的「配置承诺但未实现」项:suspended_rule/limit_up_down_rule 的 defer、
     cash_mode=reinvest/reinvest_rule、handle_rights_issue、signal_to_execution、
     max_volume_pct、liquidity_limit_pct_adv —— 全部写入 unimplemented_json。

6) 手册:新增 §0「全流程操作(选股 → 画像 → 回测)」置于最前
   - 逐步说明「命令做了什么、数据从哪来、落了哪些库、有哪些坑」;
     含实时画像闸门 9 问 9 答、未来函数守卫表、成交与成本口径、验证 SQL。
   - 修正旧 §2.4 漏传 --universe-run(选了池子却没用于回测);
     修正两处声称「停牌顺延」「分红再投资」已实现的相反表述。

测试:403 项全部通过(含新增 test_units.py、test_profile_pit.py、
未实现声明诚实性测试、行序无关性回归测试)。

注意:本提交中 docs/*、README.md、src/hdiv/web/service.py 除本轮修改外,
也含此前遗留的未提交改动(无法按文件切分)。
2026-10-04 12:47:17 +08:00
simon fb6608193b 功能:Web 前端与报告格式化(工作区中此前未提交的工作)
说明:本提交**不是本轮会话所做**,而是工作区里此前遗留的未提交改动。
为把历史分开,先单独提交它,再提交本轮会话的修改。

包含:
- Web 前端:web/index.html、web/app.js(统一 SPA,含回测/画像/Walk-forward 页面)
- 后端接口:web/server.py 路由、web/analysis.py(新增个股分析)
- 报告层:report/format.py(新增统一数字格式化 NumFmt)、
  report/{backtest,profile,sensitivity,universe,walkforward}_report.py 接入 NumFmt、
  report/renderer.py
- 股息率口径:factor/dividend_yield.py(毛刺消除 smooth_spikes)
- 筛选:universe/selector.py、universe/filters/dividend.py
- 绩效/敏感性:analysis/performance.py、analysis/sensitivity.py
- 部署:deploy/install-service.sh
- 测试:tests/test_format.py、tests/test_dividend_smoothing.py(新增)、
  tests/test_web.py、tests/test_universe.py

提交时全量测试 403 项通过。
2026-10-04 12:47:10 +08:00
46 changed files with 7122 additions and 334 deletions
+29 -3
View File
@@ -74,12 +74,38 @@ docs/ 文档
**本系统的实测结论是:策略没有稳定的样本外超额收益。** **本系统的实测结论是:策略没有稳定的样本外超额收益。**
全期回测(2015–2026)显示 +114.80% / CAGR 6.73%, 全期回测(2015–2026)显示 +92.73% / CAGR 5.75%,
但 7 窗口 Walk-forward 的样本外收益均值仅 **−0.95%**(基准 +2.29%,超额 **−3.24pp**)。 但 7 窗口 Walk-forward 的样本外收益均值仅 **+1.16%**(基准 +2.29%,超额 **−1.12pp**)。
它真正的价值在于**回撤控制**(样本外最差 −24.62%,基准同期 −46%~−52%)—— 它真正的价值在于**回撤控制**(样本外最差 −22.97%,基准同期 −46%~−52%)——
更像降低波动的配置工具,而非超额收益来源。 更像降低波动的配置工具,而非超额收益来源。
> **四条必须说的结论**(详见[实施状态 §4.6c/§4.6d/§9](docs/implementation-status.md)):
>
> 1. **修数据让结果变差、但变真实了。** 行情原先只到 2015-01-05,
> 使「过去 5 年」窗口在 2019 年前被静默截短(覆盖率仅 20%~67%)。
> 2026-10-04 已把行情回补到 **2005-01-04**、涨跌停/停牌到 **2010**,
> 5 年窗口覆盖率提升到 **98.7%~100%**。代价是全期收益从 +117.36%
> 降到 **+92.73%** —— 因为 **2015 年(牛市顶 + 股灾)从「被数据缺口
> 挡住」变成被真实交易(−3.18%)**。靠数据缺口躲过股灾不是策略能力。
> 2. **实时画像闸门在两个口径下结论相反**:
> 单条路径 **−8.47pp**(有害),样本外 **+1.16pp**(有益)。
>
> | 口径 | 闸门开 | 闸门关 |
> |---|---:|---:|
> | 全期单路径总收益 | +92.73% | **+101.20%** |
> | Walk-forward 样本外均值 | **+1.16%** | −0.00% |
> | 样本外最差回撤 | **−22.97%** | −24.22% |
>
> **按本项目一贯立场以样本外为准**:闸门是改善。是否启用由你决定
> (`entry.profile_gate.enabled`);全期剔除 1027 次买入信号。
> 3. **同一项数据修正,在两个口径下的「效果」相差 32.9pp**
> (回补对样本外贡献 **0** —— 7 个窗口逐窗口未变;对单路径贡献 −32.9pp)。
> 这是「不要采信单条路径」最有力的例证。
> 4. **`stock_daily` 的量价单位曾前后不一致**(2015-2019 存「手/千元」,
> 2020 起存「股/元」),使流动性门槛在早年低估 1000 倍、**把 2015-2019
> 的股票池整体清空**。已修复(读取层幂等归一化 + 审计 `UNIT-OHLCV` 防回归)。
**请始终以 Walk-forward 的样本外结果为主要依据,不要采信单条路径的全期数字。** **请始终以 Walk-forward 的样本外结果为主要依据,不要采信单条路径的全期数字。**
详见[实施状态 §4.6](docs/implementation-status.md)。 详见[实施状态 §4.6](docs/implementation-status.md)。
+24 -3
View File
@@ -67,9 +67,14 @@ tushare:
index_weight: 180 index_weight: 180
suspend_d: 180 suspend_d: 180
stk_limit: 180 stk_limit: 180
daily: 480 # daily/adj_factor/daily_basic:**不要设成 480**。
adj_factor: 480 # 2026-10-04 回补 2005-2014 时实测:以约 196 次/分钟跑 `daily`,
daily_basic: 480 # 300 次调用后即被 Tushare 拒绝(此 token 的实际上限 ≤ 200/min)。
# 一旦触发限频,客户端要冷却 62 秒再重试 —— 逐日回补 2,400+ 天时,
# 这种「撞墙再等」远比「按额度平滑配速」慢,而且有耗尽 4 次重试的风险。
daily: 170
adj_factor: 170
daily_basic: 170
index_daily: 180 index_daily: 180
retry: 4 retry: 4
retry_backoff_sec: 2 retry_backoff_sec: 2
@@ -84,3 +89,19 @@ paths:
templates_dir: templates templates_dir: templates
assets_dir: assets assets_dir: assets
log_dir: logs log_dir: logs
# ------------------------------------------------------------
# 行情同步的「完整性」判定(决定断点续传是否重拉整段历史)
#
# 一个交易日被视为已完整同步,要求:
# 当日股票数 >= max(min_symbols_floor, min_symbols_ratio × 当年应有上市股票数)
#
# 早年市场小得多(2005 年约 1,350 只、2010 年约 1,700 只、2024 年约 5,400 只),
# 用单一绝对阈值会把 2005-2009 的每一天都判成「未完成」——回补一旦中断就得
# 从第一天重来。所以改为「按当年规模成比例」判定。
# ------------------------------------------------------------
sync:
# 绝对下限:低于它一定判为不完整(防止比例判定在极早年失效)
min_symbols_floor: 200
# 相对下限:占「当年应有上市股票数」的比例
min_symbols_ratio: 0.6
+32
View File
@@ -38,6 +38,38 @@ entry:
# 必须未进入风险状态 # 必须未进入风险状态
require_risk_pass: true require_risk_pass: true
# ----------------------------------------------------------
# 实时(PIT)个股画像闸门(plan.md §14/§15 的落地形态)
#
# 语义:股息率分位触发买入之后,再用**当日可见的数据**重算一次画像,
# 不通过的票直接剔除(信号类型 REJECT,可在「未成交信号」里看到
# 每一条规则的实际值与阈值)。
#
# 代价:只在触发时计算;跨股票共享的面板按时点缓存,因此成本与
# 「触发次数」成正比,而不是「区间长度 × 股票数」。
#
# enabled=false 时整条链路不参与,回测行为与启用前完全一致。
# ----------------------------------------------------------
profile_gate:
enabled: true
# 画像统计窗口(年):0 = 全历史;其余必须是 config/profile.yml 的
# windows_years 之一(当前 [5, 8, 10])
window_years: 5
# 数据缺失/样本不足时:reject = 保守不买(默认);pass = 放行
on_unverifiable: reject
# 逐条规则:metric 的 stat 必须满足 op value
# stat: current_value(当日值)/ current_percentile(当日值在窗口分布中的分位)
# metric 只能是 hdiv/core/metrics.py 里声明的代码
rules:
# 分红必须可持续:连续分红年数与窗口内分红年数
- { metric: dividend_continuity_years, op: ">=", value: 5 }
# 支付率不得超过 100%(分红不能靠借钱或吃老本)
- { metric: payout_ratio, op: "<=", value: 1.0 }
# 自由现金流必须覆盖分红(与分红同一财年口径)
- { metric: fcf_dividend_cover, op: ">=", value: 1.0 }
# 5 年平均 ROE 下限(与 universe.quality.min_roe_5y_avg 同一口径)
- { metric: roe_avg, op: ">=", value: 0.08 }
# 分批建仓(plan.md §19)。留空 => 一次性建满。 # 分批建仓(plan.md §19)。留空 => 一次性建满。
scale_in: scale_in:
- { percentile: 75, weight: 0.25 } - { percentile: 75, weight: 0.25 }
+16 -1
View File
@@ -7,10 +7,16 @@
# ./deploy/install-service.sh uninstall 停止并移除 # ./deploy/install-service.sh uninstall 停止并移除
# ./deploy/install-service.sh status 查看状态与连通性 # ./deploy/install-service.sh status 查看状态与连通性
# ./deploy/install-service.sh reinstall 重新渲染 plist 并重启 # ./deploy/install-service.sh reinstall 重新渲染 plist 并重启
# ./deploy/install-service.sh restart 只重启进程(改完 src/ 或 config/ 后执行)
# #
# 为什么需要:nginx 由 brew services 托管、开机自启。若后端只是 nohup 进程, # 为什么需要:nginx 由 brew services 托管、开机自启。若后端只是 nohup 进程,
# 机器重启后它就不在了 —— 页面能打开但会显示「API 不可用」。 # 机器重启后它就不在了 —— 页面能打开但会显示「API 不可用」。
# #
# 为什么 restart 是刚需:Python 进程把 hdiv 模块与 config/*.yml 读进了内存
# (配置经 lru_cache 缓存),改完源码或配置**不会**自动生效。不重启就会出现
# 「一边读新配置、一边用旧模型校验」的假故障,例如给 datasource.yml 加了
# 新字段却报 ``Extra inputs are not permitted``。
#
# 若你不用 launchd,也可用 ./deploy/serve.sh start 手动启动(重启后需再次执行)。 # 若你不用 launchd,也可用 ./deploy/serve.sh start 手动启动(重启后需再次执行)。
# ============================================================ # ============================================================
set -euo pipefail set -euo pipefail
@@ -85,11 +91,20 @@ cmd_status() {
tail -3 "${PROJECT_ROOT}/logs/web.err.log" 2>/dev/null | sed 's/^/ /' || true tail -3 "${PROJECT_ROOT}/logs/web.err.log" 2>/dev/null | sed 's/^/ /' || true
} }
cmd_restart() {
is_loaded || die "服务未加载,先执行:./deploy/install-service.sh install"
# kickstart -k 先杀旧进程再拉新进程;PID 会变,所以顺手打印状态
launchctl kickstart -k "gui/$(id -u)/${LABEL}"
sleep 3
cmd_status
}
case "${1:-install}" in case "${1:-install}" in
install) cmd_install ;; install) cmd_install ;;
reinstall) render; launchctl unload -w "${TARGET}" 2>/dev/null || true reinstall) render; launchctl unload -w "${TARGET}" 2>/dev/null || true
launchctl load -w "${TARGET}"; sleep 3; cmd_status ;; launchctl load -w "${TARGET}"; sleep 3; cmd_status ;;
restart) cmd_restart ;;
uninstall) cmd_uninstall ;; uninstall) cmd_uninstall ;;
status) cmd_status ;; status) cmd_status ;;
*) die "未知动作:$1(可用:install|reinstall|uninstall|status)" ;; *) die "未知动作:$1(可用:install|reinstall|restart|uninstall|status)" ;;
esac esac
+741 -72
View File
@@ -9,10 +9,18 @@
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 → **系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告, 回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
全链路打通并通过 262 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。 全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
数据同步仍在后台补齐最后 ~15%(分红 99.6%、财报 ~87%), **2026-10-03 完成三项正确性改造**(详见 §9):修复 `stock_daily` 量价单位不一致、
但这不影响系统功能 —— 它只影响股票池的绝对规模。 拒绝「用未来时点的股票池跑更早区间」、新增**实时(PIT)个股画像闸门**。
三项都让「交易依据更符合实际」,且在 walk-forward 样本外口径下**都改善了绩效**
(样本外均值 −0.95% → **+1.16%**,最差回撤 −24.62% → **−22.97%**;
2026-10-04 又把行情回补到 2005,样本外逐窗口未变、单路径则从 +117.36% 降到 +92.73%),
但**仍未取得正超额**(−1.12pp)。
数据层已补齐:行情/每日指标/复权因子覆盖 **2005-01-04 起**,
停牌与涨跌停价覆盖 **2010-01-04 起**,分红 1990 起、四张财报表 1990/2001 起。
审计 `FAIL=0`(14 OK / 5 WARN,WARN 均为已知且已声明)。
--- ---
@@ -22,13 +30,13 @@
| 缺口 | 状态 | 实测结果 | | 缺口 | 状态 | 实测结果 |
|---|---|---| |---|---|---|
| **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 253,879 行(其中 56,513 条实施且有现金分红),覆盖 1990–2026 | | **G1 分红明细** | ✅ 完成 | `hd_dividend` 5,880 只 / 267,295 行,覆盖 1990–2026 |
| **G2 日线行情** | ✅ 完成 | `stock_daily` 起点 **2015-01-05**(2015 年首个交易日),2,838 个交易日;**2019 年空洞已补齐**(+89 万行) | | **G2 日线行情** | ✅ 完成 | `stock_daily` **起点 2005-01-04**(2026-10-04 回补,+3,927,792 行),16,007,169 行 / 5,265 个交易日 |
| **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,2,840 个交易日 | | **G2b 复权因子** | ✅ 完成 | `adjust_factor` 同区间,16,789,137 行 / 5,267 个交易日 |
| **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 2015-01-05,2,286 个交易日 | | **G3 每日指标** | ✅ 完成 | `daily_basic` 起点 **2005-01-04**(+4,309,461 行),15,972,821 行 / 5,275 个交易日 |
| **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) | | **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 | | **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` 67,765 行;`hd_limit` **669 万行** | | **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` **468,388 行**、`hd_limit` **14,837,155 行**,均覆盖 **2010-01-04** 起(2026-10-04 回补,各 +400,623 / +5,787,253 行) |
### 1.2 数据库(30 张 `hd_*` 表) ### 1.2 数据库(30 张 `hd_*` 表)
@@ -44,7 +52,7 @@
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ | | 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ | | PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ | | 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
| 数据审计 | `data/audit.py`(18 项检查) | ✅ | | 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ | | 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
| 因子 | `factor/dividend_yield.py` | ✅ | | 因子 | `factor/dividend_yield.py` | ✅ |
| 个股画像 | `profile/builder.py` | ✅ | | 个股画像 | `profile/builder.py` | ✅ |
@@ -115,46 +123,116 @@
> 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、 > 策略:`HD_MR_V1` v1.0 —— 市值 ≥ 500 亿、上市 ≥ 10 年、连续分红 ≥ 5 年、
> 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、 > 股息率 ≥ 3%、年均 ROE ≥ 8%(金融豁免)、买入 ≥ 历史 P75、卖出 ≤ P25、
> 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。 > 单股 ≤ 10%、行业 ≤ 25%、最多 20 只。
>
> ⚠️ **口径变更史**:2026-10-03 三项改造(量价单位修复、`--universe-run`
> 未来函数守卫、实时画像闸门默认启用)→ 2026-10-04 **行情回补到 2005**
> (见 §9.3c,让 5/8/10 年窗口首次真正完整)。
>
> **结论先行 —— 两个口径给出相反答案,以 walk-forward 为准**:
>
> | 口径 | 闸门开 | 闸门关 |
> |---|---:|---:|
> | 全期单路径总收益 | +92.73% | **+101.20%** ← 单路径说闸门有害 |
> | Walk-forward 样本外均值 | **+1.16%** | −0.00% ← 样本外说闸门有益 |
> | 样本外最差回撤 | **−22.97%** | −24.22% |
>
> **而回补数据只改变了单条路径,没有改变任何样本外结果** ——
> 单路径从 +117.36% 掉到 +92.73%,样本外 7 个窗口**逐窗口一个数字都没变**。
> 原因见 §4.6d:样本外的测试窗口是 2020–2026,其参照窗口本就落在
> 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
> 过去被数据缺口「挡住」的 2015 年(大牛市 + 股灾)现在会被真实交易。
### 4.1 与基准对比 ### 4.1 与基准对比(回补后重跑)
| 指标 | **策略** | 沪深300 | 中证红利 | 上证指数 | | 指标 | **策略(闸门开)** | 策略(闸门关) | 沪深300 | 中证红利 | 上证指数 |
|---|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|
| 总收益 | **+114.80%** | +19.66% | +53.35% | +14.67% | | 总收益 | **+92.73%** | +101.20% | +19.66% | +53.35% | +14.67% |
| 年化 CAGR | **+6.73%** | +1.54% | +3.71% | +1.17% | | 年化 CAGR | **+5.75%** | +6.14% | +1.54% | +3.71% | +1.17% |
| 最大回撤 | **−21.05%** | −46.70% | −46.51% | −52.30% | | 最大回撤 | **−25.38%** | −25.54% | −46.70% | −46.51% | −52.30% |
| Sharpe | 0.31 | — | — | — | | Sharpe | 0.22 | 0.23 | — | — | — |
| Calmar | 0.23 | 0.24 | — | — | — |
| 成交笔数 | 197 | 225 | — | — | — |
**策略跑赢天然基准「中证红利」61 个百分点,而回撤不到其一半。** `run_id`:闸门开 `fa916050ffb647b6ab4cfd90ba1adf36`,
闸门关 `5347fd13dd8fc0b0a512156489c615bc`(两次资金对账残差均为 0)。
> ⚠ **但这个数字有严重误导性,请看 §4.6 的 Walk-forward 结果。** **策略仍跑赢天然基准「中证红利」,回撤不到其一半** —— 但见 §4.6:
> 单条路径的全期回测会把「持有可能穿越熊市并在后期回本」的效应放大, 单条路径的全期数字有严重误导性。
> 而逐年样本外检验给出的是一幅**完全不同的图景**。
> **回补数据让全期收益*下降*了 24.6pp(+117.36% → +92.73%)。**
> 这不是 bug,而是把「假象」修掉了:回补前 2015 年因缺少历史分布
> 而 100% 现金(收益 0.00%),回补后 2015 年从第一个交易日起就被
> 真实交易,而那年**亏了 3.18%**(大牛市顶部建仓 + 股灾)。
> 靠数据缺口「躲过股灾」不是策略能力 —— 详情见 §4.4。
> **闸门到底拦掉了什么**:全期 141 个决策时点、2428 次画像计算、
> **1027 次买入信号被剔除**(`REJECT`,可在「未成交信号」里逐条查看原因)。
> 剔除使成交从 225 笔降到 197 笔,收益下降 8.5pp,回撤基本不变。
> 该结果是在**修正了支付率与 FCF 覆盖两个筛选条件之后**取得的 > 该结果是在**修正了支付率与 FCF 覆盖两个筛选条件之后**取得的
> (此前这两个条件因 `base_share` 漏选而静默失效)。修正后股票池由 49 只 > (此前这两个条件因 `base_share` 漏选而静默失效)。
> 收紧到 28 只,收益反而提高 —— 说明「分红可持续性」这一安全边际条件
> 确实在筛选优质标的。
### 4.2 交易与分红 ### 4.2 交易与分红(闸门开)
| 项目 | 数值 | | 项目 | 数值 |
|---|---:| |---|---:|
| 成交笔数 | 180 | | 成交笔数 | 197 |
| 累计现金分红 | 558,797 元(占期初资金 55.9%) | | 期末资金 | 1,927,312 元 |
| 已扣红利税 | 13,676 元 |
| 期末资金 | 2,148,010 元 |
| **资金对账残差** | **0.0000** ✅ | | **资金对账残差** | **0.0000** ✅ |
| 画像剔除的买入信号 | 1027 |
| 画像计算次数 / 决策时点 | 2428 / 141 |
### 4.3 逐年收益 ### 4.3 逐年收益(回补后重跑,闸门开)
| 年 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 | | 年 | 2015 | 2016 | 2017 | 2018 | 2019 | 2020 | 2021 | 2022 | 2023 | 2024 | 2025 | 2026 |
|---|---:|---:|---:|---:|---:|---:|---:|---:| |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| 策略 | +18.56% | +6.64% | +11.85% | **−3.99%** | +1.25% | +22.22% | +14.67% | +7.32% | | 策略(闸门开) | **−3.18%** | +5.91% | +28.25% | **−10.86%** | +39.52% | −1.97% | +3.05% | **−14.13%** | +4.37% | +13.37% | +11.23% | +2.43% |
| 闸门关 | −1.26% | +7.04% | +26.83% | −9.95% | +38.22% | +0.14% | +2.99% | −14.35% | +4.56% | +11.06% | +12.00% | +4.30% |
11.7 年中仅 2022 一年为负,且跌幅远小于同期市场。 **2015 年不再是 0,而是 −3.18%(闸门开)/ −1.26%(闸门关)。**
这正是全期收益下降 24.6pp 的来源:回补前 2015 年因数据缺口无法产生信号
(100% 现金、收益 0.00%),回补后它被真实交易,而在牛市顶部建仓
随后遭遇股灾是亏钱的。**「靠数据缺口躲过股灾」不是策略能力。**
### 4.4 预热期说明(重要) ### 4.4 预热期与「数据缺口假象」(重要)
> **⚠️ 2026-10-04 定论(见 §9.3b/§9.3c)**:本节历史上曾把
> 「2015–2018 组合 100% 现金」解释为「不做未来函数的代价与证明」。
> 追查后确认那是**两个数据缺陷叠加出的假象**,不是纪律的胜利:
>
> 1. `stock_daily` 量价单位不一致 → 流动性门槛在早年低估 1000 倍,
> **把 2015-2019 的股票池整体清空**(见 §9.1);
> 2. 行情只到 2015-01-05 → 滚动参照窗口填不满,即便有候选也算不出分位
> (见 §9.3b)。
>
> 两者都在 2026-10-03/04 修好。现在:
>
> - **2015-01-05 的 PIT 股票池 = 3 只**(此前 0 只)
> - **5 年窗口覆盖率 98.68%**(此前无法计算)
> - **2015 年从第一个交易日起就被真实交易**
>
> 代价是全期收益从 +117.36% 降到 +92.73% —— **修数据让结果变差,
> 但变真实了**。下面保留历史记录以对照。
> **补充(2026-10-03):预热期只在「按周期重新筛选」时成立。**
>
> 用 `--universe-run` 指定**冻结股票池**时,引擎不解自筛选,历史约束(连续分红 5 年、
> 5 年 ROE 等)不再作用于早期,于是**没有预热期**。实测同一段区间:
>
> | 模式 | 总收益 | CAGR | 最大回撤 | Sharpe |
> |---|---:|---:|---:|---:|
> | 重新筛选(无 `--universe-run`) | 114.80% | 6.73% | **−21.05%** | 0.31 |
> | 冻结股票池(`--universe-run`) | 374.61% | 14.19% | **−53.06%** | 0.57 |
>
> **冻结模式的回撤是两倍以上**,因为它在 2015 年满仓吃到了股灾,而重新筛选模式
> 因预热期空仓躲过。预热期不只是技术细节,它**实质影响风险特征**。
>
> **而且冻结模式本身是未来函数**(股票池 asof 晚于回测起点):现已默认拒绝执行,
> 只能加 `--allow-lookahead-universe` 复现,且该偏差会写入 `unimplemented_json`。
> 详见 §9.2。
>
> 另外,冻结模式曾暴露一个真实缺陷(已修复,见 §4.7):
> 行情首日的滚动窗口只有 1 个观测,分位被算成 100%,8 只股票被误买入。
**2015–2018 年组合保持 100% 现金、无任何成交**,这是**预期行为**而非缺陷: **2015–2018 年组合保持 100% 现金、无任何成交**,这是**预期行为**而非缺陷:
@@ -188,28 +266,46 @@
### 4.6 Walk-forward 样本外验证(plan.md §23/§25) ### 4.6 Walk-forward 样本外验证(plan.md §23/§25)
7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布: 7 个滚动窗口,每个窗口用训练段校准分位分布、测试段**冻结**该分布:
(下表为 2026-10-04 回补后重跑,`wf_id = e855fdf268827e1e4c31510c6736eea3`,
**实时画像闸门启用**)
| 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | > **⚠️ 重要:这 7 个数字与「回补前」逐窗口完全相同。**
|---|---|---|---:|---:| > 也就是说,把行情从 2015 补到 2005 **没有改变任何样本外结果**,
| #0 | 2015–2019 | 2020 | **+4.01%** | −11.94% | > 却让单条路径的全期收益从 +117.36% 变成 +92.73%。
| #1 | 2016–2020 | 2021 | **−3.07%** | −15.79% | > 原因:样本外的测试窗口是 2020–2026,其参照窗口(冻结在训练段)
| #2 | 2017–2021 | 2022 | **−9.17%** | −24.22% | > 本就落在 2015 年之后,不依赖 2005–2014 的数据;而单条路径从 2015 年起,
| #3 | 2018–2022 | 2023 | **−8.63%** | −18.00% | > 2015 年是否可交易会改变整条路径。**这正是「不要采信单条路径」的又一例证**
| #4 | 2019–2023 | 2024 | **+2.61%** | −24.62% | > —— 见 §4.6d。
| #5 | 2020–2024 | 2025 | **+3.78%** | −10.50% |
| #6 | 2021–2025 | 2026(部分) | **+3.83%** | −14.88% |
**样本外汇总:** | 窗口 | 训练区间 | 测试区间 | 样本外收益 | 样本外回撤 | Sharpe |
|---|---|---|---:|---:|---:|
| #0 | 2015–2019 | 2020 | **+12.27%** | −12.39% | 0.52 |
| #1 | 2016–2020 | 2021 | **−2.47%** | −15.89% | −0.29 |
| #2 | 2017–2021 | 2022 | **−3.05%** | −16.68% | −0.32 |
| #3 | 2018–2022 | 2023 | **−11.32%** | −22.97% | −0.95 |
| #4 | 2019–2023 | 2024 | **+12.16%** | −15.63% | 0.50 |
| #5 | 2020–2024 | 2025 | **+6.65%** | −11.58% | 0.33 |
| #6 | 2021–2025 | 2026(部分) | **−6.09%** | −18.39% | −0.73 |
| 指标 | 数值 | **样本外汇总(闸门开):**
|---|---:|
| 盈利窗口 | 4 / 7(胜率 57.14%) | | 指标 | 数值 | 改造前(旧口径) |
| 样本外收益 **均值** | **−0.95%** | |---|---:|---:|
| 样本外收益 中位数 | +2.61% | | 盈利窗口 | 3 / 7(胜率 42.86%) | 4 / 7(57.14%) |
| 样本外 CAGR 均值 | −0.78% | | 样本外收益 **均值** | **+1.16%** | −0.95% |
| 最差窗口回撤 | −24.62% | | 样本外收益 中位数 | −2.47% | +2.61% |
| **基准收益均值** | **+2.29%** | | 样本外 CAGR 均值 | +0.85% | −0.78% |
| **超额收益均值** | **−3.24%** | | 最差窗口回撤 | −22.97% | −24.62% |
| **基准收益均值** | **+2.29%** | +2.29% |
| **超额收益均值** | **−1.12pp** | −3.24pp |
> **结论没有变,但程度变轻了**:样本外均值由 −0.95% 转为 **+1.16%**,
> 超额仍为负(**−1.12pp**)。胜率反而从 4/7 降到 3/7 —— 均值改善主要来自
> 2020(+4.01% → +12.27%)与 2022(−9.17% → −3.05%),
> 而 2026 部分年份由 +3.83% 转为 −6.09%。
>
> 这轮数字受**三项**改动影响(量价单位修复、行情回补到 2005、画像闸门);
> 其中**回补的贡献为 0**(逐窗口未变),两者已用对照 run 分离 —— 见 §4.6d。
#### 结论:单路径回测显著高估了策略 #### 结论:单路径回测显著高估了策略
@@ -217,25 +313,113 @@
| | 全期单路径回测 | Walk-forward 样本外均值 | | | 全期单路径回测 | Walk-forward 样本外均值 |
|---|---:|---:| |---|---:|---:|
| 收益 | **+114.80%**(11.7 年) | **−0.95%/年** | | 收益 | **+117.36%**(11.7 年) | **+1.16%/年** |
| 相对基准 | +95pp | **−3.24pp** | | 相对基准 | +97.7pp | **−1.13pp** |
**这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug: **这个反差是 Walk-forward 存在的全部意义。** 两者的差异来自方法论而非 bug:
1. **全期回测允许仓位穿越牛熊**:2019–2021 建的仓在 2022–2023 的下跌中继续持有, 1. **全期回测允许仓位穿越牛熊**:2016–2019 建的仓在 2022–2023 的下跌中继续持有,
到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。 到 2024–2026 随市场回升而回本 —— 单条路径把这段「扛过去」的收益完整计入。
2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布, 2. **Walk-forward 逐年冻结参照分布**:测试年必须用**训练年**校准的股息率分布,
不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移), 不能自适应。当市场环境切换(如 2022–2023 的估值中枢下移),
训练期校准的阈值在测试期就失灵了。 训练期校准的阈值在测试期就失灵了。
3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大 3. 样本量小:每年只是一个观测点,7 个窗口的均值本身标准误很大。
(稳定性指标 −0.16,说明窗口间差异大于均值本身)。
**因此:不要采信 §4.1 的 +114.80%。** 更接近真实的表述是 **因此:不要采信 §4.1 的 +117.36%。** 更接近真实的表述是
「该策略在 2020/2024/2025/2026 的样本外为正,在 2021/2022/2023 为负, 「该策略在 2020/2024/2025 的样本外为正,在 2021/2022/2023/2026 为负,
长期看与基准相比没有稳定的超额收益,且回撤更小(防御性成立、进攻性不足)」。 长期看与基准相比没有稳定的超额收益(−1.13pp),且回撤更小
(最差 −22.97%,而基准同期回撤 −46% ~ −52%)」。
### 4.6c 实时画像闸门的净影响(两个口径结论相反)
同一份代码、同一份数据,只切换 `entry.profile_gate.enabled`
(下表为**回补后**的数字):
**① 全期单路径(2015-01-05 ~ 2026-09-30)**
| | 闸门开 | 闸门关 | 差 |
|---|---:|---:|---:|
| 总收益 | +92.73% | +101.20% | **−8.47pp** |
| CAGR | +5.75% | +6.14% | −0.39pp |
| 最大回撤 | −25.38% | −25.54% | +0.16pp(略好) |
| Sharpe | 0.22 | 0.23 | −0.01 |
| 成交笔数 | 197 | 225 | −28 |
| 买入信号被画像剔除 | 1027 | 0 | — |
| `run_id` | `fa916050…` | `5347fd13…` | |
**② Walk-forward 7 窗口样本外**(`wf_id`:开 `e855fdf2…` / 关 `231d0b29…`)
—— **回补前后逐窗口完全相同**
| | 闸门开 | 闸门关 | 差 |
|---|---:|---:|---:|
| 样本外收益 **均值** | **+1.16%** | −0.00% | **+1.16pp** |
| 样本外收益 中位数 | −2.47% | +3.78% | −6.25pp |
| 盈利窗口 | 3 / 7 | 4 / 7 | −1 |
| 样本外 CAGR 均值 | +0.85% | +0.17% | +0.68pp |
| **最差窗口回撤** | **−22.97%** | −24.22% | **+1.25pp** |
| **超额收益均值** | **−1.12pp** | −2.29pp | **+1.17pp** |
逐窗口(超额 = 策略 − 沪深300):
| 测试年 | 闸门开 | 闸门关 | 基准 | 闸门开超额 | 闸门关超额 |
|---|---:|---:|---:|---:|---:|
| 2020 | +12.27% | +10.17% | +25.51% | −13.23pp | −15.34pp |
| 2021 | −2.47% | −2.84% | −6.21% | +3.74pp | +3.37pp |
| 2022 | −3.05% | −9.17% | −21.27% | **+18.23pp** | +12.11pp |
| 2023 | −11.32% | −9.58% | −11.75% | +0.43pp | +2.17pp |
| 2024 | +12.16% | +3.80% | +16.20% | −4.04pp | −12.40pp |
| 2025 | +6.65% | +3.78% | +21.19% | −14.54pp | −17.41pp |
| 2026 | −6.09% | +3.84% | −7.63% | +1.54pp | +11.48pp |
**两个口径给出相反结论。按本项目的一贯立场 —— 以样本外为准 —— 闸门是改善**
(样本外均值 +1.16pp、最差回撤 +1.25pp、超额 +1.17pp),
虽然它**降低了盈利窗口数**(4→3)与中位数,也就是说改善集中在少数年份。
单条路径之所以给出相反答案:它被「2016–2019 一次建仓 + 2024–2026 回本」这段
**穿越牛熊的持有**主导,而闸门剔除的那些买入恰好在单路径上是赚的。
这正是 §4.6 标题那句话的又一个例证 —— **不要采信单条路径**。
### 4.6d 三维归因:三项改动各自贡献多少
因为每一项都有「开关式」的对照 run,可以逐项分离。**结论是样本外只认前两项,
而回补的贡献为 0。**
**样本外(walk-forward 均值 / 超额 / 最差回撤)**
| 版本 | 样本外收益均值 | 样本外超额均值 | 最差回撤 | 盈利窗口 |
|---|---:|---:|---:|---:|
| 改造前(量价单位错误 + 无闸门 + 数据缺 2015 前) | −0.95% | −3.24pp | −24.62% | 4 / 7 |
| 仅修量价单位(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
| + 数据回补到 2005(闸门关) | −0.00% | −2.29pp | −24.22% | 4 / 7 |
| + 实时画像闸门(**当前**) | **+1.16%** | **−1.12pp** | **−22.97%** | 3 / 7 |
**单条路径(全期总收益)**
| 版本 | 总收益 |
|---|---:|
| 数据缺 2015 前(改造前口径) | +114.80% |
| + 量价单位修复(闸门关) | +134.06% |
| + 数据回补到 2005(闸门关) | **+101.20%** |
| + 实时画像闸门(**当前**) | **+92.73%** |
**读法**:
1. **回补在样本外贡献 0、在单路径贡献 −32.9pp。** 样本外的测试窗口是
2020–2026,参照窗口本就落在 2015 年之后,不依赖 2005–2014;
而单路径从 2015 年起,回补让 2015 年(牛市顶 + 股灾)从「被数据缺口
挡住」变成「被真实交易」(−3.18%),整条路径随之改变。
**同一项数据修正,在两个口径下的"效果"相差 32.9pp —— 这就是为什么
本项目坚持只认样本外。**
2. **闸门在两个口径下依然相反**(样本外 +1.16pp、单路径 −8.47pp),
与回补前的结论一致。
3. **结论没有变**:超额仍为负(−1.12pp),当前策略依然没有稳定的
样本外超额收益。
> 需要提醒的是:+1.16% 的样本外均值建立在 **7 个观测**上,
> 标准误很大。不要把「从 −0.95% 到 +1.16%」读成「策略变好了」。
> 值得注意的是,策略的**回撤控制**在样本外依然稳定成立 > 值得注意的是,策略的**回撤控制**在样本外依然稳定成立
> (最差 −24.62%,而基准同期回撤 −46% ~ −52%), > (最差 −22.97%,而基准同期回撤 −46% ~ −52%),
> 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**, > 这与「高股息 + 安全边际」的定位一致 —— 它更像一个**降低波动的配置工具**,
> 而非超额收益来源。 > 而非超额收益来源。
@@ -243,19 +427,29 @@
1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903); 1. **分红与财报已基本完成**(财务指标 5,903/5,903,现金流 5,893/5,903);
指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。 指数成分股权重 `index_weight` 仍为空(不影响基准收益计算)。
2. **涨跌停与停牌约束覆盖 2019 年起**;2015-2018 区间为近似建模, 2. **涨跌停与停牌约束覆盖 2010 年起**(2026-10-04 回补,原为 2019 起);
引擎会在 `hd_backtest_run.unimplemented_json` 中如实声明。 回测区间 2015-01-05 起已**全部**有真实约束,不再是近似建模。
(停牌同步器起点为 2019-01-01,如需 2015-2018 可改 `--start` 重跑。) 2010 年之前的涨跌停价 Tushare 无数据(实测 2005 年 `stk_limit` 返回 0 行)。
3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。 3. **未实现部分成交**(按信号全额成交,受资金与权重上限约束)。
4. **`index_weight` 为空**:不影响基准收益计算(用指数点位), 4. **`index_weight` 为空**:不影响基准收益计算(用指数点位),
仅影响成分股分析。 仅影响成分股分析。
5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。 5. **AI Agent 层(P8)未实现** —— 属 `plan.md` 第四版扩展。
6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。 6. **敏感性结论受限于扫描区间与股票池规模**,见 §4.5 说明。
7. **2015-2018 为预热期**(无历史分布可用),见 §4.4 说明。 7. **不再有「预热期空仓」**:2026-10-04 把行情回补到 2005 后,滚动 5 年分位
在 2015-01-05 起即可计算,**2015 年(大牛市 + 股灾)从第一个交易日起
就被真实交易**。此前的「2015 年 100% 现金」不是设计,而是数据缺口造成的
假象(见 §9.3b/§9.3c)。这也使全期收益下降 —— 见 §4.4。
8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。 8. **策略缺少稳定的样本外超额收益**(见 §4.6),这是最重要的结论。
9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026), 9. **Walk-forward 已具备 7 个滚动窗口**(2015-2019/2020 … 2021-2025/2026),
与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻; 与 `plan.md §23` 的示例完全一致,train/test 均落库可下钻。
单次完整跑完约需 25 分钟,用 `hdiv backtest --mode walkforward` 执行。 10. **幸存者偏差尚有 3 只的缺口**:`stock_daily` 里有 3 个代码
(`000022.SZ`、`000043.SZ`、`300114.SZ`,均因吸收合并/重组退市)
不在 `stock` 表中,因此永远不会进入候选集。占 5,903 只的 0.05%。
根因是 `stock` 表只收录在市股票,补它需要扩展 `sync` 的
`backfill_tables` 白名单,属独立的数据补全工作。
11. **`hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录的代码**
(其中 356 中 250+106 为北交所 BJ,按设计被交易所白名单排除;
SZ 的 2/53 只与第 10 条同源)。不影响可交易标的,仅影响审计洁净度。
--- ---
@@ -363,19 +557,494 @@ hdiv report validate # 或:python -m hdiv.report.validate output
部署:`deploy/nginx.conf.example`(两种布局)+ `deploy/serve.sh`(启停脚本)。 部署:`deploy/nginx.conf.example`(两种布局)+ `deploy/serve.sh`(启停脚本)。
## 4.6b 样本外超额的真相:牛市跑输、熊市跑赢(2026-10-03 补充,同日重跑更新)
补上基准对比后(此前接口把 `benchmark_<code>` 行过滤掉了,页面上看不到超额),
逐窗口的超额呈现**高度规律**的形态:
| 测试年 | 策略 | 沪深300 | 超额 | 市场 |
|---|---:|---:|---:|---|
| 2020 | +12.27% | +25.51% | **−13.24pp** | 牛市 |
| 2021 | −2.47% | −6.21% | **+3.74pp** | 熊市 |
| 2022 | −3.05% | −21.27% | **+18.22pp** | 熊市 |
| 2023 | −11.32% | −11.75% | **+0.43pp** | 熊市 |
| 2024 | +12.16% | +16.20% | **−4.04pp** | 牛市 |
| 2025 | +6.65% | +21.19% | **−14.54pp** | 牛市 |
| 2026 | −6.09% | −7.63% | **+1.54pp** | 熊市 |
**4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。** 超额胜率 57.1%。
因此更准确的表述不是「没有超额收益」,而是:
**它是一份低 beta 的防御型配置 —— 用牛市的大幅跑输换取熊市的相对抗跌**。
超额均值 −1.13pp 是样本内牛熊比例的结果,而非策略「无效」;
在熊市占比更高的样本里,这个均值会转正。
**判读时必须分开看牛熊**,只看均值会得出误导性结论。
> **重跑后这个形态反而更清晰**:牛市跑输的幅度收窄
> (−21.50 → −13.23、−13.58 → −4.04、−17.41 → −14.54),
> 熊市跑赢的幅度扩大(2022:+12.11 → +18.23)。
> 归因已用「闸门关闭」的对照 run 分离(§4.6d):两项改造在样本外都是正向的,
> 其中闸门的贡献集中在 2022/2024/2025 三个年份。
## 4.7 已修复:退化分布伪造 100% 分位(2026-10-03)
**现象**:明细里出现「股息率 0.00%;历史分位 100.0%」并触发买入。
**根因**:分位定义为「≤当前值的观测占比」。当参考窗口只剩 1 个观测、
且恰好等于当前值时,占比恒为 100%,足以击穿任何买入阈值。
触发条件:回测起点早于行情数据起点时,滚动窗口伸进空区间。
实测 2015-01-06(行情数据首日)窗口 2010-2015 只有 **1 个观测**,
**8 只股票**因此被买入 —— 且买在 2015 年股灾前的高点。
**影响面**:该次回测 217 笔成交中有 **22 笔(10.1%)** 参考样本不足 250 天。
**修复**:新增 `backtest.yml: percentile_reference.min_observations`(默认 250 ≈ 一年),
窗口样本不足则**当日对该股不产生任何信号**(保持现状,不买不卖),
并把 `min_observations` 一并写入 `reason_json` 便于事后核查。
**修复效果**:弱样本成交 22 → 0;同区间总收益 289.73% → 374.61%
(去掉的是股灾前的错误买入,因此反而更好)。
> 这个缺陷说明:**分位类指标必须带最小样本量**。
> 与 §9.6 绩效指标的 `MIN_OBS_FOR_RISK` 是同一类问题 —— 样本不足时
> 正确做法是「不判断」,而不是照常输出一个看似合理的数字。
## 7.3 Walk-forward 前端与重跑覆盖(2026-10-03)
**补上 Walk-forward 前端入口**:此前 `hd_walkforward_run` 有数据但**没有任何接口或页面**,
跑完 25 分钟在界面上看不到任何东西。现新增 `/api/walkforwards`(列表)、
`/api/walkforwards/{wf_id}`(详情)与 `#/walkforwards` 页面,
逐窗口展示样本内/样本外收益、CAGR、Sharpe、最大回撤、成交数与**冻结阈值**。
**⚠ 判读更正(同日)**:初版页面把「训练段累计收益」与「测试段累计收益」并排比较,
得出「样本内 7.0%→60.7%、样本外仅 −0.95%,明显过拟合」的结论 —— **这是错的**。
训练段是 **1825 天(5 年)**、测试段是 **364 天(1 年)**,两者累计收益不可比。
换算年化后:
| 窗口 | 训练年化 | 测试年化 |
|---|---:|---:|
| #0 | 1.37% | **4.02%** |
| #2 | 5.01% | −9.29% |
| #6 | 9.52% | 5.25% |
窗口 #0 的样本外年化**高于**样本内。7 个窗口里 6 个是样本外年化低于样本内,
方向存在但幅度远没有累计口径显示的那么夸张。
页面与表格已改用**年化**口径,并在图下注明区间长度差异。
**筛选记录改为重跑覆盖**:`run_id` 原先含 `datetime.now()`,
导致同一 asof 反复重跑不断累积(2025-01-21 累积了 11 条内容相同的记录)。
现改为 `stable_id(名称, 时点, config_hash)` —— 不含时间,同输入即同 id,
重跑原地覆盖运行头、成员清单与因子快照。
刻意**不含 `data_version`**:用户要的是「这一天的筛选结果」,
而不是「每次数据快照各存一份」;每次运行实际使用的 data_version 仍完整记录可追溯。
候选集缩小时(新股上市/退市),上次存在而本次不再出现的成员被标记为
`fail_stage='stale'`(UPDATE,非 DELETE),以符合「禁止物理删除」的约束。
界面上的命名/备注/归档状态不在更新列中,重跑不会清掉用户标注。
## 7.4 已修复:`--universe-run` 在 walk-forward 下被静默忽略(2026-10-03)
`hdiv backtest --universe-run X --mode walkforward` 中,`--universe-run` **完全没有生效** ——
`WalkForwardRunner` 根本没有这个参数,CLI 也没校验,于是参数被丢掉且无任何提示。
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
**但正确做法不是「支持」它,而是拒绝**:
| | 时点 |
|---|---|
| 股票池 `b8dd742f…` | asof = **2025-01-21** |
| walk-forward 最早训练区间 | **2015-01-01** 起 |
把 2025 年选出的股票池套到 2015 年的训练窗口上,就是用未来信息选股 ——
恰好破坏了 walk-forward 要守护的无未来函数纪律。
现在该组合会明确报错并说明原因与替代方案。同时新增两层回归测试:
CLI 必须拒绝,且 `WalkForwardRunner` 的签名里不得出现 `universe_run_id`(防止日后被误加回去)。
同类问题:`selector.py` 中 `df["is_fresh"].fillna(False)` 触发 pandas
Downcasting `FutureWarning`(pandas 未来版本会改变行为,可能让筛选结果静默变化)。
已改为 `astype("boolean").fillna(False).astype(bool)`,语义不变;
并新增测试用 `-W error::FutureWarning` 跑筛选路径。
## 7.5 已修复:显示精度配置是死的(2026-10-03)
**现象**:改了 `config/report.yml: layout.decimals.ratio` 对股息率等百分比**毫无影响**。
**根因**:`layout.decimals` 三个设置里,**只有 `price` 曾被读取过一次**
(renderer 的价格格式化)。`ratio` 与 `money` 从未被任何代码引用 —— 纯摆设。
而百分比显示散落在 **20 余处硬编码**:
| 位置 | 原实现 |
|---|---|
| `profile_report._pct` | `f"{x*100:.2f}%"` |
| `backtest_report._pct` / `_fmt_metric` | `f"{x*100:,.2f}%"` |
| `universe_report._pct` | `dec: int = 2`(默认写死) |
| `sensitivity_report._pct` / `walkforward_report._pct` | `f"{x*100:,.2f}%"` |
| `web/service._reason_text`、`web/analysis._reason_text` | 成交理由里的股息率写死 2 位 |
| `analysis/sensitivity.py`、`analysis/performance.py` | CLI 输出写死 |
| `web/app.js` 的 `pct()` | 前端写死 `toFixed(2)` |
**修复**:新增 `src/hdiv/report/format.py`(`NumFmt`),所有格式化统一走它,
精度由配置驱动;前端通过 `/api/config/display` 获取精度,也由配置驱动。
**语义**(与用户确认):`decimals.ratio` 指**原始比率**的小数位。
比率保留 ratio 位后乘 100,恰好少两位 —— 即 **百分比小数位 = ratio - 2**:
| ratio | 原始比率 | 股息率显示 |
|---:|---|---|
| 2 | 0.06 | 6% |
| 4 | 0.0617 | 6.17% |
| 6 | 0.061715 | 6.1715% |
并加了两条硬断言:`src/` 全域不得再出现 `:.2f}%`,前端必须存在 `FMT.percent`。
## 7.6 已修复:股息率毛刺与口径分裂(2026-10-03)
### 问题一:除权间隔不规整造成的毛刺
A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每年除权日附近
制造两种**日历假象**:
| 类型 | 成因 | 实测 |
|---|---|---|
| **重叠虚高** | 间隔 < 365,新旧分红同时在窗口内 | 招商银行 2015-07-03:0.620 → **1.290**(+108%),10 天后回落 0.670 |
| **断档虚低** | 间隔 > 365,旧的已到期新的未入场 | 中国神华 2016-07-04:0.740 → **0.320**(−57%) |
旧实现只在**结果恰好为 0** 时用 `grace_days` 兜底,而实际跌落是**部分跌落**
(1.290→0.670 不是 0),所以完全没兜住。实测:
| 股票 | 修复前 >20% 跳变 | 修复后 | 修复前虚低归零天数 |
|---|---:|---:|---:|
| 招商银行 | 21 | **5** | — |
| 工商银行 | 25 | **7** | — |
| 中国银行 | 25 | **7** | **77 天** |
| 伊利股份 | 24 | **13** | **68 天** |
**修法**:把「硬窗口」换成「按后继接管」。对每次分红 i,若与下一次的间隔
落在 `window_days ± grace_days`(即 320~410 天),视为同一档年度分红,
计入区间延到 `min(下一次除权日, 除权日 + 365 + grace)`:
- 间隔略小于一年 → 后继提前接管,**消除重叠虚高**
- 间隔略大于一年 → 旧的计到新的入场,**填补断档虚低**
- 超过 `365 + grace` 仍无后继(真停发)→ 封顶,**如实归零**
- 间隔 < 320 天视为**年内多次分红**(中期+年度),互不取代 ——
否则会把中期分红误删,人为制造新的低点
### 问题二:同一个「股息率」在四处口径不同
修复过程中发现更严重的问题:TTM 参数在四个调用点来源不一。
| 调用点 | 修复前 |
|---|---|
| `profile/builder.py` | ✓ 读 `profile.yml` |
| `backtest/engine.py` | ✗ **硬编码 365 / 45** |
| `backtest/walk_forward.py` | ✗ 用函数默认值 |
| `web/analysis.py` | ✗ 用函数默认值 |
| `universe/filters/dividend.py` | ✗ **另写了一遍** trailing-12月求和 |
后果:改 `profile.yml` 只有画像会变;更糟的是**筛选器用的股息率与画像/回测不一致**,
而这是**直接决定选股**的数字。
**修法**:新增 `ttm_params()`(单一事实来源)与 `ttm_dps_at()`(单点求值),
五处全部改用同一实现,参数统一来自 `profile.yml: ttm_dividend`。
### 影响与后续
因子值变化约 **1.2% 的交易日**(每只股票 11 年约 30~64 天,即原先的毛刺日),
最大单日差异达 60%+。由于股息率是买卖信号的直接输入:
- **已有的画像与回测结果已过期**,需重跑
- 筛选结果需重新生成(`hd_universe_run` 会原地覆盖)
## 8. 测试覆盖 ## 8. 测试覆盖
``` ```
262 passed 403 passed(pytest 退出码 0)
``` ```
| 测试文件 | 覆盖 | | 测试文件 | 覆盖 |
|---|---| |---|---|
| `test_config.py` | 配置正向加载 + 18 类非法配置必须被拒 | | `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import | | `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
| `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) | | `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 | | `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 | | `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
| `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数 | | `test_profile_pit.py` | **实时画像与批量画像逐值等价**、公告日/除权日 PIT 负例、惰性与面板复用、窗口校验、**窗口覆盖率(含左开右闭分母)**、**输入行序无关性** |
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run`) | | `test_backtest.py` | 成本模型(含印花税)、A股整手、资金对账、目标仓位阶梯与死区、参数耦合、敏感性判读、Walk-forward 窗口、无未来函数、**画像闸门(剔除/放行/不动仓位/无法验证)**、**未实现声明的诚实性** |
| `test_cli_contract.py` | CLI 与使用手册的接口契约(含 `web`/`site` 命令、`--universe-run` 的未来函数守卫) |
| `test_web.py` | 前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 | | `test_web.py` | 前端↔后端接口契约、软删除可逆性、归档可见性、资源路径重写、JSON 可序列化 |
---
# 9. 三项正确性改造(2026-10-03)
三项都是「不报错、只让结果悄悄错」的类型,因此都配了负例测试与如实声明。
## 9.1 已修复:`stock_daily` 量价单位前后不一致
**现象**:2015-01~2019 的股票池被流动性门槛整体清空 —— 实测按当时可见数据筛选,
**2016/2017/2018 各得到 0 只**,2019 只有 3 只;而 `market` 过滤本应留下几十只。
**根因**:`stock_daily` 是「追加进既有 qlib 库」的表。
| 区间 | 来源 | `volume` 单位 | `amount` 单位 |
|---|---|---|---|
| 2015-01 ~ 2019 | 本项目从 Tushare 回补 | 手 | 千元 |
| 2019(同日混合) | 两者重叠 | 3596 行里 337 行已换算 | — |
| 2020 ~ | qlib 存量 | 股 | 元 |
而 `universe.market.min_avg_amount_20d: 20000000` 是按「元」写的,
`Repo.avg_amount` 又直接 `AVG(amount)` —— 于是早年门槛实际变成
**「日均成交额 ≥ 200 亿元」**。`units.py` 里的 `amount_qian_to_yuan`
写了但**从未被调用**,`sync.price.daily_frame` 也是原样落库;
审计的单位自检只看 `daily_basic.total_mv`,所以一直没报警。
**修复(两层)**:
1. 写入端 `sync.price.daily_frame`:`vol ×100`、`amount ×1000`
2. 读取端 `units.normalize_ohlcv_units`:按行判据
`成交额 / (成交量 × 收盘价)`(≈1 已换算 / ≈0.1 原始)判定并换算,**幂等**;
`Repo.price_history` 与 `Repo.avg_amount` 都走它
3. 审计新增 `UNIT-OHLCV`:逐年抽样列出仍为原始单位的行数(防回归)
**效果**(同一份配置、同一批数据):
| asof | 修复前入选 | 修复后入选 |
|---|---:|---:|
| 2016-02-01 | 0 | **7** |
| 2017-02-01 | 0 | **11** |
| 2018-02-01 | 0 | **13** |
| 2019-02-01 | 3 | **14** |
| 2024-12-31 | 46 | 46 |
> **这更正了 §4.4 的一条叙事。** 原文把「2015–2018 组合 100% 现金」
> 归因为「不做未来函数的代价与证明」。真实原因至少有一部分是**单位 bug 把股票池清空了**。
> 预热期(滚动 5 年分位参照需要 250 个观测)确实存在,但它不是唯一原因。
**未做**:没有重刷 2015-2019 的存量数据(写入是 `INSERT IGNORE`,
不动既有行是硬约束)。读取层兜底已使结果正确,存量数据的清理留给一次显式的数据维护。
## 9.2 已修复:单次回测里 `--universe-run` 的未来函数
**现象**:`hdiv backtest --universe-run <股票池>` 会把该股票池的成员
**冻结**到所有调仓日。若股票池的 `asof` 晚于回测起点,2015 年的选股就用了
2025 年的信息。实测库中 `247724798ec2…`:引用股票池 `b8dd742f…`(asof **2025-01-21**),
回测 2015-01-05 起,**2015-01-06 就有 8 笔成交**。
**为什么必须改**:项目自己在 §7.4 已经认定「把 2025 年选出的股票池套到 2015 年
的训练窗口上就是用未来信息选股」,并因此在 walk-forward 下**拒绝**该组合 ——
同一条理由对单次回测同样成立,此前却没有拦。
**修复**:引擎在执行前校验股票池 `asof` 与回测起点:
| 情形 | 行为 |
|---|---|
| `asof <= 回测起点` | 正常执行(此时股票池属于事前信息) |
| `asof > 回测起点` | **拒绝执行**,错误信息给出三种正确做法 |
| 显式 `--allow-lookahead-universe` | 放行,并把偏差写入 `hd_backtest_run.unimplemented_json` |
**测试**:`test_future_universe_is_rejected_by_default` 用**库中最晚 asof** 的股票池
跑更早区间,断言必须抛错且错误信息含放行开关;同时断言 `asof <= 起点` 时正常工作。
## 9.3 新增:实时(PIT)个股画像闸门
**需求**:交易依据要与实际情况相符 —— 2018-05-18 的决策依据应当是
**2013-05-18 ~ 2018-05-17 的画像**,即按当时可见的数据实时重算画像,
剔除「不值得买」的票。
**改造前的真实状态**(这一点必须先说清楚):
| 层 | 是否 PIT |
|---|---|
| 股息率分位(唯一的交易依据) | ✅ 已经是滚动 5 年、只用 `<= 当日` 的数据 |
| 股票池(逐 12 个月重建) | ✅ PIT;但 `--universe-run` 冻结时 ❌(见 §9.2) |
| `hd_profile_stat`(个股画像) | ⚠️ 是 asof 的**快照**,且**回测从不读取它** |
也就是说:改造前画像既不参与交易,也不存在「按每个决策日重算」的形态。
**新增 `src/hdiv/profile/pit.py`**:
- `PitProfileService.snapshot(symbol, asof)` —— 按当时可见数据重算画像。
**指标定义复用 `ProfileBuilder._profile_one`**(同一定义来源),
有**逐值等价测试**保证「回测里的画像」==「页面上的画像」
- `evaluate_gate(rules, snapshot, on_unverifiable)` —— 逐条判定,
三种结局:`PASS` / `REJECT` / 无法验证(按配置保守或放行)。
指标缺失与样本不足**绝不当作 0**
**引擎接入**(`entry.profile_gate`):只在**买入条件已触发之后**才计算 ——
这是「在触发条件的时候计算」的落点。被剔除时产出信号类型 `REJECT`、
`skip_reason = PROFILE_GATE`,前端「未成交信号」里可直接看到
**每个指标的实际值、阈值、状态与是否通过**。
**成本控制**(对应「长周期数据可以沿用」):
| 手段 | 效果 |
|---|---|
| 只在触发时计算 | 成本 ∝ 触发次数,而非「区间长度 × 股票数」 |
| 跨股票共享面板按时点缓存 | 同一 asof 的财报面板只载入一次 |
| 按规则声明所需指标 | 规则里没有财务指标时**完全不查财报表**(各约 30 万行) |
| 财务查询加 symbol 过滤 | 单次 1076ms → 41ms(语义不变,仅追加 `symbol IN (...)`) |
实测一段 2016 全年回测:20 个决策时点、46 次画像计算、
20 次财报面板载入、**0 次流动性查询**(规则未用到)、剔除 18 次。
**等价性测试抓到的两个真实缺陷**(都是「同一指标两个值」):
1. `ttm_dps` 被产出两行 `(ttm_dps, 0)`(序列循环 + 分红质量各一次),
落库后谁胜出取决于写入顺序 → 已删除重复来源
2. `free_cashflow` 同名不同义:`fin_latest`(最近一期)与
`_payout_and_cover`(**与分红同一财年**)。实测格力电器 2018-05-18
两个值分别是 67.1 亿与 70.5 亿 → 后者改名 `dividend_fy_free_cashflow`
3. 实时侧分红超集的下界算错:按 `end` 回看 13 年 →
2018 年的画像拿不到 2006-2012 的分红,格力 `dividend_continuity_years`
被算成 4(真值 10)→ 改为按**回测起点**计算超集下界
**默认策略已启用**(`config/strategy/high_dividend_v1.yml` 的
`entry.profile_gate.enabled: true`),因此**此前所有回测数字都已重跑**。
重跑后的净影响见 §4.6c/§4.6d:**样本外是改善,单条路径是变差。**
**一个必须知道的取舍**:`on_unverifiable: reject` 是默认值,它比股票池筛选
(`universe.dividend.on_missing_data: pass`)更严格。实测 2016 年初的中石化:
最新可见分红属于 FY2015,而 FY2015 年报要到 3 月才公告 ——
**支付率在当时根本无法验证**。`reject` 会放弃买入,`pass` 会照买。
这是策略取舍得由使用者决定,不是 bug。
## 9.3b 窗口覆盖率:「名义 5 年」vs「真有 5 年」(2026-10-03 补充)
**问题**:用户要求「滚动计算过去 5 年的个股画像」。机制在 §9.3 已实现,
但 `window_slice(asof, 5)` 的语义只是「把**已有**数据切成最近 5 年」——
数据起点晚于窗口左端时,窗口会被**静默截短**,而 `status` 仍报 `OK`
(`_stat_row` 的门槛是 `n_obs >= min(min_obs_days, 20)`,即 20 个观测就放行)。
**实测(600036.SH,`dv_yield`,5 年窗口)**:
| asof | 窗口内实际观测 | 应有交易日 | 覆盖率 |
|---|---:|---:|---:|
| 2015-12-31 | 239 | 1214 | **19.7%** |
| 2016-12-30 | 483 | 1214 | **39.8%** |
| 2018-05-18 | 817 | 1219 | **67.0%** |
| 2019-12-31 | 1214 | 1219 | 99.6% |
| 2020-12-31 起 | ≈1218 | ≈1218 | **100%** |
截断的指纹很明显:**2018-05-18 的四个窗口(0/5/8/10)报出完全相同的 `n_obs=817`**。
**根因是数据缺口,不是代码**:`stock_daily` / `daily_basic` / `adjust_factor`
都只从 **2015-01-05** 起(分红、财报、指数、ST 历史都覆盖到 1990 年代)。
因此任何早于 2020-01 的 asof,其 5 年窗口都不完整。
**这次补上的三件事**:
1. **量化**:新增 `profile/coverage.py`,用**交易日历的真实开市天数**作为分母
(不是 243 这种近似),区间口径与 `window_slice` 严格一致(左开右闭 ——
否则覆盖率永远差一天、`min_window_coverage=1.0` 会变成「永远拒绝」)
2. **暴露**:`ProfileSnapshot` 新增 `n_obs` / `coverage`;
gate 的 `checks[]` 记录 `n_obs` 与 `window_coverage`;
`hdiv profile` 打印覆盖率警告
3. **可强制**:策略新增 `entry.profile_gate.min_window_coverage`
(0 = 不因覆盖率淘汰,保持改造前行为;1.0 = 名义 5 年必须真有 5 年数据)
**顺带修正的两个缺陷**:
- `hdiv sync backfill` 的 `basic_start` 未暴露给 CLI(函数默认 2015-01-01),
于是「回补 2010–2014」实际只补了行情与复权因子、**`daily_basic` 仍停在 2015**。
已新增 `--basic-start`,缺省跟随 `--start`。
- `config/profile.yml: sufficiency` 的三个阈值
(`min_history_years_dividend/price`、`min_dividend_records`)
**从未被任何代码使用** —— 与 §7.5 记录的「显示精度配置是死的」同类问题。
现已在已知限制中如实声明;真正的充分性判定由
`min_window_coverage` + `on_unverifiable` 承担。
**回补方案**见 [user-guide §6.6](user-guide.md)。回补是 `INSERT IGNORE`(只追加)。
## 9.3c 回补已执行:行情补到 2005,5/8/10 年窗口全部补齐(2026-10-04)
按 §9.3b 的方案执行完毕。**Tushare 探针实测三个接口在 2005 年都有数据**
(此前担心早年无数据,实际有),因此一次性补到 2005,让
`profile.yml: windows_years = [5, 8, 10]` **三个窗口**都完整。
### 数据量变化
| 表 | 回补前 | 回补后 | 新增 | 新起点 |
|---|---:|---:|---:|---|
| `stock_daily` | 12,079,377 | 16,007,169 | +3,927,792 | **2005-01-04** |
| `adjust_factor` | 12,205,794 | 16,789,137 | +4,583,343 | **2005-01-04** |
| `daily_basic` | 11,663,360 | 15,972,821 | +4,309,461 | **2005-01-04** |
| `hd_suspend` | 67,765 | 468,388 | +400,623 | **2010-01-04** |
| `hd_limit` | 9,049,902 | 14,837,155 | +5,787,253 | **2010-01-04** |
- 命令:`hdiv sync backfill --start 2005-01-01 --end 2014-12-31 --basic-start 2005-01-01 …`
与 `hdiv sync trading --start 2010-01-01 --end 2018-12-31`
- 11,655 次 API 调用,全程 **0 次限频**(把 `daily/adj_factor/daily_basic`
的限频从 480 下调到 170 —— 实测该 token 在约 196 次/分钟即被拒,
「撞墙后冷却 62 秒」远慢于平滑配速)
- 校验:三张表均「只增不减」✅;新写入行已是**正确的「股/元」单位**(实测比值 1.005~1.018)
### 覆盖率:目标达成
| asof | 回补前 5 年覆盖 | 回补后 5 年覆盖 |
|---|---:|---:|
| 2015-01-05 | —(数据起点之外) | **98.68%** |
| 2016-12-30 | 39.79% | **99.01%** |
| 2018-05-18 | 67.02% | **99.10%** |
| 2021-06-30 | 100% | 100% |
**残下的约 1% 已逐日核实为真实停牌,不是数据洞**:600036.SH 在
2010-01-05~2015-01-05 的 5 年窗口内缺 16 个交易日,把它们与
`hd_suspend` 交叉比对,**16/16 全部命中停牌记录**
(2010-03-05~03-12、2013-08-28~09-04 两个整周正是招行的配股停牌)。
### 顺带修掉的两个「同一类」缺陷
回补过程暴露了两处与 §9.1 同源的**固定阈值**问题(早年市场只有一千多只股票,
固定阈值会把正常数据判成异常):
1. **断点续传失效**:`fetched_days` 用固定 `MIN_SYMBOLS_PER_DAY = 1500` 判定
「某日是否已完整同步」。2005-2009 每天只有 ~1,350 只 → **每一天都被判成未完成**,
回补一旦中断就要从第一天重来。已改为
`max(绝对下限, 比例 × 当年应有上市股票数)`(新增 `datasource.yml: sync` 段配置),
实测阈值 2005 年 829、2015 年 1,732、2024 年 3,397。
2. **审计误报**:`G2/G2b/G3` 同样用固定 2,000 只判定「疑似数据稀疏」,
回补后把 2005 年正常数据报成 WARN。已改为复用同一套按年份阈值。
审计结果由 **OK=11 / WARN=8** 改善为 **OK=14 / WARN=5 / FAIL=0**。
### 效果
| 项 | 回补前 | 回补后 |
|---|---|---|
| 2015-01-05 的 PIT 股票池 | 0 只 | **3 只** |
| 2015 年能否交易 | 不能(无历史分布) | **能,从第一个交易日起** |
| 涨跌停/停牌约束覆盖 | 2019 起 | **2010 起**(2015-2018 不再是近似建模) |
> 这也意味着 **§4 的全部回测数字都需要再次重跑** —— 2015 年(大牛市 + 股灾)
> 现在会被真实交易,而此前该年是 100% 现金。
> `min_window_coverage` 保持默认 `0.0`:覆盖率现在已足够高(≥98.7%),
> 强制 1.0 只会因个别停牌日误杀。
## 9.4 这三项改造带来的口径变化(重跑时必须知道)
| 变化 | 影响 |
|---|---|
| 量价单位修复 | 2015-2019 的股票池从 0~3 只变为 7~14 只,早期不再是纯预热期;样本外均值 +0.95pp |
| `--universe-run` 守卫 | 历史「冻结股票池」回测无法再原样复现,需加 `--allow-lookahead-universe` 且结果被标注为含未来信息 |
| 闸门默认启用 | 买入被进一步过滤(全期剔除 998 次);样本外均值再 +1.16pp、最差回撤 +1.25pp,但单路径收益 −16.7pp。`enabled: false` 可关闭以对比 |
| `ttm_dps` / `free_cashflow` 去重改名 | `hd_profile_stat` 的既有画像快照需重跑;`dividend_fy_free_cashflow` 是新指标代码 |
**回补后(当前数据状态)的四个基准 run(可直接复核)**:
| 用途 | run_id |
|---|---|
| 单次回测,闸门开 | `fa916050ffb647b6ab4cfd90ba1adf36` |
| 单次回测,闸门关 | `5347fd13dd8fc0b0a512156489c615bc` |
| Walk-forward,闸门开 | `e855fdf268827e1e4c31510c6736eea3` |
| Walk-forward,闸门关 | `231d0b298a007e1fb68f2e20a6cc42a9` |
(回补前的四个 run 为 `1a7e5b72…` / `e6e65382…` / `697a2ecd…` / `ae296c0f…`,
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
+673 -41
View File
@@ -7,6 +7,7 @@
# 目录 # 目录
0. [全流程操作](#0-全流程操作) ★ **先读这一节(选股 → 画像 → 回测)**
1. [系统是什么](#1-系统是什么) 1. [系统是什么](#1-系统是什么)
2. [快速开始](#2-快速开始) 2. [快速开始](#2-快速开始)
3. [目录与架构](#3-目录与架构) 3. [目录与架构](#3-目录与架构)
@@ -22,6 +23,288 @@
--- ---
# 0. 全流程操作
> **选股 → 画像 → 回测**,三步主路径。
本节是**主操作路径**:从「选出一批股票」到「跑出一份可信的回测」,
每一步都说明**命令做了什么、数据从哪来、落了哪些库、有哪些坑**。
## 0.0 五分钟全流程(可直接复制)
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
# ① 选股:按 2025-01-01 当时可见的数据筛选(实际落到交易日 2024-12-31)
.venv/bin/python -m hdiv universe --asof 2025-01-01
# → 记下打印的 run_id,例如 02485801b2805cbae0e02db66c7bc946
# ② 画像:对这 46 只看它们在**该时点**的 5/8/10 年画像
.venv/bin/python -m hdiv profile --universe-run 02485801b2805cbae0e02db66c7bc946
# ③ 登记策略(首次需要;之后改 YAML 再 register 即可)
.venv/bin/python -m hdiv strategy register
# ④ 回测:用这个池子,**起点不得早于股票池 asof**
.venv/bin/python -m hdiv backtest \
--universe-run 02485801b2805cbae0e02db66c7bc946 \
--start 2025-01-01
# ⑤ Walk-forward 样本外验证(★ 判断策略好坏的唯一依据)
.venv/bin/python -m hdiv backtest --mode walkforward
# ⑥ 打开前端看结果
.venv/bin/python -m hdiv web # → http://127.0.0.1:8099/
```
> **③④ 的顺序无所谓,②和④互不依赖**(见 §0.3 的「关键认知」)。
> **④ 与 ⑤ 的区别是本质性的**:④ 是「一条路径」,⑤ 是「逐年样本外」。
> 本项目只认 ⑤ —— 详见 §7.5。
---
## 0.1 步骤①:选股 `hdiv universe`
```bash
.venv/bin/python -m hdiv universe --asof 2025-01-01 [--no-persist] [--html]
```
### 发生了什么
```
① asof 归一化 → 2025-01-01 是元旦休市,落到「≤ asof 的最近交易日」= 2024-12-31
② 取候选集 → stock 表中 list_date <= asof 且 (delist_date 为空或 > asof)
即「当时已上市、当时未退市」——**包含此后才退市的股票**(消除生存者偏差)
③ 挂 PIT 面板 → daily_basic(当日或最近 5 个交易日内)、20 日均成交额、
最新已公告财报(ann_date <= asof)、已实施且已除权的分红
④ 依次过 4 个滤网 → market → risk → dividend → quality(先便宜的、淘汰率高的)
每只股票记录:每个滤网的通过位 + **首个未通过的滤网 + 原因 + 取值快照**
⑤ 截断 → 按股息率降序取前 output.max_members(默认 200)只
⑥ 落库 → hd_universe_run(头)+ hd_universe_member(逐股留痕)
```
各滤网的判据(全部来自 `config/universe.yml`,改 YAML 即改行为):
| 顺序 | 滤网 | 主要判据 |
|---|---|---|
| 1 | `market` | 交易所 ∈ [SSE, SZSE]、板块 ∈ [主板/创业板/科创板]、上市 ≥ 10 年、总市值 ≥ 500 亿、20 日均成交额 ≥ 2,000 万元、当日有行情 |
| 2 | `risk` | 非 ST(按 `stock_name_history` 还原**当时**的名字)、未退市、未停牌、净资产为正、资产负债率 ≤ 80%(银行/保险/证券/信托豁免) |
| 3 | `dividend` | 股息率 ≥ 3%(**自算 PIT-TTM**,非 `dv_ttm`)、连续分红 ≥ 5 年、6 年窗口内至少 5 个分红年、支付率 ≤ 100%、自由现金流为正、FCF 覆盖分红 ≥ 1 倍 |
| 4 | `quality` | 5 年平均 ROE ≥ 8%、经营现金流/净利润 ≥ 0.6(用**年报**而非季报,否则累计值会误杀) |
### 关键性质
1. **无未来函数**:每一条判据都带 `<= asof` 约束(行情 `trade_date`、财报 `ann_date`、
分红 `imp_ann_date` 与 `ex_date` 双重)。ST 状态按历史名称还原,不看今天的名字。
2. **run_id 是确定性的**:由「配置哈希 + asof」决定,**不含时间戳**。
所以同配置同时点重跑会**原地覆盖同一条记录**,不会积累重复。
3. **`asof` 会被归一化到交易日**。`--asof 2025-01-01` 与 `--asof 2024-12-31`
得到**同一个 run_id**。看到打印的 `asof=2024-12-31` 不是 bug。
4. **`--no-persist` 的后果**:结果不落库 → 前端看不到,**也无法被回测引用**。
CLI 会显式提醒。
### 落库与追溯
| 表 | 内容 |
|---|---|
| `hd_universe_run` | run_id / asof_date / 候选数 / 入选数 / 配置指纹 / 数据版本 |
| `hd_universe_member` | 逐股:每个滤网通过位、`fail_stage`(首个未通过滤网)、`fail_reason`(中文原因)、取值快照 |
> **「为什么没选上」是这个模块最重要的产出。** 前端「股票池」页可逐股下钻。
---
## 0.2 步骤②:画像 `hdiv profile`
```bash
# 对某个股票池的全部成员,按**该股票池的 asof** 画像
.venv/bin/python -m hdiv profile --universe-run <run_id>
# 指定股票 + 指定时点(任意历史时点,PIT)
.venv/bin/python -m hdiv profile --symbols 600036.SH 000651.SZ --asof 2018-05-18
```
### 发生了什么
```
① 确定对象与时点 → --universe-run 时 asof 取该池的 asof_date;--asof 可显式覆盖
② 取数 → 起点 = asof.year − max(windows_years) − 1 的 1 月 1 日,终点 = asof
价格(不复权)、daily_basic(PE/PB/PS)、分红、年报财务、指数
③ 逐股逐指标统计 → 每个指标 × 每个窗口 [全历史, 5, 8, 10] 年:
n_obs、min/max/mean/median/std、P10/P25/P50/P75/P90、
当前值、**当前值在该窗口分布中的分位**
④ 分红质量 → 连续分红年数、DPS 增速与波动、支付率、FCF 覆盖
(与「股票池筛选」共用同一套口径函数)
⑤ 安全边际评分 → 5 个分项(股息率/估值/财务质量/资产负债表/分红质量)
+ 全项齐备时才给 composite 综合分
⑥ 覆盖率自检 → 窗口实际观测数 ÷ 该窗口应有交易日数;不足则打印警告
⑦ 落库 → hd_profile_run / hd_profile_stat / hd_profile_series / hd_profile_score
```
### 输出解读
| 字段 | 含义 |
|---|---|
| `n_obs` | 该窗口内的实际观测数(**注意分位就是在这 n 个观测上算的**) |
| `current_value` | asof 当天的值(取窗口内**最后一个观测**) |
| `current_percentile` | 当前值在窗口分布中的分位(`≤ 当前值的观测占比`) |
| `status` | `OK` / `INSUFFICIENT`(样本不足,**不猜、不用 0 填充**) |
| 窗口覆盖率 | 1.0 = 名义 5 年真有 5 年数据;< 1 说明被数据起点截短 |
CLI 会打印覆盖率,例如:
```
画像 <run_id>:46 只
窗口覆盖率:最差 10 年窗口 94.6%(1.0 = 名义窗口被完整覆盖)
```
### 关键认知(最容易误解的一点)
> **画像是一次「快照」,回测并不读它。**
>
> 回测里每次买入前用的画像,是引擎**在该决策日实时重算**的
> (见 §0.3 第④步),与这里落库的快照是**两条独立路径**。
> 两者由「逐值等价测试」约束,不会给出两个不同的数。
>
> 所以:**画像页是给你看的,不是给回测用的。** 回测每天自己算。
---
## 0.3 步骤③:回测 `hdiv backtest`
```bash
# 冻结股票池(推荐用于「我看好这批票」的场景)—— 起点不得早于股票池 asof
.venv/bin/python -m hdiv backtest --universe-run <run_id> --start 2025-01-01
# 不冻结:引擎在每个调仓日按当时可见数据重新筛选(判断策略本身用这个)
.venv/bin/python -m hdiv backtest --start 2015-01-01
```
### 发生了什么(逐日事件循环)
```
① 确定区间 → 交易日历取 [start, end],默认取 config/backtest.yml 的 period
② 重建股票池 → --universe-run:守卫校验 asof ≤ 回测首个交易日,然后**冻结**该清单,
所有调仓日复用(每 12 个月不再重筛)
否则:每 universe_refresh_months=12 个月的调仓日,调 selector.run(asof=当日)
—— 用的是**当时可见**的数据(PIT)
③ 预载面板 → 价格(不复权)、分红事件、停牌、涨跌停、指数
若闸门启用:另按最长窗口预载画像面板
④ 逐日循环 ↓
④a 开盘 → 执行**昨日**收盘产生的信号,成交价 = 次日开盘价 ± 滑点
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送转股增加股数
④c 收盘 → 每月一次评估信号(signal_frequency_months=1):
· 算当日股息率 = TTM 每股分红 ÷ 不复权收盘价
· 算历史分位 = 当前值在「(当日−5年, 当日]」分布中的占比
· 分位 ≥ entry.yield_percentile(75) → 买入候选(阶梯定目标仓位)
· 分位 ≤ exit.yield_percentile(25) → 卖出候选
· **买入候选再过「实时画像闸门」**(见下)
④d 收盘 → 盯市:总市值 = 现金 + 持仓 × 不复权收盘价 → 净值曲线
⑤ 绩效与对账 → 收益/CAGR/回撤/Sharpe/Sortino/Calmar、逐年收益、
**资金恒等式残差**(应为 0)
⑥ 落库 → hd_backtest_run / _equity / _position / _trade / _signal / _metric
```
### ④c 的「实时画像闸门」(★ 你关心的那件事)
| 问题 | 答案 |
|---|---|
| 会实时算画像吗? | **会**。每个决策日、对**已触发买入条件**的标的,按当时可见数据重算过去 5 年画像 |
| 用哪一天的画像? | **该决策日自己的**。2025-03-31 用 (2020-03-31, 2025-03-31],不是「2025-01-01 那天」的 |
| 每只股票每天都算吗? | **不是**。惰性:只有股息率分位 ≥ P75 已触发的才去算 |
| 算全量指标吗? | 算 `_profile_one` 的全部指标,但**只加载规则声明用到的面板**(纯估值规则不查财报表) |
| 管卖出吗? | **不管**。卖出/减仓只看股息率分位 |
| 决定仓位大小吗? | **不决定**。`weight_scheme: score` 未实现,仓位由分位阶梯决定 |
| 不通过会怎样? | 产出信号类型 `REJECT` + `skip_reason=PROFILE_GATE`,**不成交**;前端「未成交信号」可逐条看每条规则的实际值/阈值/是否通过 |
| 数据缺失/样本不足? | 按 `on_unverifiable`(默认 `reject`)保守处理 —— **不猜** |
| 关掉它? | `entry.profile_gate.enabled: false`,整条链路不参与,行为回到改造前 |
配置与规则详见 §4.3「实时画像闸门」。
### 未来函数守卫(会拒绝执行的情况)
| 情形 | 行为 |
|---|---|
| `--universe-run` 且**股票池 asof > 回测首个交易日** | **拒绝执行**,退出码 1,错误信息给出三种正确做法 |
| `--universe-run` 用在 `--mode walkforward` | **拒绝执行**(训练窗口比股票池时点更早) |
| `--universe-run` 且 asof ≤ 起点 | 正常执行(股票池属于**事前信息**) |
| 确需复现带未来信息的旧结果 | 显式加 `--allow-lookahead-universe`;偏差会写入 `unimplemented_json` |
> **例**:`--universe-run b8dd742f…`(asof=2025-01-21)+ 默认起点 2015-01-01
> → **直接报错,不会跑出任何结果**。
> 想用这个池子,必须 `--start 2025-01-21`(或更晚)。
### 回测里的成交与成本口径
| 项 | 口径 |
|---|---|
| 成交价 | 信号**次日开盘价** ± 滑点(固定此行为,`same_close` 等分支未实现) |
| 佣金 / 印花税 / 过户费 | 佣金双边取 `max(额×费率, 最低佣金)`;印花税**仅卖出**;过户费双边 |
| 红利税 | 按持股期限:≤1 月 20%、≤1 年 10%、>1 年免征 |
| 整手 | 买入按 100 股取整 |
| 涨跌停 | 开盘即封板 → 该信号**跳过**(记 `skip_reason`) |
| 停牌 | 该信号**跳过**(`backtest.yml` 写的 `defer` **未实现**,不会顺延) |
| 分红 | 除权日入账,**留存为现金**(`cash_mode: reinvest` 未实现),下次调仓按目标权重再配置 |
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
> 你可以直接从库里读到该 run 到底哪些约束没生效 —— 不会静默。
### 验证这次回测「干净」的两个动作
```sql
-- ① 有没有被声明为「含未来信息」(应为 0)
SELECT run_id, start_date, end_date,
(unimplemented_json LIKE '%含未来信息%') AS bias_flag
FROM hd_backtest_run
WHERE universe_run_id = '<你的 run_id>'
ORDER BY created_at DESC;
-- ② 资金对账残差必须为 0(不为 0 时不要采信任何绩效指标)
SELECT run_id, status, unimplemented_json FROM hd_backtest_run
ORDER BY created_at DESC LIMIT 1;
```
CLI 也会直接打印对账残差与 `✓`。
---
## 0.4 步骤④:看结果与「能不能信」
| 入口 | 看什么 |
|---|---|
| `hdiv web` → 首页 | 数据审计、股票池、画像、回测、Walk-forward 的汇总入口 |
| 回测详情 | 净值曲线、逐笔成交(含 `reason_json`:为什么买)、未成交信号(含 `REJECT` 原因)、持仓 |
| **Walk-forward 详情** | **逐窗口样本内/样本外对比** —— 判断策略好坏的唯一依据 |
| `output/reports/` | 静态 HTML 快照(需 `--html` 显式导出) |
**读结论的三条纪律**:
1. **只认 Walk-forward 的样本外结果**,不要采信单路径全期数字。
本项目的实测就是反例:同一份数据,回补前后单路径从 +117% 掉到 +93%,
而样本外 7 个窗口**一个数字都没变**。
2. **对账残差不为 0 就不要看绩效**。
3. **样本太短不要下结论**。用 2025 起的池子跑不到 2 年(约 21 个调仓月),
统计上说明不了任何问题,更**不能拿来调参**。
---
## 0.5 一页速查:每步的命令、产出、坑
| 步骤 | 命令 | 落库 | 最容易踩的坑 |
|---|---|---|---|
| ① 选股 | `hdiv universe --asof <日期>` | `hd_universe_run` / `hd_universe_member` | `asof` 会归一化到交易日;`--no-persist` 后无法被回测引用 |
| ② 画像 | `hdiv profile --universe-run <id>` | `hd_profile_run` / `_stat` / `_series` / `_score` | 画像**不参与回测**;窗口可能被数据起点截短(看覆盖率警告) |
| ③ 回测 | `hdiv backtest [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`/`reinvest` 等未实现项在 `unimplemented_json` 里 |
| ④ 验证 | `hdiv backtest --mode walkforward` | `hd_walkforward_run` / `_window` | 必须与 `--universe-run` 分开用;约 25~80 分钟 |
| ⑤ 调参 | `hdiv sensitivity --sweep "..."` | `hd_sensitivity_run` / `_point` | 样本不足时噪声会被误读为过拟合 |
---
# 1. 系统是什么 # 1. 系统是什么
一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线: 一套 **A 股高股息策略的研究与回测系统**。它把下面这条链路串成一条可复现的流水线:
@@ -120,26 +403,34 @@ export PYTHONPATH=src
## 2.4 完整跑一遍策略研究 ## 2.4 完整跑一遍策略研究
> **完整的分步讲解见 §0(先读那一节)。** 这里只给最短的命令序列。
```bash ```bash
export PYTHONPATH=src export PYTHONPATH=src
# 股票池(时点:2024-06-28) # ① 股票池(时点:2024-06-28)
.venv/bin/python -m hdiv universe --asof 2024-06-28 .venv/bin/python -m hdiv universe --asof 2024-06-28
# → 记下输出的 run_id # → 记下输出的 run_id
# 个股画像(对股票池内全部股票) # ② 个股画像(对股票池内全部股票,按该池的时点)
.venv/bin/python -m hdiv profile --universe-run <上一步的 run_id> .venv/bin/python -m hdiv profile --universe-run <①的 run_id>
# 登记策略 # ③ 登记策略
.venv/bin/python -m hdiv strategy register .venv/bin/python -m hdiv strategy register
# 回测(默认区间取 config/backtest.yml 的 period) # ④ 回测
.venv/bin/python -m hdiv backtest # - 若要用①这个池子:必须带 --universe-run,且 --start 不得早于该池 asof
# (2024-06-28 的池子 → 起点最早 2024-06-28;否则会被未来函数守卫拒绝)
# - 若要看**策略本身**的历史表现:去掉 --universe-run,让引擎逐调仓日重筛
.venv/bin/python -m hdiv backtest --universe-run <①的 run_id> --start 2024-06-28
.venv/bin/python -m hdiv backtest --start 2015-01-01 # 策略本身
# 注意:不传 --start 时取 config/backtest.yml 的 period.start(2015-01-01),
# 这与 --universe-run 组合会被**拒绝**,详见 §0.3 的守卫表。
# Walk-forward 样本外验证(约 25 分钟) # ⑤ Walk-forward 样本外验证(★ 判断策略的唯一依据,约 25~80 分钟)
.venv/bin/python -m hdiv backtest --mode walkforward .venv/bin/python -m hdiv backtest --mode walkforward
# 参数敏感性 # ⑥ 参数敏感性
.venv/bin/python -m hdiv sensitivity .venv/bin/python -m hdiv sensitivity
``` ```
@@ -310,17 +601,26 @@ industry_exemptions:
| `series_max_points` | `1500` | 落库序列点数上限(等间隔降采样,仅影响画图) | | `series_max_points` | `1500` | 落库序列点数上限(等间隔降采样,仅影响画图) |
| `series_metrics` | `[dv_yield, pe_ttm, pb, close, drawdown]` | 哪些指标需要落时间序列 | | `series_metrics` | `[dv_yield, pe_ttm, pb, close, drawdown]` | 哪些指标需要落时间序列 |
| `ttm_dividend.window_days` | `365` | TTM 每股分红回看天数 | | `ttm_dividend.window_days` | `365` | TTM 每股分红回看天数 |
| `ttm_dividend.grace_days` | `45` | 宽限期(见下方说明) | | `ttm_dividend.grace_days` | `45` | 宽限期/容差(见下方说明) |
| `ttm_dividend.smooth_spikes` | `true` | 消除除权间隔不规整造成的毛刺 |
| `percentiles` | `[10,25,50,75,90]` | 需计算的分位数 | | `percentiles` | `[10,25,50,75,90]` | 需计算的分位数 |
| `dividend_yield_volatility.*` | 250/60/20/10 | 日/月/季/年频波动窗口 | | `dividend_yield_volatility.*` | 250/60/20/10 | 日/月/季/年频波动窗口 |
| `safety_margin.mode` | `separate` | `separate`=分项展示 / `composite`=加权综合 | | `safety_margin.mode` | `separate` | `separate`=分项展示 / `composite`=加权综合 |
| `safety_margin.weights` | 见文件 | 综合分权重(`composite` 模式下必须和为 1.0) | | `safety_margin.weights` | 见文件 | 综合分权重(`composite` 模式下必须和为 1.0) |
| `safety_margin.score_anchors` | 见文件 | 各分项的满分/零分锚点 | | `safety_margin.score_anchors` | 见文件 | 各分项的满分/零分锚点 |
> **`grace_days` 为什么必需**:A 股年度分红的除权间隔中位数约 **366 天** > **`grace_days` 的作用**:A 股相邻两次除权间隔经常 ≠ 365 天,
> (实测招商银行 5 次间隔 > 365 天,最长 393 天)。严格 365 天窗口会制造 > 硬窗口会在每年除权日附近制造两种日历假象 ——
> 1~3 天的「空窗期」,把股息率算成 0 —— 这是统计假象,会污染历史分位。 > **重叠虚高**(间隔 < 365,新旧同时在窗口内,实测招商银行 +108%)
> 仅当严格窗口结果为零时,才回退到 `365 + grace_days`。 > 与**断档虚低**(间隔 > 365,旧的已到期新的未入场,实测中国神华 −57%)。
>
> `smooth_spikes: true` 时按「同一档年度分红由后继接管」处理:
> 间隔落在 `365 ± grace_days` 内即视为同一次分红的正常漂移,
> 新旧衔接处不再双算也不再断档;间隔 < `365 − grace_days` 视为年内多次分红
> (中期+年度),彼此都保留;超过 `365 + grace_days` 仍无后继则如实归零。
>
> 实测效果:招商银行 >20% 跳变 21 → 5 次,中国银行虚低归零 77 天 → 0 天。
> 若个别股票仍有断档,把 `grace_days` 调大(如 90)。
--- ---
@@ -339,11 +639,101 @@ strategy:
|---|---:|---| |---|---:|---|
| `entry.yield_percentile` | `75` | 买入阈值:股息率 ≥ 历史 P75 | | `entry.yield_percentile` | `75` | 买入阈值:股息率 ≥ 历史 P75 |
| `entry.scale_in[]` | 75→25%, 80→50%, 85→75%, 90→100% | 分批建仓阶梯 | | `entry.scale_in[]` | 75→25%, 80→50%, 85→75%, 90→100% | 分批建仓阶梯 |
| `entry.profile_gate` | 启用,4 条规则 | **实时画像闸门**(见下节) |
| `exit.yield_percentile` | `25` | 卖出阈值:股息率 ≤ 历史 P25 | | `exit.yield_percentile` | `25` | 卖出阈值:股息率 ≤ 历史 P25 |
| `exit.scale_out[]` | 50→50%, 25→0% | 分批减仓阶梯 | | `exit.scale_out[]` | 50→50%, 25→0% | 分批减仓阶梯 |
| `position.max_position` | `0.10` | 单股仓位上限 | | `position.max_position` | `0.10` | 单股仓位上限 |
| `position.sector_max_position` | `0.25` | 单行业仓位上限 | | `position.sector_max_position` | `0.25` | 单行业仓位上限(**尚未实现**,见 §11) |
| `position.max_holdings` | `20` | 最多持仓只数 | | `position.max_holdings` | `20` | 最多持仓只数(**尚未实现**,见 §11) |
### 实时画像闸门 `entry.profile_gate`(新增)
**它解决的问题**:股票池每 12 个月才重建一次,期间持有的候选可能早已「不值得买」。
闸门的语义是 —— **股息率分位触发买入之后,再用「当日可见的数据」重算一次个股画像,
不通过的票直接剔除。**
```yaml
entry:
profile_gate:
enabled: true # false 时整条链路不参与,回测行为与启用前完全一致
window_years: 5 # 画像统计窗口;0 = 全历史;其余必须是 profile.yml 的 windows_years 之一
on_unverifiable: reject # 数据缺失/样本不足时:reject = 保守不买(默认)/ pass = 放行
rules:
- { metric: dividend_continuity_years, op: ">=", value: 5 }
- { metric: payout_ratio, op: "<=", value: 1.0 }
- { metric: fcf_dividend_cover, op: ">=", value: 1.0 }
- { metric: roe_avg, op: ">=", value: 0.08 }
```
| 字段 | 含义 |
|---|---|
| `metric` | 画像指标代码,只能是 `src/hdiv/core/metrics.py` 里声明的那批(写错在**配置期**就报错) |
| `stat` | `current_value`(当日值)/ `current_percentile`(当日值在窗口分布中的分位,仅序列型指标可用) |
| `op` | `>=` / `<=` / `>` / `<` |
| `value` | 阈值 |
三条关键性质:
1. **无未来函数**:每个决策日只用「当时可见」的价格、每日指标、分红(`imp_ann_date`
与 `ex_date` 双重约束)与财报(`ann_date <= asof`)。有负例测试守护
(公告日前一天不得看到该年报;除权日前一天不得包含该笔分红)。
2. **与批量画像同一定义**:闸门用的指标与 `hdiv profile` 页面上的指标**逐值一致**
(有等价性测试),不会出现「页面一个数、回测另一个数」。
3. **惰性与复用**:只在买入条件已触发时才计算;跨股票共享的面板按时点缓存,
长周期指标从同一份已载入面板里切窗口。因此成本与**触发次数**成正比,
而不是与「区间长度 × 股票数」成正比;规则里不含财务指标时完全不查财报。
**被剔除的信号怎么看**:信号类型为 `REJECT`,`skip_reason = PROFILE_GATE`,
会出现在「回测 → 未成交信号」列表里,标签为
**「实时画像未通过,主动放弃买入」**;`reason_json.profile_gate.checks`
逐条记录每个指标的**实际值、阈值、状态、是否通过**,可直接回答「为什么没买」。
**`on_unverifiable` 怎么选**:这是本项目的一贯取舍(同 `universe.dividend.on_missing_data`)。
| 取值 | 行为 | 适用 |
|---|---|---|
| `reject`(默认) | 数据缺失或样本不足 → 不买 | 忠于「安全边际」:宁可错过,不可踩雷 |
| `pass` | 无法验证 → 放行 | 与股票池筛选口径一致;结果更依赖数据完整度 |
> 实测差异很大:2016 年初,中石化的**最新可见分红属于 FY2015,而 FY2015 年报尚未公告**
> (约 3 月才披露),因此「支付率」在当时**根本无法验证**。
> `reject` 会放弃买入,`pass` 会照买 —— 这不是 bug,而是策略取舍得由你决定。
### 窗口覆盖率 `min_window_coverage`(新增,重要)
**「名义 5 年」不等于「真有 5 年数据」。** `window_slice(asof, 5)` 的语义是
「把已有数据切成最近 5 年」,数据起点晚于窗口左端时窗口会被**静默截短**。
本项目行情/每日指标自 **2015-01-05** 才有,所以实测(600036.SH,股息率,5 年窗口):
| asof | 窗口内实际观测 | 应有交易日 | 覆盖率 |
|---|---:|---:|---:|
| 2015-12-31 | 239 | 1214 | **19.7%** |
| 2016-12-30 | 483 | 1214 | **39.8%** |
| 2018-05-18 | 817 | 1219 | **67.0%** |
| 2019-12-31 | 1214 | 1219 | 99.6% |
| 2020-12-31 起 | ≈1218 | ≈1218 | **100%** |
也就是说:**2019 年及之前的「过去 5 年画像」实际只有 0.2~4 年数据**,
而画像的 `status` 仍报 `OK`(它的门槛低到 20 个观测)。
```yaml
entry:
profile_gate:
window_years: 5
min_window_coverage: 0.0 # 0 = 不因覆盖率淘汰(默认);1.0 = 必须完整覆盖
```
| 取值 | 行为 |
|---|---|
| `0.0`(默认) | 覆盖率只**记录**在信号的 `reason_json.profile_gate.checks[].window_coverage`,不影响判定 —— 保持改造前行为 |
| `1.0` | 名义 5 年必须真有 5 年数据;不足时按 `on_unverifiable` 处理(默认 → 不买) |
| 中间值 | 例如 `0.8` = 允许最多缺 20% |
**建议**:先把数据回补到位(见 §6.6),再考虑启用 `min_window_coverage: 1.0`;
否则 2019 年之前会几乎没有买入信号(那是**数据不足**,不是策略判断)。
> `hdiv profile` 命令也会打印覆盖率,例如:
> `⚠ 5 年窗口数据不足:最低覆盖率仅 67.0%(窗口会被数据起点截短)`
### 目标仓位阶梯(重要) ### 目标仓位阶梯(重要)
@@ -436,9 +826,9 @@ period: { start: 2015-01-01, end: latest }
| `benchmark[]` | 沪深300 / 中证红利 / 上证指数 | 基准列表 | | `benchmark[]` | 沪深300 / 中证红利 / 上证指数 | 基准列表 |
| `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) | | `risk_free_rate` | `0.02` | 无风险利率(用于 Sharpe/Sortino) |
| `fill.price` | `next_open` | 信号次日开盘成交 | | `fill.price` | `next_open` | 信号次日开盘成交 |
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过 | | `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
| `fill.suspended_rule` | `defer` | 停牌时顺延 | | `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
| `dividend.cash_mode` | `reinvest` | 分红处理:`reinvest`/`hold`/`cash_out` | | `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
--- ---
@@ -452,6 +842,9 @@ period: { start: 2015-01-01, end: latest }
| `charts.*` | 全部 `true` | 各图表开关 | | `charts.*` | 全部 `true` | 各图表开关 |
| `naming.*` | 见文件 | 输出文件命名模板 | | `naming.*` | 见文件 | 输出文件命名模板 |
| `layout.max_width` | `1440` | 页面最大宽度(px) | | `layout.max_width` | `1440` | 页面最大宽度(px) |
| `layout.decimals.ratio` | `4` | **比率的小数位**。百分比小数位 = ratio − 2(ratio=4 → 股息率 6.17%;ratio=6 → 6.1715%)。同时作用于静态报告与 Web 前端 |
| `layout.decimals.money` | `2` | 金额小数位 |
| `layout.decimals.price` | `2` | 价格小数位 |
> **`asset_mode` 怎么选**:见 [§7.8 报告部署](#78-报告部署)。 > **`asset_mode` 怎么选**:见 [§7.8 报告部署](#78-报告部署)。
@@ -504,7 +897,8 @@ hdiv sync financial [--interleaved] [--only-missing] [--apis ...] [--limit N]
hdiv sync index [--no-weight] [--start YYYYMMDD] hdiv sync index [--no-weight] [--start YYYYMMDD]
hdiv sync price --which daily|adj_factor|daily_basic --start --end [--no-resume] hdiv sync price --which daily|adj_factor|daily_basic --start --end [--no-resume]
hdiv sync trading [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--no-resume] hdiv sync trading [--start YYYY-MM-DD] [--end YYYY-MM-DD] [--no-resume]
hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-12-31] [--no-resume] hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
[--basic-start <同 --start>] [--basic-end 2019-12-31] [--no-resume]
``` ```
| 目标 | 说明 | 首次耗时 | | 目标 | 说明 | 首次耗时 |
@@ -514,11 +908,15 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-1
| `index` | 基准指数行情 + 成分股权重 | ~2 分钟 | | `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
| `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 | | `price` | 日线/复权因子/每日指标(逐交易日) | 视区间 |
| `trading` | 停牌与涨跌停 | ~20 分钟 | | `trading` | 停牌与涨跌停 | ~20 分钟 |
| `backfill` | 2015–2018 历史回补(写入 qlib 原有表,`INSERT IGNORE` 不覆盖既有行) | ~15 分钟 | | `backfill` | 历史回补(写入 qlib 原有表,`INSERT IGNORE` **不覆盖既有行**) | 视区间 |
> **`--only-missing`**:跳过已同步的标的,支持断点续传 > **`--only-missing`**:跳过已同步的标的,支持断点续传
> **`--interleaved`**:按股票一次性拉齐四张报表,使策略相关的大市值股票优先就绪 > **`--interleaved`**:按股票一次性拉齐四张报表,使策略相关的大市值股票优先就绪
> **回补需要显式授权**:`HDIV_ALLOW_BACKFILL=1` 或改 `datasource.yml` > **回补需要显式授权**:`HDIV_ALLOW_BACKFILL=1` 或改 `datasource.yml`
> **`--basic-start` 必须显式给出**:`run_backfill` 的默认 `basic_start=2015-01-01`,
> 早期 CLI 没有这个参数,于是「回补 2010–2014」实际只补了行情与复权因子,
> **`daily_basic` 仍停在 2015** —— 画像里的 PE/PB/股息率照样拿不到早年数据。
> 现在 `--basic-start` 缺省时跟随 `--start`。
## 5.3 `audit` — 数据审计 ## 5.3 `audit` — 数据审计
@@ -526,10 +924,16 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] [--basic-end 2019-1
hdiv audit [--no-persist] [--html] hdiv audit [--no-persist] [--html]
``` ```
执行 18 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、代码有效性), 执行 15 项检查(缺口 G1–G6、PIT 纪律、唯一性、单位自检、量价单位一致性、代码有效性),
结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。 结果写入 `hd_data_audit` 并生成 `output/data_audit_<date>.html`。
退出码:总体 FAIL 时为 1。 退出码:总体 FAIL 时为 1。
> 与单位有关的两项:`UNIT`(`daily_basic` 的市值恒等式 + 量级)与
> `UNIT-OHLCV`(`stock_daily` 逐年抽样的量价单位一致性)。
> 后者当前会报 **WARN**:2015-2019 的存量行仍是 Tushare 原始单位,
> 读取层已兜底换算(结果正确),但存量数据本身仍应择机重刷。
> 详见 §9.1。
## 5.4 `universe` — 股票池筛选 ## 5.4 `universe` — 股票池筛选
```bash ```bash
@@ -554,11 +958,21 @@ hdiv universe [-c config/universe.yml] [--asof 2024-06-28 | latest] [--no-persis
## 5.5 `profile` — 个股画像 ## 5.5 `profile` — 个股画像
```bash ```bash
hdiv profile --universe-run <run_id> # 对股票池全部股票 hdiv profile --universe-run <run_id> # 对股票池全部股票(asof 取该股票池的时点)
hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票 hdiv profile --symbols 600519.SH 000333.SZ --asof 2024-06-28 # 指定股票与**时点**
hdiv profile --symbols 600519.SH --asof 2018-05-18 # 任意历史时点的 PIT 画像
hdiv profile --universe-run <run_id> --html --html-limit 20 # 导出前 20 只的静态报告 hdiv profile --universe-run <run_id> --html --html-limit 20 # 导出前 20 只的静态报告
``` ```
> `--asof` 决定画像用**哪些数据**:只使用 `<= asof` 的行情、每日指标、分红与财报。
> 例如 `--asof 2018-05-18` 得到的就是「2018-05-18 当天能算出的画像」,
> 5 年窗口覆盖 (2013-05-18, 2018-05-18]。
> 不带 `--asof` 且带 `--universe-run` 时,asof 取该股票池的筛选时点。
>
> **注意**:画像是一次**快照**(每个 asof 一份)。回测并不读取它 ——
> 回测的交易依据是引擎在每个决策日**实时重算**的画像,见
> §4.3「实时画像闸门」与 §9.2。
## 5.6 `strategy` — 策略管理 ## 5.6 `strategy` — 策略管理
```bash ```bash
@@ -572,19 +986,41 @@ hdiv strategy diff -f A.yml --other B.yml # 比较两版差异
```bash ```bash
hdiv backtest [--start 2015-01-01] [--end 2026-09-30] [--no-persist] [--html] hdiv backtest [--start 2015-01-01] [--end 2026-09-30] [--no-persist] [--html]
hdiv backtest --mode walkforward # 7 个滚动窗口,约 25 分钟 hdiv backtest --mode walkforward # 7 个滚动窗口
hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建立关联 hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建立关联
# 注意 1:--universe-run 与 --mode walkforward 不能同时使用(会报错说明原因)。
# 注意 2:单次回测里,若股票池的 asof 晚于回测起点,同样会被**拒绝**
# (同一条未来函数纪律)。错误信息会给出三种正确做法:
# a) 去掉 --universe-run,让引擎逐调仓日按当时可见数据重新筛选(推荐)
# b) 把 --start 改到股票池 asof 之后
# c) 确需复现带未来信息的历史结果:显式加 --allow-lookahead-universe
# —— 该偏差会写入 hd_backtest_run.unimplemented_json,可被检索出来
``` ```
输出示例: > **为什么单次回测也要拒绝**:股票池带 asof 时点。用 2025-01-21 选出的名单去跑
> 2015 年起的区间,名单里含有 2015 年不可能知道的信息(哪些公司此后仍满足
> 连续分红、5 年 ROE、自由现金流覆盖等条件)。实测:这样跑出的回测
> **在 2015-01-06 就有 8 笔成交**,而按当时可见数据筛选,那段时间的股票池
> 只有个位数只股票。
输出示例(当前配置:实时画像闸门启用):
``` ```
期初 1,000,000 → 期末 2,148,010 | 总收益 114.80% | CAGR 6.73% | 最大回撤 -21.05% | Sharpe 0.31 | 成交 180 笔 回测 HD_MR_V1 v1.0:2015-01-05 ~ 2026-09-30(2846 个交易日)
累计现金分红 558,797(已扣红利税 13,676) | 对账残差 -0.0000 ✓ 实时画像闸门已启用:窗口 5 年,4 条规则,面板自 2004-01-01 起载入(86 只)
实时画像:计算 2428 次(缓存命中 0),涉及 141 个决策时点,
财报面板载入 141 次,流动性查询 0 次 | 画像剔除 1027 次
期初 1,000,000 → 期末 1,927,312
总收益 92.73% CAGR 5.75% 最大回撤 -25.38% Sharpe 0.22 Calmar 0.23 成交 197 笔
累计现金分红 …(已扣红利税 …) | 对账残差 -0.0000 ✓
``` ```
> **「对账残差」是资金恒等式的校验值**(期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红)。 > **「对账残差」是资金恒等式的校验值**(期初 = 期末现金 + 买入 − 卖出 + 费用 − 分红)。
> 应为 0;若不为 0 说明成本或分红入账有遗漏,**此时不要采信绩效指标**。 > 应为 0;若不为 0 说明成本或分红入账有遗漏,**此时不要采信绩效指标**。
>
> 「画像剔除 1027 次」= 有多少个买入信号被实时画像拦下。它们全部以
> `REJECT` 记录在库,可在前端「未成交信号」里逐条查看每条规则的实际值。
## 5.8 `sensitivity` — 参数敏感性 ## 5.8 `sensitivity` — 参数敏感性
@@ -692,6 +1128,87 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
> ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §7.5)。 > ⚠ **务必先做 ① 和 ②**。单条路径的全期回测会系统性高估策略(见 §7.5)。
## 6.6 把「过去 5 年」补齐(数据回补)
> **✅ 本仓库当前的数据状态:已回补完成(2026-10-04)** ——
> `stock_daily`/`adjust_factor`/`daily_basic` 均覆盖到 **2005-01-04**,
> `hd_suspend`/`hd_limit` 覆盖到 **2010-01-04**。
> 5 年窗口覆盖率自 2015-01-05 起为 **98.7%~100%**,残差已逐日与
> `hd_suspend` 交叉核实为**真实停牌**(16/16 命中)。
> 所以下面这节现在是**方法说明与重做指引**,而不是待办事项。
> 完整记录见 [implementation-status §9.3c](implementation-status.md)。
### 先看缺口:哪些数据支持 5 年回看
| 数据 | 回补前起点 | 当前 |
|---|---|---|
| `stock_daily` 行情 | 2015-01-05 | **2005-01-04** ✅ |
| `daily_basic` PE/PB/股息率 | 2015-01-05 | **2005-01-04** ✅ |
| `adjust_factor` 复权因子 | 2015-01-05 | **2005-01-04** ✅ |
| `hd_suspend` / `hd_limit` | 2019-01-02 | **2010-01-04** ✅ |
| `hd_dividend` 分红 | 1991 | 不变 ✅ |
| `hd_fina_indicator` / `hd_income` / `hd_cashflow` | 1990 / 1990 / 2001 | 不变 ✅ |
| `hd_index_daily` 基准 | 1990 | 不变 ✅ |
| `stock_name_history` ST 历史 | 1990 | 不变 ✅ |
| `trading_calendar` 交易日历 | 2000 | 不变 ✅ |
**结论:卡住「5 年画像」的只有行情与每日指标,缺口就在 2015-01-05 之前。**
### 回补到哪一年,取决于你要覆盖多早的决策日
「asof 的 5 年窗口完整」要求数据起点 ≤ `asof − 5 年`:
| 想覆盖的决策日 | 必须回补区间 | 新增交易日(约) |
|---|---|---:|
| 2015-01-05 起 | **2010-01-01 ~ 2014-12-31** | 1,212 |
| 2018-01-01 起 | 2013-01-01 ~ 2014-12-31 | 486 |
| 同时补齐 8/10 年窗口(`profile.yml: windows_years`) | **2005-01-01 ~ 2014-12-31** | 2,430 |
### 执行
```bash
cd ~/project/高股息回测
export PYTHONPATH=src
export HDIV_ALLOW_BACKFILL=1 # 回补写入 qlib 原有表需显式授权
# ① 行情 + 复权因子 + 每日指标(三者一起补,缺一个画像就残)
.venv/bin/python -m hdiv sync backfill \
--start 2010-01-01 --end 2014-12-31 \
--basic-start 2010-01-01 --basic-end 2014-12-31
# ② 若还要成交约束(涨跌停/停牌)覆盖到早年
.venv/bin/python -m hdiv sync trading --start 2010-01-01 --end 2014-12-31
# ③ 核对:只增不减,且量价单位一致
.venv/bin/python -m hdiv audit
```
**必须知道的四件事**:
1. **只追加、不改既有行**(`INSERT IGNORE`)。因此 2015-2019 已存在的行不会被
修成正确单位 —— 那由读取层兜底(见 §9.1)。回补的新行走的是**正确单位**写入路径。
2. **耗时与点数**:`daily`/`adj_factor`/`daily_basic` 的限频是 480 次/分钟,
但瓶颈是 HTTP 往返(每日 3 次调用)。1,212 个交易日 ≈ 3,600 次调用,
实测量级 **1~3 小时**;补到 2005 年约翻倍。先用 `--limit` 或在测试库试跑。
3. **Tushare 权限**:早年数据需要相应积分。若某日返回空,`sync_days` 会记录在
`hd_sync_log`,不会中断整体任务。
4. **回补后必须重跑**:股票池、画像、回测、Walk-forward 的结论都会变
(2015-2019 从「几乎无候选」变成有完整 5 年画像的候选)。
### 回补后怎么确认「5 年真的齐了」
```bash
# 画像命令会打印覆盖率(不足时给警告)
.venv/bin/python -m hdiv profile --symbols 600036.SH --asof 2015-06-30
# ⚠ 5 年窗口数据不足:最低覆盖率仅 xx%(窗口会被数据起点截短)
# 或者在 SQL 里直接量:某股在 (asof-5y, asof] 内的行情观测数
```
也可以把策略的 `entry.profile_gate.min_window_coverage` 设为 `1.0`,
让回测**只**在 5 年窗口完整时才允许买入 —— 不完整的决策日会产出
`REJECT`(`skip_reason=PROFILE_GATE`),理由写明 `window_coverage=67%`。
--- ---
# 7. 报告解读 # 7. 报告解读
@@ -754,6 +1271,11 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
## 7.5 Walk-forward 报告 ★ 最重要 ## 7.5 Walk-forward 报告 ★ 最重要
> **前端入口**:`#/walkforwards`(导航栏「样本外」)。
> 每个 wf_id 是一条独立记录,点进去可看逐窗口的样本内/样本外对比与冻结阈值。
> 该页面同时给出**样本外均值、胜率、稳定性**三个核心判据。
**这是判断策略是否真的有效的核心依据。** **这是判断策略是否真的有效的核心依据。**
**看什么**: **看什么**:
@@ -770,16 +1292,33 @@ cp config/strategy/high_dividend_v1.yml config/strategy/my_strategy.yml
|---|---| |---|---|
| 样本外超额 > 0 且稳定性 > 1 | 策略可能真的有效 | | 样本外超额 > 0 且稳定性 > 1 | 策略可能真的有效 |
| 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 | | 样本外超额 ≈ 0 | 与基准相当,无超额收益能力 |
| 样本外超额 < 0 | **策略未通过样本外检验** | | 样本外超额 < 0 | 看是否集中在牛市(见下) |
| 全期回测远好于样本外均值 | **单路径回测高估了策略** | | 全期回测远好于样本外均值 | **单路径回测高估了策略** |
> **本系统的实测结论**:全期回测 +114.80%,而 7 窗口样本外收益均值 **−0.95%** > **务必用年化口径比较样本内/外**:训练段 5 年、测试段 1 年,
> (基准 +2.29%,超额 **−3.24pp**)。即**当前策略没有稳定的样本外超额收益**。 > 直接比累计收益会得出「样本内远高于样本外」的错误印象(本项目实测踩过这个坑,
> 它的价值在于**回撤控制**(样本外最差 −24.62%,基准同期 −46%~−52%), > 详见 implementation-status.md §7.3)。页面上统一使用年化。
> **务必按牛熊分开看超额**。高股息/低估值策略的典型形态是
> **牛市跑输、熊市跑赢**(用上涨弹性换取下跌保护)。
> 此时只看「超额均值」会被样本里牛熊比例误导 ——
> 本项目实测:4 个熊市窗口全部跑赢,3 个牛市窗口全部跑输。
> 判断这类策略要问的是「我用上涨弹性换下跌保护,值不值」,
> 而不是「它有没有 alpha」。
> **本系统的实测结论**:全期回测 +92.73%,而 7 窗口样本外收益均值 **+1.16%**
> (基准 +2.29%,超额 **−1.13pp**)。即**当前策略没有稳定的样本外超额收益**。
> 它的价值在于**回撤控制**(样本外最差 −22.97%,基准同期 −46%~−52%),
> 更像降低波动的配置工具,而非超额收益来源。 > 更像降低波动的配置工具,而非超额收益来源。
> >
> 这个反差不是 bug,正是 Walk-forward 存在的意义 —— 详见 > 这个反差不是 bug,正是 Walk-forward 存在的意义 —— 详见
> [implementation-status.md §4.6](implementation-status.md)。 > [implementation-status.md §4.6](implementation-status.md)。
>
> 另外注意:实时画像闸门在两个口径下结论相反 ——
> **全期单路径**收益从 +101.20% 降到 +92.73%(更差),
> 但 **walk-forward 样本外**均值从 −0.00% 升到 +1.16%、最差回撤从 −24.22%
> 改善到 −22.97%(更好)。按本项目一贯立场以样本外为准,闸门是改善。
> 详见 §4.6c。
## 7.6 参数敏感性报告 ## 7.6 参数敏感性报告
@@ -977,15 +1516,32 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
| `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** | | `roe` / `roic` / `debt_to_assets` | 百分数 | **小数** |
| 财务报表金额 | 元 | 元(不变) | | 财务报表金额 | 元 | 元(不变) |
| `dividend.base_share` | 万股 | **股** | | `dividend.base_share` | 万股 | **股** |
| `stock_daily.vol` | 手 | **股**(×100) |
| `stock_daily.amount` | 千元 | **元**(×1000) |
**自检手段**(审计中的 `UNIT` 检查): **自检手段**(审计中的 `UNIT` 与 `UNIT-OHLCV` 检查):
1. **恒等式** `总市值 ≈ 收盘价 × 总股本` —— 能发现「只换算了一个字段」 1. **恒等式** `总市值 ≈ 收盘价 × 总股本` —— 能发现「只换算了一个字段」
2. **绝对量级** 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段 2. **绝对量级** 总市值中位数须落在 A 股合理区间 —— 这是唯一能识别整体单位错误的手段
3. **量价一致性**(`UNIT-OHLCV`):`成交额 / (成交量 × 收盘价)` 应 ≈ 1。
≈ 0.1 说明该行还是 Tushare 原始口径(手 / 千元)。审计会逐年抽样并列出
> 为什么必须两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**, > 为什么必须前两项都做:`元/股 × 万股 = 万元`,所以恒等式在**原始单位下也成立**,
> 单靠它无法发现「万元当元用」。实测该错误曾导致市值过滤选中 **0 只**股票。 > 单靠它无法发现「万元当元用」。实测该错误曾导致市值过滤选中 **0 只**股票。
> **`stock_daily` 的量价单位曾经不一致(已修复)**:该表是「追加进既有 qlib 库」的,
> 2015-01~2019 的行由本项目从 Tushare 回补,写的是**原始单位**(手 / 千元);
> 2020 起沿用 qlib 存量(股 / 元);2019 年**同日混着两种**。
> 而 `min_avg_amount_20d: 20000000` 是按「元」写的 ——
> 于是 2015-2019 的 20 日均额被低估 1000 倍,流动性门槛实际变成
> 「日均成交额 ≥ 200 亿元」,**把 2015-2019 的股票池整体清空**
> (实测 2016/2017/2018 各筛选出 0 只)。
>
> 修复方式有两层:写入端(`sync.price.daily_frame`)统一换算;
> 读取端(`units.normalize_ohlcv_units`,按行判定、**幂等**)兜住存量数据。
> 修好后 2016-02 的股票池是 7 只、2017 是 11 只、2018 是 13 只。
> 审计新增 `UNIT-OHLCV` 防止回归。
## 9.2 Point-in-Time(无未来函数) ## 9.2 Point-in-Time(无未来函数)
| 数据 | 可见性规则 | | 数据 | 可见性规则 |
@@ -995,11 +1551,25 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
| 行情/指标 | `trade_date <= 评估日` | | 行情/指标 | `trade_date <= 评估日` |
| ST 状态 | 按 `stock_name_history` 的名称生效区间还原,**不看今天的名字** | | ST 状态 | 按 `stock_name_history` 的名称生效区间还原,**不看今天的名字** |
| 股票池 | 包含**此后才退市**的股票(消除生存者偏差) | | 股票池 | 包含**此后才退市**的股票(消除生存者偏差) |
| **实时画像** | 每个决策日按上述规则重算;窗口只覆盖 `(评估日 − N 年, 评估日]` |
| **窗口覆盖率** | `n_obs / 该窗口应有交易日数`;< 1 说明窗口被数据起点截短(见 §4.3、§6.6) |
| **股票池 vs 回测区间** | 股票池的 `asof` 晚于回测起点即**拒绝执行**(可显式放行并留痕) |
另外: 另外:
- **成交在信号次日开盘**,信号日只产生信号 - **成交在信号次日开盘**,信号日只产生信号
- 滚动分位窗口的**右端必须是评估日本身** - 滚动分位窗口的**右端必须是评估日本身**
- 画像的窗口切片是 `(起点, asof]`(左开右闭),与分位参照窗口一致
- Walk-forward 测试段使用**训练段冻结**的分布 - Walk-forward 测试段使用**训练段冻结**的分布
(但实时画像闸门**不需要冻结** —— 它只用当时可见数据做过滤,
不带任何用测试期数据拟合出来的参数)
**三类未来函数,系统的处理方式不同**:
| 类型 | 处理 |
|---|---|
| 用未来数据算**当日因子** | 代码层杜绝(`Repo` 是唯一取数出口,有负例测试) |
| 用未来时点选出的**股票池**跑更早区间 | **拒绝执行**(`--universe-run` 的 asof 校验) |
| 用未来数据给人看的**研究快照**(画像/筛选页) | 允许,但**不进入回测**;回测每天自己重算 |
## 9.3 分红口径 ## 9.3 分红口径
@@ -1043,11 +1613,18 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
| 过户费 | 双边 | | 过户费 | 双边 |
| 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 | | 红利税 | 按持股期限:≤1月 20%、≤1年 10%、>1年 免征 |
| 整手 | 买入按 100 股取整 | | 整手 | 买入按 100 股取整 |
| 涨跌停 | 开盘即封板则该信号不成交,记录 `skip_reason` | | 涨跌停 | 开盘即封板则该信号**当日跳过**,记录 `skip_reason`(`defer` 未实现) |
| 停牌 | 信号顺延到下一可成交日 | | 停牌 | **当日跳过**(⚠️ `fill.suspended_rule: defer` **未实现**,不会顺延;见 §0.3) |
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**。 **分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税),
这样从根上避免了「复权收益 + 分红」的重复计算。 **留存为现金**,在下次调仓时按目标权重重新配置
(⚠️ `cash_mode: reinvest` / `reinvest_rule` **未实现**)。
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
> 以上每一项未实现都会逐条写入 `hd_backtest_run.unimplemented_json` —— 可直接查库核对。
**资金对账**(每次回测都会校验): **资金对账**(每次回测都会校验):
@@ -1233,11 +1810,17 @@ curl -I http://192.168.1.166:8080/ggx/index.html
| 股票清单 | `#/universes/<run_id>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** | | 股票清单 | `#/universes/<run_id>` | 入选与淘汰股票、逐股关键指标、**点击个股打开画像** |
| 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 | | 个股画像 | `#/stocks/<symbol>` | K线+股息率+PE 四联图、历史分布、安全边际雷达 |
| 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** | | 回测记录 | `#/backtests` | 每条记录显示**回测条件与总体结果** |
| 回测详情 | `#/backtests/<run_id>` | 净值曲线、绩效指标、**逐笔成交与理由** | | 回测详情 | `#/backtests/<run_id>` | 净值曲线、**任意日持仓明细**、逐笔成交与理由 |
| 个股买卖点 | `#/backtests/<run_id>/stocks/<symbol>` | 股价/股息率/PE/ROE 趋势图 + 买卖点标注 |
| **样本外** | `#/walkforwards` | Walk-forward 记录;**样本内 vs 样本外**逐窗口对比 |
| 归档 | `#/archive` | 已归档与已删除的记录,可恢复 | | 归档 | `#/archive` | 已归档与已删除的记录,可恢复 |
### 记录管理 ### 记录管理
- **重跑覆盖**:同一份配置 + 同一个 `asof` 时点 → 同一个 `run_id`,重跑会**原地覆盖**
旧记录(含成员清单与因子快照),不会累积重复条目。
你在界面上的**命名与备注会被保留**,不会被重跑清掉。
改了配置(阈值等)或换了时点则视为不同筛选,各留一条记录。
- **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上 - **命名**:点「命名」按钮,可设置名称与备注。名称会显示在列表与详情页标题上
- **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档 - **归档**:归档后默认列表不再显示,可在「归档」页找到并取消归档
- **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。 - **删除**:**软删除** —— 记录被隐藏,但数据完整保留在数据库中,可随时恢复。
@@ -1363,6 +1946,45 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
./deploy/install-service.sh status # 本项目(后端侧) ./deploy/install-service.sh status # 本项目(后端侧)
``` ```
### 11.2.1 `/api/health` 通,但页面报「配置校验失败」
现象:状态徽章正常、`api/health` 通,可页面整体报错,形如:
```text
加载失败:配置校验失败:/…/config/datasource.yml
1 validation error for DataSourceConfig
sync
Extra inputs are not permitted [type=extra_forbidden, input_value={'min_symbols_floor': 200…}]
```
**这不是配置写错了,是后端进程太旧**。`hdiv` 在进程启动时把
`src/hdiv/core/config.py` 的模型和 `config/*.yml` 一起读进内存(配置还经
`lru_cache` 缓存),所以:
- 你新加了配置字段 + 对应的模型字段,
- launchd 里那个进程却还在用**加字段之前**的模型校验文件,
- 于是「新文件里的 `sync` 段」成了模型眼里的多余键 → `extra_forbidden`。
注意 `KeepAlive` 只负责**崩溃后**拉起,正常运行的进程不会因为你改文件而重启。
症状最迷惑的地方是:报错看起来像 YAML 写错了,实际 YAML 完全合法。
**验证与修复**(一条命令即可):
```bash
# 绕过服务,用当前源码直接校验该文件:能通过就说明文件没问题
PYTHONPATH=src .venv/bin/python -c \
"from hdiv.core.config import load_config; print(load_config('datasource'))"
# 改完 src/ 或 config/ 之后必须重启后端,让它重新 import 模块、重新读配置
./deploy/install-service.sh restart
```
`restart` 用的是 `launchctl kickstart -k`(先杀后拉,PID 会变);
手工 nohup 启动的(`deploy/serve.sh start`)对应执行 `./deploy/serve.sh restart`。
**记住这条规则:改 `src/` 或 `config/` 之后,一律 `restart` 一次。**
只改 `web/`、`templates/` 这类静态产物不需要——它们由 nginx 每次请求重新读盘。
## 11.3 股票池为空或很少 ## 11.3 股票池为空或很少
**按顺序排查**: **按顺序排查**:
@@ -1396,6 +2018,10 @@ bash scripts/mac_nginx_ggx.sh status # system 项目
**最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。 **最可能原因:预热期**。滚动分位窗口需要历史分布;数据起点之前的时段无法产生信号。
若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。 若回测区间前 3~5 年完全空仓、之后才开始建仓,这是**预期行为**(不做未来函数的代价)。
注意:**用 `--universe-run` 冻结股票池时没有预热期** —— 引擎不再自筛选,
历史约束不作用于早期,2015 年起即可能满仓。这也是为什么冻结模式的回撤
显著大于重新筛选模式。若你看到早期就满仓,那是冻结模式的正常表现,不是 bug。
**确认方法**: **确认方法**:
```sql ```sql
@@ -1448,8 +2074,14 @@ market.min_market_capp
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 | | 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 | | 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 | | 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
| 7 | 回测 2015–2018 为预热期 | 100% 现金,无信号(见 §10.4) | | 7 | 策略/回测配置里下列字段**尚未实现** | 改了它们**回测结果不会变**:<br>`position.max_holdings`、`position.sector_max_position`、`position.weight_scheme`、`risk.max_portfolio_drawdown`、`risk.max_single_drawdown`、`risk.liquidity_limit_pct_adv`、`exit.stop_loss_pct`、`exit.max_holding_days`、`fill.max_volume_pct`、`fill.partial_fill`、<br>**`fill.suspended_rule` / `fill.limit_up_down_rule` 的 `defer`**(未成交信号当日即被丢弃,不会顺延)、**`dividend.cash_mode=reinvest` / `dividend.reinvest_rule`**(分红留存为现金,在下次调仓再配置)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
| 8 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 | | 8 | `stock_daily` 2015–2019 的存量行仍是 Tushare 原始单位 | 读取层已兜底换算(结果正确),审计 `UNIT-OHLCV` 报 WARN;重刷数据可消除 |
| 9 | ~~行情/每日指标只到 2015-01-05~~ → **已修复(2026-10-04 回补到 2005-01-04)** | 曾使「过去 5 年画像」在 2019 年前只有 0.2~4 年数据(覆盖率 20%~67%);回补后 5 年窗口覆盖率为 **98.7%~100%**,残差经逐日核实为真实停牌。另:**修好数据后全期收益从 +117.36% 降到 +92.73%**,因为 2015 年(牛市顶 + 股灾)从「被数据缺口挡住」变成被真实交易。见 §6.6 与 implementation-status §9.3c |
| 10 | `config/profile.yml: sufficiency` 三个阈值**尚未被任何代码使用** | `min_history_years_dividend` / `min_history_years_price` / `min_dividend_records` 目前是死配置。真正的充分性判定由 `profile_gate.min_window_coverage` + `on_unverifiable` 承担 |
| 11 | 实时画像闸门默认 `on_unverifiable: reject` | 数据不全时会放弃部分本可买入的标的(刻意保守,可改为 `pass`)。回补后早年数据已基本完整,影响大幅缩小 |
| 12 | 参数敏感性结论依赖扫描区间与池规模 | 样本不足时噪声可能被误读为过拟合 |
| 13 | 幸存者偏差尚有 **3 只**缺口 | `000022.SZ`/`000043.SZ`/`300114.SZ`(均因吸收合并退市)有行情但不在 `stock` 表,永远不会进入候选集;占 5,903 只的 0.05%。根因是 `stock` 只收录在市股票 |
| 14 | `hd_suspend`/`hd_limit` 含 356 个 `stock` 表未收录代码 | 其中 356 中 250+106 为北交所(按交易所白名单设计排除);SZ 部分与第 13 条同源。不影响可交易标的 |
## 12.1 使用前请务必知道 ## 12.1 使用前请务必知道
@@ -1504,6 +2136,6 @@ export PYTHONPATH=src
| 个股画像口径(窗口/分位/权重) | `config/profile.yml` | | 个股画像口径(窗口/分位/权重) | `config/profile.yml` |
| 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` | | 买卖阈值与仓位 | `config/strategy/high_dividend_v1.yml` |
| 手续费/滑点/红利税 | `config/cost.yml` | | 手续费/滑点/红利税 | `config/cost.yml` |
| 回测区间/Walk-forward/基准 | `config/backtest.yml` | | 回测区间/Walk-forward/基准/分位最小样本量 | `config/backtest.yml` |
| 报告外观与部署方式 | `config/report.yml` | | 报告外观与部署方式 | `config/report.yml` |
| 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) | | 后端监听地址/端口 | `hdiv web` 命令行参数(或 `deploy/serve.sh` / launchd plist) |
+4 -4
View File
@@ -249,7 +249,7 @@ def format_metrics(m: dict[str, Any]) -> str:
"""控制台友好的指标摘要。""" """控制台友好的指标摘要。"""
def pct(k: str) -> str: def pct(k: str) -> str:
v = m.get(k) v = m.get(k)
return "—" if v is None else f"{v * 100:,.2f}%" return "—" if v is None else NumFmt.from_config().pct(v)
def num(k: str, d: int = 2) -> str: def num(k: str, d: int = 2) -> str:
v = m.get(k) v = m.get(k)
@@ -275,9 +275,9 @@ def format_metrics(m: dict[str, Any]) -> str:
for code, v in bench.items(): for code, v in bench.items():
if isinstance(v, dict): if isinstance(v, dict):
lines.append( lines.append(
f" {code}: 总收益 {(v.get('total_return') or 0) * 100:,.2f}% " f" {code}: 总收益 {_pct(v.get('total_return'))} "
f"CAGR {(v.get('cagr') or 0) * 100:,.2f}% " f"CAGR {_pct(v.get('cagr'))} "
f"回撤 {(v.get('max_drawdown') or 0) * 100:,.2f}%" f"回撤 {_pct(v.get('max_drawdown'))}"
) )
return "\n".join(lines) return "\n".join(lines)
+12 -6
View File
@@ -21,6 +21,8 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import load_config from hdiv.core.config import load_config
from hdiv.report.format import NumFmt
from hdiv.core.errors import SchemaValidationError from hdiv.core.errors import SchemaValidationError
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import data_version from hdiv.data.repo import data_version
@@ -205,17 +207,17 @@ class SensitivityRunner:
return "(样本不足,无法评估敏感性)" return "(样本不足,无法评估敏感性)"
lines = [ lines = [
"敏感性判读(plan.md §27)", "敏感性判读(plan.md §27)",
f" CAGR 区间 {a['cagr_min'] * 100:.2f}% ~ {a['cagr_max'] * 100:.2f}%" f" CAGR 区间 {_pct(a['cagr_min'])} ~ {_pct(a['cagr_max'])}"
f"(跨度 {a['cagr_range'] * 100:.2f} 个百分点)", f"(跨度 {_pct(a['cagr_range'], plus=False)})",
f" 相邻档最大跳变 {a['max_jump'] * 100:.2f}pp,平均跳变 {a['mean_jump'] * 100:.2f}pp", f" 相邻档最大跳变 {_pp(a['max_jump'])},平均跳变 {_pp(a['mean_jump'])}",
f" 平滑度 {a['smoothness']:.2f}(越接近 1 越平滑)", f" 平滑度 {a['smoothness']:.2f}(越接近 1 越平滑)",
] ]
if a.get("spikes"): if a.get("spikes"):
lines.append(f" ⚠ 检出 {len(a['spikes'])} 处尖峰:") lines.append(f" ⚠ 检出 {len(a['spikes'])} 处尖峰:")
for s in a["spikes"]: for s in a["spikes"]:
lines.append( lines.append(
f" 第 {s['index']} 点 CAGR {s['cagr'] * 100:.2f}% " f" 第 {s['index']} 点 CAGR {_pct(s['cagr'])} "
f"高于邻居均值 {s['excess'] * 100:.2f}pp" f"高于邻居均值 {_pp(s['excess'])}"
) )
lines.append(f" 结论:{a['verdict']}") lines.append(f" 结论:{a['verdict']}")
return "\n".join(lines) return "\n".join(lines)
@@ -296,9 +298,13 @@ def _f(v: Any) -> float | None:
return None if not np.isfinite(f) else f return None if not np.isfinite(f) else f
def _pp(v: Any, *, plus: bool = True) -> str:
return NumFmt.from_config().pct_pp(v, plus=plus)
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
f = _f(v) f = _f(v)
return "—" if f is None else f"{f * 100:,.2f}%" return "—" if f is None else NumFmt.from_config().pct(f)
def _num(v: Any) -> str: def _num(v: Any) -> str:
+256 -11
View File
@@ -31,11 +31,11 @@ from hdiv.core.config import (
config_hash, config_hash,
load_config, load_config,
) )
from hdiv.core.errors import DataGapError from hdiv.core.errors import DataGapError, HdivError
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo, data_version from hdiv.data.repo import Repo, data_version
from hdiv.data.sync.base import stable_id from hdiv.data.sync.base import stable_id
from hdiv.factor.dividend_yield import build_dps_events, ttm_dps_series from hdiv.factor.dividend_yield import build_dps_events, ttm_dps_series, ttm_params
from hdiv.strategy.registry import StrategyRegistry from hdiv.strategy.registry import StrategyRegistry
@@ -160,6 +160,7 @@ class BacktestEngine:
backtest: BacktestConfig | None = None, backtest: BacktestConfig | None = None,
frozen_reference: tuple[date, date] | None = None, frozen_reference: tuple[date, date] | None = None,
universe_run_id: str | None = None, universe_run_id: str | None = None,
allow_lookahead_universe: bool = False,
) -> None: ) -> None:
db.load_dotenv_once() db.load_dotenv_once()
self.strategy = strategy self.strategy = strategy
@@ -173,7 +174,14 @@ class BacktestEngine:
# 指定 universe_run_id 时,使用该次筛选的成员作为**冻结股票池** # 指定 universe_run_id 时,使用该次筛选的成员作为**冻结股票池**
# (不再按周期重新筛选)。这同时建立了「股票池记录 ↔ 回测记录」的显式关联。 # (不再按周期重新筛选)。这同时建立了「股票池记录 ↔ 回测记录」的显式关联。
self.universe_run_id = universe_run_id self.universe_run_id = universe_run_id
# 股票池自带 asof:若它晚于回测起点,就等于用未来信息选股。
# 默认拒绝;只有显式放行才执行,且必须把「含未来信息」写进 run 记录。
self.allow_lookahead_universe = allow_lookahead_universe
self.lookahead_universe_note: str | None = None
self.universe_cfg = self.registry.resolved_universe(strategy) self.universe_cfg = self.registry.resolved_universe(strategy)
# 实时画像闸门(PIT):关闭时整条链路不参与,回测行为与启用前一致
self.gate_cfg = strategy.entry.profile_gate
self.pit: Any = None
@classmethod @classmethod
def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine: def from_strategy(cls, path: str | Path, **kw: Any) -> BacktestEngine:
@@ -262,7 +270,25 @@ class BacktestEngine:
bt.capital.initial, bt.capital.initial,
), ),
"unimplemented": state["unimplemented"], "unimplemented": state["unimplemented"],
"profile_gate": self.pit.stats() if self.pit is not None else None,
} }
if self.pit is not None:
gate_stats = self.pit.stats()
result["profile_gate_verdicts"] = {
"reject": sum(
1 for x in state["signals"] if x.kind == "REJECT"
),
}
if verbose:
print(
f" 实时画像:计算 {gate_stats['snapshots_computed']} 次"
f"(缓存命中 {gate_stats['snapshots_cached']})"
f",涉及 {gate_stats['distinct_asof']} 个决策时点"
f",财报面板载入 {gate_stats['financial_loads']} 次"
f",流动性查询 {gate_stats['liquidity_loads']} 次"
f" | 画像剔除 {result['profile_gate_verdicts']['reject']} 次",
flush=True,
)
if verbose: if verbose:
rc = result["reconciliation"] rc = result["reconciliation"]
print( print(
@@ -286,6 +312,55 @@ class BacktestEngine:
) )
return result return result
# ------------------------------------------------------------------
# 股票池未来函数守卫
# ------------------------------------------------------------------
def _universe_asof(self) -> date | None:
"""读股票池记录的 asof 日期(同时校验 run_id 是否存在)。"""
df = db.read_sql(
"SELECT asof_date FROM hd_universe_run WHERE run_id = :r",
{"r": self.universe_run_id}, cfg=load_config("datasource"),
)
if df.empty:
raise HdivError(
f"股票池 {self.universe_run_id} 不存在(hd_universe_run 无此 run_id)。\n"
f" 可执行 `python -m hdiv universe` 重新筛选,或在 Web 前端「股票池」页复制正确的 run_id。"
)
return pd.to_datetime(df["asof_date"].iloc[0]).date()
def _check_universe_asof(self, backtest_start: date) -> None:
"""拒绝「用未来时点选出的股票池去跑更早的区间」。
这是本项目自己已经在 walk-forward 上认定的纪律(见
``docs/implementation-status.md`` §7.4):股票池带 asof,把它套到更早的
区间就是用未来信息选股。单次回测没有理由例外。
"""
uasof = self._universe_asof()
if uasof is None or uasof <= backtest_start:
return
note = (
f"股票池含未来信息:{self.universe_run_id} 的 asof={uasof} "
f"晚于回测起点 {backtest_start},各调仓日复用了同一份事后名单"
)
if not self.allow_lookahead_universe:
raise HdivError(
f"拒绝执行:股票池的 asof({uasof})晚于回测起点({backtest_start})。\n"
f" 股票池 {self.universe_run_id} 是用 {uasof} 当天可见的数据选出来的,\n"
f" 名单里含有回测起点时不可能知道的信息(哪些公司此后仍满足连续分红、\n"
f" 5 年 ROE、自由现金流覆盖等条件)。把它套到更早的年份即未来函数。\n"
f" 正确做法(推荐第 1 种):\n"
f" 1) 去掉 --universe-run,让引擎在每个调仓日按当时可见数据重新筛选:\n"
f" python -m hdiv backtest --start {backtest_start} --end <end>\n"
f" 2) 若只想检验某个固定股票池,把回测起点改到该股票池 asof 之后:\n"
f" python -m hdiv backtest --universe-run {self.universe_run_id} "
f"--start {uasof}\n"
f" 确实需要复现「带未来信息」的历史结果(例如与修复前的记录对比)时,\n"
f" 显式放行:--allow-lookahead-universe\n"
f" 届时本次运行会在 hd_backtest_run.unimplemented_json 中如实声明该偏差。"
)
self.lookahead_universe_note = note
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 数据准备 # 数据准备
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -310,6 +385,11 @@ class BacktestEngine:
del cur del cur
universe_by_refresh: dict[date, set[str]] = {} universe_by_refresh: dict[date, set[str]] = {}
if self.universe_run_id: if self.universe_run_id:
# 未来函数守卫:股票池自带 asof。若它晚于回测起点,名单里就含有
# 「当时不可能知道」的信息(哪些公司此后仍满足分红/质量条件),
# 把它套到更早的年份上就是用未来信息选股 —— 与 walk-forward
# 拒绝 --universe-run 是同一条理由,这里必须同样拒绝。
self._check_universe_asof(days[0])
# 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。 # 冻结股票池:直接取该次筛选的入选成员,所有调仓日复用同一份清单。
# 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。 # 好处是可复现(同一 run_id 永远对应同一股票池),并建立双向关联。
dfu = db.read_sql( dfu = db.read_sql(
@@ -366,6 +446,24 @@ class BacktestEngine:
suspend = self._load_suspend(all_syms, days[0], days[-1]) suspend = self._load_suspend(all_syms, days[0], days[-1])
limits = self._load_limits(all_syms, days[0], days[-1]) limits = self._load_limits(all_syms, days[0], days[-1])
# --- 实时画像闸门:预载跨决策日共享的面板(仅在启用时)---
if self.gate_cfg.enabled:
from hdiv.profile.pit import PitProfileService
self.pit = PitProfileService(window_years=self.gate_cfg.window_years)
# 画像取数起点必须覆盖最长窗口(profile.yml 的 windows_years),
# 与分位参照窗口(backtest.yml 的 lookback_years)是两个独立的量。
pit_start = date(max(days[0].year - self.pit.max_years - 1, 2000), 1, 1)
self.pit.prepare(all_syms, pit_start, days[-1])
self.pit.configure({r.metric for r in self.gate_cfg.rules})
if verbose:
print(
f" 实时画像闸门已启用:窗口 {self.gate_cfg.window_years} 年,"
f"{len(self.gate_cfg.rules)} 条规则,"
f"面板自 {pit_start} 起载入({len(all_syms)} 只)",
flush=True,
)
return { return {
"days": days, "days": days,
"refresh_dates": refresh_dates, "refresh_dates": refresh_dates,
@@ -421,6 +519,16 @@ class BacktestEngine:
) )
return out return out
def _has_constraint_rows(self, table: str, start: date, end: date) -> bool:
"""该约束表在回测区间内**是否有任何数据**(表级判定,与股票池无关)。"""
if not db.table_exists(table, self.cfg_db()):
return False
df = db.read_sql(
f"SELECT COUNT(*) AS n FROM {table} WHERE trade_date BETWEEN :s AND :e",
{"s": start, "e": end}, cfg=self.cfg_db(),
)
return bool(df["n"].iloc[0])
def cfg_db(self) -> Any: def cfg_db(self) -> Any:
return load_config("datasource") return load_config("datasource")
@@ -527,13 +635,54 @@ class BacktestEngine:
peak = equity["total_value"].cummax() peak = equity["total_value"].cummax()
equity["drawdown"] = equity["total_value"] / peak - 1.0 equity["drawdown"] = equity["total_value"] / peak - 1.0
for name in ("涨跌停近似", "停牌顺延", "成交量占比约束"): # 「约束没生效」只能由**表里有没有数据**判定,不能由「过滤后集合为空」判定 ——
if name == "涨跌停近似" and not ctx["limits"]: # 后者会把「这批股票这段时间恰好没停牌/没涨跌停」误报成「数据缺失」。
unimplemented.add("涨跌停约束未生效(hd_limit 无数据,成交按可达价格近似)") # 实测:2026-08~09 的 hd_suspend 覆盖到 2026-09-30,却因该区间无停牌
if name == "停牌顺延" and not ctx["suspend"]: # 而被声明成「hd_suspend 无数据」,属于把自己的建模正常状态说成数据缺陷。
unimplemented.add("停牌约束未生效(hd_suspend 无数据)") if not self._has_constraint_rows("hd_limit", days[0], days[-1]):
unimplemented.add("涨跌停约束未生效(hd_limit 在回测区间内无数据,成交按可达价格近似)")
if not self._has_constraint_rows("hd_suspend", days[0], days[-1]):
unimplemented.add("停牌约束未生效(hd_suspend 在回测区间内无数据)")
# 以下三项**配置写了但引擎没实现**,必须如实声明 —— 否则 run 记录看起来
# 「一切正常」,而使用者以为 backtest.yml 的 defer / reinvest 生效了。
# (配置承诺与实际行为不一致,是本项目反复记录的一类缺陷。)
# 注意字段归属:fill/dividend 在 backtest.yml;execution/risk 在策略 yml。
if str(bt.fill.suspended_rule) != "skip" or str(bt.fill.limit_up_down_rule) != "skip" \
or str(s.execution.suspended_rule) != "skip" \
or str(s.execution.limit_up_down_rule) != "skip":
unimplemented.add(
"未实现停牌/涨跌停顺延(suspended_rule / limit_up_down_rule 的 "
"defer 分支):未成交信号在当日被**丢弃**,不会顺延到下一个可成交日"
)
if str(bt.dividend.cash_mode) != "hold" or bt.dividend.reinvest_rule:
unimplemented.add(
"未实现分红再投资规则(cash_mode=reinvest / reinvest_rule):"
"现金分红按除权日入账后**留存为现金**,在下次调仓时按目标权重重新配置"
)
if s.execution.signal_to_execution != "next_open" or str(bt.fill.price) != "next_open":
unimplemented.add(
f"未实现 signal_to_execution/fill.price 的 "
f"{s.execution.signal_to_execution}/{bt.fill.price} 分支:"
f"成交固定按信号次日开盘价"
)
if bt.dividend.handle_rights_issue:
unimplemented.add(
"未实现配股处理(handle_rights_issue):配股缴款/股数变动不入账"
)
if not bt.fill.partial_fill: if not bt.fill.partial_fill:
unimplemented.add("未启用部分成交(按信号全额成交,但受资金与权重上限约束)") unimplemented.add("未启用部分成交(按信号全额成交,但受资金与权重上限约束)")
if bt.fill.max_volume_pct is not None:
unimplemented.add(
"未实现成交量占比约束(fill.max_volume_pct 未被使用)"
)
if s.risk.liquidity_limit_pct_adv is not None:
unimplemented.add(
"未实现 risk.liquidity_limit_pct_adv(单笔成交不超过当日成交额的比例)"
)
# 显式放行的未来函数必须留在 run 记录里 —— 否则事后无法分辨
# 「这条收益曲线是干净的」还是「这条用了事后名单」。
if self.lookahead_universe_note:
unimplemented.add(self.lookahead_universe_note)
div_df = pd.DataFrame(dividend_ledger) div_df = pd.DataFrame(dividend_ledger)
return { return {
@@ -574,9 +723,12 @@ class BacktestEngine:
if close_hist.empty: if close_hist.empty:
continue continue
idx = pd.DatetimeIndex(close_hist.index) idx = pd.DatetimeIndex(close_hist.index)
# 参数从因子层的统一来源取,不再硬编码 ——
# 否则改了 profile.yml 的回测也不会变(曾如此)。
_w, _g, _sm = ttm_params()
dps = ttm_dps_series( dps = ttm_dps_series(
idx, ctx["events"].get(sym, pd.DataFrame()), idx, ctx["events"].get(sym, pd.DataFrame()),
ttm_days=365, grace_days=45, ttm_days=_w, grace_days=_g, smooth_spikes=_sm,
) )
with np.errstate(divide="ignore", invalid="ignore"): with np.errstate(divide="ignore", invalid="ignore"):
y = np.where(close_hist.to_numpy(dtype="float64") > 0, y = np.where(close_hist.to_numpy(dtype="float64") > 0,
@@ -589,6 +741,14 @@ class BacktestEngine:
ref_ser = ser.loc[pd.Timestamp(ref[0]): pd.Timestamp(ref[1])] if ref else ser ref_ser = ser.loc[pd.Timestamp(ref[0]): pd.Timestamp(ref[1])] if ref else ser
if ref_ser.empty: if ref_ser.empty:
ref_ser = ser ref_ser = ser
# 样本不足则不作判断 —— 保持现有仓位,既不买也不卖。
#
# 分位 = 「≤当前值的观测占比」。窗口只有 1 个观测且恰好等于当前值时
# 占比 100%,会击穿任何买入阈值。这是统计假象而非「股息率处于高位」:
# 实测 2015-01-06(行情数据首日)窗口 2010-2015 只有 1 个观测,
# 8 只股票因此被「100% 分位」买入。
if ref_ser.size < self.bt_cfg.percentile_reference.min_observations:
continue
pct = float((ref_ser <= current).sum() / ref_ser.size * 100.0) pct = float((ref_ser <= current).sum() / ref_ser.size * 100.0)
held = sym in positions held = sym in positions
@@ -601,6 +761,8 @@ class BacktestEngine:
"reference_window": [str(ref[0]), str(ref[1])] if ref else None, "reference_window": [str(ref[0]), str(ref[1])] if ref else None,
"reference_mode": self._reference_mode(), "reference_mode": self._reference_mode(),
"observation_count": int(ref_ser.size), "observation_count": int(ref_ser.size),
"min_observations": int(
self.bt_cfg.percentile_reference.min_observations),
"close": price, "close": price,
} }
@@ -611,12 +773,19 @@ class BacktestEngine:
if not held: if not held:
if target is not None and target > 0 and pct >= s.entry.yield_percentile: if target is not None and target > 0 and pct >= s.entry.yield_percentile:
gate = self._gate(sym, day)
if gate is not None and gate["verdict"] != "PASS":
out.append(self._reject_signal(
sym, day, current, pct, price, common, gate,
))
continue
out.append(Signal( out.append(Signal(
sym, day, "BUY", target, current, pct, price, sym, day, "BUY", target, current, pct, price,
{**common, {**common,
"rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g}," "rule": f"股息率历史分位 {pct:.1f}% >= P{s.entry.yield_percentile:g},"
f"目标仓位 {target:.0%}", f"目标仓位 {target:.0%}",
"reason_cn": "股息率进入历史高位区间,达到买入阈值"}, "reason_cn": "股息率进入历史高位区间,达到买入阈值",
**({"profile_gate": gate} if gate else {})},
)) ))
else: else:
if target is None: if target is None:
@@ -628,15 +797,91 @@ class BacktestEngine:
"rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}", "rule": f"股息率历史分位 {pct:.1f}% <= P{s.exit.yield_percentile:g}",
"reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"}, "reason_cn": "股息率回落至历史低位区间,达到卖出阈值,清仓"},
)) ))
else: elif target < 1.0:
out.append(Signal( out.append(Signal(
sym, day, "TRIM" if target < 1.0 else "ADD", target, current, pct, price, sym, day, "TRIM", target, current, pct, price,
{**common, {**common,
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}", "rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
"reason_cn": "股息率分位变动,按阶梯规则调整仓位"}, "reason_cn": "股息率分位变动,按阶梯规则调整仓位"},
)) ))
else:
# ADD 也是买入 —— 同样要过实时画像闸门。
# 被拒时**不动已有仓位**(REJECT 不进入待成交队列),
# 因为闸门的语义是「不值得买」,不是「该卖」。
gate = self._gate(sym, day)
if gate is not None and gate["verdict"] != "PASS":
out.append(self._reject_signal(
sym, day, current, pct, price, common, gate,
))
continue
out.append(Signal(
sym, day, "ADD", target, current, pct, price,
{**common,
"rule": f"分位 {pct:.1f}% 对应目标仓位 {target:.0%}",
"reason_cn": "股息率分位变动,按阶梯规则调整仓位",
**({"profile_gate": gate} if gate else {})},
))
return out return out
# ------------------------------------------------------------------
# 实时画像闸门
# ------------------------------------------------------------------
def _gate(self, sym: str, day: date) -> dict[str, Any] | None:
"""惰性计算该股在 ``day`` 的实时画像并判定闸门。
只在「买入条件已触发」时调用 —— 这是「在触发条件的时候计算」的落点。
未启用闸门时返回 ``None``,调用方不产生任何额外行为。
**启用但未初始化必须报错,不得静默放行**:静默放行等于
「配置了一个风险控制但它不生效」,属于最难发现的一类失效 ——
回测照跑,拿到的却是没有闸门的版本。
"""
if not self.gate_cfg.enabled:
return None
if self.pit is None:
raise HdivError(
"实时画像闸门已启用,但画像服务未初始化。\n"
" 正常路径由 BacktestEngine.run() → _prepare() 负责初始化;\n"
" 若你直接调用 _evaluate/_simulate,请先调用 _prepare(days)。"
)
from hdiv.profile.pit import evaluate_gate
snap = self.pit.snapshot(sym, day)
rules = [r.model_dump() for r in self.gate_cfg.rules]
return evaluate_gate(
rules, snap,
on_unverifiable=self.gate_cfg.on_unverifiable,
min_window_coverage=self.gate_cfg.min_window_coverage,
)
def _reject_signal(
self, sym: str, day: date, current: float, pct: float, price: float,
common: dict[str, Any], gate: dict[str, Any],
) -> Signal:
"""把「为什么不买」写成可追溯的信号记录(plan.md §32 的同一条原则)。"""
failed = gate.get("failed") or []
unver = gate.get("unverifiable") or []
if failed:
why = "实时画像未通过:" + "、".join(failed)
cn = "按当日可见数据重算画像后判定不值得买,剔除"
else:
why = "实时画像无法验证:" + "、".join(unver)
cn = "按当日可见数据重算画像,样本不足/数据缺失,保守不买"
checks = {
c["metric"]: {"stat": c["stat"], "actual": c["actual"],
"status": c["status"], "threshold": c["threshold"],
"op": c["op"], "passed": c["passed"]}
for c in gate.get("checks", [])
}
return Signal(
sym, day, "REJECT", 0.0, current, pct, price,
{**common, "rule": why, "reason_cn": cn,
"profile_gate": gate, "profile_checks": checks,
# executed=False + skip_reason 让它在「未成交信号」列表里可读
"skip_reason": "PROFILE_GATE", "executed": False},
)
def _reference_window(self, day: date) -> tuple[date, date] | None: def _reference_window(self, day: date) -> tuple[date, date] | None:
if self.frozen_reference is not None: if self.frozen_reference is not None:
return self.frozen_reference return self.frozen_reference
+7 -2
View File
@@ -27,6 +27,8 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import BacktestConfig, load_config from hdiv.core.config import BacktestConfig, load_config
from hdiv.report.format import NumFmt
from hdiv.core.errors import DataGapError, SchemaValidationError from hdiv.core.errors import DataGapError, SchemaValidationError
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo, data_version from hdiv.data.repo import Repo, data_version
@@ -222,6 +224,7 @@ class WalkForwardRunner:
from hdiv.factor.dividend_yield import ( from hdiv.factor.dividend_yield import (
build_dps_events, build_dps_events,
dividend_yield_series, dividend_yield_series,
ttm_params,
) )
sel = self.registry.selector(self.strategy) sel = self.registry.selector(self.strategy)
@@ -236,8 +239,10 @@ class WalkForwardRunner:
for sym, g in px.groupby("symbol"): for sym, g in px.groupby("symbol"):
g = g.sort_values("trade_date").copy() g = g.sort_values("trade_date").copy()
g["trade_date"] = pd.to_datetime(g["trade_date"]) g["trade_date"] = pd.to_datetime(g["trade_date"])
_w, _g, _sm = ttm_params()
ser = dividend_yield_series( ser = dividend_yield_series(
g.set_index("trade_date")["close"], events.get(sym, pd.DataFrame()) g.set_index("trade_date")["close"], events.get(sym, pd.DataFrame()),
ttm_days=_w, grace_days=_g, smooth_spikes=_sm,
) )
if ser.empty: if ser.empty:
continue continue
@@ -315,7 +320,7 @@ class WalkForwardRunner:
def pct(k: str) -> str: def pct(k: str) -> str:
v = s.get(k) v = s.get(k)
return "—" if v is None else f"{v * 100:,.2f}%" return "—" if v is None else NumFmt.from_config().pct(v)
def num(k: str, d: int = 2) -> str: def num(k: str, d: int = 2) -> str:
v = s.get(k) v = s.get(k)
+46 -4
View File
@@ -104,10 +104,15 @@ def cmd_sync(args: argparse.Namespace) -> int:
elif target == "backfill": elif target == "backfill":
from hdiv.data.sync import price from hdiv.data.sync import price
# basic_start 必须可传入:run_backfill 的默认值是 2015-01-01,
# 若要回补 2015 年之前的 daily_basic,只给 --basic-end 是不够的 ——
# 早期 CLI 没有这个参数,于是「回补 2010-2014」实际只补了行情,
# daily_basic 仍停在 2015,画像里的 PE/PB 依旧拿不到早年数据。
r = price.run_backfill( r = price.run_backfill(
price_start=args.start, price_start=args.start,
price_end=args.end, price_end=args.end,
basic_end=args.basic_end, basic_start=args.basic_start or args.start,
basic_end=args.basic_end or args.end,
resume=not args.no_resume, resume=not args.no_resume,
) )
return 0 if r.get("monotonic_ok", True) else 1 return 0 if r.get("monotonic_ok", True) else 1
@@ -234,6 +239,13 @@ def cmd_profile(args: argparse.Namespace) -> int:
pb = ProfileBuilder.from_config(args.config) pb = ProfileBuilder.from_config(args.config)
result = pb.run(universe_run_id=args.universe_run, symbols=args.symbols, asof=args.asof) result = pb.run(universe_run_id=args.universe_run, symbols=args.symbols, asof=args.asof)
print(f"画像 {result['run_id']}:{result['symbol_count']} 只") print(f"画像 {result['run_id']}:{result['symbol_count']} 只")
# 覆盖率必须显眼:名义「5 年窗口」可能只有 3 年多数据(数据起点截断)
for w in result.get("warnings", []):
print(f" ⚠ {w}")
worst = (result.get("window_coverage") or {}).get("worst")
if worst:
wy, cov = worst
print(f" 窗口覆盖率:最差 {wy} 年窗口 {cov:.1%}(1.0 = 名义窗口被完整覆盖)")
if args.html: if args.html:
from hdiv.report.build import build_profile_report from hdiv.report.build import build_profile_report
@@ -278,6 +290,23 @@ def cmd_backtest(args: argparse.Namespace) -> int:
from hdiv.backtest.walk_forward import WalkForwardRunner from hdiv.backtest.walk_forward import WalkForwardRunner
if args.mode == "walkforward": if args.mode == "walkforward":
# 冻结股票池与 walk-forward 在时序上不兼容:
# 股票池有其自身的 asof(如 2025-01-21),而 walk-forward 的窗口从
# 2015 年就开始训练 —— 用未来时点选出的股票池去跑过去的窗口,
# 就是典型的未来函数,恰好破坏 walk-forward 要守护的纪律。
#
# 早期实现没有这个参数,于是 --universe-run 被**静默忽略**,
# 用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
if args.universe_run:
raise HdivError(
"walk-forward 模式不支持 --universe-run。\n"
" 原因:股票池带有自己的时点(asof),把它套到更早的训练窗口上\n"
" 等于用未来信息选股,会破坏 walk-forward 的无未来函数纪律。\n"
" 正确做法:walk-forward 会在每个窗口内按各自时点重新筛选,\n"
" 这正是它要检验的「策略能否在未知未来上复现」。\n"
" 若确实想检验某个固定股票池,请用普通回测:\n"
" python -m hdiv backtest --universe-run <run_id>"
)
wf = WalkForwardRunner.from_strategy(args.strategy) wf = WalkForwardRunner.from_strategy(args.strategy)
res = wf.run() res = wf.run()
print(f"Walk-forward {res['wf_id']}:{res['window_count']} 个窗口") print(f"Walk-forward {res['wf_id']}:{res['window_count']} 个窗口")
@@ -289,7 +318,9 @@ def cmd_backtest(args: argparse.Namespace) -> int:
_reject_no_persist_with_html(args, "hdiv backtest") _reject_no_persist_with_html(args, "hdiv backtest")
engine = BacktestEngine.from_strategy( engine = BacktestEngine.from_strategy(
args.strategy, universe_run_id=args.universe_run args.strategy,
universe_run_id=args.universe_run,
allow_lookahead_universe=args.allow_lookahead_universe,
) )
res = engine.run(start=args.start, end=args.end, persist=not args.no_persist) res = engine.run(start=args.start, end=args.end, persist=not args.no_persist)
if args.no_persist: if args.no_persist:
@@ -366,7 +397,12 @@ def build_parser() -> argparse.ArgumentParser:
s.add_argument("--which", choices=["daily", "adj_factor", "daily_basic"]) s.add_argument("--which", choices=["daily", "adj_factor", "daily_basic"])
s.add_argument("--start", default="2015-01-01") s.add_argument("--start", default="2015-01-01")
s.add_argument("--end", default="2018-12-31") s.add_argument("--end", default="2018-12-31")
s.add_argument("--basic-end", default="2019-12-31") s.add_argument(
"--basic-start", default=None,
help="daily_basic 回补起点(默认与 --start 相同)。"
"回补 2015 年之前的数据时必须显式给出,否则 daily_basic 不会被回补",
)
s.add_argument("--basic-end", default="2019-12-31", help="daily_basic 回补终点")
s.add_argument("--no-resume", action="store_true") s.add_argument("--no-resume", action="store_true")
s.add_argument("--no-weight", action="store_true") s.add_argument("--no-weight", action="store_true")
s.set_defaults(func=cmd_sync) s.set_defaults(func=cmd_sync)
@@ -477,7 +513,13 @@ def build_parser() -> argparse.ArgumentParser:
b.add_argument("--end", default=None) b.add_argument("--end", default=None)
b.add_argument( b.add_argument(
"--universe-run", default=None, "--universe-run", default=None,
help="使用指定股票池筛选记录的成员作为冻结股票池(并建立关联)", help="使用指定股票池筛选记录的成员作为冻结股票池(并建立关联);"
"若该股票池的 asof 晚于回测起点则拒绝执行(未来函数)",
)
b.add_argument(
"--allow-lookahead-universe", action="store_true",
help="显式放行「股票池 asof 晚于回测起点」的组合(含未来信息,"
"会如实写入 hd_backtest_run.unimplemented_json)",
) )
b.add_argument("--no-persist", action="store_true") b.add_argument("--no-persist", action="store_true")
# HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。 # HTML 报告已降级为「导出件」:默认不生成,需要时显式 --html。
+101
View File
@@ -96,11 +96,36 @@ class PathsConfig(StrictModel):
log_dir: str = "logs" log_dir: str = "logs"
class SyncConfig(StrictModel):
"""行情同步的「完整性」判定口径(决定断点续传会不会重拉整段历史)。
一个交易日被视为**已完整同步**,要求当日股票数 ≥
``max(min_symbols_floor, min_symbols_ratio × 当年应有上市股票数)``。
**为什么不能只用一个绝对阈值**:A 股 2005 年只有约 1,350 只股票,
2010 年约 1,700 只。若固定要求 1,500 只,2005-2009 的**每一个交易日**
都会被判成「未完成」,于是断点续传完全失效 —— 回补中断一次就要从
第一天重新拉,且每次重跑都会把整段早年历史再拉一遍。
"""
min_symbols_floor: int = 200
min_symbols_ratio: float = 0.6
@model_validator(mode="after")
def _check(self) -> SyncConfig:
if self.min_symbols_floor < 1:
raise SchemaValidationError("sync.min_symbols_floor 必须为正")
if not 0.0 < self.min_symbols_ratio <= 1.0:
raise SchemaValidationError("sync.min_symbols_ratio 必须落在 (0, 1]")
return self
class DataSourceConfig(StrictModel): class DataSourceConfig(StrictModel):
version: int = 1 version: int = 1
database: DatabaseConfig database: DatabaseConfig
tushare: TushareConfig = Field(default_factory=TushareConfig) tushare: TushareConfig = Field(default_factory=TushareConfig)
paths: PathsConfig = Field(default_factory=PathsConfig) paths: PathsConfig = Field(default_factory=PathsConfig)
sync: SyncConfig = Field(default_factory=SyncConfig)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -292,6 +317,8 @@ class SufficiencyConfig(StrictModel):
class TtmDividendConfig(StrictModel): class TtmDividendConfig(StrictModel):
window_days: int = 365 window_days: int = 365
grace_days: int = 45 grace_days: int = 45
# 是否消除除权间隔不规整造成的毛刺(重叠虚高 / 断档虚低)
smooth_spikes: bool = True
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> TtmDividendConfig: def _check(self) -> TtmDividendConfig:
@@ -479,6 +506,7 @@ class ScheduleConfig(StrictModel):
class PercentileReferenceConfig(StrictModel): class PercentileReferenceConfig(StrictModel):
mode: Literal["rolling", "frozen"] = "rolling" mode: Literal["rolling", "frozen"] = "rolling"
lookback_years: int = 5 lookback_years: int = 5
min_observations: int = 250
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> PercentileReferenceConfig: def _check(self) -> PercentileReferenceConfig:
@@ -602,11 +630,84 @@ class ScaleStep(StrictModel):
return self return self
class ProfileGateRule(StrictModel):
"""一条实时画像闸门规则。
语义:``<metric> 的 <stat> <op> <value>`` 必须成立,否则不买。
指标名与分位可用性在**配置期**校验 —— 写错一个指标名若拖到运行时,
只会得到「无法验证 → 保守不买」,表现为策略再也不交易,极难定位。
"""
metric: str
stat: Literal["current_value", "current_percentile"] = "current_value"
op: Literal[">=", "<=", ">", "<"] = ">="
value: float
@model_validator(mode="after")
def _check(self) -> ProfileGateRule:
from hdiv.core.metrics import GATE_METRICS, PERCENTILE_METRICS
if self.metric not in GATE_METRICS:
head = self.metric.split("_")[0]
near = sorted(m for m in GATE_METRICS if head and head in m)
raise SchemaValidationError(
f"profile_gate 规则引用了未知指标 {self.metric!r}。"
f"可选指标见 hdiv/core/metrics.py;相近的有 {near[:6]}"
)
if self.stat == "current_percentile" and self.metric not in PERCENTILE_METRICS:
raise SchemaValidationError(
f"{self.metric} 是标量指标,没有历史分位,不能用 "
f"stat=current_percentile。有分位的指标:{sorted(PERCENTILE_METRICS)}"
)
return self
class ProfileGateConfig(StrictModel):
"""实时(PIT)个股画像闸门。
在每个决策日、**买入条件已经触发之后**,用「当时可见的数据」重算画像,
不通过的票直接剔除。跨股票共享的面板按时点缓存,代价与「触发次数」成正比,
而不是与「回测区间 × 股票数」成正比。
"""
#: 是否启用。关闭时回测行为与启用前完全一致(可用于复现历史结果)
enabled: bool = False
#: 画像统计窗口(年)。0 = 全历史;其余必须是 config/profile.yml 的 windows_years 之一
window_years: int = 5
#: 数据缺失/样本不足(无法验证)时:reject = 保守不买,pass = 放行
on_unverifiable: Literal["reject", "pass"] = "reject"
#: 窗口**实际覆盖率**下限(1.0 = 名义 5 年就必须真有 5 年数据)。
#: 0 = 不因覆盖率淘汰(默认,保持改造前行为)。
#:
#: 为什么需要它:`window_slice(asof, 5)` 只是「把已有数据切成最近 5 年」,
#: 数据起点晚于窗口左端时窗口会被静默截短 —— 实测 2018-05-18 的「5 年」
#: 窗口只有 3.4 年(817/1215 个交易日,67%),而画像仍报 OK。
min_window_coverage: float = 0.0
rules: list[ProfileGateRule] = Field(default_factory=list)
@model_validator(mode="after")
def _check(self) -> ProfileGateConfig:
if self.window_years < 0:
raise SchemaValidationError("profile_gate.window_years 不能为负")
if not 0.0 <= self.min_window_coverage <= 1.0:
raise SchemaValidationError(
f"profile_gate.min_window_coverage 必须落在 [0, 1],"
f"当前 {self.min_window_coverage}"
)
if self.enabled and not self.rules:
raise SchemaValidationError(
"profile_gate.enabled=true 但 rules 为空 —— 空闸门等于每次都要"
"算一遍画像再无条件放行。请补齐规则,或把 enabled 设为 false。"
)
return self
class EntryConfig(StrictModel): class EntryConfig(StrictModel):
yield_percentile: float yield_percentile: float
require_universe_pass: bool = True require_universe_pass: bool = True
require_risk_pass: bool = True require_risk_pass: bool = True
scale_in: list[ScaleStep] = Field(default_factory=list) scale_in: list[ScaleStep] = Field(default_factory=list)
profile_gate: ProfileGateConfig = Field(default_factory=ProfileGateConfig)
@model_validator(mode="after") @model_validator(mode="after")
def _check(self) -> EntryConfig: def _check(self) -> EntryConfig:
+62
View File
@@ -0,0 +1,62 @@
"""画像指标代码清单(闸门配置的唯一校验来源)。
放在 ``core`` 而不是 ``profile`` 里,是为了让 ``core.config``(策略配置校验)
可以引用它,而 ``profile.pit`` 也能引用同一份定义 —— 避免出现
「配置允许的指标」与「画像实际能算的指标」两个清单。
**为什么必须显式列清单**:闸门规则里写错一个指标名,若不在配置期拒绝,
运行时就只会得到「该指标缺失 → 无法验证」,在保守策略下等于**永久不交易**。
这类静默失效必须挡在配置校验里。
"""
from __future__ import annotations
#: 只需价格/每日指标即可计算的指标
VALUATION_METRICS: frozenset[str] = frozenset(
{"dv_yield", "ttm_dps", "close", "drawdown", "pe_ttm", "pb", "ps_ttm",
"dv_vol_daily", "dv_vol_monthly", "dv_vol_quarterly", "dv_vol_annual"}
)
#: 需要风险/收益统计(仍只需价格 + 指数)
RETURN_METRICS: frozenset[str] = frozenset(
{"vol_250d", "max_drawdown_3y", "ret_1y", "ret_3y", "ret_5y", "cagr_5y", "beta_300"}
)
#: 需要分红明细
DIVIDEND_METRICS: frozenset[str] = frozenset(
{"dividend_continuity_years", "dividend_years_in_window", "dps",
"dps_cagr_5y", "dps_volatility", "total_cash_dividend",
"dividend_fy_free_cashflow"}
)
#: 需要财报(最重的查询:财务表各约 30 万行)
FINANCIAL_METRICS: frozenset[str] = frozenset(
{"roe", "roic", "gross_margin", "net_margin", "ocf_to_profit", "ocf_to_profit_calc",
"roe_avg", "roic_avg", "gross_margin_avg", "net_margin_avg", "ocf_to_profit_avg",
"debt_ratio", "free_cashflow", "n_income_attr_p", "total_assets",
"payout_ratio", "fcf_dividend_cover"}
)
#: 需要流动性(20 日均成交额)
LIQUIDITY_METRICS: frozenset[str] = frozenset({"avg_amount_20d"})
#: 有「历史分位」的指标 —— 即按窗口输出分布统计的那些。
#: 其余是标量(只有最新值),对其做分位判断无意义,配置校验直接拒绝。
PERCENTILE_METRICS: frozenset[str] = VALUATION_METRICS - {
"dv_vol_daily", "dv_vol_monthly", "dv_vol_quarterly", "dv_vol_annual"
}
#: 实时画像能产出的全部指标代码
ALL_METRICS: frozenset[str] = (
VALUATION_METRICS | RETURN_METRICS | DIVIDEND_METRICS
| FINANCIAL_METRICS | LIQUIDITY_METRICS
)
#: 观测单位是**交易日**的指标 —— 只有这些能用交易日历当覆盖率分母。
#:
#: 其余指标的 ``n_obs`` 不是交易日数:
#: - 年报均值类(``roe_avg`` / ``ocf_to_profit_avg`` …)的 ``n_obs`` 是**财年数**(常为 3~5),
#: 拿它比「5 年窗口应有 1219 个交易日」会算出 0.4% 这种荒谬覆盖率;
#: - 标量快照类(``payout_ratio`` / ``debt_ratio`` …)的 ``n_obs`` 是 1。
#:
#: 量纲对不上就不能比 —— 这是本项目反复踩到的同一类错误。
DAILY_OBSERVATION_METRICS: frozenset[str] = VALUATION_METRICS | RETURN_METRICS
#: 闸门规则允许引用的指标(与 ALL_METRICS 相同,单独命名以表达「对外契约」)
GATE_METRICS: frozenset[str] = ALL_METRICS
+99 -10
View File
@@ -123,12 +123,16 @@ def _coverage_check(
col: str, col: str,
want_start: date, want_start: date,
cfg: DataSourceConfig, cfg: DataSourceConfig,
min_symbols_per_day: int, min_symbols_per_day: int | None = None,
sample_days: int = 6, sample_days: int = 6,
) -> CheckResult: ) -> CheckResult:
"""覆盖度检查。 """覆盖度检查。
性能说明:``stock_daily`` 有 770 万行,对全部交易日做 ``want_start`` 是「数据应至少覆盖到哪一天」的目标;``min_symbols_per_day``
是**绝对下限的覆盖**(一般不用传,留空即按 ``datasource.yml: sync`` 的
「按年份成比例」口径判定 —— 早年市场只有一千多只股票,固定阈值会误报)。
性能说明:``stock_daily`` 有 1,600 万行,对全部交易日做
``GROUP BY trade_date`` + ``COUNT(DISTINCT symbol)`` 会触发全表聚合(数分钟)。 ``GROUP BY trade_date`` + ``COUNT(DISTINCT symbol)`` 会触发全表聚合(数分钟)。
因此改为**采样若干交易日**再取最小股票数 —— 足以发现「某段区间数据稀疏」的问题, 因此改为**采样若干交易日**再取最小股票数 —— 足以发现「某段区间数据稀疏」的问题,
成本从 O(全部行) 降到 O(采样日)。 成本从 O(全部行) 降到 O(采样日)。
@@ -168,9 +172,15 @@ def _coverage_check(
) )
counts.append((d, n)) counts.append((d, n))
min_day, min_per_day = min(counts, key=lambda x: x[1]) min_day, min_per_day = min(counts, key=lambda x: x[1])
# 阈值必须**随年份变化**:2005 年 A 股只有约 1,350 只股票,
# 用固定 2,000 只会把早年正常数据误报成「疑似数据稀疏」——
# 与同步器断点续传曾经踩到的是同一个坑(见 sync.price.fetched_days)。
floor, ratio, by_year = _expected_thresholds(cfg)
need_min = max(floor, int(ratio * by_year.get(min_day.year, 0)))
metrics["sampled_days"] = {str(d): n for d, n in counts} metrics["sampled_days"] = {str(d): n for d, n in counts}
metrics["min_symbols_per_day"] = min_per_day metrics["min_symbols_per_day"] = min_per_day
metrics["min_symbols_date"] = str(min_day) metrics["min_symbols_date"] = str(min_day)
metrics["min_symbols_expected"] = need_min
metrics["total_trading_days"] = len(all_days) metrics["total_trading_days"] = len(all_days)
if not enough_years: if not enough_years:
@@ -183,13 +193,13 @@ def _coverage_check(
f"当前覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日)", f"当前覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日)",
metrics, metrics,
) )
if min_per_day < min_symbols_per_day: if min_per_day < need_min:
return CheckResult( return CheckResult(
code, code,
name, name,
WARN, WARN,
table, table,
f"{min_day} 仅有 {min_per_day} 只股票(低于 {min_symbols_per_day}),疑似数据稀疏", f"{min_day} 仅有 {min_per_day} 只股票(按当年市场规模应 ≥ {need_min}),疑似数据稀疏",
metrics, metrics,
) )
return CheckResult( return CheckResult(
@@ -197,28 +207,38 @@ def _coverage_check(
name, name,
OK, OK,
table, table,
f"覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日),采样最少 {min_per_day} 只({min_day})", f"覆盖 {mn} ~ {mx}({len(all_days):,} 个交易日),"
f"采样最少 {min_per_day} 只({min_day},当年应 ≥ {need_min})",
metrics, metrics,
) )
def _expected_thresholds(cfg: DataSourceConfig) -> tuple[int, float, dict[int, int]]:
"""复用同步器的「按年份的规模阈值」,避免两处各写一套判定。"""
from hdiv.data.sync.price import _day_thresholds
return _day_thresholds(cfg)
def check_g2_price(cfg: DataSourceConfig) -> CheckResult: def check_g2_price(cfg: DataSourceConfig) -> CheckResult:
return _coverage_check( return _coverage_check(
# 目标起点 = 2005:windows_years 最长 10 年,回测自 2015-01-05 起,
# 要补满 10 年窗口就必须有 2005 年的数据
"G2", "日线行情(含复权因子)", "stock_daily", "trade_date", "G2", "日线行情(含复权因子)", "stock_daily", "trade_date",
date(2015, 1, 31), cfg, 2000, date(2005, 1, 31), cfg,
) )
def check_g2b_adjust(cfg: DataSourceConfig) -> CheckResult: def check_g2b_adjust(cfg: DataSourceConfig) -> CheckResult:
return _coverage_check( return _coverage_check(
"G2b", "复权因子", "adjust_factor", "trade_date", date(2015, 1, 31), cfg, 2000 "G2b", "复权因子", "adjust_factor", "trade_date", date(2005, 1, 31), cfg
) )
def check_g3_daily_basic(cfg: DataSourceConfig) -> CheckResult: def check_g3_daily_basic(cfg: DataSourceConfig) -> CheckResult:
r = _coverage_check( r = _coverage_check(
"G3", "每日指标(PE/PB/股息率/市值)", "daily_basic", "trade_date", "G3", "每日指标(PE/PB/股息率/市值)", "daily_basic", "trade_date",
date(2015, 1, 31), cfg, 2000, date(2005, 1, 31), cfg,
) )
if r.severity == OK: if r.severity == OK:
dv = _scalar( dv = _scalar(
@@ -415,8 +435,8 @@ def check_g5b_index_weight(cfg: DataSourceConfig) -> CheckResult:
def check_g6_trading(cfg: DataSourceConfig) -> list[CheckResult]: def check_g6_trading(cfg: DataSourceConfig) -> list[CheckResult]:
out: list[CheckResult] = [] out: list[CheckResult] = []
for code, table, label, want in [ for code, table, label, want in [
("G6", "hd_suspend", "停牌记录", date(2015, 1, 31)), ("G6", "hd_suspend", "停牌记录", date(2010, 1, 31)),
("G6b", "hd_limit", "涨跌停价", date(2015, 1, 31)), ("G6b", "hd_limit", "涨跌停价", date(2010, 1, 31)),
]: ]:
st = _table_stats(table, cfg) st = _table_stats(table, cfg)
if not st.get("exists") or st.get("rows", 0) == 0: if not st.get("exists") or st.get("rows", 0) == 0:
@@ -536,6 +556,74 @@ def check_units(cfg: DataSourceConfig) -> CheckResult:
) )
def check_ohlcv_units(cfg: DataSourceConfig) -> CheckResult:
"""``stock_daily`` 量价单位一致性(成交量/成交额)。
``stock_daily`` 是「追加进既有 qlib 库」的表:2015-01~2019 的行由本项目
从 Tushare 回补(原始单位:手 / 千元),2020 起沿用 qlib 存量(股 / 元),
2019 年同日混着两种。``min_avg_amount_20d`` 之类阈值是按「元」写的,
所以这种混用会**静默**把早年流动性低估 1000 倍、把股票池清空 ——
必须由审计主动发现(单位错误不会抛异常,只会让结果全错)。
判据:``成交额 / (成交量 × 收盘价)``,≈1 = 已换算,≈0.1 = 原始口径。
取多个年份的样本日,任何一天存在原始口径行即判 WARN(不是 FAIL:
读取层 :func:`hdiv.data.units.normalize_ohlcv_units` 已做兜底换算,
但存量数据仍应择机修复,且新增写入不得再引入原始单位)。
"""
from hdiv.data.units import OHLCV_RAW, OHLCV_UNKNOWN, detect_ohlcv_units
df_max = db.read_sql("SELECT MAX(trade_date) AS d FROM stock_daily", cfg=cfg)
if df_max.empty or df_max["d"].iloc[0] is None:
return CheckResult("UNIT-OHLCV", "量价单位自检", WARN, "stock_daily", "stock_daily 无数据")
last = pd.to_datetime(df_max["d"].iloc[0]).date()
# 每年取一个样本日:覆盖回补区间与 qlib 存量区间
sample_days: list[date] = []
for y in range(last.year, max(last.year - 12, 2009), -1):
row = db.read_sql(
"SELECT MAX(trade_date) AS d FROM stock_daily WHERE YEAR(trade_date) = :y",
{"y": y}, cfg=cfg,
)
if not row.empty and row["d"].iloc[0] is not None:
sample_days.append(pd.to_datetime(row["d"].iloc[0]).date())
if not sample_days:
return CheckResult("UNIT-OHLCV", "量价单位自检", WARN, "stock_daily", "无法取样")
per_day: list[dict[str, Any]] = []
raw_days: list[str] = []
for d in sample_days:
s = db.read_sql(
"SELECT symbol, close, volume, amount FROM stock_daily WHERE trade_date = :d",
{"d": d}, cfg=cfg,
)
if s.empty:
continue
for c in ("close", "volume", "amount"):
s[c] = pd.to_numeric(s[c], errors="coerce")
unit = detect_ohlcv_units(s)
n_raw = int((unit == OHLCV_RAW).sum())
n_conv = int((unit != OHLCV_RAW).sum() - (unit == OHLCV_UNKNOWN).sum())
per_day.append({"date": str(d), "rows": len(s), "raw": n_raw,
"converted": n_conv,
"unknown": int((unit == OHLCV_UNKNOWN).sum())})
if n_raw:
raw_days.append(f"{d}({n_raw}/{len(s)} 行为原始单位)")
detail = {"sampled_days": per_day}
if raw_days:
return CheckResult(
"UNIT-OHLCV", "量价单位自检", WARN, "stock_daily",
"存在 Tushare 原始单位(手/千元)的行:" + ";".join(raw_days[:6])
+ "。读取层已兜底换算为「股/元」,但存量数据应择机重刷,"
"且新增写入必须走 sync.price.daily_frame 的换算路径。",
detail,
)
return CheckResult(
"UNIT-OHLCV", "量价单位自检", OK, "stock_daily",
f"{len(per_day)} 个抽样年份的量价单位一致(股/元)", detail,
)
def check_universe_ready(cfg: DataSourceConfig) -> CheckResult: def check_universe_ready(cfg: DataSourceConfig) -> CheckResult:
"""第一版策略所需的最小数据条件是否齐备。""" """第一版策略所需的最小数据条件是否齐备。"""
needed = { needed = {
@@ -575,6 +663,7 @@ ALL_CHECKS = [
check_g5b_index_weight, check_g5b_index_weight,
check_g6_trading, check_g6_trading,
check_units, check_units,
check_ohlcv_units,
check_duplicates, check_duplicates,
check_symbol_orphans, check_symbol_orphans,
check_universe_ready, check_universe_ready,
+110 -29
View File
@@ -29,6 +29,7 @@ from hdiv.data import db
from hdiv.data.units import ( from hdiv.data.units import (
normalize_financial_panel, normalize_financial_panel,
normalize_market_panel, normalize_market_panel,
normalize_ohlcv_units,
) )
@@ -47,6 +48,21 @@ class PanelSpec:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _symbol_filter(
symbols: list[str] | None, params: dict, col: str = "symbol"
) -> str:
"""构造 ``AND <col> IN (...)`` 片段并把值绑进 ``params``。
``symbols`` 为空(或 None)时返回空串 —— 与旧行为完全一致(全市场)。
这是**纯性能开关**:不改变任何 PIT 语义,只把全表扫描缩到目标股票。
"""
if not symbols:
return ""
ph = ", ".join(f":sym{i}" for i in range(len(symbols)))
params.update({f"sym{i}": s for i, s in enumerate(symbols)})
return f" AND {col} IN ({ph})"
def data_version(cfg: DataSourceConfig | None = None) -> str: def data_version(cfg: DataSourceConfig | None = None) -> str:
"""数据版本指纹 = 关键表的最大日期摘要,写入每次 run 以支持复现。 """数据版本指纹 = 关键表的最大日期摘要,写入每次 run 以支持复现。
@@ -231,6 +247,11 @@ class Repo:
none —— 不复权原始价 none —— 不复权原始价
qfq —— 前复权(以区间末为基准) qfq —— 前复权(以区间末为基准)
hfq —— 后复权(以区间首为基准) hfq —— 后复权(以区间首为基准)
成交量/成交额**一律归一化为「股 / 元」**再返回:``stock_daily`` 里
2015-2019 的行是 Tushare 原始单位(手 / 千元),2020 起是「股 / 元」,
直接使用会让按「元」写的流动性阈值低估 1000 倍。见
:func:`hdiv.data.units.normalize_ohlcv_units`。
""" """
if not symbols: if not symbols:
return pd.DataFrame(columns=["symbol", "trade_date", "close", "open", "high", "low"]) return pd.DataFrame(columns=["symbol", "trade_date", "close", "open", "high", "low"])
@@ -252,6 +273,7 @@ class Repo:
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date
for c in ("open", "high", "low", "close", "volume", "amount", "factor"): for c in ("open", "high", "low", "close", "volume", "amount", "factor"):
df[c] = pd.to_numeric(df[c], errors="coerce") df[c] = pd.to_numeric(df[c], errors="coerce")
df, _diag = normalize_ohlcv_units(df)
if adjust != "none": if adjust != "none":
f = df["factor"].fillna(1.0) f = df["factor"].fillna(1.0)
if adjust == "hfq": if adjust == "hfq":
@@ -276,20 +298,42 @@ class Repo:
df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date df["trade_date"] = pd.to_datetime(df["trade_date"]).dt.date
return df return df
def avg_amount(self, asof: date, window: int = 20) -> pd.DataFrame: def avg_amount(
"""asof 前 window 个交易日的日均成交额(流动性过滤)。""" self, asof: date, window: int = 20, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""asof 前 window 个交易日的日均成交额(流动性过滤)。
返回的 ``avg_amount`` 单位是**元**。为此必须逐行归一化后再取均值 ——
早期实现直接 ``AVG(amount)``,把 2015-2019 的「千元」与 2020 起的「元」
混在一起平均,且与按「元」配置的阈值比较,低估 1000 倍。
实测后果:``min_avg_amount_20d: 20000000`` 在 2015-2019 实际等价于
「日均成交额 ≥ 200 亿元」,股票池被整体清空。
``symbols`` 为纯性能开关(全市场约 10 万行;限定几十只股票后降到毫秒级)。
"""
d0 = self.trading_day(asof) d0 = self.trading_day(asof)
days = self.trading_days(d0 - timedelta(days=window * 3), d0) days = self.trading_days(d0 - timedelta(days=window * 3), d0)
days = days[-window:] days = days[-window:]
if not days: if not days:
return pd.DataFrame(columns=["symbol", "avg_amount"]) return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
params: dict = {"s": days[0], "e": days[-1]}
sym_in = _symbol_filter(symbols, params)
df = db.read_sql( df = db.read_sql(
"SELECT symbol, AVG(amount) AS avg_amount, COUNT(*) AS n " "SELECT symbol, close, volume, amount "
"FROM stock_daily WHERE trade_date BETWEEN :s AND :e GROUP BY symbol", "FROM stock_daily WHERE trade_date BETWEEN :s AND :e" + sym_in,
{"s": days[0], "e": days[-1]}, params,
cfg=self.cfg, cfg=self.cfg,
) )
return df if df.empty:
return pd.DataFrame(columns=["symbol", "avg_amount", "n"])
for c in ("close", "volume", "amount"):
df[c] = pd.to_numeric(df[c], errors="coerce")
df, _diag = normalize_ohlcv_units(df)
# n 沿用 COUNT(*) 语义(该窗口内有行情的交易日数),与归一化无关
g = df.groupby("symbol", as_index=False).agg(
avg_amount=("amount", "mean"), n=("amount", "size")
)
return g
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 停牌 / 涨跌停 # 停牌 / 涨跌停
@@ -320,24 +364,31 @@ class Repo:
# 财务数据(PIT) # 财务数据(PIT)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def financial_panel(self, asof: date) -> pd.DataFrame: def financial_panel(
self, asof: date, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""asof 时点可见的最新一期财务数据(``ann_date <= asof``)。 """asof 时点可见的最新一期财务数据(``ann_date <= asof``)。
实现要点:用窗口函数取「报告期最新」的那条; 实现要点:用窗口函数取「报告期最新」的那条;
``ann_date <= asof`` 保证不使用未公告数据。 ``ann_date <= asof`` 保证不使用未公告数据。
``symbols`` 非空时只取这些股票 —— 纯粹的性能开关(财务表各约 30 万行,
全表扫描约 1.7 秒;限定几十只股票后降到毫秒级)。**不改变 PIT 语义**:
它只是在原有 WHERE 上追加一个 ``symbol IN (...)``,不会改变任何
(symbol, end_date) 分区内的 ``ROW_NUMBER`` 结果。
""" """
fin = self._latest_financial(asof, "hd_fina_indicator", ["roe", "roic", "debt_to_assets", fin = self._latest_financial(asof, "hd_fina_indicator", ["roe", "roic", "debt_to_assets",
"grossprofit_margin", "netprofit_margin", "grossprofit_margin", "netprofit_margin",
"ocf_to_profit"]) "ocf_to_profit"], symbols=symbols)
if fin.empty: if fin.empty:
return fin return fin
cf = self._latest_financial(asof, "hd_cashflow", ["n_cashflow_act", "free_cashflow", cf = self._latest_financial(asof, "hd_cashflow", ["n_cashflow_act", "free_cashflow",
"c_pay_dist_dpcp_int_exp"]) "c_pay_dist_dpcp_int_exp"], symbols=symbols)
bs = self._latest_financial(asof, "hd_balancesheet", ["total_assets", "total_liab", bs = self._latest_financial(asof, "hd_balancesheet", ["total_assets", "total_liab",
"total_hldr_eqy_exc_min_int", "total_hldr_eqy_exc_min_int",
"money_cap", "goodwill"]) "money_cap", "goodwill"], symbols=symbols)
inc = self._latest_financial(asof, "hd_income", ["total_revenue", "revenue", "n_income", inc = self._latest_financial(asof, "hd_income", ["total_revenue", "revenue", "n_income",
"n_income_attr_p"]) "n_income_attr_p"], symbols=symbols)
out = fin out = fin
for other in (cf, bs, inc): for other in (cf, bs, inc):
if other.empty: if other.empty:
@@ -352,7 +403,10 @@ class Repo:
out = self._derive_financial(out) out = self._derive_financial(out)
return out return out
def _latest_financial(self, asof: date, table: str, cols: list[str]) -> pd.DataFrame: def _latest_financial(
self, asof: date, table: str, cols: list[str],
*, symbols: list[str] | None = None,
) -> pd.DataFrame:
if not db.table_exists(table, self.cfg): if not db.table_exists(table, self.cfg):
return pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols]) return pd.DataFrame(columns=["symbol", "end_date", "ann_date", *cols])
rt = ( rt = (
@@ -361,6 +415,8 @@ class Repo:
else "" else ""
) )
sel = ", ".join(cols) sel = ", ".join(cols)
params: dict = {"asof": asof}
sym_cond = _symbol_filter(symbols, params)
sql = f""" sql = f"""
SELECT symbol, end_date, ann_date, {sel} FROM ( SELECT symbol, end_date, ann_date, {sel} FROM (
SELECT symbol, end_date, ann_date, {sel}, SELECT symbol, end_date, ann_date, {sel},
@@ -368,13 +424,13 @@ class Repo:
PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC PARTITION BY symbol ORDER BY end_date DESC, ann_date DESC
) AS rn ) AS rn
FROM {table} FROM {table}
WHERE ann_date <= :asof {rt} WHERE ann_date <= :asof {rt} {sym_cond}
-- 公告日不可能早于报告期;这类行是数据源错误(实测 920185.BJ 有 2 条), -- 公告日不可能早于报告期;这类行是数据源错误(实测 920185.BJ 有 2 条),
-- 若不过滤会构成未来函数。审计中仍会如实报告其数量。 -- 若不过滤会构成未来函数。审计中仍会如实报告其数量。
AND ann_date >= end_date AND ann_date >= end_date
) t WHERE rn = 1 ) t WHERE rn = 1
""" """
df = db.read_sql(sql, {"asof": asof}, cfg=self.cfg) df = db.read_sql(sql, params, cfg=self.cfg)
for c in ("end_date", "ann_date"): for c in ("end_date", "ann_date"):
if c in df.columns and not df.empty: if c in df.columns and not df.empty:
df[c] = pd.to_datetime(df[c]).dt.date df[c] = pd.to_datetime(df[c]).dt.date
@@ -449,7 +505,9 @@ class Repo:
df[c] = pd.to_numeric(df[c], errors="coerce") df[c] = pd.to_numeric(df[c], errors="coerce")
return df return df
def annual_financial_history(self, asof: date, *, years: int = 6) -> pd.DataFrame: def annual_financial_history(
self, asof: date, *, years: int = 6, symbols: list[str] | None = None
) -> pd.DataFrame:
"""asof 时点可见的**年度**财务指标历史(用于 5 年平均等长期口径)。 """asof 时点可见的**年度**财务指标历史(用于 5 年平均等长期口径)。
为什么必须用年报而不是最新季报: 为什么必须用年报而不是最新季报:
@@ -458,22 +516,29 @@ class Repo:
会把几乎所有好公司误杀(实测:600036.SH 的 2024Q1 ROE 仅 3.47%)。 会把几乎所有好公司误杀(实测:600036.SH 的 2024Q1 ROE 仅 3.47%)。
因此这里只取 ``end_date`` 为 12-31 的年报,且 ``ann_date <= asof``(PIT)。 因此这里只取 ``end_date`` 为 12-31 的年报,且 ``ann_date <= asof``(PIT)。
``symbols`` 是纯性能开关(见 :func:`_symbol_filter`):内层子查询与外层
用同一个 symbol 过滤条件,因此不会改变任何 ``(symbol, end_date)`` 分组
的 ``MAX(ann_date)``,结果与全市场口径逐行一致。
""" """
if not db.table_exists("hd_fina_indicator", self.cfg): if not db.table_exists("hd_fina_indicator", self.cfg):
return pd.DataFrame(columns=["symbol", "year", "roe", "roic"]) return pd.DataFrame(columns=["symbol", "year", "roe", "roic"])
since = date(asof.year - years - 1, 12, 31) since = date(asof.year - years - 1, 12, 31)
params: dict = {"asof": asof, "since": since}
sym_in = _symbol_filter(symbols, params)
sym_in_f = _symbol_filter(symbols, params, col="f.symbol")
fin = db.read_sql( fin = db.read_sql(
"SELECT f.symbol, f.end_date, f.ann_date, f.roe, f.roic, f.grossprofit_margin, " "SELECT f.symbol, f.end_date, f.ann_date, f.roe, f.roic, f.grossprofit_margin, "
" f.netprofit_margin, f.ocf_to_profit " " f.netprofit_margin, f.ocf_to_profit "
"FROM hd_fina_indicator f " "FROM hd_fina_indicator f "
"JOIN (SELECT symbol, end_date, MAX(ann_date) AS a FROM hd_fina_indicator " "JOIN (SELECT symbol, end_date, MAX(ann_date) AS a FROM hd_fina_indicator "
" WHERE ann_date <= :asof AND MONTH(end_date) = 12 AND end_date >= :since " " WHERE ann_date <= :asof AND MONTH(end_date) = 12 AND end_date >= :since "
" AND ann_date >= end_date " " AND ann_date >= end_date" + sym_in + " "
" GROUP BY symbol, end_date) m " " GROUP BY symbol, end_date) m "
" ON m.symbol = f.symbol AND m.end_date = f.end_date AND m.a = f.ann_date " " ON m.symbol = f.symbol AND m.end_date = f.end_date AND m.a = f.ann_date "
"WHERE MONTH(f.end_date) = 12 AND f.end_date >= :since", "WHERE MONTH(f.end_date) = 12 AND f.end_date >= :since" + sym_in_f,
{"asof": asof, "since": since}, params,
cfg=self.cfg, cfg=self.cfg,
) )
if fin.empty: if fin.empty:
@@ -484,23 +549,27 @@ class Repo:
fin[c] = pd.to_numeric(fin[c], errors="coerce") / 100.0 fin[c] = pd.to_numeric(fin[c], errors="coerce") / 100.0
# 经营现金流/净利润:用现金流量表与利润表年报口径补算(比 fina_indicator 更可靠) # 经营现金流/净利润:用现金流量表与利润表年报口径补算(比 fina_indicator 更可靠)
ocf = self._annual_ocf_ratio(asof, since) ocf = self._annual_ocf_ratio(asof, since, symbols=symbols)
if not ocf.empty: if not ocf.empty:
fin = fin.merge(ocf, on=["symbol", "year"], how="left") fin = fin.merge(ocf, on=["symbol", "year"], how="left")
return fin return fin
def _annual_ocf_ratio(self, asof: date, since: date) -> pd.DataFrame: def _annual_ocf_ratio(
self, asof: date, since: date, *, symbols: list[str] | None = None
) -> pd.DataFrame:
"""年报口径的 经营现金流 / 归母净利润。""" """年报口径的 经营现金流 / 归母净利润。"""
if not (db.table_exists("hd_cashflow", self.cfg) and db.table_exists("hd_income", self.cfg)): if not (db.table_exists("hd_cashflow", self.cfg) and db.table_exists("hd_income", self.cfg)):
return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"]) return pd.DataFrame(columns=["symbol", "year", "ocf_to_netprofit_calc"])
params: dict = {"asof": asof, "since": since}
sym_in = _symbol_filter(symbols, params, col="c.symbol")
df = db.read_sql( df = db.read_sql(
"SELECT c.symbol, c.end_date, c.n_cashflow_act, i.n_income_attr_p " "SELECT c.symbol, c.end_date, c.n_cashflow_act, i.n_income_attr_p "
"FROM hd_cashflow c " "FROM hd_cashflow c "
"JOIN hd_income i ON i.symbol = c.symbol AND i.end_date = c.end_date " "JOIN hd_income i ON i.symbol = c.symbol AND i.end_date = c.end_date "
" AND i.ann_date = c.ann_date AND i.report_type = c.report_type " " AND i.ann_date = c.ann_date AND i.report_type = c.report_type "
"WHERE c.report_type = '1' AND MONTH(c.end_date) = 12 " "WHERE c.report_type = '1' AND MONTH(c.end_date) = 12 "
" AND c.end_date >= :since AND c.ann_date <= :asof", " AND c.end_date >= :since AND c.ann_date <= :asof" + sym_in,
{"asof": asof, "since": since}, params,
cfg=self.cfg, cfg=self.cfg,
) )
if df.empty: if df.empty:
@@ -512,7 +581,8 @@ class Repo:
return df[["symbol", "year", "ocf_to_netprofit_calc"]] return df[["symbol", "year", "ocf_to_netprofit_calc"]]
def annual_financial_averages( def annual_financial_averages(
self, asof: date, *, years: int = 5, min_years: int = 3 self, asof: date, *, years: int = 5, min_years: int = 3,
symbols: list[str] | None = None, hist: pd.DataFrame | None = None,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""把年报历史聚合成「N 年平均」指标。 """把年报历史聚合成「N 年平均」指标。
@@ -520,8 +590,14 @@ class Repo:
``net_margin_avg`` / ``ocf_to_profit_avg`` / ``fin_years_count`` / ``net_margin_avg`` / ``ocf_to_profit_avg`` / ``fin_years_count`` /
``fin_latest_year``。样本年数不足 ``min_years`` 时对应平均值为 NaN ``fin_latest_year``。样本年数不足 ``min_years`` 时对应平均值为 NaN
(宁可标注「不可得」,也不要用不足的样本猜)。 (宁可标注「不可得」,也不要用不足的样本猜)。
``hist`` 允许调用方传入已取好的 :meth:`annual_financial_history` 结果,
避免同一时点重复查询(PIT 画像逐日调用时这是 2 倍开销)。
""" """
hist = self.annual_financial_history(asof, years=years) hist = (
self.annual_financial_history(asof, years=years, symbols=symbols)
if hist is None else hist
)
if hist.empty: if hist.empty:
return pd.DataFrame( return pd.DataFrame(
columns=["symbol", "roe_avg", "roic_avg", "gross_margin_avg", columns=["symbol", "roe_avg", "roic_avg", "gross_margin_avg",
@@ -552,7 +628,9 @@ class Repo:
out.loc[thin, c] = pd.NA out.loc[thin, c] = pd.NA
return out return out
def annual_financials(self, asof: date, *, years: int = 12) -> pd.DataFrame: def annual_financials(
self, asof: date, *, years: int = 12, symbols: list[str] | None = None
) -> pd.DataFrame:
"""按**财年**对齐的年度财务数据(PIT)。 """按**财年**对齐的年度财务数据(PIT)。
为什么必须单独提供:``financial_panel`` 返回的是**最新一期**财报 为什么必须单独提供:``financial_panel`` 返回的是**最新一期**财报
@@ -562,7 +640,7 @@ class Repo:
返回列:``symbol / year / n_income_attr_p / n_cashflow_act / 返回列:``symbol / year / n_income_attr_p / n_cashflow_act /
free_cashflow / c_pay_dist_dpcp_int_exp``,仅取年报(``MONTH(end_date)=12``), free_cashflow / c_pay_dist_dpcp_int_exp``,仅取年报(``MONTH(end_date)=12``),
且 ``ann_date <= asof``。 且 ``ann_date <= asof``。``symbols`` 为纯性能开关。
""" """
if not (db.table_exists("hd_income", self.cfg) and db.table_exists("hd_cashflow", self.cfg)): if not (db.table_exists("hd_income", self.cfg) and db.table_exists("hd_cashflow", self.cfg)):
return pd.DataFrame( return pd.DataFrame(
@@ -570,6 +648,8 @@ class Repo:
"free_cashflow", "c_pay_dist_dpcp_int_exp"] "free_cashflow", "c_pay_dist_dpcp_int_exp"]
) )
since = date(asof.year - years - 1, 12, 31) since = date(asof.year - years - 1, 12, 31)
params: dict = {"asof": asof, "since": since}
sym_in = _symbol_filter(symbols, params, col="i.symbol")
df = db.read_sql( df = db.read_sql(
"SELECT i.symbol, i.end_date, i.n_income, i.n_income_attr_p, " "SELECT i.symbol, i.end_date, i.n_income, i.n_income_attr_p, "
" c.n_cashflow_act, c.free_cashflow, c.c_pay_dist_dpcp_int_exp " " c.n_cashflow_act, c.free_cashflow, c.c_pay_dist_dpcp_int_exp "
@@ -577,8 +657,9 @@ class Repo:
"LEFT JOIN hd_cashflow c ON c.symbol = i.symbol AND c.end_date = i.end_date " "LEFT JOIN hd_cashflow c ON c.symbol = i.symbol AND c.end_date = i.end_date "
" AND c.ann_date = i.ann_date AND c.report_type = i.report_type " " AND c.ann_date = i.ann_date AND c.report_type = i.report_type "
"WHERE i.report_type = '1' AND MONTH(i.end_date) = 12 " "WHERE i.report_type = '1' AND MONTH(i.end_date) = 12 "
" AND i.end_date >= :since AND i.ann_date <= :asof AND i.ann_date >= i.end_date", " AND i.end_date >= :since AND i.ann_date <= :asof AND i.ann_date >= i.end_date"
{"asof": asof, "since": since}, + sym_in,
params,
cfg=self.cfg, cfg=self.cfg,
) )
if df.empty: if df.empty:
+73 -10
View File
@@ -21,6 +21,7 @@ from hdiv.core.config import DataSourceConfig, load_config
from hdiv.data import db from hdiv.data import db
from hdiv.data.sync.base import sync_job, to_date, to_float, upsert from hdiv.data.sync.base import sync_job, to_date, to_float, upsert
from hdiv.data.tushare_client import TushareClient from hdiv.data.tushare_client import TushareClient
from hdiv.data.units import amount_qian_to_yuan, vol_shou_to_shares
EXCHANGES = ("SSE", "SZSE", "BSE") EXCHANGES = ("SSE", "SZSE", "BSE")
EXCHANGE_CODE = {"SSE": "SH", "SZSE": "SZ", "BSE": "BJ"} EXCHANGE_CODE = {"SSE": "SH", "SZSE": "SZ", "BSE": "BJ"}
@@ -60,19 +61,59 @@ def open_days(start: date, end: date, cfg: DataSourceConfig | None = None) -> li
#: 一个交易日被视为「已同步完成」所需的最少股票数。 #: 一个交易日被视为「已同步完成」所需的最少股票数。
#: A 股自 2015 年起每个交易日都有 2,000 只以上在交易。 #: 保留为**绝对下限**:A 股自 2015 年起每个交易日都有 2,000 只以上在交易。
#: 早期实现只看「该日期是否存在」,会把**只填了几百只**的半成品日当成已完成 #: 早期实现只看「该日期是否存在」,会把**只填了几百只**的半成品日当成已完成
#: (实测:原 qlib 数据在 2019 年仅 243 只/日,被误判为已同步,形成整年数据空洞)。 #: (实测:原 qlib 数据在 2019 年仅 243 只/日,被误判为已同步,形成整年数据空洞)。
#: 但现在**不再单独使用它** —— 2005 年 A 股只有约 1,350 只,固定 1,500 会让
#: 2005-2009 的每一天都判为「未完成」,断点续传失效。实际阈值见
#: :func:`_day_threshold`:``max(绝对下限, 比例 × 当年应有上市股票数)``。
MIN_SYMBOLS_PER_DAY = 1500 MIN_SYMBOLS_PER_DAY = 1500
def _expected_symbols_by_year(cfg: DataSourceConfig) -> dict[int, int]:
"""``{年份: 该年末累计上市股票数}``(来自 ``stock.list_date``,独立于行情表)。
用独立的 ``stock`` 表当参照,而不是行情表自身的观测数 —— 后者是循环论证:
整段缺失的年份根本没有行,无从判断它「应该有」多少。
忽略退市会让这个数偏大(是上界),0.6 的比例留了足够余量。
"""
try:
df = db.read_sql(
"SELECT YEAR(list_date) AS y, COUNT(*) AS n FROM stock "
"WHERE list_date IS NOT NULL AND YEAR(list_date) > 1990 GROUP BY y",
cfg=cfg,
)
except Exception:
return {}
if df.empty:
return {}
counts = {int(r["y"]): int(r["n"]) for _, r in df.iterrows()}
cum, out = 0, {}
for y in range(min(counts), max(counts) + 1):
cum += counts.get(y, 0)
out[y] = cum
return out
def _day_thresholds(cfg: DataSourceConfig) -> tuple[int, float, dict[int, int]]:
scfg = getattr(cfg, "sync", None)
floor = int(getattr(scfg, "min_symbols_floor", 200))
ratio = float(getattr(scfg, "min_symbols_ratio", 0.6))
return floor, ratio, _expected_symbols_by_year(cfg)
def fetched_days( def fetched_days(
table: str, start: date, end: date, cfg: DataSourceConfig, *, min_symbols: int = MIN_SYMBOLS_PER_DAY table: str, start: date, end: date, cfg: DataSourceConfig,
*, min_symbols: int | None = None,
) -> set[date]: ) -> set[date]:
"""**数据完整**的交易日集合(用于断点续传)。 """**数据完整**的交易日集合(用于断点续传)。
判定标准是「当日股票数 >= min_symbols」,而不是「当日是否存在行」—— 判定标准是「当日股票数 >= 该日应有的规模」,而不是「当日是否存在行」——
否则半成品日期会被跳过,留下难以察觉的数据空洞。 否则半成品日期会被跳过,留下难以察觉的数据空洞。
阈值随年份变化(见 ``SyncConfig``):早年 A 股只有一千多只股票,
固定阈值会把 2005-2009 的每一天都判成「未完成」,断点续传失效。
``min_symbols`` 显式给定时按旧口径(固定阈值)判定,保持向后兼容。
""" """
df = db.read_sql( df = db.read_sql(
f"SELECT trade_date AS d, COUNT(DISTINCT symbol) AS n FROM `{table}` " f"SELECT trade_date AS d, COUNT(DISTINCT symbol) AS n FROM `{table}` "
@@ -82,11 +123,19 @@ def fetched_days(
) )
if df.empty: if df.empty:
return set() return set()
return { floor, ratio, by_year = _day_thresholds(cfg)
to_date(r["d"]) # type: ignore[misc] out: set[date] = set()
for _, r in df.iterrows() for _, r in df.iterrows():
if int(r["n"]) >= min_symbols d = to_date(r["d"])
} if d is None:
continue
if min_symbols is not None:
need = int(min_symbols)
else:
need = max(floor, int(ratio * by_year.get(d.year, 0)))
if int(r["n"]) >= need:
out.add(d)
return out
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -121,6 +170,14 @@ def _tp(rows: list[dict]) -> tuple[list[date], list[str]]:
def daily_frame(rows: list[dict]) -> pd.DataFrame: def daily_frame(rows: list[dict]) -> pd.DataFrame:
"""Tushare ``daily`` → ``stock_daily`` 行,**并统一量价单位**。
Tushare 的 ``vol`` 是「手」、``amount`` 是「千元」,而既有的 ``stock_daily``
(qlib 存量数据)是「股 / 元」。早期实现原样写入,于是同一列在 2015-2019 与
2020 起是两套单位,按「元」写的流动性阈值在早年低估 1000 倍。
这里在写入端就换算,读取端的 :func:`hdiv.data.units.normalize_ohlcv_units`
作为存量数据的兜底(对已换算行幂等)。
"""
if not rows: if not rows:
return pd.DataFrame() return pd.DataFrame()
td, sym = _tp(rows) td, sym = _tp(rows)
@@ -133,8 +190,14 @@ def daily_frame(rows: list[dict]) -> pd.DataFrame:
"high": [to_float(r.get("high")) for r in rows], "high": [to_float(r.get("high")) for r in rows],
"low": [to_float(r.get("low")) for r in rows], "low": [to_float(r.get("low")) for r in rows],
"close": [to_float(r.get("close")) for r in rows], "close": [to_float(r.get("close")) for r in rows],
"volume": [to_float(r.get("vol")) for r in rows], # 手 → 股
"amount": [to_float(r.get("amount")) for r in rows], "volume": vol_shou_to_shares(
pd.Series([to_float(r.get("vol")) for r in rows], dtype="float64")
),
# 千元 → 元
"amount": amount_qian_to_yuan(
pd.Series([to_float(r.get("amount")) for r in rows], dtype="float64")
),
"source": "tushare", "source": "tushare",
"adjust": "none", "adjust": "none",
} }
+8 -1
View File
@@ -81,7 +81,14 @@ def sync_trading_constraints(
cfg = load_config("datasource") cfg = load_config("datasource")
days = open_days(start, end, cfg) days = open_days(start, end, cfg)
if resume: if resume:
done_s = fetched_days(SUSPEND_TABLE, start, end, cfg) # 两张表的「完整性」判定不能用同一把尺子:
# hd_limit —— 每个交易日有全市场约 2,600~3,500 行,可用按年份的规模阈值;
# hd_suspend —— 每天只有**当天停牌的那几十只**(实测 18~260 行),
# 永远达不到「市场规模的 60%」,于是每一天都被判成「未完成」,
# 断点续传彻底失效(每次重跑都重新拉全部停牌日)。
# 停牌表只能退化为「有行即视为已同步」;真正无停牌的交易日会被重复拉取,
# 代价极小(每天 1 次调用且返回空)。
done_s = fetched_days(SUSPEND_TABLE, start, end, cfg, min_symbols=1)
done_l = fetched_days(LIMIT_TABLE, start, end, cfg) done_l = fetched_days(LIMIT_TABLE, start, end, cfg)
days_s = [d for d in days if d not in done_s] days_s = [d for d in days if d not in done_s]
days_l = [d for d in days if d not in done_l] days_l = [d for d in days if d not in done_l]
+105
View File
@@ -66,6 +66,111 @@ def vol_shou_to_shares(s: pd.Series) -> pd.Series:
return pd.to_numeric(s, errors="coerce") * SHOU return pd.to_numeric(s, errors="coerce") * SHOU
# ---------------------------------------------------------------------------
# stock_daily 的量价单位(历史遗留:同一列混着两种单位)
# ---------------------------------------------------------------------------
#: ``stock_daily`` 中一行已经处于本项目统一口径(成交量=股,成交额=元)。
OHLCV_CONVERTED = "converted"
#: ``stock_daily`` 中一行仍是 Tushare 原始口径(成交量=手,成交额=千元)。
OHLCV_RAW = "raw"
#: 无法判定(缺 close/volume/amount,或非正值)。
OHLCV_UNKNOWN = "unknown"
#: raw 与 converted 的比值相差正好 10 倍(见 :func:`ohlcv_unit_ratio`),
#: 而 A 股有 ±10% 涨跌幅限制使 VWAP/收盘价落在 0.9~1.1,所以 0.3 这个阈值
#: 有 ~3 倍的安全边际 —— 不会把正常行情误判成另一种单位。
_RAW_MAX_RATIO = 0.3
def ohlcv_unit_ratio(df: pd.DataFrame) -> pd.Series:
"""``成交额 / (成交量 × 收盘价)``:≈1 表示已换算,≈0.1 表示 Tushare 原始单位。
这是唯一可靠的判据:列名完全看不出单位,而量级会。推导:
- 统一口径(股 / 元):``amount / (volume × close) = 1``
- 原始口径(手 / 千元):``amount / (volume × close) = 100 / 1000 = 0.1``
``VWAP = amount / volume`` 与 ``close`` 的比值受涨跌幅限制约束,
因此该比值只有 1 或 0.1 两个可能,不存在中间态。
"""
need = {"amount", "volume", "close"}
if df.empty or not need <= set(df.columns):
return pd.Series(dtype="float64", index=df.index)
amt = pd.to_numeric(df["amount"], errors="coerce")
vol = pd.to_numeric(df["volume"], errors="coerce")
close = pd.to_numeric(df["close"], errors="coerce")
denom = vol * close
ok = (denom > 0) & amt.notna()
out = pd.Series(float("nan"), index=df.index, dtype="float64")
out[ok] = amt[ok] / denom[ok]
return out
def detect_ohlcv_units(df: pd.DataFrame) -> pd.Series:
"""逐行判定 ``stock_daily`` 的量价单位,返回 ``raw`` / ``converted`` / ``unknown``。"""
if df.empty:
return pd.Series(dtype="object", index=df.index)
r = ohlcv_unit_ratio(df)
out = pd.Series(OHLCV_UNKNOWN, index=df.index, dtype="object")
out[r.notna() & (r > _RAW_MAX_RATIO)] = OHLCV_CONVERTED
out[r.notna() & (r <= _RAW_MAX_RATIO)] = OHLCV_RAW
return out
def normalize_ohlcv_units(
df: pd.DataFrame,
*,
volume_col: str = "volume",
amount_col: str = "amount",
close_col: str = "close",
) -> tuple[pd.DataFrame, dict[str, Any]]:
"""把 ``stock_daily`` 的成交量/成交额统一到「股 / 元」。
**为什么必须在读取时做**:``stock_daily`` 是「追加进既有 qlib 库」的表 ——
2015-01~2019 的行由本项目从 Tushare 回补,写的是原始单位(手 / 千元);
2020 起的行沿用 qlib 既有数据(股 / 元);2019 年同日混着两种。
而 ``min_avg_amount_20d`` 这类阈值是按「元」写的,于是 2015-2019 的
20 日均额被低估 1000 倍 —— 流动性门槛实际变成「日均成交额 ≥ 200 亿元」,
把 2015-2019 的股票池整体清空(实测 2016/2017/2018 各筛选出 0 只)。
该函数是**幂等**的:已换算的行比值 ≈1,不会被二次换算。
返回 ``(新 DataFrame, 诊断信息)``,不修改入参。
"""
if df.empty:
return df, {"total": 0, "raw": 0, "converted": 0, "unknown": 0, "fixed": 0}
for col in (volume_col, amount_col, close_col):
if col not in df.columns:
# 缺少任一列都无法判定单位,只能原样返回(并在诊断里体现)
return df, {
"total": int(len(df)), "raw": 0, "converted": 0,
"unknown": int(len(df)), "fixed": 0,
"error": f"缺少列 {col},无法判定单位",
}
unit = detect_ohlcv_units(
df.rename(columns={volume_col: "volume", amount_col: "amount",
close_col: "close"})
)
out = df.copy()
raw_mask = unit.to_numpy() == OHLCV_RAW
if raw_mask.any():
out.loc[raw_mask, volume_col] = (
pd.to_numeric(out.loc[raw_mask, volume_col], errors="coerce") * SHOU
)
out.loc[raw_mask, amount_col] = (
pd.to_numeric(out.loc[raw_mask, amount_col], errors="coerce") * QIAN
)
counts = unit.value_counts()
diag = {
"total": int(len(df)),
"raw": int(counts.get(OHLCV_RAW, 0)),
"converted": int(counts.get(OHLCV_CONVERTED, 0)),
"unknown": int(counts.get(OHLCV_UNKNOWN, 0)),
"fixed": int(raw_mask.sum()),
}
return out, diag
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 面板归一化 # 面板归一化
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+93 -30
View File
@@ -24,6 +24,37 @@ import pandas as pd
TTM_DAYS = 365 TTM_DAYS = 365
def ttm_params() -> tuple[int, int, bool]:
"""读取 TTM 股息率的统一参数 ``(window_days, grace_days, smooth_spikes)``。
**单一事实来源**:筛选、画像、回测、Web 前端四处都用这一份参数。
早期实现里四处各写各的 —— 画像读配置、回测硬编码 365/45、
walk-forward 与 Web 用函数默认值 —— 同一个「股息率」在不同环节定义不同,
改了配置只有画像会变。现在统一从这里取。
"""
from hdiv.core.config import load_config
c = load_config("profile").ttm_dividend
return int(c.window_days), int(c.grace_days), bool(
getattr(c, "smooth_spikes", True)
)
def ttm_dps_at(asof: date, events: pd.DataFrame) -> float | None:
"""单点 TTM 每股分红(供筛选器等只需要一个时点的场景使用)。
与 ``ttm_dps_series`` 用同一个实现,避免「筛选一个口径、画像另一个口径」。
"""
if events is None or events.empty:
return None
w, g, sm = ttm_params()
# 用「asof 之前一年半」的稀疏日期轴求值:series 的语义是右端点取值,
# 这里只要 asof 当天的值
idx = pd.DatetimeIndex([pd.Timestamp(asof)])
v = ttm_dps_series(idx, events, ttm_days=w, grace_days=g, smooth_spikes=sm)
return float(v[0]) if len(v) else None
def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]: def build_dps_events(dividends: pd.DataFrame) -> dict[str, pd.DataFrame]:
"""按股票整理分红事件(只保留现金分红 > 0)。 """按股票整理分红事件(只保留现金分红 > 0)。
@@ -46,19 +77,35 @@ def ttm_dps_series(
*, *,
ttm_days: int = TTM_DAYS, ttm_days: int = TTM_DAYS,
grace_days: int = 45, grace_days: int = 45,
smooth_spikes: bool = True,
) -> np.ndarray: ) -> np.ndarray:
"""给定日期序列,向量化计算每一天的 TTM 每股分红。 """给定日期序列,向量化计算每一天的 TTM 每股分红。
**为什么需要 grace_days**:A 股年度分红的除权间隔中位数约 **366 天** **毛刺从哪来**:A 股相邻两次除权的间隔经常不是 365 天(实测招商银行
(实测招商银行 5 次间隔 > 365 天,最长 393 天)。若严格用 365 天窗口, 14 次分红中多次落在 355~395 天)。硬 365 天窗口于是在每年除权日附近
每年都会出现 1~3 天的「空窗期」,股息率被算成 0 —— 这是统计假象, 制造出两种假象:
会拉低 min 与低分位,进而污染「历史分位」这一核心信号。
因此:先用严格 ``ttm_days`` 窗口计算;仅当结果为零时, - **重叠虚高**:间隔 < 365 天时,新分红入场而旧的尚未到期,两者同时在窗口内。
回退到 ``ttm_days + grace_days`` 的窗口。公司真正停止分红时, 实测招商银行 2015-07-03:0.620 → 1.290(+108%),10 天后回落到 0.670。
超过宽限期后两者都会归零,不会被误判为仍在分红。 - **断档虚低**:间隔 > 365 天时,旧的已到期而新的尚未入场。
实测中国神华 2016-07-04:0.740 → 0.320(−57%)。
实现为对每个事件做区间增量累加,复杂度 O(n + m)。 两者都是日历假象而非分红能力变化,却会直接污染「历史分位」这一核心信号
(虚高点拉高分位、虚低点压低 min 与低分位)。
**修法**:把「硬窗口」换成「按后继接管」。对每次分红 i:
- 若与下一次分红的间隔 ``gap >= ttm_days - grace_days``,视为**同一档年度分红**,
计入区间延到 ``min(下一次除权日, 除权日 + ttm_days + grace_days)``:
间隔略小于一年 → 由后继提前接管,**消除重叠虚高**;
间隔略大于一年 → 旧的一直计到新的入场,**填补断档虚低**;
超过 ``ttm_days + grace_days`` 仍无后继(真停发)→ 封顶,如实归零。
- 若 ``gap < ttm_days - grace_days``,视为**年内多次分红**(中期+年度),
彼此不取代,各自保留标准 ``ttm_days`` 窗口 —— 否则会把中期分红误删,
人为制造出新的低点。
- 最后一次分红没有后继:沿用宽限期兜底(与旧行为一致)。
``grace_days`` 现在同时承担两件事:判定「同一档」的容差,以及真停发时的兜底宽度。
""" """
n = len(dates) n = len(dates)
if n == 0: if n == 0:
@@ -71,28 +118,39 @@ def ttm_dps_series(
dps = events["cash_div_tax"].to_numpy(dtype="float64") dps = events["cash_div_tax"].to_numpy(dtype="float64")
d = dates.to_numpy(dtype="datetime64[ns]") d = dates.to_numpy(dtype="datetime64[ns]")
def accumulate(window_days: int) -> np.ndarray: span_strict = np.timedelta64(ttm_days, "D")
span = np.timedelta64(window_days, "D") span_ext = np.timedelta64(ttm_days + max(0, grace_days), "D")
acc = np.zeros(n, dtype="float64") # 「同一档年度分红」的判定阈值:间隔小于它即视为年内多次分红
for e in range(len(ex)): same_slot_min = np.timedelta64(max(0, ttm_days - max(0, grace_days)), "D")
if np.isnat(ex[e]):
continue
start = int(np.searchsorted(d, ex[e], side="left"))
end = int(np.searchsorted(d, ex[e] + span, side="left"))
if not np.isnat(imp[e]):
start = max(start, int(np.searchsorted(d, imp[e], side="left")))
if end > start:
acc[start:end] += dps[e]
return acc
strict = accumulate(ttm_days) acc = np.zeros(n, dtype="float64")
if grace_days <= 0: m = len(ex)
return strict for i in range(m):
gap = strict == 0 if np.isnat(ex[i]):
if not gap.any(): continue
return strict
relaxed = accumulate(ttm_days + grace_days) if not smooth_spikes:
return np.where(gap, relaxed, strict) end_ts = ex[i] + span_strict
elif i + 1 < m and not np.isnat(ex[i + 1]):
gap = ex[i + 1] - ex[i]
if gap >= same_slot_min:
# 同一档:由后继接管,但不超过宽限期封顶
end_ts = min(ex[i + 1], ex[i] + span_ext)
else:
# 年内多次分红:保留标准窗口,互不取代
end_ts = ex[i] + span_strict
else:
# 最后一次分红:宽限期兜底
end_ts = ex[i] + span_ext
start = int(np.searchsorted(d, ex[i], side="left"))
if not np.isnat(imp[i]):
# PIT:公告日之前不可见
start = max(start, int(np.searchsorted(d, imp[i], side="left")))
end = int(np.searchsorted(d, end_ts, side="left"))
if end > start:
acc[start:end] += dps[i]
return acc
def dividend_yield_series( def dividend_yield_series(
@@ -101,11 +159,15 @@ def dividend_yield_series(
*, *,
ttm_days: int = TTM_DAYS, ttm_days: int = TTM_DAYS,
grace_days: int = 45, grace_days: int = 45,
smooth_spikes: bool = True,
) -> pd.DataFrame: ) -> pd.DataFrame:
"""构造单只股票的股息率日序列。 """构造单只股票的股息率日序列。
``close`` 为**不复权**收盘价序列(index 为交易日)。 ``close`` 为**不复权**收盘价序列(index 为交易日)。
返回列:``trade_date / close / ttm_dps / dividend_yield``。 返回列:``trade_date / close / ttm_dps / dividend_yield``。
``smooth_spikes`` 必须在此显式声明并透传 —— 曾经只加了调用方传参
而忘了这里接收,导致 walk-forward 直接 TypeError 崩掉。
""" """
if close.empty: if close.empty:
return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"]) return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"])
@@ -114,7 +176,8 @@ def dividend_yield_series(
if s.empty: if s.empty:
return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"]) return pd.DataFrame(columns=["trade_date", "close", "ttm_dps", "dividend_yield"])
idx = pd.DatetimeIndex(pd.to_datetime(s.index)) idx = pd.DatetimeIndex(pd.to_datetime(s.index))
dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days) dps = ttm_dps_series(idx, events, ttm_days=ttm_days, grace_days=grace_days,
smooth_spikes=smooth_spikes)
out = pd.DataFrame({"trade_date": idx, "close": s.to_numpy(dtype="float64"), "ttm_dps": dps}) out = pd.DataFrame({"trade_date": idx, "close": s.to_numpy(dtype="float64"), "ttm_dps": dps})
out["dividend_yield"] = out["ttm_dps"] / out["close"] out["dividend_yield"] = out["ttm_dps"] / out["close"]
return out.reset_index(drop=True) return out.reset_index(drop=True)
+89 -12
View File
@@ -37,6 +37,11 @@ from hdiv.factor.dividend_yield import (
rolling_volatility, rolling_volatility,
window_slice, window_slice,
) )
from hdiv.profile.coverage import (
expected_by_window,
format_warning,
summarise,
)
# 指标展示名与单位(呈现层用) # 指标展示名与单位(呈现层用)
METRIC_META: dict[str, dict[str, str]] = { METRIC_META: dict[str, dict[str, str]] = {
@@ -55,6 +60,13 @@ METRIC_META: dict[str, dict[str, str]] = {
"dps": {"label": "每股分红", "unit": "price"}, "dps": {"label": "每股分红", "unit": "price"},
"payout_ratio": {"label": "分红支付率", "unit": "pct"}, "payout_ratio": {"label": "分红支付率", "unit": "pct"},
"fcf_dividend_cover": {"label": "FCF 对分红覆盖", "unit": "ratio"}, "fcf_dividend_cover": {"label": "FCF 对分红覆盖", "unit": "ratio"},
# 两个「自由现金流」是不同的口径,必须分开:
# free_cashflow —— 最近一期已公告财报的自由现金流
# dividend_fy_free_cashflow —— 与最近一次分红**同一财年**的自由现金流
# 后者才是计算 FCF 覆盖倍数的分子;混用一个代码会让同一指标在不同报告里
# 显示两个不同的数(实测格力电器 2018-05-18:67.1 亿 vs 70.5 亿)。
"free_cashflow": {"label": "自由现金流(最近一期)", "unit": "money"},
"dividend_fy_free_cashflow": {"label": "自由现金流(分红同财年)", "unit": "money"},
"dividend_continuity_years": {"label": "连续分红年数", "unit": "years"}, "dividend_continuity_years": {"label": "连续分红年数", "unit": "years"},
"dps_cagr_5y": {"label": "DPS 5年复合增速", "unit": "pct"}, "dps_cagr_5y": {"label": "DPS 5年复合增速", "unit": "pct"},
"dps_volatility": {"label": "DPS 波动率", "unit": "ratio"}, "dps_volatility": {"label": "DPS 波动率", "unit": "ratio"},
@@ -102,7 +114,15 @@ class ProfileBuilder:
asof: str | date | None = None, asof: str | date | None = None,
persist: bool = True, persist: bool = True,
verbose: bool = True, verbose: bool = True,
return_rows: bool = False,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""计算画像。
``return_rows=True`` 时在结果里附带 ``stat_rows`` / ``series_rows`` /
``score_rows`` 原始行(不落库也能检查逐指标取值)。这是给**等价性测试**
用的:实时画像(``profile.pit``)与批量画像必须逐值一致,而验证这一点
需要一个不写库也能读到逐指标结果的入口。
"""
cfg = load_config("datasource") cfg = load_config("datasource")
c = self.config c = self.config
@@ -202,6 +222,20 @@ class ProfileBuilder:
if verbose and i % 50 == 0: if verbose and i % 50 == 0:
print(f" [{i}/{len(syms)}] 已画像", flush=True) print(f" [{i}/{len(syms)}] 已画像", flush=True)
# 窗口覆盖率自检:名义窗口与实际可用数据是两件事。
# 数据起点晚于窗口左端时窗口会被**静默截短**(实测 2018-05-18 的
# 「5 年」窗口只有 3.4 年),而 n_obs 门槛低到 20 就放行 ——
# 这里至少把它说出来,避免把「3.4 年」当成「5 年」用。
cov_summary = summarise(
stat_rows,
expected_by_window(
self.repo, asof_d,
# 除配置窗口外,收益/回撤类指标还会产出 1/3 年窗口
sorted({int(w) for w in c.windows_years} | {1, 3}),
),
)
cov_warn = format_warning(cov_summary)
result = { result = {
"run_id": run_id, "run_id": run_id,
"asof_date": asof_d, "asof_date": asof_d,
@@ -211,7 +245,15 @@ class ProfileBuilder:
"score_count": len(score_rows), "score_count": len(score_rows),
"skipped": skipped, "skipped": skipped,
"symbols": sorted({r["symbol"] for r in stat_rows}), "symbols": sorted({r["symbol"] for r in stat_rows}),
"window_coverage": cov_summary,
} }
if cov_warn:
result["warnings"] = [cov_warn]
if return_rows:
# 不落库也能逐指标核对(等价性测试用)
result["stat_rows"] = stat_rows
result["series_rows"] = series_rows
result["score_rows"] = score_rows
if persist: if persist:
result["written"] = self._persist( result["written"] = self._persist(
@@ -244,11 +286,25 @@ class ProfileBuilder:
fin_latest: pd.DataFrame, fin_latest: pd.DataFrame,
div_by_symbol: dict[str, list[dict]] | None = None, div_by_symbol: dict[str, list[dict]] | None = None,
fy_table: dict[tuple[str, int], Any] | None = None, fy_table: dict[tuple[str, int], Any] | None = None,
build_series: bool = True,
) -> dict[str, Any] | None: ) -> dict[str, Any] | None:
"""单股画像。
``build_series=False`` 跳过**仅供绘图**的降采样序列,统计量完全不受影响。
实时画像(``profile.pit``)只需要 ``current_value`` / ``current_percentile``,
不需要画图 —— 而构造那几条最多 1500 点的序列占掉本函数约一半耗时
(实测每个快照 0.86s → 0.37s)。
**本函数强制按 ``trade_date`` 排序**,不依赖调用方给有序帧:所有序列的
「当日值」都是取最后一个观测(``current = sub.iloc[-1]``),行序错了就会
取到任意一天的值,而且**不会报错**。2026-10-04 回补数据时就踩到过
(详见 :meth:`_load_daily_basic`)。
"""
c = self.config c = self.config
px = price[price["symbol"] == sym] px = price[price["symbol"] == sym]
if px.empty: if px.empty:
return None return None
px = px.sort_values("trade_date") # 见 docstring:行序即语义
close = px.set_index("trade_date")["close"] close = px.set_index("trade_date")["close"]
close.index = pd.to_datetime(close.index) close.index = pd.to_datetime(close.index)
@@ -258,6 +314,7 @@ class ProfileBuilder:
events.get(sym, pd.DataFrame()), events.get(sym, pd.DataFrame()),
ttm_days=c.ttm_dividend.window_days, ttm_days=c.ttm_dividend.window_days,
grace_days=c.ttm_dividend.grace_days, grace_days=c.ttm_dividend.grace_days,
smooth_spikes=c.ttm_dividend.smooth_spikes,
) )
if yser.empty: if yser.empty:
return None return None
@@ -271,6 +328,7 @@ class ProfileBuilder:
} }
bs = basics[basics["symbol"] == sym] bs = basics[basics["symbol"] == sym]
if not bs.empty: if not bs.empty:
bs = bs.sort_values("trade_date") # 同上:行序即「当日值」的语义
b = bs.set_index(pd.to_datetime(bs["trade_date"])) b = bs.set_index(pd.to_datetime(bs["trade_date"]))
for col in ("pe_ttm", "pb", "ps_ttm"): for col in ("pe_ttm", "pb", "ps_ttm"):
if col in b.columns: if col in b.columns:
@@ -309,8 +367,8 @@ class ProfileBuilder:
"OK" if st.get("n_obs", 0) >= min(c.min_obs_days, 20) else "INSUFFICIENT", "OK" if st.get("n_obs", 0) >= min(c.min_obs_days, 20) else "INSUFFICIENT",
) )
) )
# 展示序列只落配置指定的指标 # 展示序列只落配置指定的指标(且仅在需要时构造 —— 见 build_series)
if metric in c.series_metrics: if build_series and metric in c.series_metrics:
disp = resample_for_storage( disp = resample_for_storage(
pd.DataFrame({"trade_date": s.index, "value": s.to_numpy()}), pd.DataFrame({"trade_date": s.index, "value": s.to_numpy()}),
c.series_max_points, c.series_max_points,
@@ -452,17 +510,25 @@ class ProfileBuilder:
st.update(DividendFilter._payout_and_cover(recs, fy_row, asof, c)) st.update(DividendFilter._payout_and_cover(recs, fy_row, asof, c))
out: list[dict[str, Any]] = [] out: list[dict[str, Any]] = []
# 标量型质量指标 # 标量型质量指标。
for code in ( # 刻意**不含 ttm_dps** —— 它已由上面的序列循环按窗口产出(带完整分布统计),
"dividend_continuity_years", "dividend_years_in_window", # 这里再产一次会写出两条 (ttm_dps, window=0) 行:落库时互相覆盖,
"ttm_dps", "dps_cagr_5y", "dps_volatility", # 而「哪条胜出」取决于写入顺序,属于不确定行为。
"payout_ratio", "fcf_dividend_cover", "free_cashflow", #
"total_cash_dividend", # 同理 **不含 free_cashflow**:``fin_latest`` 已按「最近一期公告」产出该代码,
# 而这里是**与分红同一财年**的自由现金流 —— 两个不同口径不能共用一个代码。
# 后者改用 ``dividend_fy_free_cashflow``,两者都保留、都唯一。
for code, emit_code in (
("dividend_continuity_years", None), ("dividend_years_in_window", None),
("dps_cagr_5y", None), ("dps_volatility", None),
("payout_ratio", None), ("fcf_dividend_cover", None),
("free_cashflow", "dividend_fy_free_cashflow"),
("total_cash_dividend", None),
): ):
v = _f(st.get(code)) v = _f(st.get(code))
if v is None: if v is None:
continue continue
out.append(self._stat_row(sym, code, None, {"n_obs": 1}, v, None, "OK")) out.append(self._stat_row(sym, emit_code or code, None, {"n_obs": 1}, v, None, "OK"))
# DPS 年度序列(用于趋势展示与分位) # DPS 年度序列(用于趋势展示与分位)
dps_by_year = st.get("dps_by_year") or {} dps_by_year = st.get("dps_by_year") or {}
if dps_by_year: if dps_by_year:
@@ -663,7 +729,16 @@ class ProfileBuilder:
} }
def _load_daily_basic(self, syms: list[str], start: date, end: date) -> pd.DataFrame: def _load_daily_basic(self, syms: list[str], start: date, end: date) -> pd.DataFrame:
"""批量取每日指标(估值序列),按 symbol 分片以控制单条 SQL 的规模。""" """批量取每日指标(估值序列),按 symbol 分片以控制单条 SQL 的规模。
**必须 ``ORDER BY symbol, trade_date``**:下游按「最后一个观测」取当日值
(``current = sub.iloc[-1]``),若行序不是日期序就会取到任意一天的值。
早期实现漏了排序 —— 在「行按日期顺序插入」时侥幸正确,
但 2026-10-04 回补 2005-2014 时新行是**追加**进去的,
于是同一股票的行序变成「2015-2026 在前、2005-2014 在后」,
格力电器 2018-05-18 的 PE(TTM) 被取成 2014 年的 8.51(真值 12.12)。
等价性测试(实时画像 vs 批量画像)当场抓到该分歧。
"""
out: list[pd.DataFrame] = [] out: list[pd.DataFrame] = []
cfg = load_config("datasource") cfg = load_config("datasource")
for i in range(0, len(syms), 500): for i in range(0, len(syms), 500):
@@ -673,7 +748,8 @@ class ProfileBuilder:
params.update({f"s{j}": s for j, s in enumerate(batch)}) params.update({f"s{j}": s for j, s in enumerate(batch)})
df = db.read_sql( df = db.read_sql(
"SELECT symbol, trade_date, pe_ttm, pb, ps_ttm, dv_ttm " "SELECT symbol, trade_date, pe_ttm, pb, ps_ttm, dv_ttm "
f"FROM daily_basic WHERE symbol IN ({ph}) AND trade_date BETWEEN :start AND :end", f"FROM daily_basic WHERE symbol IN ({ph}) AND trade_date BETWEEN :start AND :end "
"ORDER BY symbol, trade_date",
params, cfg=cfg, params, cfg=cfg,
) )
if not df.empty: if not df.empty:
@@ -682,7 +758,8 @@ class ProfileBuilder:
return pd.DataFrame(columns=["symbol", "trade_date", "pe_ttm", "pb", "ps_ttm"]) return pd.DataFrame(columns=["symbol", "trade_date", "pe_ttm", "pb", "ps_ttm"])
df = pd.concat(out, ignore_index=True) df = pd.concat(out, ignore_index=True)
df["trade_date"] = pd.to_datetime(df["trade_date"]) df["trade_date"] = pd.to_datetime(df["trade_date"])
return df # 分片拼接后仍需全局有序(各分片内部有序 ≠ 整体有序的日期序)
return df.sort_values(["symbol", "trade_date"], ignore_index=True)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# 落库 # 落库
+119
View File
@@ -0,0 +1,119 @@
"""画像窗口的**实际覆盖度**(不是「报告了 N 年」,而是「真的有 N 年数据」)。
**为什么需要它**:``window_slice(asof, 5)`` 的语义是「把已有数据切成最近 5 年」,
不是「保证有 5 年数据」。若数据起点晚于窗口左端,窗口会被**静默截短**,
而 ``_stat_row`` 只看 ``n_obs >= min(min_obs_days, 20)`` 就标 ``OK`` ——
20 个观测(约 1 个月)也算通过。
实测(600036.SH,``dv_yield``,5 年窗口):
| asof | 窗口内实际观测 | 应有权重 | 覆盖率 |
|---|---:|---:|---:|
| 2015-12-31 | 239 | 1212 | 19.7% |
| 2016-12-30 | 483 | 1212 | 39.9% |
| 2017-12-29 | 727 | 1212 | 60.0% |
| 2018-12-28 | 970 | 1212 | 80.0% |
| 2019-12-31 | 1214 | 1215 | 99.9% |
| 2020 起 | ≈1215 | ≈1215 | 100% |
而 ``n_obs=817`` 的 2018-05-18、四个窗口(0/5/8/10)**报出完全相同的 n_obs**
—— 这正是「被数据起点截断」的指纹。
本模块提供统一的分母(**交易日历的真实开市天数**,不是 243 这种近似),
供实时画像(``profile.pit``)与批量画像(``profile.builder``)共用。
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import Any
#: 覆盖率低于该值时,画像页与 CLI 打印警告(不改变任何判定,只是不让人误以为有 5 年)
WARN_COVERAGE = 0.95
def window_start(asof: date, years: int) -> date:
"""窗口左端(与 ``factor.dividend_yield.window_slice`` 完全一致的口径)。"""
return asof - timedelta(days=int(years * 365.25))
def expected_trading_days(repo: Any, asof: date, years: int) -> int:
"""``(asof - N 年, asof]`` 内**应有**的交易日数(按交易日历)。
``years <= 0`` 表示全历史:没有可比的「应有」天数,返回 0 让调用方跳过覆盖率判定。
区间必须是**左开右闭** —— 与 ``factor.dividend_yield.window_slice`` 的
``trade_date > start`` 完全一致。``repo.trading_days`` 取的是闭区间,
所以左端点恰为交易日时要减 1;否则覆盖率永远差一天、达不到 100%,
会让 ``min_window_coverage = 1.0`` 变成「永远拒绝」。
"""
if years <= 0:
return 0
lo = window_start(asof, years)
days = repo.trading_days(lo, asof)
n = len(days)
if n and days[0] == lo:
n -= 1
return n
def coverage_ratio(n_obs: int, expected: int) -> float | None:
"""实际观测数 / 应有交易日数,封顶 1.0。``expected<=0`` 时返回 None(不适用)。"""
if expected <= 0:
return None
if n_obs >= expected:
return 1.0
return max(0.0, float(n_obs) / float(expected))
def expected_by_window(repo: Any, asof: date, windows: list[int]) -> dict[int, int]:
"""``{窗口年数: 应有交易日数}``(含 0 → 0)。"""
return {int(w): expected_trading_days(repo, asof, int(w)) for w in windows}
def summarise(stat_rows: list[dict[str, Any]], expected: dict[int, int]) -> dict[str, Any]:
"""把一批 stat 行按窗口汇总覆盖率(供 CLI/报告打印警告)。
只统计 :data:`DAILY_OBSERVATION_METRICS` —— 其余指标的 ``n_obs`` 是财年数或 1,
用交易日当分母会算出「0.4%」这种量纲错误的覆盖率。
返回 ``{"windows": {年数: {"min": 最低覆盖率, "n": 行数}}, "worst": (年数, 比率)}``。
"""
from hdiv.core.metrics import DAILY_OBSERVATION_METRICS
agg: dict[int, list[float]] = {}
for r in stat_rows:
if r.get("metric_code") not in DAILY_OBSERVATION_METRICS:
continue
wy = int(r.get("window_years") or 0)
exp = expected.get(wy, 0)
c = coverage_ratio(int(r.get("n_obs") or 0), exp)
if c is None:
continue
agg.setdefault(wy, []).append(c)
windows = {
wy: {"min": min(vals), "n": len(vals)} for wy, vals in sorted(agg.items())
}
worst: tuple[int, float] | None = None
for wy, info in windows.items():
if worst is None or info["min"] < worst[1]:
worst = (wy, info["min"])
return {"windows": windows, "worst": worst}
def format_warning(summary: dict[str, Any]) -> str | None:
"""覆盖率不足时的一句话说明(否则 None)。
逐个列出**所有**不足的窗口,而不是只报最差的那个 ——
否则「5 年已齐、只有 10 年不足」也会被说成「数据不足」,容易误导。
"""
windows = (summary or {}).get("windows") or {}
short = [(wy, info["min"]) for wy, info in sorted(windows.items())
if info["min"] < WARN_COVERAGE]
if not short:
return None
detail = "、".join(f"{wy} 年窗口 {cov:.1%}" for wy, cov in short)
return (
f"窗口数据不足:{detail}"
f"(窗口被数据起点截短,分位/统计量的实际样本期短于名义窗口)"
)
+555
View File
@@ -0,0 +1,555 @@
"""Point-in-Time(实时)个股画像服务。
**为什么需要它**:``ProfileBuilder`` 是**批量 + 单一时点**的研究工具 ——
它的 ``run()`` 一次算完整个股票池、落库一份快照,供人看。回测需要的却是
「每个决策日按当时可见的数据重新画像」。两者共用同一套指标定义,但调用形态不同。
本模块提供回测侧的形态:
1. **PIT 语义与 ProfileBuilder 完全一致**:价格、每日指标、分红、财报一律只在
``<= asof`` 的范围内取数;每个窗口用同一把 ``window_slice`` 切分。
等价性有回归测试(``tests/test_profile_pit.py``),而不是靠注释保证。
2. **惰性**:只在「买入条件已触发」时才计算 —— 绝大多数股票日根本不需要画像。
3. **可复用**:跨决策日共享的面板(价格、每日指标、指数)只载入一次;
与时点强相关的面板(分红、财报)按 asof 缓存,同一 asof 内多只股票复用。
这正是「长期数据可以沿用、在触发条件时计算」的落地方式。
4. **成本可观测**:``stats()`` 汇报载入次数/查询次数,避免「悄悄变慢」。
一次完整回测(12 年、每月评估、49 只候选)的画像计算量取决于触发次数,
而不是 12 年 × 49 只 —— 这是惰性的核心收益。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import date, timedelta
from typing import Any
import pandas as pd
from hdiv.core.errors import HdivError
from hdiv.core.metrics import (
ALL_METRICS,
DAILY_OBSERVATION_METRICS,
DIVIDEND_METRICS,
FINANCIAL_METRICS,
GATE_METRICS,
LIQUIDITY_METRICS,
PERCENTILE_METRICS,
RETURN_METRICS,
VALUATION_METRICS,
)
from hdiv.data.repo import Repo
from hdiv.factor.dividend_yield import build_dps_events
from hdiv.profile.builder import METRIC_META, ProfileBuilder
from hdiv.profile.coverage import coverage_ratio, expected_by_window
__all__ = [
"ALL_METRICS", "DIVIDEND_METRICS", "FINANCIAL_METRICS", "GATE_METRICS",
"LIQUIDITY_METRICS", "METRIC_META", "PERCENTILE_METRICS", "RETURN_METRICS",
"VALUATION_METRICS", "PitProfileService", "ProfileSnapshot",
"evaluate_gate", "metrics_needing_dividends", "metrics_needing_financials",
]
# 指标分组定义在 hdiv.core.metrics(叶子模块),此处引用同一份 ——
# 配置校验(core.config)与画像实现(profile.pit)不允许出现两个清单。
def metrics_needing_financials(metrics: set[str]) -> bool:
"""是否需要财报面板。
**分红质量指标也算「需要财报」**:``ProfileBuilder._dividend_quality_stats``
用「最近一个已公告年报」的 ``fin_end_date`` 反推考核财年(``_target_years``),
再用同一财年的 ``annual_financials`` 算支付率/FCF 覆盖。缺了 ``fin_latest``
会静默退回 ``asof.year - 1`` 这个猜测值 —— 实测会让格力电器
2018-05-18 的 ``dividend_continuity_years`` 从 10 变成 4。
宁可多付一次查询,也不接受两套口径。
"""
return bool(metrics & (FINANCIAL_METRICS | DIVIDEND_METRICS))
def metrics_needing_dividends(metrics: set[str]) -> bool:
return bool(metrics & (DIVIDEND_METRICS | VALUATION_METRICS))
# 指标分组(VALUATION/RETURN/DIVIDEND/FINANCIAL/LIQUIDITY_METRICS、
# PERCENTILE_METRICS、ALL_METRICS)定义在 ``hdiv.core.metrics`` 这个叶子模块里,
# 此处已 import —— 配置校验(core.config)与画像实现共用同一份清单,
# 不允许出现两个版本的「允许哪些指标」。
#: ``ProfileBuilder._profile_one`` 会直接对 ``fin_*`` 面板做 ``["symbol"]`` 取列,
#: 所以「不需要财报」时也必须给出**带列名的空表**,而不是无列的 ``DataFrame()``
#: (否则会 KeyError,而不是安静地跳过财务指标)。
_EMPTY_FIN_HIST = (
"symbol", "year", "roe", "roic", "grossprofit_margin", "netprofit_margin",
"ocf_to_profit", "ocf_to_netprofit_calc",
)
_EMPTY_FIN_AVG = (
"symbol", "roe_avg", "roic_avg", "gross_margin_avg", "net_margin_avg",
"ocf_to_profit_avg", "fin_years_count", "fin_latest_year",
)
_EMPTY_FIN_LATEST = (
"symbol", "end_date", "ann_date", "debt_ratio", "free_cashflow",
"n_income_attr_p", "total_assets",
)
_EMPTY_BASICS = ("symbol", "trade_date", "pe_ttm", "pb", "ps_ttm")
def _with_columns(df: pd.DataFrame, cols: tuple[str, ...]) -> pd.DataFrame:
"""空表 → 带列名的空表;非空表原样返回。"""
if df.empty and "symbol" not in df.columns:
return pd.DataFrame(columns=list(cols))
return df
# ---------------------------------------------------------------------------
# 单只股票的画像快照
# ---------------------------------------------------------------------------
@dataclass
class ProfileSnapshot:
"""某只股票在某个 asof 的实时画像(只保留决策需要的形态)。"""
symbol: str
asof: date
window_years: int
#: 指标 → 当前值(``current_value``)
values: dict[str, float] = field(default_factory=dict)
#: 指标 → 当前在窗口分布中的分位(仅 PERCENTILE_METRICS)
percentiles: dict[str, float] = field(default_factory=dict)
#: 指标 → ``OK`` / ``INSUFFICIENT``(样本不足时不得当作可用)
status: dict[str, str] = field(default_factory=dict)
#: 指标 → 实际取数的窗口年数(0 = 全历史)
windows: dict[str, int] = field(default_factory=dict)
#: 指标 → 窗口内的实际观测数
n_obs: dict[str, int] = field(default_factory=dict)
#: 指标 → 窗口**实际覆盖率**(1.0 = 名义窗口被完整覆盖;窗口 0 不适用)
coverage: dict[str, float] = field(default_factory=dict)
#: 安全边际分项得分(供留痕,不参与闸门判定)
scores: dict[str, float] = field(default_factory=dict)
def get(self, metric: str) -> tuple[float | None, str, int]:
"""返回 ``(值, 状态, 窗口)``。缺失指标的状态为 ``MISSING``。"""
return (
self.values.get(metric),
self.status.get(metric, "MISSING"),
self.windows.get(metric, -1),
)
def get_percentile(self, metric: str) -> float | None:
return self.percentiles.get(metric)
# ---------------------------------------------------------------------------
# 按 asof 缓存的时点上下文
# ---------------------------------------------------------------------------
@dataclass
class _AsOfContext:
"""同一 asof 上所有股票共用的 PIT 面板(惰性装载)。"""
asof: date
symbols: list[str]
dividends: pd.DataFrame
div_by_symbol: dict[str, list[dict[str, Any]]]
fin_hist: pd.DataFrame = field(default_factory=pd.DataFrame)
fin_avg: pd.DataFrame = field(default_factory=pd.DataFrame)
fin_latest: pd.DataFrame = field(default_factory=pd.DataFrame)
fy_table: dict[tuple[str, int], Any] = field(default_factory=dict)
needs_financial: bool = False
#: ``{窗口年数: 该窗口应有的交易日数}`` —— 覆盖率的分母(按交易日历,非近似)
expected_obs: dict[int, int] = field(default_factory=dict)
# ---------------------------------------------------------------------------
# 服务
# ---------------------------------------------------------------------------
class PitProfileService:
"""实时画像服务(PIT,惰性,按 asof 缓存)。
用法::
svc = PitProfileService(window_years=5)
svc.prepare(symbols, data_start, end)
snap = svc.snapshot("600036.SH", date(2018, 5, 18))
"""
def __init__(
self,
*,
window_years: int = 5,
repo: Repo | None = None,
builder: ProfileBuilder | None = None,
load_index: bool = True,
load_basics: bool = True,
) -> None:
self.window_years = int(window_years)
self.repo = repo or Repo()
self.builder = builder or ProfileBuilder.from_config()
self.max_years = max(self.builder.config.windows_years)
if self.window_years != 0 and self.window_years not in self.builder.config.windows_years:
raise HdivError(
f"实时画像窗口 {self.window_years} 年不可用:config/profile.yml 的 "
f"windows_years={self.builder.config.windows_years} 里没有它。\n"
f" 请把它加进 windows_years(例如 [3, 5, 8, 10]),"
f"或把策略的 profile_gate.window_years 改成其中一个值。"
)
self.load_index = load_index
self.load_basics = load_basics
self._prepared = False
self._symbols: list[str] = []
self._price = pd.DataFrame()
self._basics = pd.DataFrame()
self._index = pd.DataFrame()
self._all_dividends = pd.DataFrame()
self._ctx: dict[date, _AsOfContext] = {}
self._snapshots: dict[tuple[str, date], ProfileSnapshot] = {}
#: 闸门规则用到的指标集合;None = 未知,按「全都可能需要」处理(保守)
self._needed: set[str] | None = None
self._financial_required: bool | None = None
self._counters: dict[str, int] = {
"price_loaded": 0, "basics_loaded": 0, "asof_contexts": 0,
"financial_loads": 0, "liquidity_loads": 0,
"snapshots_computed": 0, "snapshots_cached": 0,
}
# ------------------------------------------------------------------
# 成本控制:只载入规则真正需要的面板
# ------------------------------------------------------------------
def configure(self, metrics: set[str]) -> None:
"""声明闸门用到的指标集合。
这一步是**成本控制的关键**:若规则里没有任何财务指标,就不必付财报全表
查询的代价(各约 30 万行)。未调用时按「全都可能需要」保守处理。
"""
self._needed = set(metrics)
self._financial_required = metrics_needing_financials(self._needed)
def _needs(self, metric: str) -> bool:
return self._needed is None or metric in self._needed
def _needs_financials(self) -> bool:
"""未 ``configure`` 时按「需要」处理 —— 宁可多查,不可少算。"""
return self._financial_required is not False
def require_financials(self, required: bool) -> None:
"""显式声明是否需要财报面板(覆盖 ``configure`` 的推断)。"""
self._financial_required = bool(required)
# ------------------------------------------------------------------
# 批量预载(跨越整个回测区间、与 asof 无关的部分)
# ------------------------------------------------------------------
def prepare(self, symbols: list[str], start: date, end: date) -> None:
"""载入跨决策日共享的面板。
只有 ``trade_date`` 范围过滤,没有 PIT 语义 —— 真正的 PIT 剪裁发生在
:meth:`snapshot` 里逐 asof 进行(与 ``ProfileBuilder.run`` 的取数起点
规则完全一致:``asof.year - max_years - 1`` 的 1 月 1 日)。
"""
self._symbols = sorted(set(symbols))
if not self._symbols:
self._prepared = True
return
self._price = self.repo.price_history(self._symbols, start, end, adjust="none")
self._counters["price_loaded"] += 1
if self.load_basics:
self._basics = self.builder._load_daily_basic(self._symbols, start, end)
self._counters["basics_loaded"] += 1
if self.load_index:
self._index = self.repo.index_history("000300.SH", start, end)
# 分红:为**整个回测区间**取一次超集,逐 asof 再用与 repo.dividend_records
# 完全相同的三重 PIT 条件(imp_ann_date / ex_date / 回看窗口)在 pandas 里剪裁。
#
# 超集下界必须覆盖**最早**的 asof 所需的回看窗口,而不是最后一个 asof ——
# 早期实现按 end 回看 13 年,于是 2018 年的画像拿不到 2006-2012 的分红,
# 格力电器的 dividend_continuity_years 被算成 4(真值 10)。
span_start = start - timedelta(days=int((self.max_years + 2) * 365.25))
span_years = int((end - span_start).days / 365.25) + 2
self._all_dividends = self.repo.dividend_records(end, years_back=span_years)
if not self._all_dividends.empty:
self._all_dividends = self._all_dividends[
self._all_dividends["symbol"].isin(set(self._symbols))
]
self._prepared = True
# ------------------------------------------------------------------
# 单股快照
# ------------------------------------------------------------------
def snapshot(self, symbol: str, asof: date) -> ProfileSnapshot | None:
"""计算(或取缓存)``symbol`` 在 ``asof`` 的实时画像。"""
if not self._prepared:
raise HdivError("PitProfileService 必须先 prepare(symbols, start, end)")
key = (symbol, asof)
hit = self._snapshots.get(key)
if hit is not None:
self._counters["snapshots_cached"] += 1
return hit
ctx = self._context(asof, needs_financial=self._needs_financials())
snap = self._compute(symbol, asof, ctx)
if snap is not None:
self._snapshots[key] = snap
self._counters["snapshots_computed"] += 1
return snap
def stats(self) -> dict[str, int]:
out = dict(self._counters)
out["distinct_asof"] = len(self._ctx)
out["cached_symbols"] = len(self._snapshots)
return out
# ------------------------------------------------------------------
# 内部
# ------------------------------------------------------------------
def _context(self, asof: date, *, needs_financial: bool | None) -> _AsOfContext:
hit = self._ctx.get(asof)
if hit is not None and (hit.needs_financial or not needs_financial):
return hit
# ---- 分红:与 repo.dividend_records 完全相同的 PIT 三重条件 ----
d = self._all_dividends
if d.empty:
div = d
else:
since = asof - timedelta(days=int((self.max_years + 2) * 365.25))
imp = pd.to_datetime(d["imp_ann_date"]).dt.date
ex = pd.to_datetime(d["ex_date"]).dt.date
div = d[(imp <= asof) & (ex <= asof) & (ex >= since)]
div_by_symbol: dict[str, list[dict[str, Any]]] = {}
if not div.empty:
for rec in div.to_dict("records"):
div_by_symbol.setdefault(rec["symbol"], []).append(rec)
ctx = _AsOfContext(
asof=asof, symbols=self._symbols, dividends=div,
div_by_symbol=div_by_symbol, needs_financial=bool(needs_financial),
expected_obs=expected_by_window(
self.repo, asof,
# 窗口不止 profile.yml 的 [5,8,10]:收益类指标会产出 1/3 年窗口
# (ret_1y / ret_3y / max_drawdown_3y),分母必须一并备好
sorted({int(w) for w in self.builder.config.windows_years} | {1, 3}),
),
)
if needs_financial:
syms = self._symbols
ctx.fin_hist = self.repo.annual_financial_history(
asof, years=self.max_years + 1, symbols=syms
)
ctx.fin_avg = self.repo.annual_financial_averages(
asof, years=5, symbols=syms, hist=ctx.fin_hist
)
ctx.fin_latest = self.repo.financial_panel(asof, symbols=syms)
annual = self.repo.annual_financials(asof, years=self.max_years + 2, symbols=syms)
if not annual.empty:
ctx.fy_table = {
(r["symbol"], int(r["year"])): r for _, r in annual.iterrows()
}
self._counters["financial_loads"] += 1
self._ctx[asof] = ctx
self._counters["asof_contexts"] += 1
return ctx
def _compute(
self, symbol: str, asof: date, ctx: _AsOfContext
) -> ProfileSnapshot | None:
"""调用 ``ProfileBuilder._profile_one`` —— **指标定义的单一口径来源**。"""
start = date(asof.year - self.max_years - 1, 1, 1)
def _slice(df: pd.DataFrame) -> pd.DataFrame:
if df.empty:
return df
td = pd.to_datetime(df["trade_date"]).dt.date
return df[(td >= start) & (td <= asof)]
price = _slice(self._price) if not self._price.empty else self._price
price = price[price["symbol"] == symbol] if not price.empty else price
if price.empty:
return None
basics = _slice(self._basics) if not self._basics.empty else self._basics
if not basics.empty:
basics = basics[basics["symbol"] == symbol]
basics = _with_columns(basics, _EMPTY_BASICS)
index = _slice(self._index) if not self._index.empty else self._index
sym_div = ctx.div_by_symbol.get(symbol, [])
# ProfileBuilder 期望的 events 是 {symbol: DataFrame}
events = build_dps_events(pd.DataFrame(sym_div)) if sym_div else {}
# 流动性是逐 (股票, 时点) 的查询:只有在规则真的用到时才付出这个代价
if self._needs("avg_amount_20d"):
avg = self.repo.avg_amount(asof, window=20, symbols=[symbol])
self._counters["liquidity_loads"] += 1
else:
avg = pd.DataFrame(columns=["symbol", "avg_amount", "n"])
res = self.builder._profile_one(
symbol, asof, price, events, basics, index, avg,
_with_columns(ctx.fin_hist, _EMPTY_FIN_HIST),
_with_columns(ctx.fin_avg, _EMPTY_FIN_AVG),
_with_columns(ctx.fin_latest, _EMPTY_FIN_LATEST),
ctx.div_by_symbol, ctx.fy_table,
# 闸门只需要当日值与分位,不需要绘图序列(省掉约一半耗时)
build_series=False,
)
if res is None:
return None
snap = ProfileSnapshot(symbol=symbol, asof=asof, window_years=self.window_years)
by_code: dict[str, dict[str, Any]] = {}
for row in res["stats"]:
code = row["metric_code"]
wy = int(row["window_years"])
# 序列型指标同时有「全历史(0)」与各窗口行 —— 按配置的窗口优先,
# 找不到则退回全历史,并把实际窗口如实记录(windows[code])。
if code not in by_code or self._prefer(wy, by_code[code]["window_years"]):
by_code[code] = row
for code, row in by_code.items():
cur = row.get("current_value")
if cur is not None:
snap.values[code] = float(cur)
pct = row.get("current_percentile")
if pct is not None:
snap.percentiles[code] = float(pct)
snap.status[code] = str(row.get("status") or "MISSING")
snap.windows[code] = int(row["window_years"])
n_obs = int(row.get("n_obs") or 0)
snap.n_obs[code] = n_obs
# 覆盖率:实际观测数 / 该窗口应有的交易日数。
# **这是「名义 5 年」与「真的有 5 年数据」的区别所在**。
# 只对**观测单位是交易日**的指标计算 —— 年报均值类的 n_obs 是财年数、
# 标量类的 n_obs 是 1,拿交易日当分母是量纲错误。
# 窗口 0(全历史)没有「应有」天数,记为 1.0(不参与判定)。
if code in DAILY_OBSERVATION_METRICS:
cov = coverage_ratio(
n_obs, ctx.expected_obs.get(int(row["window_years"]), 0)
)
else:
cov = None
snap.coverage[code] = 1.0 if cov is None else cov
for row in res["scores"]:
if row.get("score") is not None:
snap.scores[row["score_code"]] = float(row["score"])
return snap
def _prefer(self, new_wy: int, old_wy: int) -> bool:
"""窗口优先级:配置窗口 > 全历史 > 其它;**同窗口时后来者胜出**。
「同窗口后来者胜出」是刻意与落库语义对齐:``hd_profile_stat`` 对
``(run_id, symbol, metric_code, window_years)`` 唯一,若画像产出了重复行,
数据库里留下的是**最后写入**的那条。实时画像必须与页面上看到的数是同一个。
(重复行本身正在被逐一消除,这里只是兜底,不允许出现两套口径。)
"""
rank = {self.window_years: 0, 0: 1}
rn, ro = rank.get(new_wy, 2), rank.get(old_wy, 2)
return rn <= ro
# ---------------------------------------------------------------------------
# 闸门判定
# ---------------------------------------------------------------------------
def evaluate_gate(
rules: list[dict[str, Any]],
snapshot: ProfileSnapshot | None,
*,
on_unverifiable: str = "reject",
min_window_coverage: float = 0.0,
) -> dict[str, Any]:
"""按规则逐条判定;返回可直接写入 ``reason_json`` 的可追溯结果。
三种结局:
- ``PASS`` —— 全部规则成立
- ``REJECT`` —— 至少一条规则不成立
- ``UNVERIFIABLE`` —— 指标缺失、样本不足,**或窗口数据覆盖不足**;
按 ``on_unverifiable`` 决定是保守淘汰(``reject``)还是放行(``pass``)
**不猜**:指标缺失(MISSING)、样本不足(INSUFFICIENT)绝不当作 0 或当作通过。
``min_window_coverage``:窗口实际覆盖率下限(1.0 = 必须完整覆盖名义窗口)。
默认 0 表示**不因覆盖率淘汰**(保持改造前行为);设成 1.0 时,
「名义 5 年但实际只有 3.4 年数据」会被判为无法验证。
"""
checks: list[dict[str, Any]] = []
failed: list[str] = []
unverifiable: list[str] = []
for r in rules:
metric = str(r["metric"])
stat = str(r.get("stat", "current_value"))
op = str(r.get("op", ">="))
threshold = float(r["value"])
actual: float | None = None
status = "MISSING"
window = -1
coverage = 1.0
n_obs = 0
if snapshot is not None:
if stat == "current_percentile":
actual = snapshot.get_percentile(metric)
status = snapshot.status.get(metric, "MISSING")
window = snapshot.windows.get(metric, -1)
else:
actual, status, window = snapshot.get(metric)
coverage = snapshot.coverage.get(metric, 1.0)
n_obs = snapshot.n_obs.get(metric, 0)
# 覆盖率不足 = 用**不完整**的窗口算出来的统计量,不能当作已验证
short_window = window > 0 and coverage < min_window_coverage - 1e-9
ok: bool | None
if actual is None or status != "OK":
ok = None
unverifiable.append(f"{metric}.{stat}")
elif short_window:
ok = None
unverifiable.append(f"{metric}.window_coverage={coverage:.0%}")
else:
ok = _compare(actual, op, threshold)
if not ok:
failed.append(f"{metric}.{stat}{op}{threshold:g}")
checks.append({
"metric": metric, "stat": stat, "op": op, "threshold": threshold,
"actual": actual, "status": status, "window_years": window,
"n_obs": n_obs, "window_coverage": round(coverage, 4),
"passed": ok,
})
if failed:
verdict = "REJECT"
elif unverifiable:
verdict = "REJECT" if on_unverifiable == "reject" else "PASS"
else:
verdict = "PASS"
detail: dict[str, Any] = {
"verdict": verdict,
"checks": checks,
"failed": failed,
"unverifiable": unverifiable,
"on_unverifiable": on_unverifiable,
"min_window_coverage": min_window_coverage,
}
if snapshot is not None:
detail["asof"] = str(snapshot.asof)
detail["window_years"] = snapshot.window_years
if snapshot.scores:
detail["safety_margin_scores"] = snapshot.scores
return detail
_OPS = {
">=": lambda a, b: a >= b,
"<=": lambda a, b: a <= b,
">": lambda a, b: a > b,
"<": lambda a, b: a < b,
}
def _compare(actual: float, op: str, threshold: float) -> bool:
fn = _OPS.get(op)
if fn is None: # pragma: no cover - 配置层已校验
raise HdivError(f"不支持的比较符:{op}")
return bool(fn(actual, threshold))
+6 -4
View File
@@ -10,6 +10,7 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import load_config from hdiv.core.config import load_config
from hdiv.report.format import NumFmt
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo from hdiv.data.repo import Repo
from hdiv.report.renderer import Provenance, Renderer, query from hdiv.report.renderer import Provenance, Renderer, query
@@ -97,7 +98,7 @@ def build_backtest_report(run_id: str, *, cfg: Any = None) -> Path:
) )
bt_cfg = load_config("backtest") bt_cfg = load_config("backtest")
rf = f"{bt_cfg.risk_free_rate * 100:.2f}%" rf = NumFmt.from_config().pct(bt_cfg.risk_free_rate)
# 是否已被同策略同模式的更新运行取代? # 是否已被同策略同模式的更新运行取代?
# 历史 run 必须保留(可复现性要求),但报告要如实标注, # 历史 run 必须保留(可复现性要求),但报告要如实标注,
@@ -300,7 +301,8 @@ def _fmt_metric(code: str, v: Any) -> str:
return "—" return "—"
x = float(v) x = float(v)
if code in _PCT_CODES: if code in _PCT_CODES:
return f"{x * 100:,.2f}%" # 小数位来自 config/report.yml: layout.decimals.ratio(百分比 = ratio - 2 位)
return NumFmt.from_config().pct(x)
if code in _MONEY_CODES: if code in _MONEY_CODES:
return f"{x:,.0f}" return f"{x:,.0f}"
if code == "trade_count": if code == "trade_count":
@@ -313,7 +315,7 @@ def _fmt_metric(code: str, v: Any) -> str:
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
if v is None or (isinstance(v, float) and not np.isfinite(v)): if v is None or (isinstance(v, float) and not np.isfinite(v)):
return "—" return "—"
return f"{float(v) * 100:,.2f}%" return NumFmt.from_config().pct(float(v))
def _money(v: Any) -> str: def _money(v: Any) -> str:
@@ -375,7 +377,7 @@ def _reason_text(js: Any) -> str:
return str(js)[:120] return str(js)[:120]
parts = [] parts = []
if d.get("dividend_yield") is not None: if d.get("dividend_yield") is not None:
parts.append(f"股息率 {d['dividend_yield'] * 100:.2f}%") parts.append(f"股息率 {NumFmt.from_config().pct(d['dividend_yield'])}")
if d.get("yield_percentile") is not None: if d.get("yield_percentile") is not None:
parts.append(f"历史分位 {d['yield_percentile']:.1f}%") parts.append(f"历史分位 {d['yield_percentile']:.1f}%")
if d.get("rule"): if d.get("rule"):
+155
View File
@@ -0,0 +1,155 @@
"""统一的数值格式化。
**为什么需要这个模块**:`config/report.yml` 的 ``layout.decimals`` 长期是个摆设 ——
``ratio`` 与 ``money`` 从未被任何代码读取(只有 ``price`` 在渲染器里用过一次),
而各报告模块各自硬编码小数位:
profile_report._pct → f"{x*100:.2f}%"
backtest_report._pct → f"{x*100:.2f}%"
universe_report._pct → dec: int = 2
sensitivity_report._pct / walkforward_report._pct → f"{x*100:.2f}%"
结果就是**改了配置不生效**,7 处实现也容易各自漂移。现在所有格式化都走这里。
语义(与用户确认过)::
decimals.ratio = 4 → 原始比率保留 4 位小数:0.06171491 → 0.0617
再乘 100 得到百分比:6.17%
即 **百分比小数位 = ratio - 2**
所以 ratio=4 时股息率显示 6.17%,ratio=6 时显示 6.1715%。
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
def _is_missing(v: Any) -> bool:
if v is None or v == "":
return True
if isinstance(v, float) and v != v: # NaN
return True
try: # numpy / pandas 的 NaN
return bool(v != v)
except Exception:
return False
@dataclass(frozen=True)
class NumFmt:
"""由 ``config/report.yml: layout.decimals`` 构造的格式化器。"""
ratio: int = 4
money: int = 2
price: int = 2
# -- 派生 ---------------------------------------------------------------
@property
def percent(self) -> int:
"""百分比的小数位。
比率保留 ``ratio`` 位后再乘 100,恰好少两位 —— 所以百分比小数位 = ratio - 2。
ratio=4 → 6.17%;ratio=6 → 6.1715%。
"""
return max(0, self.ratio - 2)
# -- 基础 ---------------------------------------------------------------
@staticmethod
def _f(v: Any, dec: int, *, thousands: bool = False) -> str:
if _is_missing(v):
return "—"
try:
x = float(v)
except (TypeError, ValueError):
return str(v)
return f"{x:,.{dec}f}" if thousands else f"{x:.{dec}f}"
# -- 各类值 -------------------------------------------------------------
def ratio_str(self, v: Any) -> str:
"""原始比率,保留 ratio 位。"""
return self._f(v, self.ratio)
def pct(self, v: Any, *, plus: bool = False) -> str:
"""比率 → 百分比字符串。"""
if _is_missing(v):
return "—"
try:
x = float(v) * 100.0
except (TypeError, ValueError):
return str(v)
sign = "+" if (plus and x > 0) else ""
return f"{sign}{x:.{self.percent}f}%"
def pct_pp(self, v: Any, *, plus: bool = True) -> str:
"""百分点(用于超额收益等差值),带单位 pp。"""
if _is_missing(v):
return "—"
try:
x = float(v) * 100.0
except (TypeError, ValueError):
return str(v)
sign = "+" if (plus and x > 0) else ""
return f"{sign}{x:.{self.percent}f}pp"
def money_str(self, v: Any, *, thousands: bool = True) -> str:
return self._f(v, self.money, thousands=thousands)
def yi(self, v: Any) -> str:
"""元 → 亿元。"""
if _is_missing(v):
return "—"
try:
return f"{float(v) / 1e8:,.{self.money}f}亿"
except (TypeError, ValueError):
return str(v)
def price_str(self, v: Any) -> str:
return self._f(v, self.price, thousands=True)
def years(self, v: Any) -> str:
return self._f(v, 0, thousands=False)
def count(self, v: Any) -> str:
return self._f(v, 0, thousands=True)
def by_unit(self, v: Any, unit: str) -> str:
"""按单位自动选择(供画像的分布表等使用)。"""
return {
"pct": self.pct,
"money": self.yi,
"years": self.years,
"price": self.price_str,
"int": self.count,
"ratio": self.ratio_str,
}.get(unit, self.ratio_str)(v)
# -- 从配置构造 ---------------------------------------------------------
@classmethod
def from_config(cls, cfg: Any = None) -> NumFmt:
"""从 report.yml 读取;配置不可用时回落到默认值(不抛异常)。"""
if cfg is None:
try:
from hdiv.core.config import load_config
cfg = load_config("report")
except Exception:
return cls()
try:
d = cfg.layout.decimals
return cls(ratio=int(d.ratio), money=int(d.money), price=int(d.price))
except Exception:
return cls()
_DEFAULT = NumFmt()
def default() -> NumFmt:
"""进程级默认格式化器(读一次配置)。"""
return _DEFAULT
+5 -10
View File
@@ -269,7 +269,8 @@ def _histogram(stats: pd.DataFrame, series: pd.DataFrame, metric: str, row: Any)
hi = lo + 1e-6 hi = lo + 1e-6
edges = np.linspace(lo, hi, 13) edges = np.linspace(lo, hi, 13)
counts, _ = np.histogram(vals, bins=edges) counts, _ = np.histogram(vals, bins=edges)
labels = [f"{(edges[i] + edges[i + 1]) / 2 * 100:.2f}%" for i in range(len(edges) - 1)] labels = [NumFmt.from_config().pct((edges[i] + edges[i + 1]) / 2)
for i in range(len(edges) - 1)]
cur = float(row["current_value"]) if row is not None and pd.notna(row["current_value"]) else None cur = float(row["current_value"]) if row is not None and pd.notna(row["current_value"]) else None
bucket = None bucket = None
if cur is not None: if cur is not None:
@@ -313,22 +314,16 @@ def _scores(scores: pd.DataFrame) -> tuple[list[dict], list[dict]]:
def _fmt(v: Any, unit: str) -> str: def _fmt(v: Any, unit: str) -> str:
"""按单位格式化。小数位由 config/report.yml: layout.decimals 决定。"""
if v is None or pd.isna(v): if v is None or pd.isna(v):
return "—" return "—"
x = float(v) return NumFmt.from_config().by_unit(v, unit)
if unit == "pct":
return f"{x * 100:.2f}%"
if unit == "money":
return f"{x / 1e8:,.2f}亿"
if unit == "years":
return f"{x:.0f}"
return f"{x:,.4f}"
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
if v is None or pd.isna(v): if v is None or pd.isna(v):
return "—" return "—"
return f"{float(v) * 100:.2f}%" return NumFmt.from_config().pct(v)
def _n(v: Any) -> float | None: def _n(v: Any) -> float | None:
+20 -9
View File
@@ -27,6 +27,7 @@ from hdiv.core.paths import output_dir, project_root, resolve
from hdiv.data import db from hdiv.data import db
from hdiv.data.sync.base import stable_id from hdiv.data.sync.base import stable_id
from hdiv.report import theme from hdiv.report import theme
from hdiv.report.format import NumFmt
def _json_for_script(value: Any) -> Markup: def _json_for_script(value: Any) -> Markup:
@@ -89,16 +90,26 @@ class Renderer:
# -- 数值格式化(模板层零计算,只做呈现) -------------------------------- # -- 数值格式化(模板层零计算,只做呈现) --------------------------------
def fmt_num(self, v: Any, decimals: int = 2) -> str: @property
if v is None or v == "" or (isinstance(v, float) and v != v): def fmt(self) -> NumFmt:
return "—" """由 config/report.yml: layout.decimals 驱动的统一格式化器。
try:
return f"{float(v):,.{decimals}f}"
except (TypeError, ValueError):
return str(v)
def fmt_pct(self, v: Any, decimals: int = 2) -> str: 早期版本的 fmt_pct 默认 hardcode 2 位,且从不读取 decimals.ratio ——
if v is None or (isinstance(v, float) and v != v): 于是「改了配置不生效」。现在所有格式化都经由此处,配置是真的。
"""
if getattr(self, "_fmt", None) is None:
self._fmt = NumFmt.from_config(self.cfg)
return self._fmt
def fmt_num(self, v: Any, decimals: int | None = None) -> str:
if decimals is None:
return self.fmt.ratio_str(v)
return NumFmt._f(v, decimals, thousands=True)
def fmt_pct(self, v: Any, decimals: int | None = None) -> str:
if decimals is None:
return self.fmt.pct(v)
if v is None:
return "—" return "—"
try: try:
return f"{float(v) * 100:.{decimals}f}%" return f"{float(v) * 100:.{decimals}f}%"
+5 -4
View File
@@ -10,6 +10,7 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import load_config from hdiv.core.config import load_config
from hdiv.report.format import NumFmt
from hdiv.data import db from hdiv.data import db
from hdiv.report.renderer import Provenance, Renderer, query from hdiv.report.renderer import Provenance, Renderer, query
@@ -133,7 +134,7 @@ def _analyse(cagrs: list[float]) -> dict[str, Any]:
"neighbour_mean": float(neigh), "neighbour_mean": float(neigh),
"neighbour_mean_s": _pct(neigh), "neighbour_mean_s": _pct(neigh),
"excess": float(arr[i] - neigh), "excess": float(arr[i] - neigh),
"excess_s": f"{(arr[i] - neigh) * 100:,.2f}pp", "excess_s": NumFmt.from_config().pct_pp(arr[i] - neigh),
}) })
robust = smoothness >= 0.6 and not spikes robust = smoothness >= 0.6 and not spikes
return { return {
@@ -153,8 +154,8 @@ def _analyse(cagrs: list[float]) -> dict[str, Any]:
), ),
"cagr_min_s": _pct(arr.min()), "cagr_min_s": _pct(arr.min()),
"cagr_max_s": _pct(arr.max()), "cagr_max_s": _pct(arr.max()),
"cagr_range_s": f"{(arr.max() - arr.min()) * 100:,.2f}pp", "cagr_range_s": NumFmt.from_config().pct_pp(arr.max() - arr.min(), plus=False),
"max_jump_s": f"{diffs.max() * 100:,.2f}pp", "max_jump_s": NumFmt.from_config().pct_pp(diffs.max(), plus=False),
"smoothness_s": f"{smoothness:.2f}", "smoothness_s": f"{smoothness:.2f}",
} }
@@ -205,7 +206,7 @@ def _r(v: float | None) -> float | None:
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
f = _f(v) f = _f(v)
return "—" if f is None else f"{f * 100:,.2f}%" return "—" if f is None else NumFmt.from_config().pct(f)
def _num(v: Any) -> str: def _num(v: Any) -> str:
+8 -1
View File
@@ -9,6 +9,7 @@ from typing import Any
import pandas as pd import pandas as pd
from hdiv.core.config import config_hash, load_config from hdiv.core.config import config_hash, load_config
from hdiv.report.format import NumFmt
from hdiv.data import db from hdiv.data import db
from hdiv.data.repo import Repo from hdiv.data.repo import Repo
from hdiv.report.renderer import Provenance, Renderer, query from hdiv.report.renderer import Provenance, Renderer, query
@@ -233,10 +234,16 @@ def _num(v: Any, dec: int = 2) -> str:
return str(v) return str(v)
def _pct(v: Any, dec: int = 2) -> str: def _pct(v: Any, dec: int | None = None) -> str:
"""百分比。``dec`` 显式给出时按其格式化,否则用配置的精度。
早期实现默认 dec=2 且从不读配置,于是 decimals.ratio 改了也没反应。
"""
if v is None or (isinstance(v, float) and v != v) or pd.isna(v): if v is None or (isinstance(v, float) and v != v) or pd.isna(v):
return "—" return "—"
try: try:
if dec is None:
return NumFmt.from_config().pct(v)
return f"{float(v) * 100:.{dec}f}%" return f"{float(v) * 100:.{dec}f}%"
except (TypeError, ValueError): except (TypeError, ValueError):
return str(v) return str(v)
+2 -1
View File
@@ -15,6 +15,7 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import load_config from hdiv.core.config import load_config
from hdiv.report.format import NumFmt
from hdiv.data import db from hdiv.data import db
from hdiv.report.renderer import Provenance, Renderer, query from hdiv.report.renderer import Provenance, Renderer, query
@@ -248,7 +249,7 @@ def _r(v: float | None) -> float | None:
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
f = _f(v) f = _f(v)
return "—" if f is None else f"{f * 100:,.2f}%" return "—" if f is None else NumFmt.from_config().pct(f)
def _num(v: Any) -> str: def _num(v: Any) -> str:
+28 -9
View File
@@ -19,6 +19,7 @@ from typing import Any
import pandas as pd import pandas as pd
from hdiv.core.config import DividendFilterConfig from hdiv.core.config import DividendFilterConfig
from hdiv.factor.dividend_yield import ttm_dps_at
from hdiv.universe.filters.base import Filter, FilterOutcome from hdiv.universe.filters.base import Filter, FilterOutcome
# 年报到次年 4 月 30 日前披露完毕(法定上限) # 年报到次年 4 月 30 日前披露完毕(法定上限)
@@ -174,17 +175,35 @@ class DividendFilter(Filter):
window_start = start_year - cfg.window_years + 1 window_start = start_year - cfg.window_years + 1
in_window = sorted(x for x in years if window_start <= x <= start_year) in_window = sorted(x for x in years if window_start <= x <= start_year)
# TTM 股息:除权日落在过去 12 个月内 # TTM 股息:与画像/回测**共用同一实现**(factor.ttm_dps_at)。
one_year_ago = _shift_year(asof, -1) #
# 早期此处另写了一遍「trailing 12 个月求和」,两个问题:
# 1) 同一个「股息率」在筛选与画像/回测里口径可能不同;
# 2) 同样受除权间隔不规整造成的毛刺影响 —— 若 asof 恰好落在
# 「新旧重叠」窗口里会虚高一倍,落在「断档」窗口里会虚低一半,
# 而这是**直接决定选股**的数字。
ev_df = pd.DataFrame(
[
{
"ex_date": r.get("ex_date"),
"imp_ann_date": r.get("imp_ann_date"),
"cash_div_tax": r.get("cash_div_tax"),
}
for r in cash
]
)
ttm = 0.0 ttm = 0.0
has_ttm = False has_ttm = False
for r in cash: if not ev_df.empty:
ex = r.get("ex_date") ev_df["cash_div_tax"] = pd.to_numeric(
if ex is None or pd.isna(ex): ev_df["cash_div_tax"], errors="coerce"
continue ).fillna(0.0)
ex = pd.to_datetime(ex).date() ev_df["ex_date"] = pd.to_datetime(ev_df["ex_date"], errors="coerce")
if one_year_ago < ex <= asof: ev_df["imp_ann_date"] = pd.to_datetime(ev_df["imp_ann_date"], errors="coerce")
ttm += float(r["cash_div_tax"] or 0) ev_df = ev_df.dropna(subset=["ex_date"]).sort_values("ex_date")
v = ttm_dps_at(asof, ev_df)
if v is not None and v > 0:
ttm = float(v)
has_ttm = True has_ttm = True
# 年度 DPS(按报告期汇总),用于 CAGR 与波动 # 年度 DPS(按报告期汇总),用于 CAGR 与波动
+36 -4
View File
@@ -166,12 +166,20 @@ class UniverseSelector:
f"股票池 {member_count} 只 < 期望下限 {self.config.output.min_members} 只" f"股票池 {member_count} 只 < 期望下限 {self.config.output.min_members} 只"
) )
# run_id 必须由「输入」唯一决定,**不含时间戳**。
#
# 早期实现把 datetime.now() 编进指纹,导致同样的筛选每跑一次就多一条记录
# (同一 asof 累积了 4 条内容相同的记录)。现在的语义是:
# 同一份配置 + 同一时点 → 同一个 run_id → 重跑即原地覆盖。
#
# 注意:刻意**不含 data_version**。数据更新后重跑仍覆盖同一条记录,
# 因为用户要的是「这一天的筛选结果」,而不是「每次数据快照各存一份」;
# 每次运行使用的 data_version 仍完整记录在 hd_universe_run 里可供追溯。
run_id = stable_id( run_id = stable_id(
"universe", "universe",
self.config.name, self.config.name,
str(effective), str(effective),
config_hash(self.config), config_hash(self.config),
datetime.now().isoformat(),
) )
result = { result = {
"run_id": run_id, "run_id": run_id,
@@ -244,9 +252,15 @@ class UniverseSelector:
if not avgs.empty: if not avgs.empty:
df = df.merge(avgs, on="symbol", how="left") df = df.merge(avgs, on="symbol", how="left")
# 缺少当日行情的股票:is_fresh 为 NaN → 视为非当日(停牌/未交易) # 缺少当日行情的股票:is_fresh 为 NaN → 视为非当日(停牌/未交易)。
#
# merge(how="left") 后该列是 object(True/False/NaN 混合),直接
# .fillna(False) 会触发 pandas 的 Downcasting object dtype FutureWarning。
# 先转 nullable boolean 再填充,语义相同且不产生警告。
if "is_fresh" in df.columns: if "is_fresh" in df.columns:
df["is_fresh"] = df["is_fresh"].fillna(False).astype(bool) df["is_fresh"] = (
df["is_fresh"].astype("boolean").fillna(False).astype(bool)
)
return df return df
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -280,7 +294,10 @@ class UniverseSelector:
), ),
cfg=cfg, cfg=cfg,
update_columns=[ update_columns=[
"member_count", "candidate_count", "stats_json", "status", "config_json" "member_count", "candidate_count", "stats_json", "status",
"config_json", "data_version", "code_version",
# 刻意不含 display_name / notes / archived_at / deleted_at:
# 那些是用户在界面上的标注,重跑不应把命名或归档状态清掉。
], ],
) )
@@ -319,6 +336,21 @@ class UniverseSelector:
"passed", "fail_stage", "fail_reason", "values_json", "filter_json" "passed", "fail_stage", "fail_reason", "values_json", "filter_json"
], ],
) )
# 覆盖语义下的收尾:上次运行存在、本次不再出现在候选集里的成员,
# 标记为失效而不是删除(项目禁止物理删除)。
# 这种情况只在数据变动(新股上市/退市)时出现,属边缘情形。
syms = [r["symbol"] for r in rows]
placeholders = ",".join(f":s{i}" for i in range(len(syms)))
params = {f"s{i}": v for i, v in enumerate(syms)}
params["r"] = result["run_id"]
db.execute(
f"UPDATE hd_universe_member SET passed = 0, fail_stage = 'stale', "
f" fail_reason = '本次运行未出现在候选范围内' "
f"WHERE run_id = :r AND symbol NOT IN ({placeholders}) "
f" AND (fail_stage IS NULL OR fail_stage <> 'stale')",
params,
cfg=cfg,
)
# 因子快照(决策时点因子值,供后续画像/回测复用) # 因子快照(决策时点因子值,供后续画像/回测复用)
snap_rows = [] snap_rows = []
+494
View File
@@ -0,0 +1,494 @@
"""回测结果分析:组合持仓查询与个股买卖点序列。
与 ``service.py`` 的分工:
- ``service.py`` 管「运行记录」本身(列表、命名、归档、关联)
- 本模块管「一次回测内部的明细」(某日持仓、某股买卖点与指标曲线)
**所有派生计算都在服务端完成**(TTM 股息率、ROE 的 PIT 对齐等),
前端只负责渲染 —— 与报告「模板不做计算」的原则一致,保证页面上每个数字
都能对应到一段可复核的 SQL。
口径要点:
- **股价用不复权收盘价**(``daily_basic.close``;``stock_daily`` 已验证与之逐日一致,
但 ``daily_basic`` 覆盖更全,且同表带 ``pe_ttm``,一次查询即可)
- **股息率 = PIT-TTM 每股分红 / 不复权收盘价**,复用因子层的 ``ttm_dps_series``,
与筛选、画像用的是同一套逻辑(含 45 天宽限期)
- **ROE 按公告日对齐**(``ann_date <= 当日``),是阶梯函数而非插值 ——
插值会制造「当时还不知道的」中间值
"""
from __future__ import annotations
import json
from datetime import date, timedelta
from decimal import Decimal
from typing import Any
import numpy as np
import pandas as pd
from hdiv.core.config import load_config
from hdiv.report.format import NumFmt
def _fmt() -> NumFmt:
"""当前配置的格式化器(每次读取,保证改配置立即生效)。"""
return NumFmt.from_config()
from hdiv.core.errors import HdivError
from hdiv.data import db
from hdiv.factor.dividend_yield import ttm_dps_series, ttm_params
#: 可在趋势图上叠加的序列(前端勾选项)
SERIES_KEYS = ("close", "dv_yield", "pe_ttm", "roe", "pb", "drawdown")
#: 单只股票最多返回的点数(约 12 年日频)。超出则等间隔降采样,
#: 只影响画图,不影响买卖点(买卖点单独返回且不降采样)。
MAX_POINTS = 3200
def _v(x: Any) -> Any:
if x is None:
return None
if isinstance(x, np.generic):
x = x.item()
if isinstance(x, Decimal):
return float(x)
if isinstance(x, (pd.Timestamp,)):
return x.date().isoformat()
if isinstance(x, date):
return x.isoformat()
if isinstance(x, float) and x != x:
return None
return x
def _fnum(x: Any) -> float | None:
try:
f = float(x)
except (TypeError, ValueError):
return None
return None if f != f else f
def _int_or_none(x: Any) -> int | None:
"""NaN 安全的整数转换。
``holding_days`` 这类列在 Pandas 里缺失时是 NaN 而**不是** None,
所以 ``int(r["holding_days"]) if r["holding_days"] is not None else None``
会在卖出成交(没有持仓天数)上抛 ``ValueError: cannot convert float NaN``,
让整个个股详情接口 500 —— 有卖出的个股因此整页打不开。
"""
f = _fnum(x)
return None if f is None else int(f)
def _reason_text(d: dict[str, Any]) -> str:
parts = []
y = _fnum(d.get("dividend_yield"))
p = _fnum(d.get("yield_percentile"))
if y is not None:
parts.append(f"股息率 {_fmt().pct(y)}")
if p is not None:
parts.append(f"历史分位 {p:.1f}%")
if d.get("rule"):
parts.append(str(d["rule"]))
if d.get("observation_count"):
parts.append(f"参照样本 {d['observation_count']}")
if d.get("reason_cn"):
parts.append(str(d["reason_cn"]))
return ";".join(parts) or "—"
# ---------------------------------------------------------------------------
# 组合持仓
# ---------------------------------------------------------------------------
def position_dates(run_id: str, *, limit: int | None = None,
detail: bool = False) -> dict[str, Any]:
"""该回测所有的持仓快照日期(供前端做日期选择/时间轴)。
``detail=False``(默认)只返回日期字符串 —— 前端翻上下一个交易日
只需要这份清单,带全部数值字段会让响应从约 30KB 膨胀到 460KB。
"""
cfg = load_config("datasource")
df = db.read_sql(
"SELECT e.trade_date, e.nav, e.total_value, e.cash, e.position_value, "
" e.drawdown, e.holding_count "
"FROM hd_backtest_equity e WHERE e.run_id = :r ORDER BY e.trade_date",
{"r": run_id}, cfg=cfg,
)
if df.empty:
raise HdivError(f"回测 {run_id} 没有净值数据,无法查询持仓")
items = [{
"date": _v(r["trade_date"]),
"total_value": _fnum(r["total_value"]),
"cash": _fnum(r["cash"]),
"position_value": _fnum(r["position_value"]),
"nav": _fnum(r["nav"]),
"drawdown": _fnum(r["drawdown"]),
"holding_count": int(r["holding_count"] or 0),
} for _, r in df.iterrows()]
out = {"dates": [x["date"] for x in items],
"count": len(items), "start": items[0]["date"], "end": items[-1]["date"]}
if detail:
out["items"] = items
if limit:
# 等间隔抽样,用于画持仓数量时间轴;不影响按日查询
step = max(1, len(items) // int(limit))
out["sampled"] = items[::step]
return out
def portfolio_on_date(run_id: str, day: str | None = None) -> dict[str, Any]:
"""查询某一交易日的组合汇总与逐股持仓明细。
``day`` 为空时取该回测最后一个交易日。若指定日非交易日,
自动回退到**之前最近**的一个有快照的交易日,并在返回中说明。
注意:日期清单在此处只用日期(``detail=False``),2500+ 个交易日的
全字段明细会让单次请求从约 30KB 涨到 460KB。
"""
cfg = load_config("datasource")
all_dates = position_dates(run_id)["dates"]
requested = day
if not day:
target = all_dates[-1]
else:
if day in all_dates:
target = day
else:
earlier = [d for d in all_dates if d <= day]
if not earlier:
raise HdivError(
f"{day} 早于该回测的首个快照 {all_dates[0]};"
f"可选区间 {all_dates[0]} ~ {all_dates[-1]}"
)
target = earlier[-1] # 回退到之前最近的交易日
eq = db.read_sql(
"SELECT trade_date, nav, total_value, cash, position_value, daily_return, "
" cum_return, drawdown, holding_count "
"FROM hd_backtest_equity WHERE run_id = :r AND trade_date = :d",
{"r": run_id, "d": target}, cfg=cfg,
)
# 名称/行业直接 JOIN 取回:既少一次往返,也避开 IN 元组绑定
# (pymysql + pandas 下 `IN %(s)s` 不是合法语法)
pos = db.read_sql(
"SELECT p.symbol, p.quantity, p.avg_cost, p.close, p.market_value, p.weight, "
" p.unrealized_pnl, p.holding_days, s.name, s.industry "
"FROM hd_backtest_position p "
"LEFT JOIN stock s ON s.symbol = p.symbol "
"WHERE p.run_id = :r AND p.trade_date = :d "
"ORDER BY p.weight DESC, p.symbol",
{"r": run_id, "d": target}, cfg=cfg,
)
positions = []
for _, r in pos.iterrows():
nm, ind = r["name"], r["industry"]
cost = _fnum(r["avg_cost"])
close = _fnum(r["close"])
pnl_pct = ((close / cost - 1.0) if (cost and close) else None)
positions.append({
"symbol": r["symbol"], "name": nm, "industry": ind,
"quantity": _fnum(r["quantity"]),
"avg_cost": cost, "close": close,
"market_value": _fnum(r["market_value"]),
"weight": _fnum(r["weight"]),
"unrealized_pnl": _fnum(r["unrealized_pnl"]),
"pnl_pct": pnl_pct,
"holding_days": _int_or_none(r["holding_days"]),
})
eq_row = eq.iloc[0] if not eq.empty else {}
total_mv = sum(p["market_value"] or 0.0 for p in positions)
total_cost = sum((p["avg_cost"] or 0.0) * (p["quantity"] or 0.0) for p in positions)
total_pnl = sum(p["unrealized_pnl"] or 0.0 for p in positions)
return {
"run_id": run_id,
"date": target,
"requested_date": requested,
"adjusted": bool(requested and requested != target),
"range": {"start": all_dates[0], "end": all_dates[-1], "count": len(all_dates)},
"equity": {
"nav": _fnum(eq_row.get("nav")),
"total_value": _fnum(eq_row.get("total_value")),
"cash": _fnum(eq_row.get("cash")),
"position_value": _fnum(eq_row.get("position_value")),
"daily_return": _fnum(eq_row.get("daily_return")),
"cum_return": _fnum(eq_row.get("cum_return")),
"drawdown": _fnum(eq_row.get("drawdown")),
"holding_count": int(eq_row.get("holding_count") or 0),
},
"positions": positions,
"summary": {
"count": len(positions),
"market_value": total_mv,
"cost": total_cost,
"unrealized_pnl": total_pnl,
"unrealized_pnl_pct": (total_pnl / total_cost) if total_cost else None,
},
}
# ---------------------------------------------------------------------------
# 个股买卖点与指标序列
# ---------------------------------------------------------------------------
def run_stocks(run_id: str) -> list[dict[str, Any]]:
"""该回测涉及的全部股票(持仓过或成交过),供前端选择。"""
cfg = load_config("datasource")
df = db.read_sql(
"""
SELECT p.symbol,
MAX(s.name) AS name,
MAX(s.industry) AS industry,
COUNT(*) AS hold_days,
MAX(p.trade_date) AS last_hold
FROM hd_backtest_position p
LEFT JOIN stock s ON s.symbol = p.symbol
WHERE p.run_id = :r
GROUP BY p.symbol
ORDER BY hold_days DESC, p.symbol
""",
{"r": run_id}, cfg=cfg,
)
tdf = db.read_sql(
"SELECT symbol, COUNT(*) AS n, SUM(side='BUY') AS buys, SUM(side='SELL') AS sells "
"FROM hd_backtest_trade WHERE run_id = :r GROUP BY symbol",
{"r": run_id}, cfg=cfg,
)
tmap = {r["symbol"]: (int(r["n"]), int(r["buys"] or 0), int(r["sells"] or 0))
for _, r in tdf.iterrows()}
out = []
for _, r in df.iterrows():
n, buys, sells = tmap.get(r["symbol"], (0, 0, 0))
out.append({
"symbol": r["symbol"], "name": r["name"], "industry": r["industry"],
"hold_days": int(r["hold_days"]), "last_hold": _v(r["last_hold"]),
"trade_count": n, "buy_count": buys, "sell_count": sells,
})
return out
def _price_panel(symbol: str, start: date, end: date, cfg: Any) -> pd.DataFrame:
"""不复权收盘价 + PE/PB(同一张 daily_basic,一次查询)。
``daily_basic`` 与 ``stock_daily`` 的收盘价已逐日核对一致,
但前者覆盖更全且自带估值指标,因此作为唯一价格源。
"""
return db.read_sql(
"SELECT trade_date, close, pe_ttm, pb, ps_ttm, dv_ttm, turnover_rate "
"FROM daily_basic WHERE symbol = :s AND trade_date BETWEEN :a AND :b "
"ORDER BY trade_date",
{"s": symbol, "a": start, "b": end}, cfg=cfg,
)
def _roe_series(symbol: str, dates: pd.Series, cfg: Any) -> np.ndarray:
"""把季度 ROE 对齐成日频阶梯序列(PIT:只看当日已公告的)。
刻意用「向前填充」而不是插值:插值会凭空造出当时并不存在的中间值,
属于未来函数。
"""
df = db.read_sql(
"SELECT ann_date, end_date, roe FROM hd_fina_indicator "
"WHERE symbol = :s AND roe IS NOT NULL AND ann_date IS NOT NULL "
"ORDER BY ann_date, end_date",
{"s": symbol}, cfg=cfg,
)
if df.empty:
return np.full(len(dates), np.nan)
df["ann_date"] = pd.to_datetime(df["ann_date"])
dts = pd.to_datetime(dates)
# merge_asof:对每个交易日取 ann_date <= 当日 的最后一条
left = pd.DataFrame({"trade_date": dts}).sort_values("trade_date")
merged = pd.merge_asof(
left, df[["ann_date", "roe"]].sort_values("ann_date"),
left_on="trade_date", right_on="ann_date", direction="backward",
)
return merged["roe"].to_numpy(dtype=float)
def _dividend_yield_series(
symbol: str, dates: pd.Series, close: np.ndarray, cfg: Any
) -> np.ndarray:
"""PIT-TTM 股息率 = TTM 每股分红 / 不复权收盘价。
复用因子层的 ``ttm_dps_series``(与筛选、画像同一套逻辑,含 45 天宽限期),
避免此处另写一份导致口径漂移。
"""
from hdiv.data.repo import Repo
if not len(dates):
return np.array([])
dts = pd.to_datetime(dates)
lo, hi = dts.min().date(), dts.max().date()
# 往前多取一年,保证 TTM 窗口在起点也是完整的
ev = Repo(cfg=cfg).dividend_events(lo - timedelta(days=400), hi)
if ev is None or ev.empty:
return np.full(len(dates), np.nan)
ev = ev[ev["symbol"] == symbol]
if ev.empty:
return np.full(len(dates), np.nan)
_w, _g, _sm = ttm_params()
dps = ttm_dps_series(pd.DatetimeIndex(dts), ev,
ttm_days=_w, grace_days=_g, smooth_spikes=_sm)
with np.errstate(divide="ignore", invalid="ignore"):
out = np.where((close > 0) & np.isfinite(dps), dps / close, np.nan)
return out.astype(float)
def _downsample(n: int, target: int) -> np.ndarray:
"""等间隔取索引,保留首尾。仅用于画图,买卖点不降采样。"""
if n <= target:
return np.arange(n)
idx = np.linspace(0, n - 1, target).round().astype(int)
return np.unique(idx)
def stock_detail(
run_id: str,
symbol: str,
*,
start: str | None = None,
end: str | None = None,
series: list[str] | None = None,
) -> dict[str, Any]:
"""某只股票在该回测中的买卖点与指标曲线。
``series`` 指定需要计算哪些序列;未指定的不会计算(省时),
但返回结构中仍会列出 ``available_series`` 供前端画勾选框。
"""
cfg = load_config("datasource")
wanted = [s for s in (series or list(SERIES_KEYS)) if s in SERIES_KEYS]
if not wanted:
raise HdivError(f"series 无效:{series};可选 {list(SERIES_KEYS)}")
info = db.read_sql(
"SELECT symbol, name, industry, market, list_date FROM stock WHERE symbol = :s",
{"s": symbol}, cfg=cfg,
)
if info.empty:
raise HdivError(f"股票不存在:{symbol}")
# 缺省区间:该股在该回测中的持仓区间;无持仓则用整个回测区间
hold = db.read_sql(
"SELECT MIN(trade_date) AS a, MAX(trade_date) AS b, COUNT(*) AS n "
"FROM hd_backtest_position WHERE run_id = :r AND symbol = :s",
{"r": run_id, "s": symbol}, cfg=cfg,
)
run = db.read_sql(
"SELECT start_date, end_date FROM hd_backtest_run WHERE run_id = :r",
{"r": run_id}, cfg=cfg,
)
if run.empty:
raise HdivError(f"回测不存在:{run_id}")
run_a, run_b = run["start_date"].iloc[0], run["end_date"].iloc[0]
has_hold = not hold.empty and hold["n"].iloc[0]
d_a = pd.to_datetime(start).date() if start else (
hold["a"].iloc[0] if has_hold else run_a)
d_b = pd.to_datetime(end).date() if end else (
hold["b"].iloc[0] if has_hold else run_b)
panel = _price_panel(symbol, d_a, d_b, cfg)
if panel.empty:
raise HdivError(
f"{symbol} 在 {d_a} ~ {d_b} 没有行情数据。"
f"该股行情覆盖见 stock_daily/daily_basic。"
)
dates = panel["trade_date"]
close = panel["close"].to_numpy(dtype=float)
series_out: dict[str, list[Any]] = {}
if "close" in wanted:
series_out["close"] = [_fnum(x) for x in close]
if "pe_ttm" in wanted:
series_out["pe_ttm"] = [_fnum(x) for x in panel["pe_ttm"]]
if "pb" in wanted:
series_out["pb"] = [_fnum(x) for x in panel["pb"]]
if "dv_yield" in wanted:
series_out["dv_yield"] = [_fnum(x) for x in
_dividend_yield_series(symbol, dates, close, cfg)]
if "roe" in wanted:
series_out["roe"] = [_fnum(x) for x in _roe_series(symbol, dates, cfg)]
if "drawdown" in wanted:
running_max = np.maximum.accumulate(np.where(np.isfinite(close), close, np.nan))
with np.errstate(divide="ignore", invalid="ignore"):
series_out["drawdown"] = [_fnum(x) for x in (close / running_max - 1.0)]
# 买卖点:不降采样,且带完整成交信息
tdf = db.read_sql(
"SELECT trade_id, signal_date, execution_date, side, price, quantity, amount, "
" commission, stamp_tax, transfer_fee, slippage_cost, total_cost, "
" realized_pnl, holding_days, reason_json "
"FROM hd_backtest_trade WHERE run_id = :r AND symbol = :s "
"ORDER BY execution_date, trade_id",
{"r": run_id, "s": symbol}, cfg=cfg,
)
trades = []
for _, r in tdf.iterrows():
reason = {}
if r["reason_json"]:
try:
reason = json.loads(r["reason_json"])
except Exception:
reason = {}
trades.append({
"trade_id": r["trade_id"],
"signal_date": _v(r["signal_date"]),
"execution_date": _v(r["execution_date"]),
"side": r["side"],
"price": _fnum(r["price"]),
"quantity": _fnum(r["quantity"]),
"amount": _fnum(r["amount"]),
"commission": _fnum(r["commission"]),
"stamp_tax": _fnum(r["stamp_tax"]),
"transfer_fee": _fnum(r["transfer_fee"]),
"slippage_cost": _fnum(r["slippage_cost"]),
"total_cost": _fnum(r["total_cost"]),
"realized_pnl": _fnum(r["realized_pnl"]),
"holding_days": _int_or_none(r["holding_days"]),
"reason": reason,
"reason_text": _reason_text(reason),
})
idx = _downsample(len(dates), MAX_POINTS)
dates_out = [_v(dates.iloc[i]) for i in idx]
series_out = {k: [v[i] for i in idx] for k, v in series_out.items()}
buys = [t for t in trades if t["side"] == "BUY"]
sells = [t for t in trades if t["side"] == "SELL"]
realized = sum(t["realized_pnl"] or 0.0 for t in sells)
fees = sum((t["commission"] or 0) + (t["stamp_tax"] or 0) + (t["transfer_fee"] or 0)
for t in trades)
return {
"run_id": run_id, "symbol": symbol,
"info": {k: _v(v) for k, v in info.iloc[0].items()},
"range": {"start": _v(dates.iloc[0]), "end": _v(dates.iloc[-1]),
"requested_start": d_a.isoformat(), "requested_end": d_b.isoformat(),
"points": len(dates), "downsampled": len(idx) < len(dates)},
"available_series": list(SERIES_KEYS),
"series": series_out,
"dates": dates_out,
"trades": trades,
"stats": {
"trade_count": len(trades),
"buy_count": len(buys), "sell_count": len(sells),
"realized_pnl": realized,
"total_fees": fees,
"buy_amount": sum(t["amount"] or 0.0 for t in buys),
"sell_amount": sum(t["amount"] or 0.0 for t in sells),
"first_trade": trades[0]["execution_date"] if trades else None,
"last_trade": trades[-1]["execution_date"] if trades else None,
},
}
+78 -6
View File
@@ -29,8 +29,9 @@ from urllib.parse import parse_qs, unquote, urlparse
import numpy as np import numpy as np
from hdiv.core.errors import HdivError
from hdiv.core.paths import output_dir, project_root from hdiv.core.paths import output_dir, project_root
from hdiv.web import service from hdiv.web import analysis, service
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 路由表 # 路由表
@@ -64,6 +65,27 @@ def _health(**_: Any) -> dict[str, Any]:
return {"ok": True, "time": datetime.now().isoformat(timespec="seconds")} return {"ok": True, "time": datetime.now().isoformat(timespec="seconds")}
@route("GET", r"/api/config/display")
def _display_config(**_: Any) -> dict[str, Any]:
"""把 config/report.yml 的显示精度暴露给前端。
前端曾把百分比硬编码为 2 位小数,改配置不会有任何反应 ——
与报告层是同一个毛病(配置是死的)。这里让前端也由配置驱动。
"""
from hdiv.core.config import load_config
from hdiv.report.format import NumFmt
cfg = load_config("report")
f = NumFmt.from_config(cfg)
return {
"ratio": f.ratio, "money": f.money, "price": f.price,
"percent": f.percent,
"max_width": cfg.layout.max_width,
"table_page_size": cfg.layout.table_page_size,
"theme": cfg.theme,
}
@route("GET", r"/api/summary") @route("GET", r"/api/summary")
def _summary(**_: Any) -> dict[str, Any]: def _summary(**_: Any) -> dict[str, Any]:
return service.summary() return service.summary()
@@ -135,6 +157,19 @@ def _stock(symbol: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
return r return r
@route("GET", r"/api/walkforwards")
def _walkforwards(**_: Any) -> dict[str, Any]:
return {"items": service.list_walkforwards()}
@route("GET", r"/api/walkforwards/(?P<wf_id>[\w-]+)")
def _walkforward(wf_id: str, **_: Any) -> dict[str, Any]:
r = service.get_walkforward(wf_id)
if r is None:
raise ApiError(404, f"Walk-forward 记录不存在:{wf_id}")
return r
@route("GET", r"/api/backtests") @route("GET", r"/api/backtests")
def _backtests(q: dict[str, list[str]], **_: Any) -> dict[str, Any]: def _backtests(q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
return {"items": service.list_backtests( return {"items": service.list_backtests(
@@ -162,9 +197,16 @@ def _backtest_metrics(run_id: str, **_: Any) -> dict[str, Any]:
return {"items": service.get_backtest_metrics(run_id)} return {"items": service.get_backtest_metrics(run_id)}
@route("GET", r"/api/indices")
def _indices(**_: Any) -> dict[str, Any]:
"""可叠加到净值曲线右轴的基准指数。"""
return {"items": service.list_indices()}
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/equity") @route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/equity")
def _backtest_equity(run_id: str, **_: Any) -> dict[str, Any]: def _backtest_equity(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
return service.get_backtest_equity(run_id) """净值曲线;index= 指定右轴叠加的指数(缺省不叠加)。"""
return service.get_backtest_equity(run_id, index_code=_one(q, "index"))
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/trades") @route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/trades")
@@ -174,9 +216,34 @@ def _backtest_trades(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str
) )
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/positions") @route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/portfolio")
def _backtest_positions(run_id: str, **_: Any) -> dict[str, Any]: def _portfolio(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
return {"items": service.get_backtest_positions(run_id)} """任意交易日的组合汇总 + 逐股持仓明细。date 省缺则取最后一日。"""
return analysis.portfolio_on_date(run_id, _one(q, "date"))
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/position-dates")
def _position_dates(run_id: str, q: dict[str, list[str]], **_: Any) -> dict[str, Any]:
return analysis.position_dates(
run_id, limit=int(_one(q, "sample") or 0) or None,
detail=_bool(q, "detail"),
)
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/stocks")
def _run_stocks(run_id: str, **_: Any) -> dict[str, Any]:
return {"items": analysis.run_stocks(run_id)}
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/stocks/(?P<symbol>[\w.]+)")
def _run_stock_detail(run_id: str, symbol: str, q: dict[str, list[str]],
**_: Any) -> dict[str, Any]:
"""某股在该回测中的买卖点与指标曲线(series 可勾选)。"""
raw = _one(q, "series")
wanted = [x.strip() for x in raw.split(",") if x.strip()] if raw else None
return analysis.stock_detail(
run_id, symbol, start=_one(q, "start"), end=_one(q, "end"), series=wanted
)
@route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/signals") @route("GET", r"/api/backtests/(?P<run_id>[\w-]+)/signals")
@@ -291,6 +358,11 @@ def make_handler(static: StaticFiles, *, api_only: bool = False) -> type[BaseHTT
self._serve_static(path) self._serve_static(path)
except ApiError as exc: except ApiError as exc:
self._json(exc.status, {"error": exc.message}) self._json(exc.status, {"error": exc.message})
except HdivError as exc:
# HdivError = 用户可理解的问题(参数越界、数据缺失等)。
# 返回 400 + 原始信息,而不是笼统的 500「服务端内部错误」——
# 后者会把「日期超出范围」这种可自行修正的问题说成服务故障。
self._json(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
self._json(HTTPStatus.INTERNAL_SERVER_ERROR, self._json(HTTPStatus.INTERNAL_SERVER_ERROR,
+251 -5
View File
@@ -21,6 +21,18 @@ import numpy as np
import pandas as pd import pandas as pd
from hdiv.core.config import load_config from hdiv.core.config import load_config
from hdiv.core.errors import HdivError
from hdiv.report.format import NumFmt
#: 净值曲线默认叠加的指数(沪深300)
DEFAULT_INDEX_CODE = "000300.SH"
def _fmt() -> NumFmt:
"""当前配置的格式化器(每次读取,保证改配置立即生效)。"""
return NumFmt.from_config()
from hdiv.data import db from hdiv.data import db
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -151,7 +163,7 @@ def _yi(v: Any) -> str:
def _pct(v: Any) -> str: def _pct(v: Any) -> str:
n = _num(v) n = _num(v)
return "—" if n is None else f"{n * 100:.2f}%" return "—" if n is None else _fmt().pct(n)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -550,6 +562,175 @@ def get_backtest(run_id: str) -> dict[str, Any] | None:
return r return r
# ---------------------------------------------------------------------------
# Walk-forward(样本外验证)
# ---------------------------------------------------------------------------
def list_walkforwards() -> list[dict[str, Any]]:
"""Walk-forward 运行列表。每个 wf_id 是一条独立记录。"""
cfg = load_config("datasource")
df = db.read_sql(
"""
SELECT w.wf_id, w.strategy_id, w.strategy_version, w.scheme,
w.train_years, w.test_years, w.step_months, w.window_count,
w.config_hash, w.data_version, w.code_version, w.status,
w.created_at, w.config_json,
(SELECT COUNT(*) FROM hd_walkforward_window k
WHERE k.wf_id = w.wf_id) AS windows_actual
FROM hd_walkforward_run w
ORDER BY w.created_at DESC
""",
cfg=cfg,
)
out: list[dict[str, Any]] = []
for _, row in df.iterrows():
r = _rec(row)
r["strategy"] = describe_strategy(_json_field(r.pop("config_json", None)) or {})
r["title"] = (
f"{r['strategy_id']} v{r['strategy_version']} · "
f"{r['scheme']} 训练{r['train_years']}年/测试{r['test_years']}年 · "
f"{r['window_count']} 窗口"
)
# 汇总样本外表现。逐窗口取「该窗口 test_run 的 total_return」,
# 每个窗口只贡献一个样本 —— 早期版本靠 metric 行数反推窗口数,
# 一旦某个指标缺失就会算错胜率。
agg = db.read_sql(
"""
SELECT k.window_index,
MAX(CASE WHEN m.metric_code='total_return'
THEN m.metric_value END) AS ret,
MAX(CASE WHEN m.metric_code='max_drawdown'
THEN m.metric_value END) AS dd
FROM hd_walkforward_window k
LEFT JOIN hd_backtest_metric m
ON m.run_id = k.test_run_id AND m.scope = 'all'
AND (m.benchmark_code IS NULL OR m.benchmark_code = '')
WHERE k.wf_id = :w
GROUP BY k.window_index
""",
{"w": r["wf_id"]}, cfg=cfg,
)
rets = [x for x in agg["ret"].tolist() if x is not None]
dds = [x for x in agg["dd"].tolist() if x is not None]
r["oos"] = {
"window_count": len(agg),
"sample_count": len(rets),
"mean_return": (sum(rets) / len(rets)) if rets else None,
"win_rate": (sum(1 for x in rets if x > 0) / len(rets)) if rets else None,
"worst_drawdown": min(dds) if dds else None,
}
out.append(r)
return out
def get_walkforward(wf_id: str) -> dict[str, Any] | None:
"""Walk-forward 详情:逐窗口的样本内/外表现与冻结参数。"""
cfg = load_config("datasource")
head = db.read_sql(
"SELECT * FROM hd_walkforward_run WHERE wf_id = :w", {"w": wf_id}, cfg=cfg
)
if head.empty:
return None
r = _rec(head.iloc[0])
r["strategy"] = describe_strategy(_json_field(r.pop("config_json", None)) or {})
r["config"] = _json_field(r.pop("config_json", None))
r["title"] = (
f"{r['strategy_id']} v{r['strategy_version']} · "
f"{r['window_count']} 窗口({r['scheme']})"
)
win = db.read_sql(
"SELECT window_index, train_start, train_end, test_start, test_end, "
" frozen_params_json, train_run_id, test_run_id "
"FROM hd_walkforward_window WHERE wf_id = :w ORDER BY window_index",
{"w": wf_id}, cfg=cfg,
)
run_ids = [x for x in win["train_run_id"].tolist() + win["test_run_id"].tolist() if x]
mets: dict[str, dict[str, Any]] = {}
if run_ids:
placeholders = ",".join(f":r{i}" for i in range(len(run_ids)))
params = {f"r{i}": v for i, v in enumerate(run_ids)}
md = db.read_sql(
f"SELECT run_id, metric_code, metric_value FROM hd_backtest_metric "
f"WHERE run_id IN ({placeholders}) AND scope='all' "
f" AND (benchmark_code IS NULL OR benchmark_code = '') "
f" AND metric_code IN ('total_return','cagr','max_drawdown','sharpe',"
f" 'trade_count','annual_volatility')",
params, cfg=cfg,
)
for _, x in md.iterrows():
mets.setdefault(x["run_id"], {})[x["metric_code"]] = _num(x["metric_value"])
# 基准指标存成 benchmark_<code>(如 benchmark_000300.SH),
# 上面的查询用 benchmark_code='' 过滤掉了它 —— 于是页面上看不到
# **超额收益**,而那恰恰是判读样本外最该看的数字。这里单独取回。
bm = db.read_sql(
f"SELECT run_id, metric_code, metric_value FROM hd_backtest_metric "
f"WHERE run_id IN ({placeholders}) AND category = 'benchmark'",
params, cfg=cfg,
)
for _, x in bm.iterrows():
code = str(x["metric_code"]).replace("benchmark_", "")
mets.setdefault(x["run_id"], {})[f"benchmark::{code}"] = _num(x["metric_value"])
# 基准:从 equity 表取被比较基准区间收益(回测落库时已算入 metric)
windows = []
for _, w in win.iterrows():
tr, te = w["train_run_id"], w["test_run_id"]
oos = {k: v for k, v in mets.get(te, {}).items() if not k.startswith("benchmark::")}
bench = {k.split("::", 1)[1]: v
for k, v in mets.get(te, {}).items() if k.startswith("benchmark::")}
# 超额 = 策略样本外收益 − 基准同期收益(取第一个基准,与 backtest.yml 顺序一致)
bench_code = next(iter(bench), None)
bench_ret = bench.get(bench_code) if bench_code else None
strat_ret = oos.get("total_return")
windows.append({
"window_index": int(w["window_index"]),
"train_start": _v(w["train_start"]), "train_end": _v(w["train_end"]),
"test_start": _v(w["test_start"]), "test_end": _v(w["test_end"]),
"frozen_params": _json_field(w["frozen_params_json"]) or {},
"train_run_id": tr, "test_run_id": te,
"in_sample": {k: v for k, v in mets.get(tr, {}).items()
if not k.startswith("benchmark::")},
"out_of_sample": oos,
"benchmark_code": bench_code,
"benchmark_return": bench_ret,
"excess_return": (strat_ret - bench_ret)
if (strat_ret is not None and bench_ret is not None) else None,
})
oos = [x["out_of_sample"].get("total_return") for x in windows
if x["out_of_sample"].get("total_return") is not None]
dds = [x["out_of_sample"].get("max_drawdown") for x in windows
if x["out_of_sample"].get("max_drawdown") is not None]
bench = [x["benchmark_return"] for x in windows if x["benchmark_return"] is not None]
excess = [x["excess_return"] for x in windows if x["excess_return"] is not None]
import statistics as _st
summary = {
"window_count": len(windows),
"oos_returns": oos,
"benchmark_returns": bench,
"excess_returns": excess,
"benchmark_mean": (_st.fmean(bench) if bench else None),
"excess_mean": (_st.fmean(excess) if excess else None),
"excess_win_rate": (sum(1 for x in excess if x > 0) / len(excess)) if excess else None,
"oos_mean": (_st.fmean(oos) if oos else None),
"oos_median": (_st.median(oos) if oos else None),
"oos_win_rate": (sum(1 for x in oos if x > 0) / len(oos)) if oos else None,
"oos_worst_drawdown": (min(dds) if dds else None),
# 稳定性 = 均值 / 标准差:<1 说明窗口间差异大于均值本身,结论不稳
"oos_stability": (
_st.fmean(oos) / _st.pstdev(oos)
if len(oos) > 1 and _st.pstdev(oos) > 0 else None
),
}
r["windows"] = windows
r["summary"] = summary
return r
def get_backtest_metrics(run_id: str) -> list[dict[str, Any]]: def get_backtest_metrics(run_id: str) -> list[dict[str, Any]]:
cfg = load_config("datasource") cfg = load_config("datasource")
return _records(db.read_sql( return _records(db.read_sql(
@@ -559,7 +740,30 @@ def get_backtest_metrics(run_id: str) -> list[dict[str, Any]]:
)) ))
def get_backtest_equity(run_id: str) -> dict[str, Any]: def list_indices() -> list[dict[str, Any]]:
"""可叠加到净值曲线上的基准指数(库里有多少列多少)。
一并返回各自的行情的起止日期与点数:前端据此提示
「该指数在本次回测区间内没有行情」,而不是画一条空线让人猜。
"""
cfg = load_config("datasource")
df = db.read_sql(
"SELECT index_code, MAX(index_name) AS index_name, COUNT(*) AS points, "
" MIN(trade_date) AS start, MAX(trade_date) AS end "
"FROM hd_index_daily GROUP BY index_code ORDER BY index_code",
cfg=cfg,
)
return [{
"code": r["index_code"],
"name": _v(r["index_name"]) or r["index_code"],
"points": int(r["points"]),
"start": _v(r["start"]),
"end": _v(r["end"]),
"is_default": r["index_code"] == DEFAULT_INDEX_CODE,
} for _, r in df.iterrows()]
def get_backtest_equity(run_id: str, *, index_code: str | None = None) -> dict[str, Any]:
cfg = load_config("datasource") cfg = load_config("datasource")
df = db.read_sql( df = db.read_sql(
"SELECT trade_date, nav, total_value, cash, position_value, drawdown, " "SELECT trade_date, nav, total_value, cash, position_value, drawdown, "
@@ -568,13 +772,52 @@ def get_backtest_equity(run_id: str) -> dict[str, Any]:
{"r": run_id}, cfg=cfg, {"r": run_id}, cfg=cfg,
) )
if df.empty: if df.empty:
return {"dates": [], "nav": [], "bench": [], "drawdown": [], "holding_count": []} return {"dates": [], "nav": [], "bench": [], "drawdown": [],
"holding_count": [], "benchmark_code": None, "index": None}
dates = [str(pd.Timestamp(x).date()) for x in df["trade_date"]]
return { return {
"dates": [str(pd.Timestamp(x).date()) for x in df["trade_date"]], "dates": dates,
"nav": [_num(x) for x in df["nav"]], "nav": [_num(x) for x in df["nav"]],
"bench": [_num(x) for x in df["benchmark_nav"]], "bench": [_num(x) for x in df["benchmark_nav"]],
"drawdown": [_num(x) for x in df["drawdown"]], "drawdown": [_num(x) for x in df["drawdown"]],
"holding_count": [int(x) if x is not None else 0 for x in df["holding_count"]], "holding_count": [int(x) if x is not None else 0 for x in df["holding_count"]],
"benchmark_code": _v(df["benchmark_code"].iloc[-1]),
# 可选叠加指数(右轴);不传就是纯净值曲线
"index": _index_overlay(index_code, dates, cfg=cfg) if index_code else None,
}
def _index_overlay(index_code: str, dates: list[str], *, cfg: Any) -> dict[str, Any]:
"""把指数收盘价对齐到净值曲线的日期上,供右轴叠加。
类目轴上每个类目一个点,序列必须**逐点对齐**(缺的补 None),
否则整条指数线会相对净值曲线整体错位。
"""
df = db.read_sql(
"SELECT trade_date, close, index_name FROM hd_index_daily "
"WHERE index_code = :c AND trade_date BETWEEN :s AND :e "
"ORDER BY trade_date",
{"c": index_code, "s": dates[0], "e": dates[-1]}, cfg=cfg,
)
if df.empty:
# 区分「库里没这个指数」(用户传错,应当报错)
# 与「这个指数在该区间没有行情」(创业板指对更早的回测),后者只提示。
n = int(db.read_sql(
"SELECT COUNT(*) AS n FROM hd_index_daily WHERE index_code = :c",
{"c": index_code}, cfg=cfg)["n"].iloc[0])
if not n:
raise HdivError(f"未知指数:{index_code}")
return {"code": index_code, "name": index_code, "close": [None] * len(dates),
"points": 0, "covered": False}
close_by_date = {str(pd.Timestamp(d).date()): _num(c)
for d, c in zip(df["trade_date"], df["close"])}
close = [close_by_date.get(d) for d in dates]
return {
"code": index_code,
"name": _v(df["index_name"].iloc[0]) or index_code,
"close": close,
"points": sum(1 for x in close if x is not None),
"covered": True,
} }
@@ -612,7 +855,7 @@ def _reason_text(d: dict[str, Any]) -> str:
y = _num(d.get("dividend_yield")) y = _num(d.get("dividend_yield"))
p = _num(d.get("yield_percentile")) p = _num(d.get("yield_percentile"))
if y is not None: if y is not None:
parts.append(f"股息率 {y * 100:.2f}%") parts.append(f"股息率 {_fmt().pct(y)}")
if p is not None: if p is not None:
parts.append(f"历史分位 {p:.1f}%") parts.append(f"历史分位 {p:.1f}%")
if d.get("rule"): if d.get("rule"):
@@ -652,6 +895,9 @@ _SKIP_LABELS = {
"ALREADY_AT_TARGET": "已达目标仓位", "ALREADY_AT_TARGET": "已达目标仓位",
"NO_POSITION": "无持仓", "NO_POSITION": "无持仓",
"BELOW_MIN_TRADE": "低于最小交易量", "BELOW_MIN_TRADE": "低于最小交易量",
# 实时画像闸门剔除(信号类型 REJECT):不是撮合失败,而是「按当日可见
# 数据重算画像后判定不值得买」。详情在 reason_json.profile_gate.checks。
"PROFILE_GATE": "实时画像未通过,主动放弃买入",
} }
+233
View File
@@ -458,3 +458,236 @@ def test_metrics_do_not_invent_values() -> None:
}) })
m = compute_metrics(eq, [], load_config("backtest"), "r3") m = compute_metrics(eq, [], load_config("backtest"), "r3")
assert m["sharpe"] is None, "1 个观测算不出波动率,Sharpe 必须是 None" assert m["sharpe"] is None, "1 个观测算不出波动率,Sharpe 必须是 None"
# ---------------------------------------------------------------------------
# 分位参照的最小样本量保护
# ---------------------------------------------------------------------------
def test_min_observations_config_exists_with_sane_default() -> None:
"""回归:分位参考必须设最小样本量,否则退化分布会伪造 100% 分位。
分位 = 「≤当前值的观测占比」。窗口里只有 1 个观测且恰好等于当前值时
占比 100%,击穿任何买入阈值 —— 实测 2015-01-06(行情数据首日)
8 只股票因此被「100% 分位」买入。
"""
from hdiv.core.config import load_config
ref = load_config("backtest").percentile_reference
assert hasattr(ref, "min_observations"), "缺少 min_observations 配置"
assert ref.min_observations >= 60, \
f"最小样本量过低({ref.min_observations}),至少应约一个季度"
def test_engine_guards_against_insufficient_reference_sample() -> None:
"""引擎必须在样本不足时跳过信号,而不是照常算分位。"""
import inspect
from hdiv.backtest import engine as eng
src = inspect.getsource(eng)
assert "min_observations" in src, "引擎未使用 min_observations"
# 保护必须在计算 pct 之前,且以 continue 跳过该股当日
i_guard = src.find("ref_ser.size < self.bt_cfg.percentile_reference.min_observations")
i_pct = src.find("pct = float((ref_ser <= current)")
assert i_guard != -1, "未找到最小样本量判断"
assert i_pct != -1 and i_guard < i_pct, "样本量判断必须早于分位计算"
assert "continue" in src[i_guard:i_pct], "样本不足应跳过(continue)而非降级计算"
@pytest.mark.db
def test_recent_backtests_have_no_weak_sample_trades() -> None:
"""按新配置跑出的回测不应存在弱样本成交(样本 < min_observations)。"""
from hdiv.core.config import load_config
from hdiv.data import db
db.load_dotenv_once()
cfg = load_config("datasource")
min_obs = load_config("backtest").percentile_reference.min_observations
df = db.read_sql(
"SELECT run_id, COUNT(*) AS n FROM hd_backtest_trade "
"WHERE JSON_EXTRACT(reason_json, '$.observation_count') IS NOT NULL "
" AND JSON_EXTRACT(reason_json, '$.observation_count') < :m "
" AND run_id IN (SELECT run_id FROM hd_backtest_run "
" WHERE created_at > '2026-10-03 14:30:00') "
"GROUP BY run_id",
{"m": min_obs}, cfg=cfg,
)
assert df.empty, (
f"存在弱样本成交的回测(应为 0):"
f"{[(r['run_id'][:10], int(r['n'])) for _, r in df.iterrows()]}"
)
# ---------------------------------------------------------------------------
# 实时画像闸门(entry.profile_gate)
# ---------------------------------------------------------------------------
def _trigger_ctx(sym: str = "000001.SZ", n: int = 280) -> dict:
"""构造一个「股息率处于历史最高分位」的最小上下文。
每股分红恒定 1 元、股价从 20 元跌到 10 元 → 股息率从 5% 升到 10%,
当前值即窗口最大值,分位 = 100% ≥ P75,必然触发买入条件。
``n`` 必须 **同时** 满足两个约束:
- ≥ ``backtest.yml: percentile_reference.min_observations``(250,否则引擎跳过);
- ≈ ≤ 410 个自然日(TTM 分红窗口 365 + 宽限 45),否则序列尾部 TTM 分红归零、
股息率变成 0、分位塌到 30% 以下,触发不了买入。280 个交易日 ≈ 392 天,两者都满足。
"""
days = pd.bdate_range("2015-01-05", periods=n)
close = np.concatenate([np.full(n - 50, 20.0), np.linspace(20.0, 10.0, 50)])
px = pd.DataFrame({"open": close, "close": close}, index=days)
ev = pd.DataFrame([{
"ex_date": days[0], "imp_ann_date": days[0], "cash_div_tax": 1.0,
}])
return {"px_by_sym": {sym: px}, "events": {sym: ev}, "_last_day": days[-1].date()}
def _pass_gate(*_a, **_k) -> dict:
return {"verdict": "PASS", "checks": [], "failed": [], "unverifiable": []}
def _reject_gate(*_a, **_k) -> dict:
return {
"verdict": "REJECT",
"checks": [{"metric": "payout_ratio", "stat": "current_value", "op": "<=",
"threshold": 1.0, "actual": 1.4, "status": "OK",
"window_years": 0, "passed": False}],
"failed": ["payout_ratio.current_value<=1"],
"unverifiable": [],
}
def test_gate_rejects_buy(engine, monkeypatch) -> None:
"""闸门不通过时必须改为 REJECT,且不产生 BUY。"""
ctx = _trigger_ctx()
day = ctx["_last_day"]
monkeypatch.setattr(engine, "_gate", _reject_gate)
sigs = engine._evaluate(day, 1e6, {}, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert "BUY" not in kinds, f"闸门未拦住买入:{kinds}"
assert kinds == ["REJECT"]
rej = sigs[0]
assert rej.reason["skip_reason"] == "PROFILE_GATE"
assert rej.reason["executed"] is False
# 「为什么不买」必须可追溯:逐条规则的实际值与阈值都要留下
chk = rej.reason["profile_checks"]["payout_ratio"]
assert chk["actual"] == pytest.approx(1.4) and chk["threshold"] == 1.0
assert chk["passed"] is False
def test_gate_pass_keeps_buy(engine, monkeypatch) -> None:
"""闸门通过时买入必须照常发生(不能误杀)。"""
ctx = _trigger_ctx()
monkeypatch.setattr(engine, "_gate", _pass_gate)
sigs = engine._evaluate(ctx["_last_day"], 1e6, {}, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert kinds == ["BUY"], kinds
def test_gate_reject_leaves_existing_position_untouched(engine, monkeypatch) -> None:
"""闸门语义是「不值得买」,不是「该卖」—— 被拒时不得动已有仓位。"""
ctx = _trigger_ctx()
pos = {"000001.SZ": Position(symbol="000001.SZ", quantity=1000.0, avg_cost=15.0,
first_buy_date=date(2015, 1, 5), last_buy_date=date(2015, 1, 5),
cost_basis=15000.0)}
monkeypatch.setattr(engine, "_gate", _reject_gate)
sigs = engine._evaluate(ctx["_last_day"], 1e6, pos, {"000001.SZ"}, ctx)
kinds = [s.kind for s in sigs]
assert "TRIM" not in kinds and "SELL" not in kinds and "ADD" not in kinds, kinds
assert kinds == ["REJECT"], "高仓位侧被拒时应只留痕,不调仓"
def test_gate_handles_unverifiable_conservatively(engine, monkeypatch) -> None:
"""无法验证(数据缺失/样本不足)时按配置保守处理,且理由要能区分。"""
def _unver(*_a, **_k):
return {"verdict": "REJECT", "checks": [
{"metric": "roe_avg", "stat": "current_value", "op": ">=", "threshold": 0.08,
"actual": None, "status": "INSUFFICIENT", "window_years": 0, "passed": None}],
"failed": [], "unverifiable": ["roe_avg.current_value"]}
ctx = _trigger_ctx()
monkeypatch.setattr(engine, "_gate", _unver)
sigs = engine._evaluate(ctx["_last_day"], 1e6, {}, {"000001.SZ"}, ctx)
assert [s.kind for s in sigs] == ["REJECT"]
assert "无法验证" in sigs[0].reason["rule"]
assert "样本不足" in sigs[0].reason["reason_cn"]
def test_gate_disabled_returns_none(engine) -> None:
"""闸门关闭时 _gate 必须返回 None —— 调用方不产生任何额外行为。"""
from hdiv.backtest.engine import BacktestEngine
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
eng.gate_cfg.enabled = False
eng.pit = object() # 即使被注入也不得被使用
assert eng._gate("000001.SZ", date(2018, 5, 18)) is None
def test_gate_enabled_but_uninitialized_fails_loudly() -> None:
"""启用但未初始化必须报错,不得静默放行(否则等于风控悄悄失效)。"""
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.errors import HdivError
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
eng.gate_cfg.enabled = True
eng.pit = None
with pytest.raises(HdivError) as ei:
eng._gate("000001.SZ", date(2018, 5, 18))
assert "_prepare" in str(ei.value)
@pytest.mark.db
def test_gate_enabled_backtest_records_rejections() -> None:
"""端到端:启用闸门的短区间回测必须留下可追溯的 REJECT 记录且资金对账平衡。"""
from hdiv.backtest.engine import BacktestEngine
try:
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
assert eng.gate_cfg.enabled is True, "默认策略应已启用实时画像闸门"
res = eng.run(start=date(2016, 1, 1), end=date(2016, 12, 31),
persist=False, verbose=False)
except Exception as exc: # 数据不可用
pytest.skip(f"数据库不可用:{exc}")
assert res["reconciliation"]["balanced"] is True
stats = res["profile_gate"]
assert stats["asof_contexts"] > 0, "应产生实时画像时点"
rejects = [s for s in res["signals"] if s.kind == "REJECT"]
assert rejects, "该区间应存在被画像剔除的买入信号"
for s in rejects:
assert s.reason["skip_reason"] == "PROFILE_GATE"
gate = s.reason["profile_gate"]
assert gate["verdict"] in {"REJECT", "UNVERIFIABLE"}
assert gate["checks"], "每条 REJECT 都必须带逐规则留痕"
assert gate["failed"] or gate["unverifiable"]
for c in gate["checks"]:
assert set(c) >= {"metric", "op", "threshold", "actual", "status", "passed"}
@pytest.mark.db
def test_unimplemented_declarations_are_honest() -> None:
"""自我声明必须两头都准:既不能漏报「配置写了但没实现」,
也不能把「这批股票恰好没停牌」误报成「数据缺失」。
背景:2026-10-04 之前,``unimplemented`` 用「过滤后集合为空」判定约束失效,
于是 2026-08~09(hd_suspend 明明覆盖到 2026-09-30,只是这批股票没停牌)
被声明成「hd_suspend 无数据」—— 把自己的建模正常状态说成数据缺陷。
"""
from hdiv.backtest.engine import BacktestEngine
try:
eng = BacktestEngine.from_strategy("config/strategy/high_dividend_v1.yml")
res = eng.run(start=date(2026, 8, 3), end=date(2026, 9, 30),
persist=False, verbose=False)
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
decl = " ".join(res["unimplemented"])
# ① 不得把「无停牌」误报成「无数据」(约束表在 2010 起有数据)
assert "无数据" not in decl, f"误报数据缺失:{decl}"
# ② 必须如实声明「配置承诺但未实现」的项
for must in ("defer", "分红再投资", "配股", "成交量占比"):
assert must in decl, f"漏报未实现项 {must}:{decl}"
+125 -4
View File
@@ -11,6 +11,7 @@
from __future__ import annotations from __future__ import annotations
import pandas as pd
import pytest import pytest
from hdiv.cli import build_parser from hdiv.cli import build_parser
@@ -107,10 +108,12 @@ def test_documented_actions_exist(parser, cmd: str, expected: set[str]) -> None:
"cmd,flags", "cmd,flags",
[ [
("sync", {"--only-missing", "--limit", "--symbols", "--apis", ("sync", {"--only-missing", "--limit", "--symbols", "--apis",
"--interleaved", "--start", "--end", "--no-resume", "--no-weight"}), "--interleaved", "--start", "--end", "--no-resume", "--no-weight",
"--basic-start", "--basic-end"}),
("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}), ("universe", {"-c", "--config", "--asof", "--no-persist", "--no-html"}),
("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}), ("profile", {"--universe-run", "--symbols", "--asof", "--html-limit"}),
("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run"}), ("backtest", {"-s", "--strategy", "--mode", "--start", "--end", "--universe-run",
"--allow-lookahead-universe"}),
("sensitivity", {"-s", "--strategy", "--sweep"}), ("sensitivity", {"-s", "--strategy", "--sweep"}),
("strategy", {"-f", "--file", "--other"}), ("strategy", {"-f", "--file", "--other"}),
("audit", {"--no-persist", "--no-html"}), ("audit", {"--no-persist", "--no-html"}),
@@ -140,13 +143,22 @@ def test_backtest_universe_run_flag(parser) -> None:
"""股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。""" """股票池 ↔ 回测 的关联入口:``--universe-run`` 必须存在且可解析。"""
args = parser.parse_args(["backtest", "--universe-run", "abc123"]) args = parser.parse_args(["backtest", "--universe-run", "abc123"])
assert args.universe_run == "abc123" assert args.universe_run == "abc123"
assert args.allow_lookahead_universe is False, "默认不得放行未来函数"
args2 = parser.parse_args(["backtest"]) args2 = parser.parse_args(["backtest"])
assert args2.universe_run is None assert args2.universe_run is None
def test_backtest_lookahead_override_flag(parser) -> None:
"""放行未来函数必须是一个显式开关,不能是默认行为。"""
a = parser.parse_args(
["backtest", "--universe-run", "abc123", "--allow-lookahead-universe"]
)
assert a.allow_lookahead_universe is True
@pytest.mark.db @pytest.mark.db
def test_frozen_universe_is_used_when_run_id_given() -> None: def test_frozen_universe_is_used_when_run_id_given() -> None:
"""指定 --universe-run 时引擎必须使用该股票池,而不是重新筛选。""" """显式放行时,冻结股票池在各调仓日必须完全相同。"""
from datetime import date from datetime import date
from hdiv.backtest.engine import BacktestEngine from hdiv.backtest.engine import BacktestEngine
@@ -167,7 +179,8 @@ def test_frozen_universe_is_used_when_run_id_given() -> None:
pytest.skip(f"数据库不可用:{exc}") pytest.skip(f"数据库不可用:{exc}")
eng = BacktestEngine.from_strategy( eng = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid "config/strategy/high_dividend_v1.yml", universe_run_id=rid,
allow_lookahead_universe=True,
) )
assert eng.universe_run_id == rid assert eng.universe_run_id == rid
ctx = eng._prepare(eng.repo.trading_days(date(2024, 1, 1), date(2024, 6, 28)), ctx = eng._prepare(eng.repo.trading_days(date(2024, 1, 1), date(2024, 6, 28)),
@@ -177,6 +190,56 @@ def test_frozen_universe_is_used_when_run_id_given() -> None:
assert all(x == sets[0] for x in sets), "冻结股票池在各调仓日必须完全相同" assert all(x == sets[0] for x in sets), "冻结股票池在各调仓日必须完全相同"
@pytest.mark.db
def test_future_universe_is_rejected_by_default() -> None:
"""回归:股票池 asof 晚于回测起点 = 未来函数,必须默认拒绝。
早期实现允许用 2025 年选出的股票池跑 2015 起的单次回测,2015-01-06
就按那份事后名单成交 —— 与 walk-forward 已明确拒绝的做法自相矛盾。
"""
from datetime import date
from hdiv.backtest.engine import BacktestEngine
from hdiv.core.config import load_config
from hdiv.core.errors import HdivError
from hdiv.data import db
db.load_dotenv_once()
try:
cfg = load_config("datasource")
df = db.read_sql(
"SELECT run_id, asof_date FROM hd_universe_run WHERE deleted_at IS NULL "
"ORDER BY asof_date DESC LIMIT 1", cfg=cfg,
)
if df.empty:
pytest.skip("没有筛选记录")
rid = df["run_id"].iloc[0]
uasof = pd.to_datetime(df["asof_date"].iloc[0]).date()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
eng = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
)
early = date(uasof.year - 5, 1, 1)
with pytest.raises(HdivError) as ei:
eng._prepare(eng.repo.trading_days(early, date(uasof.year - 5, 3, 31)), verbose=False)
msg = str(ei.value)
assert "晚于回测起点" in msg
assert "--allow-lookahead-universe" in msg, "错误信息必须给出放行开关"
# 起点晚于股票池 asof 时是合法的:此时股票池属于「事前信息」
later = date(uasof.year + 1, 1, 2)
eng2 = BacktestEngine.from_strategy(
"config/strategy/high_dividend_v1.yml", universe_run_id=rid
)
days = eng2.repo.trading_days(later, date(later.year, 3, 31))
if len(days) >= 2:
ctx = eng2._prepare(days, verbose=False)
assert ctx["universe_by_refresh"], "asof 不晚于起点时应正常使用该股票池"
assert eng2.lookahead_universe_note is None
def test_backtest_mode_choices(parser) -> None: def test_backtest_mode_choices(parser) -> None:
"""手册只承诺 single / walkforward 两种模式。""" """手册只承诺 single / walkforward 两种模式。"""
sub = _subparsers(parser)["backtest"] sub = _subparsers(parser)["backtest"]
@@ -301,3 +364,61 @@ def test_html_flag_exists_on_all_report_producing_commands() -> None:
for cmd in ("universe", "profile", "backtest", "audit", "sensitivity"): for cmd in ("universe", "profile", "backtest", "audit", "sensitivity"):
args = p.parse_args([cmd]) args = p.parse_args([cmd])
assert getattr(args, "html", None) is False, f"hdiv {cmd} --html 默认应为 False" assert getattr(args, "html", None) is False, f"hdiv {cmd} --html 默认应为 False"
def test_walkforward_rejects_universe_run() -> None:
"""回归:--universe-run 与 walk-forward 时序不兼容,必须明确拒绝。
股票池自带 asof(如 2025-01-21),而 walk-forward 窗口从 2015 年就开始训练;
把未来时点选出的股票池套到更早的窗口上等于用未来信息选股。
早期实现没有该参数,于是 --universe-run 被**静默忽略** ——
用户以为按自己的股票池跑了,实际跑的是逐窗口自筛选。
"""
import os
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-m", "hdiv", "backtest",
"--universe-run", "whatever", "--mode", "walkforward"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": "src"},
)
assert r.returncode == 1
out = r.stdout + r.stderr
assert "不支持 --universe-run" in out, out[:400]
assert "未来" in out, "应说明原因(未来函数)"
assert "Traceback" not in out
def test_walkforward_runner_has_no_universe_param() -> None:
"""再确认一层:runner 本身不接受冻结股票池,避免日后被误加回去。"""
import inspect
from hdiv.backtest.walk_forward import WalkForwardRunner
sig = inspect.signature(WalkForwardRunner.__init__)
assert "universe_run_id" not in sig.parameters, (
"WalkForwardRunner 不应接受 universe_run_id —— "
"冻结股票池与 walk-forward 的时序纪律冲突"
)
def test_no_future_warning_on_selector() -> None:
"""回归:object 列的 fillna 曾触发 pandas Downcasting FutureWarning。
用 -W error::FutureWarning 跑一遍筛选路径,确保不再产生该警告
(pandas 未来版本会改变行为,届时结果可能静默变化)。
"""
import os
import subprocess
import sys
r = subprocess.run(
[sys.executable, "-W", "error::FutureWarning", "-m", "hdiv",
"universe", "--asof", "2025-03-21", "--no-persist", "--no-html"],
capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": "src"}, timeout=600,
)
assert "FutureWarning" not in (r.stdout + r.stderr), \
f"仍存在 FutureWarning:{(r.stdout + r.stderr)[-400:]}"
+71
View File
@@ -229,6 +229,77 @@ def test_strategy_bad_status() -> None:
_load_strategy_mutated(lambda r: r["strategy"].update({"status": "RUNNING"})) _load_strategy_mutated(lambda r: r["strategy"].update({"status": "RUNNING"}))
# ---------------------------------------------------------------------------
# 实时画像闸门(profile_gate)配置校验
#
# 这些必须挡在配置期:写错指标名若拖到运行时,只会表现为
# 「无法验证 → 保守不买」,即策略悄悄再也不交易,极难定位。
# ---------------------------------------------------------------------------
def test_profile_gate_unknown_metric_rejected() -> None:
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["rules"] = [
{"metric": "not_a_metric", "op": ">=", "value": 1.0}
]
_load_strategy_mutated(mutate)
def test_profile_gate_percentile_on_scalar_metric_rejected() -> None:
"""标量指标没有历史分位,不能用 current_percentile。"""
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["rules"] = [
{"metric": "payout_ratio", "stat": "current_percentile",
"op": "<=", "value": 1.0}
]
_load_strategy_mutated(mutate)
def test_profile_gate_enabled_without_rules_rejected() -> None:
def mutate(r: dict) -> None:
r["entry"]["profile_gate"] = {"enabled": True, "rules": []}
_load_strategy_mutated(mutate)
def test_profile_gate_bad_operator_rejected() -> None:
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["rules"] = [
{"metric": "dv_yield", "op": "~=", "value": 1.0}
]
_load_strategy_mutated(mutate)
def test_profile_gate_coverage_bounds() -> None:
"""min_window_coverage 必须落在 [0,1]:1.0 = 必须完整覆盖名义窗口。"""
def mutate(r: dict) -> None:
r["entry"]["profile_gate"]["min_window_coverage"] = 1.5
_load_strategy_mutated(mutate)
def test_profile_gate_disabled_without_rules_is_allowed() -> None:
"""默认(未启用、无规则)必须能正常加载 —— 否则所有历史配置都会失效。"""
raw = yaml.safe_load(
(config_dir() / "strategy" / "high_dividend_v1.yml").read_text(encoding="utf-8")
)
raw["entry"]["profile_gate"] = {"enabled": False, "rules": []}
import tempfile
from pathlib import Path
with tempfile.NamedTemporaryFile("w", suffix=".yml", delete=False, encoding="utf-8") as fh:
yaml.safe_dump(raw, fh, allow_unicode=True)
tmp = Path(fh.name)
try:
cfg = load_config(f"strategy:{tmp}")
assert cfg.entry.profile_gate.enabled is False
finally:
tmp.unlink(missing_ok=True)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 可复现性:config_hash 稳定性 # 可复现性:config_hash 稳定性
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+250
View File
@@ -0,0 +1,250 @@
"""TTM 股息率毛刺消除测试。
**背景**:A 股相邻两次除权的间隔经常不是 365 天。硬 365 天窗口于是在每年
除权日附近制造两种日历假象:
- **重叠虚高**:间隔 < 365 时新旧分红同时在窗口内。实测招商银行 2015-07-03
股息率 0.620 → 1.290(+108%),10 天后回落到 0.670。
- **断档虚低**:间隔 > 365 时旧的已到期而新的未入场。实测中国神华
2016-07-04:0.740 → 0.320(−57%)。
两者都会污染「历史分位」这一核心信号,且筛选器用的也是同一个数
(直接决定选股),因此必须消除。
"""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from hdiv.factor.dividend_yield import (
build_dps_events,
ttm_dps_at,
ttm_dps_series,
ttm_params,
)
TTM = 365
GRACE = 45
def _events(pairs: list[tuple[str, float]]) -> pd.DataFrame:
"""构造分红事件表:(除权日, 金额)。"""
return pd.DataFrame({
"ex_date": pd.to_datetime([d for d, _ in pairs]),
"imp_ann_date": pd.to_datetime([d for d, _ in pairs]),
"cash_div_tax": [a for _, a in pairs],
})
def _daily(start: str, end: str) -> pd.DatetimeIndex:
return pd.date_range(start, end, freq="D")
# ---------------------------------------------------------------------------
# 核心:两种毛刺都要消除
# ---------------------------------------------------------------------------
def test_overlap_spike_is_removed() -> None:
"""间隔 360 天:新分红入场时旧的不应再计入(消除 +100% 虚高)。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2), ("2023-05-22", 1.4)])
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=False), index=d)
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 未平滑时:2022-05-27 当刻涨到 1.0+1.2=2.2,5 天后旧的到期回落
assert raw.max() > 2.1, f"未平滑应出现重叠虚高,实际 max={raw.max()}"
# 平滑后:不应出现两笔相加
assert sm.max() <= 1.45, f"平滑后不应双算,实际 max={sm.max()}"
# 且切换当天不跳变
after = sm.loc[pd.Timestamp("2022-05-27"):].iloc[:5]
assert after.max() / after.min() - 1 < 0.05, "接管当天不应有跳变"
def test_gap_dip_is_filled() -> None:
"""间隔 370 天:旧的到期后应继续计到新的入场(消除断档虚低)。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2)]) # 间隔 370 天
raw = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=False), index=d)
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 只看「第一笔到第二笔」这段(尾部无后继本就该归零,属正确行为)
win = slice(pd.Timestamp("2021-06-01"), pd.Timestamp("2022-06-06"))
raw_a, sm_a = raw.loc[win], sm.loc[win]
# 未平滑:2022-06-01 旧的到期、新的还没来 → 归零 5 天
assert raw_a.min() == 0.0, "未平滑应出现断档归零"
# 平滑后:同区间不应归零
assert sm_a.min() > 0.9, f"平滑后不应断档,实际 min={sm_a.min()}"
def test_intra_year_multiple_payments_are_not_merged() -> None:
"""年内多次分红(间隔 180 天)必须都保留 —— 否则会把中期分红误删。"""
d = _daily("2021-01-01", "2023-12-31")
ev = _events([
("2022-06-01", 0.3), ("2022-11-28", 0.7),
("2023-05-29", 0.3), ("2023-11-25", 0.7),
])
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 年中确实应同时含两笔(0.3 + 0.7 = 1.0)
peak = sm.loc[pd.Timestamp("2023-06-01"):pd.Timestamp("2023-11-20")]
assert peak.max() > 0.95, f"年内两笔分红应同时计入,实际 max={peak.max()}"
def test_true_cessation_still_goes_to_zero() -> None:
"""真停发必须如实归零,不能因为平滑就永远挂着旧分红。"""
d = _daily("2020-01-01", "2025-12-31")
ev = _events([("2021-06-01", 1.0), ("2023-06-01", 1.0)]) # 中间空了两年
sm = pd.Series(ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE,
smooth_spikes=True), index=d)
# 2021 那笔在 2022-06-01 + 45 天宽限后必须归零
gap = sm.loc[pd.Timestamp("2022-09-01"):pd.Timestamp("2023-05-31")]
assert gap.max() == 0.0, f"停发期间应归零,实际 max={gap.max()}"
def test_smoothing_can_be_disabled() -> None:
"""smooth_spikes=False 应精确复现旧的「硬窗口 + 归零才兜底」行为。"""
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
off = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=False)
# 旧行为:重叠期双算
assert off.max() >= 2.1
on = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert on.max() < off.max()
def test_build_dps_events_matches_series_expectations() -> None:
"""事件表经 build_dps_events 规范化后仍可用。"""
raw = pd.DataFrame({
"symbol": ["X"] * 3,
"ex_date": ["2021-06-01", "2022-05-27", "2023-05-22"],
"imp_ann_date": ["2021-05-25", "2022-05-20", "2023-05-15"],
"cash_div_tax": [1.0, 1.2, 1.4],
})
e = build_dps_events(raw)["X"]
d = _daily("2021-01-01", "2023-12-31")
out = ttm_dps_series(d, e, ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert len(out) == len(d) and out.max() <= 1.45
# ---------------------------------------------------------------------------
# 口径统一:筛选 / 画像 / 回测 / Web 必须用同一份参数
# ---------------------------------------------------------------------------
def test_ttm_params_is_single_source_of_truth() -> None:
"""四处调用点必须都从 ttm_params() 取参,不得各自硬编码。"""
import inspect
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
# 回测引擎曾硬编码 ttm_days=365, grace_days=45
eng = (root / "backtest" / "engine.py").read_text(encoding="utf-8")
assert "ttm_days=365, grace_days=45" not in eng, "引擎仍在硬编码 TTM 参数"
assert "ttm_params()" in eng
# walk-forward 与 web 曾用函数默认值
for rel in ("backtest/walk_forward.py", "web/analysis.py"):
text = (root / rel).read_text(encoding="utf-8")
assert "ttm_params()" in text, f"{rel} 未使用统一参数"
# 因子层自身
f = inspect.getsource(__import__(
"hdiv.factor.dividend_yield", fromlist=["x"]))
assert "def ttm_params" in f
def test_ttm_dps_at_matches_series_right_endpoint() -> None:
"""单点求值(筛选器用)必须与序列右端点一致。"""
d = _daily("2020-01-01", "2022-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
asof = pd.Timestamp("2021-12-31").date()
one = ttm_dps_at(asof, ev)
ser = ttm_dps_series(pd.DatetimeIndex([pd.Timestamp(asof)]), ev,
ttm_days=TTM, grace_days=GRACE, smooth_spikes=True)
assert one is not None
assert abs(one - float(ser[0])) < 1e-9
def test_ttm_params_reads_config() -> None:
from hdiv.core.config import load_config
w, g, sm = ttm_params()
c = load_config("profile").ttm_dividend
assert (w, g, sm) == (c.window_days, c.grace_days, c.smooth_spikes)
def test_config_exposes_smooth_spikes_switch() -> None:
"""开关必须暴露在 YAML 里,用户可自行关闭。"""
from hdiv.core.config import load_config
assert hasattr(load_config("profile").ttm_dividend, "smooth_spikes")
@pytest.mark.parametrize("grace", [0, 10, 45, 90])
def test_smoothing_never_produces_negative_or_nan(grace: int) -> None:
d = _daily("2020-01-01", "2023-12-31")
ev = _events([("2021-06-01", 1.0), ("2022-06-06", 1.2), ("2023-06-01", 1.4)])
out = ttm_dps_series(d, ev, ttm_days=TTM, grace_days=grace, smooth_spikes=True)
assert np.isfinite(out).all()
assert (out >= 0).all()
# ---------------------------------------------------------------------------
# 透传一致性:包装函数必须接收并转发所有参数
# ---------------------------------------------------------------------------
def test_wrapper_signature_forwards_all_params() -> None:
"""回归:`dividend_yield_series` 是 `ttm_dps_series` 的包装。
曾经只给**调用方**加了 `smooth_spikes`,却忘了在包装函数签名里声明,
于是 walk-forward 直接 `TypeError` 崩在第一个窗口 —— 而测试全绿,
因为测试没走 walk-forward 那条路径。
"""
import inspect
from hdiv.factor import dividend_yield as dy
inner = set(inspect.signature(dy.ttm_dps_series).parameters) - {"dates", "events"}
outer = set(inspect.signature(dy.dividend_yield_series).parameters) - {"close", "events"}
missing = inner - outer
assert not missing, (
f"dividend_yield_series 未转发参数 {sorted(missing)};"
"调用方传了就会 TypeError"
)
# 且必须真的往下传
src = inspect.getsource(dy.dividend_yield_series)
for name in inner:
assert f"{name}={name}" in src, f"包装函数未把 {name} 传给 ttm_dps_series"
def test_wrapper_accepts_smooth_spikes() -> None:
"""直接以关键字调用,确保签名真的可用(不只是字符串包含)。"""
from hdiv.factor.dividend_yield import dividend_yield_series
d = _daily("2021-01-01", "2022-12-31")
close = pd.Series(10.0, index=d)
ev = _events([("2021-06-01", 1.0), ("2022-05-27", 1.2)])
for flag in (True, False):
out = dividend_yield_series(close, ev, ttm_days=365, grace_days=45,
smooth_spikes=flag)
assert not out.empty
assert "dividend_yield" in out.columns
def test_all_ttm_callers_pass_the_unified_params() -> None:
"""五处调用点都必须显式传 smooth_spikes,不能靠默认值(否则与配置脱钩)。"""
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
for rel in ("profile/builder.py", "backtest/engine.py",
"backtest/walk_forward.py", "web/analysis.py"):
src = (root / rel).read_text(encoding="utf-8")
assert "smooth_spikes" in src, f"{rel} 未传 smooth_spikes(会与配置脱钩)"
+159
View File
@@ -0,0 +1,159 @@
"""统一数值格式化测试。
**背景**:`config/report.yml: layout.decimals` 长期是摆设 —— `ratio` 与
`money` 从未被读取,各报告模块各自硬编码小数位(7 处),
前端也把百分比写死 2 位。结果是「改了配置不生效」。
本测试钉住「配置必须真的驱动输出」。
"""
from __future__ import annotations
import pytest
from hdiv.report.format import NumFmt
# ---------------------------------------------------------------------------
# 语义:百分比小数位 = ratio - 2
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"ratio,expect",
[(2, "6%"), (4, "6.17%"), (6, "6.1715%"), (8, "6.171491%")],
)
def test_percent_decimals_derive_from_ratio(ratio: int, expect: str) -> None:
"""比率保留 ratio 位后乘 100,恰好少两位 —— 这是与用户确认的语义。"""
f = NumFmt(ratio=ratio)
assert f.pct(0.06171491) == expect
assert f.percent == max(0, ratio - 2)
def test_ratio_str_keeps_ratio_decimals() -> None:
assert NumFmt(ratio=4).ratio_str(0.06171491) == "0.0617"
assert NumFmt(ratio=6).ratio_str(0.06171491) == "0.061715"
def test_ratio_below_two_does_not_go_negative() -> None:
"""ratio=1 时百分比不能出现负小数位(会抛异常)。"""
f = NumFmt(ratio=1)
assert f.percent == 0
assert f.pct(0.0617) == "6%"
def test_missing_values_render_as_dash() -> None:
f = NumFmt()
for v in (None, float("nan")):
assert f.pct(v) == "—"
assert f.ratio_str(v) == "—"
assert f.yi(v) == "—"
def test_non_numeric_falls_back_to_str() -> None:
"""传进来已格式化的字符串不应崩,也不应二次加工。"""
f = NumFmt()
assert f.pct("已格式化") == "已格式化"
def test_pp_and_plus_signs() -> None:
f = NumFmt(ratio=4)
assert f.pct_pp(0.1211) == "+12.11pp"
assert f.pct_pp(-0.0324) == "-3.24pp"
assert f.pct(0.0401, plus=True) == "+4.01%"
def test_by_unit_dispatch() -> None:
f = NumFmt(ratio=4, money=2)
assert f.by_unit(0.0617, "pct") == "6.17%"
assert f.by_unit(123456789.0, "money") == "1.23亿"
assert f.by_unit(16.72, "years") == "17"
assert f.by_unit(0.0617, "ratio") == "0.0617"
assert f.by_unit(2838, "int") == "2,838"
# ---------------------------------------------------------------------------
# 配置必须真的驱动输出
# ---------------------------------------------------------------------------
def test_from_config_reads_report_yml() -> None:
from hdiv.core.config import load_config
cfg = load_config("report")
f = NumFmt.from_config(cfg)
d = cfg.layout.decimals
assert (f.ratio, f.money, f.price) == (d.ratio, d.money, d.price)
assert f.percent == max(0, d.ratio - 2)
def test_from_config_survives_broken_config() -> None:
"""配置不可用时回落默认值,而不是让报告生成崩掉。"""
class Boom:
@property
def layout(self):
raise RuntimeError("配置坏了")
f = NumFmt.from_config(Boom())
assert f.ratio == 4
def test_renderer_uses_config_not_hardcoded_defaults() -> None:
"""回归:渲染器的 fmt_pct 曾默认 2 位且从不读 decimals.ratio。"""
import inspect
from hdiv.report.renderer import Renderer
src = inspect.getsource(Renderer.fmt_pct)
assert "NumFmt" in src or "self.fmt" in src, "fmt_pct 应走统一格式化器"
assert ":.2f}%" not in src, "fmt_pct 不应再硬编码 2 位"
def test_no_hardcoded_percent_format_in_report_modules() -> None:
"""五个报告模块都不应再有硬编码的百分比精度。"""
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv" / "report"
offenders = []
for name in ("profile_report", "backtest_report", "universe_report",
"sensitivity_report", "walkforward_report"):
text = (root / f"{name}.py").read_text(encoding="utf-8")
for i, line in enumerate(text.splitlines(), 1):
if ":.2f}%" in line or ":.2f}pp" in line:
offenders.append(f"{name}:{i}")
assert not offenders, f"仍硬编码百分比精度:{offenders}"
def test_frontend_precision_endpoint_exists() -> None:
"""前端也必须由配置驱动(曾把百分比写死 2 位)。"""
from hdiv.web.server import ROUTES
assert any(pat.match("/api/config/display") for _m, pat, _f in ROUTES), \
"缺少 /api/config/display,前端无法获知配置的显示精度"
def test_no_hardcoded_percent_anywhere_in_src() -> None:
"""整个 src/ 都不应再有硬编码的百分比精度(含接口层与 CLI 输出)。
曾散落 20 余处,其中「成交理由里的股息率」是用户直接看到的那种。
唯一允许的例外是 format.py 自身的文档说明。
"""
from pathlib import Path
root = Path(__file__).resolve().parents[1] / "src" / "hdiv"
offenders = []
for f in root.rglob("*.py"):
if f.name == "format.py":
continue
for i, line in enumerate(f.read_text(encoding="utf-8").splitlines(), 1):
if ":.2f}%" in line or "100:.2f" in line:
offenders.append(f"{f.relative_to(root)}:{i}")
assert not offenders, f"仍硬编码百分比精度:{offenders}"
def test_frontend_uses_config_precision() -> None:
from pathlib import Path
js = (Path(__file__).resolve().parents[1] / "web" / "app.js").read_text(encoding="utf-8")
assert "FMT" in js and "config/display" in js, "前端未接入配置驱动精度"
assert "FMT.percent" in js, "百分比应使用配置的 percent"
+590
View File
@@ -0,0 +1,590 @@
"""实时(Point-in-Time)个股画像测试。
三条必须被锁定的性质:
1. **与批量画像同一定义** —— ``PitProfileService`` 在某个 asof 上算出的指标,
必须与 ``ProfileBuilder.run(asof=...)`` 逐值一致。否则「回测用的画像」和
「页面上看的画像」是两个东西,这正是最难发现的一类错误。
2. **PIT 纪律** —— 未公告的财报、未实施/未除权的分红一律不得影响当日画像。
用一个「公告日前一天 vs 公告日当天」的对照来证明,而不是靠注释。
3. **不猜** —— 指标缺失/样本不足必须报 ``MISSING`` / ``INSUFFICIENT``,
闸门据此判定为「无法验证」并按配置保守处理。
"""
from __future__ import annotations
from datetime import date, timedelta
import pytest
from hdiv.profile.builder import METRIC_META
from hdiv.profile.pit import (
ALL_METRICS,
FINANCIAL_METRICS,
PERCENTILE_METRICS,
ProfileSnapshot,
evaluate_gate,
metrics_needing_financials,
)
# ---------------------------------------------------------------------------
# 非 DB:指标集合与闸门语义
# ---------------------------------------------------------------------------
class TestMetricGroups:
def test_declared_metrics_cover_display_names(self) -> None:
"""有中文展示名的指标必须都能作为闸门条件(否则页面能看、回测不能用)。"""
assert frozenset(METRIC_META) <= ALL_METRICS
def test_groups_are_disjoint_and_complete(self) -> None:
from hdiv.profile.pit import (
DIVIDEND_METRICS,
LIQUIDITY_METRICS,
RETURN_METRICS,
VALUATION_METRICS,
)
groups = [VALUATION_METRICS, RETURN_METRICS, DIVIDEND_METRICS,
FINANCIAL_METRICS, LIQUIDITY_METRICS]
union: set[str] = set()
for g in groups:
assert not (union & g), f"指标分组重叠:{union & g}"
union |= g
assert frozenset(union) == ALL_METRICS
def test_percentile_metrics_are_subset_of_valuation(self) -> None:
"""只有按窗口输出分布统计的指标才有历史分位。"""
assert PERCENTILE_METRICS <= ALL_METRICS
assert "dv_vol_daily" not in PERCENTILE_METRICS, "波动率是标量,没有历史分位"
def test_financial_detection(self) -> None:
assert metrics_needing_financials({"roe_avg"}) is True
# 分红质量指标依赖「最近已公告年报」反推考核财年,因此也算需要财报
assert metrics_needing_financials({"dividend_continuity_years"}) is True
assert metrics_needing_financials({"dv_yield", "pe_ttm"}) is False
class TestGateEvaluation:
def _snap(self, **kw) -> ProfileSnapshot:
s = ProfileSnapshot(symbol="X", asof=date(2018, 5, 18), window_years=5)
s.values.update(kw.get("values", {}))
s.percentiles.update(kw.get("percentiles", {}))
s.status.update(kw.get("status", {}))
s.windows.update({
k: kw.get("window", 5)
# 窗口要对**值指标与分位指标**都设上,否则 window=-1 会让覆盖率检查失效
for k in list(kw.get("values", {})) + list(kw.get("percentiles", {}))
})
s.coverage.update(kw.get("coverage", {}))
return s
def test_short_window_is_unverifiable_when_coverage_enforced(self) -> None:
"""名义 5 年但实际只有 67% 数据时:默认放行,强制覆盖率则拦下。
这是「声称 5 年」与「真有 5 年」的分界。实测 600036.SH 在 2018-05-18
的 5 年窗口只有 817/1219 个交易日(67%),而画像仍报 status=OK。
"""
s = self._snap(
percentiles={"dv_yield": 90.0},
status={"dv_yield": "OK"},
coverage={"dv_yield": 0.67},
)
rules = [{"metric": "dv_yield", "stat": "current_percentile",
"op": ">=", "value": 75}]
assert evaluate_gate(rules, s)["verdict"] == "PASS", "默认不因覆盖率淘汰"
g = evaluate_gate(rules, s, min_window_coverage=1.0)
assert g["verdict"] == "REJECT"
assert g["unverifiable"] == ["dv_yield.window_coverage=67%"]
assert g["checks"][0]["window_coverage"] == pytest.approx(0.67)
def test_full_window_passes_coverage_check(self) -> None:
s = self._snap(
percentiles={"dv_yield": 90.0},
status={"dv_yield": "OK"},
coverage={"dv_yield": 1.0},
)
rules = [{"metric": "dv_yield", "stat": "current_percentile",
"op": ">=", "value": 75}]
g = evaluate_gate(rules, s, min_window_coverage=1.0)
assert g["verdict"] == "PASS" and not g["unverifiable"]
def test_full_history_window_is_exempt_from_coverage(self) -> None:
"""窗口 0(全历史)没有「应有天数」,不得因覆盖率被拦。"""
s = ProfileSnapshot(symbol="X", asof=date(2018, 5, 18), window_years=5)
s.values["roe"] = 0.12
s.status["roe"] = "OK"
s.windows["roe"] = 0
s.coverage["roe"] = 1.0
g = evaluate_gate([{"metric": "roe", "op": ">=", "value": 0.08}], s,
min_window_coverage=1.0)
assert g["verdict"] == "PASS"
def test_all_rules_pass(self) -> None:
s = self._snap(
values={"payout_ratio": 0.4},
percentiles={"dv_yield": 80.0},
status={"payout_ratio": "OK", "dv_yield": "OK"},
)
r = evaluate_gate([
{"metric": "dv_yield", "stat": "current_percentile", "op": ">=", "value": 75},
{"metric": "payout_ratio", "op": "<=", "value": 1.0},
], s)
assert r["verdict"] == "PASS" and not r["failed"]
def test_one_rule_fails_is_reject(self) -> None:
s = self._snap(
values={"payout_ratio": 1.4},
percentiles={"dv_yield": 90.0},
status={"payout_ratio": "OK", "dv_yield": "OK"},
)
r = evaluate_gate([
{"metric": "dv_yield", "stat": "current_percentile", "op": ">=", "value": 75},
{"metric": "payout_ratio", "op": "<=", "value": 1.0},
], s)
assert r["verdict"] == "REJECT"
assert r["failed"] == ["payout_ratio.current_value<=1"]
def test_missing_metric_is_not_treated_as_zero(self) -> None:
"""缺失指标绝不能当作 0 —— 否则 `<= 1.0` 这类规则会永远通过。"""
s = self._snap(values={}, status={})
r = evaluate_gate([{"metric": "payout_ratio", "op": "<=", "value": 1.0}], s)
assert r["verdict"] == "REJECT", "默认必须保守(无法验证即不买)"
assert r["unverifiable"] == ["payout_ratio.current_value"]
assert r["checks"][0]["actual"] is None
def test_unverifiable_can_be_configured_to_pass(self) -> None:
s = self._snap(values={}, status={})
r = evaluate_gate(
[{"metric": "payout_ratio", "op": "<=", "value": 1.0}], s,
on_unverifiable="pass",
)
assert r["verdict"] == "PASS" and r["unverifiable"]
def test_insufficient_sample_is_unverifiable(self) -> None:
s = self._snap(values={"roe": 0.1}, status={"roe": "INSUFFICIENT"})
r = evaluate_gate([{"metric": "roe", "op": ">=", "value": 0.08}], s)
assert r["verdict"] == "REJECT"
assert r["checks"][0]["status"] == "INSUFFICIENT"
def test_none_snapshot_is_unverifiable(self) -> None:
r = evaluate_gate([{"metric": "roe", "op": ">=", "value": 0.08}], None)
assert r["verdict"] == "REJECT" and r["checks"][0]["status"] == "MISSING"
def test_all_comparison_operators(self) -> None:
s = self._snap(values={"x": 5.0}, status={"x": "OK"})
for op, thr, ok in ((">=", 5.0, True), (">", 5.0, False),
("<=", 5.0, True), ("<", 5.0, False)):
r = evaluate_gate([{"metric": "x", "op": op, "value": thr}], s)
assert (r["verdict"] == "PASS") is ok, f"{op} {thr}"
# ---------------------------------------------------------------------------
# DB:与批量画像等价 + PIT 纪律
# ---------------------------------------------------------------------------
def _db_ready():
from hdiv.data import db
db.load_dotenv_once()
return db
@pytest.mark.db
def test_pit_profile_matches_batch_builder() -> None:
"""实时画像必须与 `hdiv profile --asof` 的结果逐值一致(同一定义)。
这是「回测里用的画像」与「页面上看到的画像」不会分叉的唯一保证。
"""
db = _db_ready()
from hdiv.profile.builder import ProfileBuilder
from hdiv.profile.pit import PitProfileService
try:
cfg = db.read_sql(
"SELECT symbol FROM stock WHERE symbol IN ('600036.SH','601398.SH','000651.SZ') "
"ORDER BY symbol LIMIT 3", cfg=__import__("hdiv.core.config", fromlist=["load_config"]).load_config("datasource"),
)
if cfg.empty:
pytest.skip("数据库无样本股票")
syms = cfg["symbol"].tolist()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
asof = date(2018, 5, 18)
batch = ProfileBuilder.from_config()
svc = PitProfileService(window_years=5)
svc.prepare(syms, date(2004, 1, 1), date(2026, 9, 30))
for sym in syms:
res = batch.run(symbols=[sym], asof=asof, persist=False, verbose=False,
return_rows=True)
want = {
(r["metric_code"], int(r["window_years"])): r["current_value"]
for r in res["stat_rows"] if r["current_value"] is not None
}
snap = svc.snapshot(sym, asof)
assert snap is not None, f"{sym} 应能算出画像"
# 反向守护:实时画像不得产出未声明的指标代码(否则闸门配置无从校验)
assert set(snap.status) <= ALL_METRICS, (
f"未声明的指标:{set(snap.status) - ALL_METRICS}"
)
for (code, wy), v in want.items():
if wy not in (0, 5):
continue
# 实时画像每个指标只保留一个窗口(配置窗口优先)
got, status, got_wy = snap.get(code)
if got is None:
continue
if got_wy != wy:
continue
assert got == pytest.approx(v, rel=1e-9), (
f"{sym} {code} window={wy}: 实时画像 {got} != 批量画像 {v}"
)
@pytest.mark.db
def test_pit_profile_excludes_unannounced_report() -> None:
"""PIT 纪律:公告日前一天不得看到该年报的 ROE。
反例证明:若实现漏了 `ann_date <= asof`,公告日前后两个快照
会给出同一个 ROE(都用了新财报),测试即失败。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
sql = (
"SELECT symbol, end_date, ann_date, roe FROM hd_fina_indicator "
"WHERE MONTH(end_date) = 12 AND roe IS NOT NULL AND ann_date >= end_date "
" AND ann_date >= '2016-01-01' AND ann_date <= '2022-12-31' "
"ORDER BY symbol, ann_date"
)
df = db.read_sql(sql, cfg=load_config("datasource"))
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
if df.empty:
pytest.skip("没有可用的年报样本")
import pandas as pd
df = df.sort_values(["symbol", "ann_date"])
sym = None
row = None
for s, g in df.groupby("symbol"):
g = g.sort_values("ann_date")
if len(g) >= 2:
sym, row = s, g.iloc[1]
break
if sym is None:
pytest.skip("没有「至少两期年报」的样本")
ann = pd.to_datetime(row["ann_date"]).date()
svc = PitProfileService(window_years=5)
svc.prepare([sym], date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"roe"})
before = svc.snapshot(sym, ann - timedelta(days=1))
after = svc.snapshot(sym, ann)
if before is None or after is None:
pytest.skip(f"{sym} 在 {ann} 前后无行情")
roe_before, st_before, _ = before.get("roe")
roe_after, st_after, _ = after.get("roe")
if roe_before is None or roe_after is None:
pytest.skip(f"{sym} 缺少 ROE 数据")
assert roe_after == pytest.approx(float(row["roe"]) / 100.0, rel=1e-6), (
"公告日当天应已能看到该年报"
)
assert roe_before != pytest.approx(roe_after, rel=1e-12), (
f"公告日({ann})前一天不得看到该年报的 ROE —— 否则是未来函数"
)
@pytest.mark.db
def test_pit_profile_excludes_future_dividend() -> None:
"""PIT 纪律:未除权的分红不得进入当日 TTM 股息率。"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
df = db.read_sql(
"SELECT symbol, ex_date, cash_div_tax FROM hd_dividend "
"WHERE div_proc='实施' AND cash_div_tax > 0.2 AND ex_date >= '2016-01-01' "
" AND ex_date <= '2022-12-31' ORDER BY cash_div_tax DESC LIMIT 5",
cfg=load_config("datasource"),
)
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
if df.empty:
pytest.skip("没有分红样本")
import pandas as pd
sym = df.iloc[0]["symbol"]
ex = pd.to_datetime(df.iloc[0]["ex_date"]).date()
svc = PitProfileService(window_years=5)
svc.prepare([sym], date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"ttm_dps"})
before = svc.snapshot(sym, ex - timedelta(days=1))
after = svc.snapshot(sym, ex)
if before is None or after is None:
pytest.skip(f"{sym} 在除权日 {ex} 前后无行情")
v_before, _, _ = before.get("ttm_dps")
v_after, _, _ = after.get("ttm_dps")
if v_before is None or v_after is None:
pytest.skip(f"{sym} 缺少 TTM DPS")
assert v_after >= v_before, "除权日当天 TTM 分红应把新分红计入"
assert v_after != pytest.approx(v_before, rel=1e-12), (
f"除权日({ex})前一天不得包含该笔分红 —— 否则是未来函数"
)
@pytest.mark.db
def test_pit_profile_rejects_unavailable_window() -> None:
"""请求 profile.yml 未定义的窗口必须报错,而不是悄悄退回全历史。"""
_db_ready()
from hdiv.core.errors import HdivError
from hdiv.profile.pit import PitProfileService
with pytest.raises(HdivError) as ei:
PitProfileService(window_years=3)
assert "windows_years" in str(ei.value)
@pytest.mark.db
def test_pit_profile_is_lazy_and_reuses_panels() -> None:
"""惰性 + 面板复用:这是「长周期数据沿用、触发时才计算」的落地证据。
- 只配估值类规则时,绝不触碰财报表(各约 30 万行);
- 同一 asof 上多只股票复用同一份时点面板(而不是每股查一次库);
- 同一 (股票, asof) 第二次调用直接命中缓存。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol IN "
"('600036.SH','601398.SH','000651.SZ','600519.SH') ORDER BY symbol",
cfg=load_config("datasource"),
)
if rows.empty:
pytest.skip("数据库无样本股票")
syms = rows["symbol"].tolist()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
asof = date(2020, 6, 30)
svc = PitProfileService(window_years=5)
svc.prepare(syms, date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"dv_yield", "pe_ttm", "pb"}) # 纯估值:不需要任何财报
snaps = [svc.snapshot(s, asof) for s in syms]
assert all(x is not None for x in snaps)
st = svc.stats()
assert st["financial_loads"] == 0, "纯估值规则不得载入财报面板"
assert st["liquidity_loads"] == 0, "未用到成交额时不得查询流动性"
assert st["asof_contexts"] == 1, "同一 asof 只应构建一次时点面板"
assert st["snapshots_computed"] == len(syms)
before = st["snapshots_cached"]
svc.snapshot(syms[0], asof)
assert svc.stats()["snapshots_cached"] == before + 1, "重复调用必须命中缓存"
# 需要财报的指标才会付出那次查询(并且同一 asof 只付一次)
svc2 = PitProfileService(window_years=5)
svc2.prepare(syms, date(2004, 1, 1), date(2026, 9, 30))
svc2.configure({"roe_avg"})
for s in syms:
svc2.snapshot(s, asof)
assert svc2.stats()["financial_loads"] == 1, "同一 asof 的财报面板只应载入一次"
# ---------------------------------------------------------------------------
# 窗口覆盖率:分母必须与 window_slice 的左开右闭口径一致
# ---------------------------------------------------------------------------
class _StubRepo:
"""只提供 trading_days 的最小替身(纯函数测试,不碰数据库)。"""
def __init__(self, days: list[date]) -> None:
self._days = days
def trading_days(self, start: date, end: date) -> list[date]:
return [d for d in self._days if start <= d <= end]
class TestWindowCoverage:
def test_left_endpoint_is_excluded_like_window_slice(self) -> None:
"""交易日历取闭区间,而 window_slice 是 `> start` —— 左端点要减掉。
不减会让覆盖率永远差一天,`min_window_coverage=1.0` 就变成「永远拒绝」。
"""
from hdiv.profile.coverage import expected_trading_days, window_start
asof = date(2021, 1, 4)
lo = window_start(asof, 1) # 2020-01-03
repo = _StubRepo([lo, date(2020, 1, 6), date(2020, 12, 31), asof])
# 闭区间 4 天,去掉左端点 → 3 天
assert expected_trading_days(repo, asof, 1) == 3
def test_left_endpoint_not_a_trading_day(self) -> None:
from hdiv.profile.coverage import expected_trading_days
asof = date(2021, 1, 4)
repo = _StubRepo([date(2020, 1, 6), date(2020, 12, 31), asof])
assert expected_trading_days(repo, asof, 1) == 3
def test_full_history_has_no_denominator(self) -> None:
from hdiv.profile.coverage import expected_trading_days
repo = _StubRepo([date(2020, 1, 6)])
assert expected_trading_days(repo, date(2021, 1, 4), 0) == 0
def test_coverage_ratio_is_capped_at_one(self) -> None:
from hdiv.profile.coverage import coverage_ratio
assert coverage_ratio(100, 200) == pytest.approx(0.5)
assert coverage_ratio(250, 200) == 1.0, "多出来的观测不放大覆盖率"
assert coverage_ratio(10, 0) is None, "没有分母时返回 None(不适用)"
def test_format_warning_only_when_short(self) -> None:
"""只列**不足**的窗口,不要因为 10 年窗口不足就说成「5 年数据不足」。"""
from hdiv.profile.coverage import format_warning
assert format_warning({}) is None
assert format_warning({"windows": {5: {"min": 1.0, "n": 9}}}) is None
msg = format_warning({"windows": {5: {"min": 0.67, "n": 9}}})
assert msg is not None and "67.0%" in msg and "5 年窗口" in msg
mixed = format_warning({
"windows": {5: {"min": 1.0, "n": 9}, 10: {"min": 0.34, "n": 7}},
})
assert mixed is not None
assert "10 年窗口" in mixed
assert "5 年窗口" not in mixed, "已达标的窗口不该出现在警告里"
@pytest.mark.db
def test_real_window_coverage_grows_with_asof() -> None:
"""真实数据:5 年窗口的覆盖率随数据积累而上升,2020 起才满覆盖。
行情/每日指标自 2015-01-05 才有,因此任何早于 2020-01 的 asof,
其「5 年窗口」都是被截短的 —— 这是**数据事实**,不是代码问题,
但必须能被看见。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol = '600036.SH'", cfg=load_config("datasource")
)
if rows.empty:
pytest.skip("无样本股票")
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
svc = PitProfileService(window_years=5)
svc.prepare(["600036.SH"], date(2015, 1, 1), date(2026, 9, 30))
svc.configure({"dv_yield"})
vals = {}
for a in ("2016-12-30", "2018-05-18", "2021-06-30"):
s = svc.snapshot("600036.SH", date.fromisoformat(a))
if s is None:
pytest.skip(f"{a} 无行情")
vals[a] = s.coverage["dv_yield"]
assert vals["2016-12-30"] < 0.5, f"2016 年 5 年窗口应严重不足:{vals}"
assert vals["2018-05-18"] == pytest.approx(0.67, abs=0.02)
assert vals["2021-06-30"] >= 0.999, f"2021 年应已满覆盖:{vals}"
# n_obs 必须一并暴露 —— 它是判断「窗口是否被截断」的原始依据
s = svc.snapshot("600036.SH", date(2018, 5, 18))
assert s.n_obs["dv_yield"] == 817, "实测 2018-05-18 的 5 年窗口为 817 个观测"
@pytest.mark.db
def test_snapshot_ignores_input_row_order() -> None:
"""回归:画像的「当日值」必须由**日期**决定,不能受输入行序影响。
2026-10-04 回补 2005-2014 时,新行是**追加**进 ``daily_basic`` 的,
同一股票的物理行序变成「2015-2026 在前、2005-2014 在后」;
而当时 ``_load_daily_basic`` 没有 ``ORDER BY``、``_profile_one`` 用
``iloc[-1]`` 取当日值 —— 于是格力电器 2018-05-18 的 PE(TTM)
被取成 2014 年的 8.51(真值 12.12)。这个测试把该不变量钉死:
**随机打乱输入面板的行序,结果必须逐值不变。**
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.pit import PitProfileService
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol = '000651.SZ'",
cfg=load_config("datasource"),
)
if rows.empty:
pytest.skip("无样本股票")
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
asof = date(2018, 5, 18)
sym = "000651.SZ"
def _snapshot(shuffle_seed: int | None) -> ProfileSnapshot:
svc = PitProfileService(window_years=5)
svc.prepare([sym], date(2004, 1, 1), date(2026, 9, 30))
svc.configure({"pe_ttm", "pb", "dv_yield"})
if shuffle_seed is not None:
# 直接打乱内部面板:模拟「行序不是日期序」
svc._basics = svc._basics.sample(frac=1.0, random_state=shuffle_seed)
svc._price = svc._price.sample(frac=1.0, random_state=shuffle_seed + 1)
s = svc.snapshot(sym, asof)
assert s is not None
return s
ref = _snapshot(None)
for seed in (1, 7, 42):
got = _snapshot(seed)
for metric in ("pe_ttm", "pb", "dv_yield"):
if metric in ref.values and metric in got.values:
assert got.values[metric] == pytest.approx(ref.values[metric], rel=1e-9), (
f"打乱输入行序后 {metric} 变了:{got.values[metric]} != {ref.values[metric]}"
)
# 顺带断言该日 PE(TTM) 就是 12.12 那个量级(防止排序修好后取到错窗口)
assert ref.values["pe_ttm"] == pytest.approx(12.115, rel=1e-3), ref.values["pe_ttm"]
@pytest.mark.db
def test_daily_basic_loader_is_date_sorted() -> None:
"""``_load_daily_basic`` 必须返回按 (symbol, trade_date) 有序的帧。
下游把「最后一个观测」当作当日值 —— 有序性是**语义前提**,不是可选优化。
"""
db = _db_ready()
from hdiv.core.config import load_config
from hdiv.profile.builder import ProfileBuilder
try:
rows = db.read_sql(
"SELECT symbol FROM stock WHERE symbol IN ('000651.SZ','600036.SH') ORDER BY symbol",
cfg=load_config("datasource"),
)
if rows.empty:
pytest.skip("无样本股票")
syms = rows["symbol"].tolist()
except Exception as exc:
pytest.skip(f"数据库不可用:{exc}")
df = ProfileBuilder.from_config()._load_daily_basic(syms, date(2005, 1, 1), date(2026, 9, 30))
assert not df.empty
for sym, g in df.groupby("symbol", sort=False):
assert g["trade_date"].is_monotonic_increasing, f"{sym} 的行序不是日期序"
assert df["trade_date"].min().date() <= date(2005, 1, 10), "回补后应包含 2005 年数据"
+6 -2
View File
@@ -188,7 +188,8 @@ def test_price_frames_map_tushare_columns() -> None:
assert list(d.columns) == ["symbol", "trade_date", "open", "high", "low", assert list(d.columns) == ["symbol", "trade_date", "open", "high", "low",
"close", "volume", "amount", "source", "adjust"] "close", "volume", "amount", "source", "adjust"]
assert d.iloc[0]["symbol"] == "000001.SZ" assert d.iloc[0]["symbol"] == "000001.SZ"
assert d.iloc[0]["volume"] == 100, "Tushare 的 vol 列映射为 volume" assert d.iloc[0]["volume"] == 10000, "Tushare 的 vol(手)必须换算为股(×100)"
assert d.iloc[0]["amount"] == 1000000, "Tushare 的 amount(千元)必须换算为元(×1000)"
a = adj_frame([{"ts_code": "000001.SZ", "trade_date": "20150105", "adj_factor": 1.2}]) a = adj_frame([{"ts_code": "000001.SZ", "trade_date": "20150105", "adj_factor": 1.2}])
assert a.iloc[0]["factor"] == 1.2 assert a.iloc[0]["factor"] == 1.2
@@ -243,7 +244,10 @@ def test_per_api_limits_are_independent() -> None:
cfg = __import__("hdiv.core.config", fromlist=["load_config"]).load_config("datasource") cfg = __import__("hdiv.core.config", fromlist=["load_config"]).load_config("datasource")
ts = cfg.tushare ts = cfg.tushare
assert ts.limit_for("dividend") == 180 assert ts.limit_for("dividend") == 180
assert ts.limit_for("daily") == 480 # daily 系列的额度必须**低于实测上限**:以 ~196 次/分钟跑 daily 会被 Tushare
# 拒绝,触发限频后要冷却 62 秒,逐日回补时远慢于平滑配速(见 datasource.yml 注释)
assert 100 <= ts.limit_for("daily") <= 200, ts.limit_for("daily")
assert ts.limit_for("daily") == ts.limit_for("adj_factor") == ts.limit_for("daily_basic")
assert ts.limit_for("未知接口") == ts.rate_limit_default assert ts.limit_for("未知接口") == ts.rate_limit_default
# 构造客户端需要 token;此处仅验证配置层 # 构造客户端需要 token;此处仅验证配置层
assert ts.rate_limit_cooldown_sec >= 60, "冷却必须覆盖 Tushare 的 60 秒滑动窗口" assert ts.rate_limit_cooldown_sec >= 60, "冷却必须覆盖 Tushare 的 60 秒滑动窗口"
+138
View File
@@ -0,0 +1,138 @@
"""量价单位归一化(``stock_daily`` 的历史遗留混用)测试。
背景:``stock_daily`` 里 2015-01~2019 的行是 Tushare 原始单位(手 / 千元),
2020 起沿用既有 qlib 存量(股 / 元),2019 年同日混着两种。
按「元」配置的流动性阈值因此把早年低估 1000 倍,会把股票池整体清空。
这些测试锁定「读取层必须幂等地归一化」这一契约。
"""
from __future__ import annotations
import pandas as pd
import pytest
from hdiv.data.units import (
OHLCV_CONVERTED,
OHLCV_UNKNOWN,
amount_qian_to_yuan,
detect_ohlcv_units,
normalize_ohlcv_units,
ohlcv_unit_ratio,
vol_shou_to_shares,
)
def _row(symbol: str, close: float, shares: float, **kw) -> dict:
"""按「股 / 元」口径构造一行(即换算后的目标形态)。"""
return {
"symbol": symbol,
"close": close,
"volume": shares,
"amount": shares * close,
**kw,
}
def _raw_row(symbol: str, close: float, shares: float, **kw) -> dict:
"""按 Tushare 原始口径构造一行:成交量为手、成交额为千元。
``shares`` 是真实股数。手 = 股/100;千元 = (股 × 价)/1000。
"""
return {
"symbol": symbol,
"close": close,
"volume": shares / 100.0,
"amount": shares * close / 1000.0,
**kw,
}
class TestScalarConversions:
def test_vol_shou_to_shares(self) -> None:
assert vol_shou_to_shares(pd.Series([100.0])).iloc[0] == 10000.0
def test_amount_qian_to_yuan(self) -> None:
assert amount_qian_to_yuan(pd.Series([1000.0])).iloc[0] == 1_000_000.0
class TestUnitRatio:
def test_converted_rows_ratio_is_one(self) -> None:
df = pd.DataFrame([_row("000001.SZ", 10.0, 1e6)])
assert ohlcv_unit_ratio(df).iloc[0] == pytest.approx(1.0)
def test_raw_rows_ratio_is_point_one(self) -> None:
# 手 / 千元:volume 是股数/100,amount 是元/1000 → 比值 1/10
df = pd.DataFrame([_raw_row("000001.SZ", 10.0, 1e6)])
assert ohlcv_unit_ratio(df).iloc[0] == pytest.approx(0.1)
def test_ratio_tolerates_intraday_move(self) -> None:
"""VWAP 与收盘价相差 ±10%(涨跌停)时不得误判单位。"""
df = pd.DataFrame([
_row("A", 10.0, 1e6, amount=1e6 * 11.0), # VWAP 高于收盘 10%
_row("B", 10.0, 1e6, amount=1e6 * 9.0), # VWAP 低于收盘 10%
])
assert list(detect_ohlcv_units(df)) == [OHLCV_CONVERTED, OHLCV_CONVERTED]
def test_degenerate_rows_are_unknown(self) -> None:
df = pd.DataFrame([
{"symbol": "A", "close": 0.0, "volume": 100.0, "amount": 1000.0},
{"symbol": "B", "close": 10.0, "volume": 0.0, "amount": 1000.0},
{"symbol": "C", "close": 10.0, "volume": 100.0, "amount": float("nan")},
])
assert list(detect_ohlcv_units(df)) == [OHLCV_UNKNOWN] * 3
class TestNormalizeOhlcvUnits:
def test_raw_rows_are_converted(self) -> None:
raw = pd.DataFrame([{"symbol": "000001.SZ", "close": 9.80,
"volume": 417732.0, "amount": 412636.0}])
out, diag = normalize_ohlcv_units(raw)
assert diag["raw"] == 1 and diag["fixed"] == 1
# 41,773,200 股 × 9.8784 ≈ 4.126 亿元
assert out.iloc[0]["volume"] == pytest.approx(41_773_200.0)
assert out.iloc[0]["amount"] == pytest.approx(412_636_000.0)
assert out.iloc[0]["amount"] / out.iloc[0]["volume"] == pytest.approx(9.878, abs=0.01)
def test_is_idempotent(self) -> None:
raw = pd.DataFrame([_raw_row("A", 10.0, 1e6)])
once, diag1 = normalize_ohlcv_units(raw)
assert diag1["fixed"] == 1
twice, diag2 = normalize_ohlcv_units(once)
pd.testing.assert_frame_equal(once, twice)
assert diag2["raw"] == 0, "已换算的行不得被二次换算"
def test_mixed_units_within_one_date(self) -> None:
"""2019 年同日两种单位并存(实测 3596 行里 237 行已换算)。"""
df = pd.DataFrame([
_raw_row("RAW", 10.0, 1e6),
_row("CONV", 10.0, 1e6),
])
out, diag = normalize_ohlcv_units(df)
assert diag["raw"] == 1 and diag["converted"] == 1
for i in out.index:
assert out.at[i, "amount"] / (out.at[i, "volume"] * out.at[i, "close"]) == pytest.approx(1.0)
def test_does_not_mutate_input(self) -> None:
raw = pd.DataFrame([_raw_row("A", 10.0, 1e6)])
before = raw.copy()
normalize_ohlcv_units(raw)
pd.testing.assert_frame_equal(raw, before)
def test_empty_frame(self) -> None:
out, diag = normalize_ohlcv_units(pd.DataFrame())
assert out.empty and diag["total"] == 0
def test_missing_column_is_reported_not_guessed(self) -> None:
"""缺 close 时无法判定单位 —— 必须原样返回并说明,不得瞎猜。"""
df = pd.DataFrame([{"symbol": "A", "volume": 1e4, "amount": 1e5}])
out, diag = normalize_ohlcv_units(df)
pd.testing.assert_frame_equal(out, df)
assert "error" in diag and diag["fixed"] == 0
def test_custom_column_names(self) -> None:
df = pd.DataFrame([_raw_row("A", 10.0, 1e6)].copy())
df = df.rename(columns={"volume": "vol", "amount": "amt", "close": "px"})
out, diag = normalize_ohlcv_units(df, volume_col="vol", amount_col="amt", close_col="px")
assert diag["fixed"] == 1
assert out.iloc[0]["vol"] == pytest.approx(1e6)
assert out.iloc[0]["amt"] == pytest.approx(1e7)
+59
View File
@@ -560,3 +560,62 @@ def test_dividend_records_include_base_share() -> None:
src = inspect.getsource(Repo.dividend_records) src = inspect.getsource(Repo.dividend_records)
assert "base_share" in src, "dividend_records 必须选出 base_share" assert "base_share" in src, "dividend_records 必须选出 base_share"
# ---------------------------------------------------------------------------
# 重跑覆盖同一条记录
# ---------------------------------------------------------------------------
def test_universe_run_id_is_deterministic() -> None:
"""回归:run_id 不得含时间戳,否则同参数重跑会不断累积重复记录。
早期实现把 datetime.now() 编进指纹,同一 asof 最多累积了 11 条内容相同的记录。
现在的语义是「同一份配置 + 同一时点 → 同一个 run_id → 重跑原地覆盖」。
"""
import inspect
from hdiv.universe import selector
src = inspect.getsource(selector.UniverseSelector)
i = src.find("run_id = stable_id(")
assert i != -1, "未找到 run_id 生成处"
# 取到该语句结束的分号行(不能用第一个 ')',那会截断在 config_hash(self.config) 里)
end = src.find("\n )", i)
assert end != -1, "未找到 run_id 语句结尾"
block = src[i:end]
assert "datetime.now" not in block, f"run_id 指纹仍含时间戳:{block}"
for must in ("config_hash", "effective", "self.config.name"):
assert must in block, f"run_id 指纹缺少 {must}:{block}"
@pytest.mark.db
def test_universe_rerun_overwrites_same_record() -> None:
"""同参数重跑不新增记录,且成员行数等于候选数(无重复堆积)。"""
from hdiv.core.config import load_config
from hdiv.data import db
from hdiv.data.sync.base import stable_id
db.load_dotenv_once()
cfg = load_config("datasource")
df = db.read_sql(
"SELECT r.run_id, r.candidate_count, r.asof_date, r.config_hash, r.name, "
" COUNT(m.id) AS member_rows "
"FROM hd_universe_run r LEFT JOIN hd_universe_member m ON m.run_id = r.run_id "
"GROUP BY r.run_id HAVING member_rows > 0 "
"ORDER BY r.created_at DESC LIMIT 5",
cfg=cfg,
)
if df.empty:
pytest.skip("没有筛选记录")
checked = 0
for _, r in df.iterrows():
expect = stable_id("universe", r["name"], str(r["asof_date"]), r["config_hash"])
if r["run_id"] != expect:
continue # 确定化之前的历史记录,跳过
checked += 1
assert int(r["member_rows"]) == int(r["candidate_count"]), (
f"run_id={r['run_id'][:10]} 成员行数 {r['member_rows']} "
f"应等于候选数 {r['candidate_count']}(出现重复堆积)"
)
assert checked > 0, "未找到确定化之后生成的筛选记录,无法验证"
+386 -12
View File
@@ -26,10 +26,12 @@ from hdiv.web.server import ROUTES
#: 路径样例用于匹配路由正则;改前端时需同步此表。 #: 路径样例用于匹配路由正则;改前端时需同步此表。
FRONTEND_CALLS: list[tuple[str, str]] = [ FRONTEND_CALLS: list[tuple[str, str]] = [
("GET", "/api/health"), ("GET", "/api/health"),
("GET", "/api/config/display"),
("GET", "/api/summary"), ("GET", "/api/summary"),
("GET", "/api/universes"), ("GET", "/api/universes"),
("GET", "/api/universes/abc123"), ("GET", "/api/universes/abc123"),
("GET", "/api/universes/abc123/members"), ("GET", "/api/universes/abc123/members"),
("GET", "/api/universes/abc123/members/600519.SH"),
("GET", "/api/universes/abc123/backtests"), ("GET", "/api/universes/abc123/backtests"),
("PATCH", "/api/universes/abc123"), ("PATCH", "/api/universes/abc123"),
("GET", "/api/stocks/600519.SH"), ("GET", "/api/stocks/600519.SH"),
@@ -38,8 +40,18 @@ FRONTEND_CALLS: list[tuple[str, str]] = [
("GET", "/api/backtests/abc123/metrics"), ("GET", "/api/backtests/abc123/metrics"),
("GET", "/api/backtests/abc123/equity"), ("GET", "/api/backtests/abc123/equity"),
("GET", "/api/backtests/abc123/trades"), ("GET", "/api/backtests/abc123/trades"),
# 净值曲线右轴可叠加的基准指数
("GET", "/api/indices"),
("GET", "/api/backtests/abc123/signals"), ("GET", "/api/backtests/abc123/signals"),
("PATCH", "/api/backtests/abc123"), ("PATCH", "/api/backtests/abc123"),
# 回测内分析:任意日持仓 + 个股买卖点
("GET", "/api/backtests/abc123/portfolio"),
("GET", "/api/backtests/abc123/position-dates"),
("GET", "/api/backtests/abc123/stocks"),
("GET", "/api/backtests/abc123/stocks/600519.SH"),
# Walk-forward 样本外
("GET", "/api/walkforwards"),
("GET", "/api/walkforwards/abc123"),
] ]
@@ -87,18 +99,25 @@ def test_members_endpoint_defaults_to_selected() -> None:
"未指定 passed 时应视为 1(仅入选)" "未指定 passed 时应视为 1(仅入选)"
def test_every_route_has_a_frontend_or_cli_consumer() -> None: def test_every_route_is_covered_by_the_contract() -> None:
"""反向检查:后端不应暴露无人使用的接口(便于发现遗留死接口)。""" """反向检查:每条后端路由都必须出现在契约表里。
known_paths = {p for _m, p in FRONTEND_CALLS}
orphans = [] 这样契约表就是「前后端接口清单」的唯一事实来源:
for _m, pat, fn in ROUTES: 新增接口忘了登记会被发现,删接口忘了清契约也会被发现。
# 用契约表中的样例路径试探该路由是否有消费者
sample = pat.pattern.replace("^", "").replace("$", "") 早期版本给两个「可选下钻」接口开了后门(阈值 <= 2),
sample = re.sub(r"\(\?P<\w+>\[[^\]]+\]\+?\)", "abc123", sample) 结果新增的三个接口漏登记却被放行 —— 所以现在零容忍。
if not any(pat.match(p) for p in known_paths) and "/stocks/" not in sample: """
orphans.append((_m, pat.pattern)) known = {p for _m, p in FRONTEND_CALLS}
# /stocks/ 由画像页使用;/universes/{id}/members/{sym} 为可选下钻 uncovered = []
assert len(orphans) <= 2, f"疑似无人使用的接口:{orphans}" for method, pat, _fn in ROUTES:
if not any(m == method and pat.match(p) for m, p in FRONTEND_CALLS) and \
not any(pat.match(p) for p in known):
uncovered.append((method, pat.pattern))
assert not uncovered, (
f"以下路由未登记在 FRONTEND_CALLS 中:{uncovered}\n"
"新增接口时请同步更新契约表,否则前端改动无法被发现。"
)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -326,6 +345,44 @@ def test_all_api_payloads_are_json_serializable() -> None:
json.dumps(p, ensure_ascii=False, cls=_Encoder) # 不应抛异常 json.dumps(p, ensure_ascii=False, cls=_Encoder) # 不应抛异常
@requires_db
def test_equity_index_overlay_is_date_aligned() -> None:
"""净值曲线右轴叠加的指数必须与日期**逐点对齐**。
类目轴上每个类目一个点:指数序列只要少一天,
整条指数线就会相对净值曲线整体错位,画出错误的对比。
"""
from hdiv.core.errors import HdivError
from hdiv.web import service
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有可用的回测")
items = service.list_indices()
if not items:
pytest.skip("hd_index_daily 没有指数行情")
assert [x["code"] for x in items if x["is_default"]] == [service.DEFAULT_INDEX_CODE], \
"应恰好把默认指数(沪深300)标成 default"
base = service.get_backtest_equity(rid)
if not base["dates"]:
pytest.skip("该回测没有净值曲线")
assert base["index"] is None, "不传 index 时不应凭空叠加指数"
for it in items:
ix = service.get_backtest_equity(rid, index_code=it["code"])["index"]
assert ix["code"] == it["code"] and ix["name"]
assert len(ix["close"]) == len(base["dates"]), f"{it['code']} 未与净值曲线对齐"
vals = [v for v in ix["close"] if v is not None]
# 叠加的是指数点位,不是净值;量级错了说明取错了列
assert not vals or min(vals) > 10, f"{it['code']} 取值不像指数点位:{vals[:3]}"
json.dumps(ix, allow_nan=False)
# 库里没有的指数应当明确报错,而不是画一条空线
with pytest.raises(HdivError):
service.get_backtest_equity(rid, index_code="999999.XX")
@requires_db @requires_db
def test_reason_text_is_human_readable() -> None: def test_reason_text_is_human_readable() -> None:
"""成交理由必须渲染成人话,而不是丢一坨 JSON 给前端。""" """成交理由必须渲染成人话,而不是丢一坨 JSON 给前端。"""
@@ -573,3 +630,320 @@ def test_site_build_does_not_clobber_spa() -> None:
site.sync_frontend(verbose=False) site.sync_frontend(verbose=False)
html = (project_root() / "output" / "index.html").read_text(encoding="utf-8") html = (project_root() / "output" / "index.html").read_text(encoding="utf-8")
assert "app/app.js" in html assert "app/app.js" in html
# ---------------------------------------------------------------------------
# 回测内分析:任意日持仓 + 个股买卖点
# ---------------------------------------------------------------------------
def _sample_backtest_run() -> str | None:
from hdiv.core.config import load_config
from hdiv.data import db
df = db.read_sql(
"SELECT r.run_id FROM hd_backtest_run r "
"JOIN hd_backtest_position p ON p.run_id = r.run_id "
"WHERE r.mode = 'single' "
"GROUP BY r.run_id ORDER BY COUNT(*) DESC LIMIT 1",
cfg=load_config("datasource"),
)
return None if df.empty else str(df["run_id"].iloc[0])
@requires_db
def test_position_dates_is_compact_by_default() -> None:
"""默认只返回日期字符串:带全字段会让响应从约 30KB 涨到 460KB。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
d = analysis.position_dates(rid)
assert "dates" in d and d["dates"], "应返回日期数组"
assert "items" not in d, "默认不应返回逐日全字段明细"
assert d["count"] == len(d["dates"])
assert d["dates"] == sorted(d["dates"]), "日期应升序"
# 紧凑形式必须显著更小
import json
compact = len(json.dumps(d, ensure_ascii=False).encode())
full = len(json.dumps(analysis.position_dates(rid, detail=True),
ensure_ascii=False).encode())
assert compact < full / 3, f"紧凑形式应远小于明细({compact} vs {full})"
@requires_db
def test_portfolio_falls_back_to_previous_trading_day() -> None:
"""非交易日应回退到之前最近的有快照交易日,并如实标注。"""
from hdiv.web import analysis
from hdiv.core.errors import HdivError
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
dates = analysis.position_dates(rid)["dates"]
d = analysis.portfolio_on_date(rid, dates[-1])
assert d["date"] == dates[-1] and not d["adjusted"]
# 区间内但非交易日(用周末构造)
import datetime as _dt
mid = _dt.date.fromisoformat(dates[len(dates) // 2])
weekend = mid + _dt.timedelta(days=(5 - mid.weekday()) % 7 + 1)
d2 = analysis.portfolio_on_date(rid, weekend.isoformat())
assert d2["date"] <= weekend.isoformat()
assert d2["adjusted"] is True, "非交易日应标注已回退"
# 早于首个快照应给出可理解错误
with pytest.raises(HdivError, match="早于该回测的首个快照"):
analysis.portfolio_on_date(rid, "1990-01-01")
@requires_db
def test_portfolio_summary_is_internally_consistent() -> None:
"""持仓汇总必须自洽:市值合计 = 逐股之和;权重合计 ≈ 仓位占比。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
dates = analysis.position_dates(rid)["dates"]
# 找一个有持仓的交易日
for day in reversed(dates):
d = analysis.portfolio_on_date(rid, day)
if d["positions"]:
break
else:
pytest.skip("没有非空持仓日")
s = sum(p["market_value"] or 0 for p in d["positions"])
assert abs(s - d["summary"]["market_value"]) < 1.0
assert d["summary"]["count"] == len(d["positions"])
w = sum(p["weight"] or 0 for p in d["positions"])
tv = d["equity"]["total_value"] or 0
if tv:
assert abs(w - d["summary"]["market_value"] / tv) < 0.02, \
f"权重合计 {w:.4f} 应约等于仓位占比 {d['summary']['market_value']/tv:.4f}"
# 每只股票都应带名称(JOIN stock)
assert all(p["symbol"] for p in d["positions"])
@requires_db
def test_stock_detail_series_and_trades() -> None:
"""个股买卖点:序列长度一致、买卖点带完整成交信息。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
stocks = analysis.run_stocks(rid)
if not stocks:
pytest.skip("该回测没有持仓股票")
sym = stocks[0]["symbol"]
d = analysis.stock_detail(rid, sym)
n = len(d["dates"])
assert n > 0
for k, v in d["series"].items():
assert len(v) == n, f"序列 {k} 长度与日期不一致({len(v)} vs {n})"
assert "close" in d["series"]
# 股息率必须在合理量级内(单位错误会让它变成 0 或几百)
dv = [x for x in d["series"]["dv_yield"] if x is not None]
if dv:
assert max(dv) < 1.0, f"股息率不应超过 100%:{max(dv)}"
assert min(dv) >= 0.0, "股息率不应为负"
for t in d["trades"]:
assert t["side"] in {"BUY", "SELL"}
assert t["price"] and t["price"] > 0
assert t["quantity"] and t["quantity"] > 0
assert t["amount"] and t["amount"] > 0
assert t["reason_text"] and t["reason_text"] != ""
assert d["stats"]["trade_count"] == len(d["trades"])
@requires_db
def test_stock_detail_with_sell_trades_does_not_500() -> None:
"""回归:有卖出的个股必须能打开。
卖出成交的 ``holding_days`` 在库里是 NaN 而**不是** None,
老代码 ``int(r["holding_days"]) if ... is not None else None`` 会抛
``ValueError: cannot convert float NaN to integer``,
让个股详情接口 500 —— 22/35 只有卖出的个股整页打不开。
"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
stocks = analysis.run_stocks(rid)
sells = [s for s in stocks if (s.get("sell_count") or 0) > 0]
if not sells:
pytest.skip("该回测没有卖出成交")
sym = sells[0]["symbol"]
d = analysis.stock_detail(rid, sym) # 老代码在这一行 500
assert any(t["side"] == "SELL" for t in d["trades"]), "应至少有一笔卖出"
for t in d["trades"]:
hd = t["holding_days"]
assert hd is None or isinstance(hd, int), f"holding_days 应为整数或 None:{hd!r}"
assert hd is None or hd >= 0
# NaN 会以非法 JSON 的形式漏到前端,这里一并卡住
json.dumps(d, allow_nan=False)
@requires_db
def test_stock_detail_respects_series_selection() -> None:
"""勾选哪些指标就只算哪些(不为没勾的做无谓计算)。"""
from hdiv.web import analysis
from hdiv.core.errors import HdivError
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
sym = analysis.run_stocks(rid)[0]["symbol"]
d = analysis.stock_detail(rid, sym, series=["close", "roe"])
assert set(d["series"]) == {"close", "roe"}
assert "pe_ttm" not in d["series"]
# 无效指标应报错而不是静默忽略
with pytest.raises(HdivError):
analysis.stock_detail(rid, sym, series=["不存在的指标"])
@requires_db
def test_roe_series_is_stepwise_not_interpolated() -> None:
"""ROE 必须按公告日对齐成阶梯(插值会造出当时不存在的值)。"""
from hdiv.web import analysis
rid = _sample_backtest_run()
if not rid:
pytest.skip("没有带持仓的回测")
sym = analysis.run_stocks(rid)[0]["symbol"]
d = analysis.stock_detail(rid, sym, series=["roe"])
vals = [x for x in d["series"]["roe"] if x is not None]
if len(vals) < 50:
pytest.skip("ROE 样本不足")
# 阶梯序列的不同取值数应远少于样本数(季度更新,约 4 次/年)
distinct = len(set(round(v, 6) for v in vals))
assert distinct < len(vals) / 5, \
f"ROE 取值数 {distinct} 相对样本 {len(vals)} 过多,疑似插值而非阶梯"
# ---------------------------------------------------------------------------
# Walk-forward 前端可见性
# ---------------------------------------------------------------------------
@requires_db
def test_walkforward_list_and_detail() -> None:
"""Walk-forward 记录必须在接口层可见(此前完全没有入口)。"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
w = items[0]
assert w["wf_id"] and w["window_count"] > 0
assert w["title"], "应有可读标题"
assert w["strategy"]["conditions"], "应带策略条件说明"
assert "oos" in w and w["oos"].get("window_count") == w["window_count"]
d = service.get_walkforward(w["wf_id"])
assert d is not None
assert len(d["windows"]) == w["window_count"]
for win in d["windows"]:
# 每个窗口都必须有训练段与测试段
assert win["train_start"] and win["test_start"]
assert win["train_run_id"] and win["test_run_id"]
assert "in_sample" in win and "out_of_sample" in win
s = d["summary"]
assert len(s["oos_returns"]) == w["window_count"]
assert s["oos_mean"] is not None
assert 0.0 <= s["oos_win_rate"] <= 1.0
@requires_db
def test_walkforward_summary_is_consistent() -> None:
"""汇总必须与逐窗口数据自洽(曾靠 metric 行数反推导致胜率算错)。"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
for w in items[:3]:
d = service.get_walkforward(w["wf_id"])
rets = [x["out_of_sample"].get("total_return") for x in d["windows"]]
rets = [x for x in rets if x is not None]
if not rets:
continue
assert abs(d["summary"]["oos_mean"] - sum(rets) / len(rets)) < 1e-9
expect_win = sum(1 for x in rets if x > 0) / len(rets)
assert abs(d["summary"]["oos_win_rate"] - expect_win) < 1e-9
# 列表页的汇总应与详情页一致
assert abs((w["oos"]["mean_return"] or 0) - d["summary"]["oos_mean"]) < 1e-9
def test_walkforward_frontend_page_exists() -> None:
"""前端必须有 walk-forward 页与导航入口。"""
js = (project_root() / "web" / "app.js").read_text(encoding="utf-8")
html = (project_root() / "web" / "index.html").read_text(encoding="utf-8")
assert "viewWalkforwards" in js and "viewWalkforwardDetail" in js
assert "#/walkforwards" in html, "导航缺「样本外」入口"
assert "mountWalkforwardDetail" in js, "详情页应挂载对比图"
@requires_db
def test_walkforward_exposes_benchmark_and_excess() -> None:
"""回归:基准指标存为 benchmark_<code>,曾被 benchmark_code='' 过滤掉,
导致页面上看不到最重要的「超额收益」。"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
d = service.get_walkforward(items[0]["wf_id"])
s = d["summary"]
assert s.get("benchmark_mean") is not None, "缺少基准均值"
assert s.get("excess_mean") is not None, "缺少超额收益均值"
assert s.get("excess_win_rate") is not None
got = 0
for w in d["windows"]:
if w["benchmark_return"] is None:
continue
got += 1
assert w["benchmark_code"], "应记录基准代码"
o = w["out_of_sample"].get("total_return")
if o is not None:
assert abs(w["excess_return"] - (o - w["benchmark_return"])) < 1e-9, \
"超额必须等于 策略收益 − 基准收益"
# 基准行不得混进策略指标里
assert not any(k.startswith("benchmark::") for k in w["out_of_sample"])
assert got > 0, "没有任何窗口带基准收益"
@requires_db
def test_walkforward_frozen_params_record_calibration() -> None:
"""冻结参数必须记录「校准出的绝对阈值」,而不只是配置里的分位。
训练段的作用是把相对分位(P75)转成绝对股息率;若只有分位、
没有绝对阈值,说明训练段实际上没做校准。
"""
from hdiv.web import service
items = service.list_walkforwards()
if not items:
pytest.skip("没有 walk-forward 记录")
d = service.get_walkforward(items[0]["wf_id"])
abs_entries = []
for w in d["windows"]:
f = w["frozen_params"]
assert "entry_yield_percentile" in f, "应保留分位口径"
assert f.get("absolute_entry_yield"), f"窗口 {w['window_index']} 缺校准阈值"
assert f.get("calibration_obs"), "应记录校准样本数"
abs_entries.append(f["absolute_entry_yield"])
# 各窗口的绝对阈值应随市场水平变化(全相同说明没真校准)
assert len(set(round(x, 6) for x in abs_entries)) > 1, \
"各窗口校准出的绝对阈值完全相同,疑似未真正校准"
+762 -24
View File
@@ -18,10 +18,19 @@ const COLORS = ['#1E40AF','#D97706','#3B82F6','#059669','#DC2626',
const esc = s => String(s ?? '').replace(/[&<>"']/g, const esc = s => String(s ?? '').replace(/[&<>"']/g,
c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const num = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v)) // 显示精度来自 config/report.yml: layout.decimals(启动时从 /api/config/display 取)。
? '—' : Number(v).toLocaleString('zh-CN', {minimumFractionDigits: d, maximumFractionDigits: d}); // 曾经这里硬编码 2 位,改配置不会生效 —— 与报告层是同一个毛病。
const pct = (v, d = 2) => (v === null || v === undefined || Number.isNaN(v)) const FMT = {ratio: 4, money: 2, price: 2, percent: 2};
? '—' : (v * 100).toFixed(d) + '%';
const num = (v, d) => (v === null || v === undefined || Number.isNaN(v))
? '—' : Number(v).toLocaleString('zh-CN',
{minimumFractionDigits: d === undefined ? FMT.ratio : d,
maximumFractionDigits: d === undefined ? FMT.ratio : d});
// 百分比小数位 = ratio - 2(比率保留 ratio 位后乘 100,恰好少两位)
const pct = (v, d) => (v === null || v === undefined || Number.isNaN(v))
? '—' : (v * 100).toFixed(d === undefined ? FMT.percent : d) + '%';
const pp = (v, d) => (v === null || v === undefined || Number.isNaN(v))
? '—' : ((v > 0 ? '+' : '') + (v * 100).toFixed(d === undefined ? FMT.percent : d) + 'pp');
const yi = v => (v === null || v === undefined) ? '—' const yi = v => (v === null || v === undefined) ? '—'
: (Math.abs(v) >= 1e8 ? (v/1e8).toFixed(1) + ' 亿' : num(v, 0)); : (Math.abs(v) >= 1e8 ? (v/1e8).toFixed(1) + ' 亿' : num(v, 0));
const sign = v => (v === null || v === undefined) ? '' : (v > 0 ? 'gain' : (v < 0 ? 'loss' : '')); const sign = v => (v === null || v === undefined) ? '' : (v > 0 ? 'gain' : (v < 0 ? 'loss' : ''));
@@ -59,6 +68,16 @@ function chart(id, option) {
return c; return c;
} }
function disposeCharts() { while (charts.length) { try { charts.pop().dispose(); } catch (e) {} } } function disposeCharts() { while (charts.length) { try { charts.pop().dispose(); } catch (e) {} } }
/** 只销毁一张图(切换叠加指数时重画同一张图,不动页面上其它图)。 */
function disposeChart(id) {
const el = document.getElementById(id);
if (!el || typeof echarts === 'undefined') return;
const inst = echarts.getInstanceByDom(el);
if (!inst) return;
inst.dispose();
const i = charts.indexOf(inst);
if (i >= 0) charts.splice(i, 1);
}
window.addEventListener('resize', () => charts.forEach(c => c.resize())); window.addEventListener('resize', () => charts.forEach(c => c.resize()));
/* ---------------- 模态框 ---------------- */ /* ---------------- 模态框 ---------------- */
@@ -634,7 +653,33 @@ async function viewBacktestDetail(runId) {
${rc.balanced ? '成本与分红入账完整无遗漏。' : '<b>此时不应采信上方绩效指标。</b>'} ${rc.balanced ? '成本与分红入账完整无遗漏。' : '<b>此时不应采信上方绩效指标。</b>'}
</div> </div>
<div class="card"><h2>净值曲线与基准</h2><div id="c_eq" class="chart"></div></div> <div class="card">
<h2>净值曲线与基准</h2>
<div class="toolbar">
<span class="small muted">叠加指数(右轴)</span>
<select id="eq-index" style="width:auto"></select>
<span class="small muted" id="eq-index-note"></span>
</div>
<div id="c_eq" class="chart"></div>
<div class="callout" style="margin-top:12px">
<b>右轴</b>叠加的是所选指数的<b>点位</b>(不归一化),左轴是净值(起点 = 1);
虚线「基准净值」是本次回测<b>自带</b>的基准,已归一到 1,可直接与策略净值比高低。
指数默认沪深300,可在上方下拉切换或取消叠加。
</div>
</div>
<div class="card" id="portfolio-card">
<h2>持仓明细 <span class="badge info" id="pf-badge">…</span></h2>
<div class="toolbar">
<span class="small muted">日期</span>
<input type="date" id="pf-date" style="width:auto">
<button data-act="pf-shift" data-d="-1">← 上一交易日</button>
<button data-act="pf-shift" data-d="1">下一交易日 →</button>
<button data-act="pf-shift" data-d="0">最新</button>
<span class="small muted" id="pf-range"></span>
</div>
<div id="pf-body"><div class="loading">加载中…</div></div>
</div>
<div class="card"> <div class="card">
<h2>回测条件</h2> <h2>回测条件</h2>
@@ -678,26 +723,105 @@ async function viewBacktestDetail(runId) {
</div>`; </div>`;
} }
async function mountBacktestDetail(runId) { const eqState = {runId: null, index: '', indices: []};
const eq = await api(`backtests/${runId}/equity`);
if (eq.dates && eq.dates.length) { /** 净值曲线 + 可选的指数叠加(指数画在右轴,单位是点位,不参与净值刻度)。 */
chart('c_eq', { async function mountEquityChart(runId, indexCode) {
tooltip:{trigger:'axis'}, legend:{top:0,textStyle:{color:'#64748B'}}, const host = document.getElementById('c_eq');
grid:{left:66,right:30,top:38,bottom:56}, if (!host) return;
xAxis:{type:'category',data:eq.dates,axisLabel:{color:'#64748B', disposeChart('c_eq');
formatter:v=>String(v).slice(0,7)}}, const eq = await api(`backtests/${runId}/equity`
yAxis:{type:'value',scale:true,name:'净值',axisLabel:{color:'#64748B'}, + (indexCode ? `?index=${encodeURIComponent(indexCode)}` : ''));
splitLine:{lineStyle:{color:'#E9EEF6'}}}, const note = document.getElementById('eq-index-note');
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}], if (!eq.dates || !eq.dates.length) {
series:[ host.innerHTML = '<div class="empty">该回测没有净值曲线</div>';
{name:'策略净值',type:'line',data:eq.nav,showSymbol:false,lineStyle:{width:2,color:COLORS[0]}}, if (note) note.textContent = '';
{name:'基准净值',type:'line',data:eq.bench,showSymbol:false, return;
lineStyle:{width:1.4,color:COLORS[1],type:'dashed'}}, }
{name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,yAxisIndex:0, host.innerHTML = '';
lineStyle:{width:1,color:COLORS[4]},areaStyle:{opacity:.1}}] disposeChart('c_eq'); // 连续切换下拉时,上一次请求可能已经 init 过
const ix = eq.index;
const isNav = n => n === '策略净值' || n === '基准净值';
// itemStyle 必须跟着写:图例与提示框的圆点取 itemStyle(缺省按调色板序号取色),
// 只写 lineStyle 会让「回撤」的圆点变成浅蓝、指数圆点变成绿色,与线色对不上。
const series = [
{name:'策略净值',type:'line',data:eq.nav,showSymbol:false,
lineStyle:{width:2,color:COLORS[0]},itemStyle:{color:COLORS[0]}},
{name:'基准净值',type:'line',data:eq.bench,showSymbol:false,
lineStyle:{width:1.4,color:COLORS[1],type:'dashed'},itemStyle:{color:COLORS[1]}},
{name:'回撤',type:'line',data:eq.drawdown,showSymbol:false,
lineStyle:{width:1,color:COLORS[4]},itemStyle:{color:COLORS[4]},
areaStyle:{opacity:.1}},
];
if (ix) {
series.push({
name:`${ix.name}(右轴)`, type:'line', yAxisIndex:1, data:ix.close,
showSymbol:false, connectNulls:true, // 指数偶有缺日,不因此断线
// 用紫色:与策略净值的深蓝、基准的橙虚线、回撤的红都拉开
lineStyle:{width:1.6,color:COLORS[5]}, itemStyle:{color:COLORS[5]},
}); });
} }
// 净值 / 回撤 / 指数点位三种量纲,不能用同一个小数位
const rowFmt = p => {
const v = p.value;
const text = v == null ? '—'
: p.seriesName === '回撤' ? pct(v, 2)
: isNav(p.seriesName) ? num(v, 4)
: num(v, 2) + ' 点';
return `<div style="display:flex;gap:18px;justify-content:space-between;line-height:1.7">
<span>${p.marker}${esc(p.seriesName)}</span>
<b style="font-family:monospace">${text}</b></div>`;
};
chart('c_eq', {
tooltip:{trigger:'axis',axisPointer:{type:'cross'},
formatter: ps => `<div style="font-weight:600;margin-bottom:4px">`
+ `${esc(ps.length ? ps[0].axisValue : '')}</div>${ps.map(rowFmt).join('')}`},
legend:{top:0,textStyle:{color:'#64748B'}},
grid:{left:66,right:ix?62:30,top:38,bottom:56},
xAxis:{type:'category',data:eq.dates,axisLabel:{color:'#64748B',
formatter:v=>String(v).slice(0,7)}},
yAxis:[
{type:'value',scale:true,name:'净值',axisLabel:{color:'#64748B'},
splitLine:{lineStyle:{color:'#E9EEF6'}}},
...(ix ? [{type:'value',scale:true,name:'指数点位',position:'right',
axisLabel:{color:'#64748B',formatter:v=>num(v,0)},
nameTextStyle:{color:'#64748B'},splitLine:{show:false}}] : []),
],
dataZoom:[{type:'inside'},{type:'slider',height:16,bottom:12}],
series,
});
if (note) {
if (!ix) note.textContent = '';
else if (!ix.covered)
note.innerHTML = '<span class="badge warn">该指数在本次回测区间内没有行情</span>';
else note.textContent =
`${ix.name} ${ix.code} · 覆盖 ${ix.points}/${eq.dates.length} 个交易日`;
}
}
async function mountBacktestDetail(runId) {
// 持仓面板与净值曲线并行加载:持仓查询较慢,不想拖住曲线
pfState.runId = runId;
loadPortfolio(null);
// 指数下拉:拿不到列表也不该挡住净值曲线本身
eqState.runId = runId;
try { eqState.indices = (await api('indices')).items || []; }
catch (e) { eqState.indices = []; }
const def = eqState.indices.find(x => x.is_default) || eqState.indices[0];
eqState.index = def ? def.code : '';
const sel = document.getElementById('eq-index');
if (sel) {
sel.innerHTML = eqState.indices.map(x =>
`<option value="${esc(x.code)}"${x.code === eqState.index ? ' selected' : ''}>`
+ `${esc(x.name)}(${esc(x.code)})</option>`).join('')
+ `<option value=""${eqState.index ? '' : ' selected'}>不叠加</option>`;
}
await mountEquityChart(runId, eqState.index);
const t = await api(`backtests/${runId}/trades?size=200`); const t = await api(`backtests/${runId}/trades?size=200`);
document.getElementById('trade-count').textContent = t.total + ' 笔'; document.getElementById('trade-count').textContent = t.total + ' 笔';
const host = document.getElementById('trades'); const host = document.getElementById('trades');
@@ -709,7 +833,7 @@ async function mountBacktestDetail(runId) {
<th>已实现盈亏</th><th>持仓天数</th><th class="l">触发理由</th></tr></thead> <th>已实现盈亏</th><th>持仓天数</th><th class="l">触发理由</th></tr></thead>
<tbody>${t.items.map((x,i)=>`<tr> <tbody>${t.items.map((x,i)=>`<tr>
<td class="num">${i+1}</td> <td class="num">${i+1}</td>
<td class="l"><a href="#/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td> <td class="l"><a href="#/backtests/${esc(runId)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
<td class="num">${esc(x.signal_date)}</td><td class="num">${esc(x.execution_date)}</td> <td class="num">${esc(x.signal_date)}</td><td class="num">${esc(x.execution_date)}</td>
<td><span class="badge ${x.side==='BUY'?'gain':'loss'}">${esc(x.side)}</span></td> <td><span class="badge ${x.side==='BUY'?'gain':'loss'}">${esc(x.side)}</span></td>
<td class="num">${num(x.price,3)}</td><td class="num">${num(x.quantity,0)}</td> <td class="num">${num(x.price,3)}</td><td class="num">${num(x.quantity,0)}</td>
@@ -738,6 +862,577 @@ async function mountBacktestDetail(runId) {
} catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ } } catch (e) { /* 未成交信号是可选信息,失败不影响主流程 */ }
} }
/* ---------------- 回测详情:持仓明细 ---------------- */
const pfState = {runId: null, dates: [], cursor: null, loadedFor: null};
// 日期清单在进入回测时取一次即可 —— 曾在每次切日期时重复拉取,
// 单次 460KB,翻页几下就很浪费。
async function ensurePfDates(runId) {
if (pfState.loadedFor === runId && pfState.dates.length) return pfState.dates;
const d = await api(`backtests/${runId}/position-dates`);
pfState.dates = d.dates || [];
pfState.loadedFor = runId;
return pfState.dates;
}
async function loadPortfolio(date) {
const host = document.getElementById('pf-body');
if (!host) return;
host.innerHTML = '<div class="loading">加载中…</div>';
try {
const q = date ? `?date=${encodeURIComponent(date)}` : '';
const d = await api(`backtests/${pfState.runId}/portfolio${q}`);
await ensurePfDates(pfState.runId);
pfState.cursor = d.date;
const el = document.getElementById('pf-date');
if (el) el.value = d.date;
const badge = document.getElementById('pf-badge');
if (badge) badge.textContent = d.adjusted
? `请求 ${d.requested_date} → 最近交易日 ${d.date}` : d.date;
const rng = document.getElementById('pf-range');
if (rng) rng.textContent = `可选区间 ${d.range.start} ~ ${d.range.end}(${d.range.count} 个交易日)`;
const e = d.equity, sm = d.summary;
const rows = d.positions.map((x, i) => `<tr>
<td class="num">${i + 1}</td>
<td class="l"><a href="#/backtests/${esc(d.run_id)}/stocks/${esc(x.symbol)}" class="mono">${esc(x.symbol)}</a></td>
<td class="l">${esc(x.name || '')}</td>
<td class="l small muted">${esc(x.industry || '')}</td>
<td class="num">${num(x.quantity, 0)}</td>
<td class="num">${num(x.avg_cost, 3)}</td>
<td class="num">${num(x.close, 3)}</td>
<td class="num">${num(x.market_value, 0)}</td>
<td class="num">${pct(x.weight)}</td>
<td class="num ${sign(x.unrealized_pnl)}">${num(x.unrealized_pnl, 0)}</td>
<td class="num ${sign(x.pnl_pct)}">${pct(x.pnl_pct)}</td>
<td class="num">${x.holding_days ?? '—'}</td>
</tr>`).join('');
host.innerHTML = `
<div class="kpi-grid" style="margin-bottom:14px">
<div class="kpi"><div class="label">总资产</div><div class="value">${yi(e.total_value)}</div>
<div class="note">净值 ${num(e.nav, 4)}</div></div>
<div class="kpi"><div class="label">现金</div><div class="value">${yi(e.cash)}</div>
<div class="note">占比 ${pct(e.total_value ? e.cash / e.total_value : null)}</div></div>
<div class="kpi"><div class="label">持仓市值</div><div class="value">${yi(e.position_value)}</div>
<div class="note">${sm.count} 只</div></div>
<div class="kpi"><div class="label">浮动盈亏</div>
<div class="value ${sign(sm.unrealized_pnl)}">${yi(sm.unrealized_pnl)}</div>
<div class="note">成本 ${yi(sm.cost)} · ${pct(sm.unrealized_pnl_pct)}</div></div>
<div class="kpi"><div class="label">当日涨跌</div>
<div class="value ${sign(e.daily_return)}">${pct(e.daily_return)}</div>
<div class="note">累计 ${pct(e.cum_return)}</div></div>
<div class="kpi"><div class="label">回撤</div>
<div class="value ${e.drawdown < 0 ? 'loss' : ''}">${pct(e.drawdown)}</div>
<div class="note">相对历史高点</div></div>
</div>
${d.positions.length ? `<div class="tw"><table class="data">
<thead><tr><th>#</th><th class="l">代码</th><th class="l">名称</th><th class="l">行业</th>
<th>股数</th><th>成本</th><th>收盘</th><th>市值</th><th>权重</th>
<th>浮动盈亏</th><th>收益率</th><th>持仓天数</th></tr></thead>
<tbody>${rows}</tbody>
<tfoot><tr><td colspan="7" class="l"><b>合计</b></td>
<td class="num"><b>${num(sm.market_value, 0)}</b></td>
<td class="num"><b>${pct(e.total_value ? sm.market_value / e.total_value : null)}</b></td>
<td class="num ${sign(sm.unrealized_pnl)}"><b>${num(sm.unrealized_pnl, 0)}</b></td>
<td class="num ${sign(sm.unrealized_pnl_pct)}"><b>${pct(sm.unrealized_pnl_pct)}</b></td>
<td></td></tr></tfoot>
</table></div>` : '<div class="empty">该日空仓(100% 现金)</div>'}
<div class="callout" style="margin-top:12px">
点击任意股票代码可打开<b>该股在本次回测中的买卖点与趋势图</b>。
</div>`;
} catch (err) {
host.innerHTML = `<div class="callout fail">加载失败:${esc(err.message)}</div>`;
}
}
function shiftPortfolio(delta) {
if (!pfState.dates.length) return loadPortfolio(null);
if (delta === 0) return loadPortfolio(null);
const i = pfState.dates.indexOf(pfState.cursor);
const j = Math.max(0, Math.min(pfState.dates.length - 1,
(i < 0 ? pfState.dates.length - 1 : i) + delta));
loadPortfolio(pfState.dates[j]);
}
/* ---------------- 视图:回测内个股买卖点 ---------------- */
const stState = {runId: null, symbol: null, data: null, shown: {}};
async function viewBacktestStock(runId, symbol) {
let d;
try { d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`); }
catch (e) {
return `<div class="crumb"><a href="#/backtests/${esc(runId)}">返回回测</a></div>
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
}
const st = d.stats;
const i = d.info;
return `
<div class="crumb"><a href="#/backtests">回测</a><span>/</span>
<a href="#/backtests/${esc(runId)}">${esc(runId.slice(0, 12))}…</a><span>/</span>个股</div>
<h1 class="page">${esc(i.name || symbol)}
<span class="mono muted" style="font-size:15px">${esc(symbol)}</span></h1>
<div class="page-sub">${esc(i.industry || '—')} · 区间 ${esc(d.range.start)} ~ ${esc(d.range.end)}
${d.range.downsampled ? `(原始 ${d.range.points} 点,已降采样显示)` : `(${d.range.points} 个交易日)`}</div>
<div class="kpi-grid">
<div class="kpi"><div class="label">成交笔数</div><div class="value">${st.trade_count}</div>
<div class="note">买 ${st.buy_count} / 卖 ${st.sell_count}</div></div>
<div class="kpi"><div class="label">买入金额</div><div class="value">${yi(st.buy_amount)}</div>
<div class="note">卖出 ${yi(st.sell_amount)}</div></div>
<div class="kpi"><div class="label">已实现盈亏</div>
<div class="value ${sign(st.realized_pnl)}">${yi(st.realized_pnl)}</div>
<div class="note">仅平仓部分</div></div>
<div class="kpi"><div class="label">交易费用</div><div class="value">${num(st.total_fees, 2)}</div>
<div class="note">佣金+印花税+过户费</div></div>
<div class="kpi"><div class="label">首笔 / 末笔</div>
<div class="value" style="font-size:14px">${esc(st.first_trade || '—')}<br>${esc(st.last_trade || '—')}</div>
<div class="note">成交日期</div></div>
</div>
<div class="card">
<h2>趋势与买卖点</h2>
<div class="toolbar">
<span class="small muted">显示指标(勾几项就是几联图,自上而下排列):</span>
${d.available_series.map(k => `<label class="check">
<input type="checkbox" class="st-ser" value="${k}"
${['close','dv_yield','pe_ttm','roe'].includes(k) ? 'checked' : ''}>
${esc(SERIES_LABEL[k] || k)}</label>`).join('')}
<button data-act="st-apply" style="margin-left:auto">应用</button>
</div>
<div id="c_stock" class="chart"></div>
<div id="c_stock_note"></div>
<div class="callout" style="margin-top:12px">
每个指标<b>独占一个面板</b>(N 联图):时间轴、缩放与十字光标上下联动,
便于对照同一时点的估值与质量读数。<b>▲ 买入 / ▼ 卖出</b> 同时标注在<b>每个面板</b>上,
位置取该指标在成交日的取值;鼠标悬停可一次看全部指标与成交的价格、股数、金额。
股息率为 PIT-TTM 口径(TTM 每股分红 ÷ 不复权收盘价),
ROE 按<b>公告日</b>对齐成阶梯线(不插值,避免未来函数)。
</div>
</div>
<div class="card">
<h2>逐笔成交明细 <span class="badge info">${st.trade_count}</span></h2>
${st.trade_count ? `<div class="tw"><table class="data">
<thead><tr><th>#</th><th>信号日</th><th>成交日</th><th>方向</th>
<th>价格</th><th>股数</th><th>金额</th><th>佣金</th><th>印花税</th><th>过户费</th>
<th>费用合计</th><th>滑点成本</th><th>已实现盈亏</th><th>持仓天数</th>
<th class="l">触发理由</th></tr></thead>
<tbody>${d.trades.map((t, i2) => `<tr>
<td class="num">${i2 + 1}</td>
<td class="num">${esc(t.signal_date)}</td><td class="num">${esc(t.execution_date)}</td>
<td><span class="badge ${t.side === 'BUY' ? 'gain' : 'loss'}">${t.side === 'BUY' ? '买入' : '卖出'}</span></td>
<td class="num">${num(t.price, 3)}</td><td class="num">${num(t.quantity, 0)}</td>
<td class="num">${num(t.amount, 0)}</td>
<td class="num">${num(t.commission, 2)}</td><td class="num">${num(t.stamp_tax, 2)}</td>
<td class="num">${num(t.transfer_fee, 2)}</td><td class="num">${num(t.total_cost, 2)}</td>
<td class="num">${num(t.slippage_cost, 2)}</td>
<td class="num ${sign(t.realized_pnl)}">${t.realized_pnl == null ? '—' : num(t.realized_pnl, 0)}</td>
<td class="num">${t.holding_days ?? '—'}</td>
<td class="l small">${esc(t.reason_text)}</td></tr>`).join('')}
</tbody></table></div>` : '<div class="empty">该股在本次回测中没有成交</div>'}
</div>`;
}
const SERIES_LABEL = {close: '股价', dv_yield: '股息率', pe_ttm: 'PE(TTM)',
pb: 'PB', roe: 'ROE', drawdown: '回撤'};
// 面板排列顺序:股价永远在首位(买卖点以成交价标注),其余按 估值 → 质量 → 风险 排。
// 顺序固定而非按勾选先后,避免同一组指标因勾选次序不同而换位置。
const SERIES_ORDER = ['close', 'dv_yield', 'pe_ttm', 'pb', 'roe', 'drawdown'];
const SERIES_UNIT = {close: '元', dv_yield: '', pe_ttm: '倍', pb: '倍',
roe: '', drawdown: ''};
// 轴刻度 / 十字光标标签:比率型指标(股息率、回撤)在此转为 %
const SERIES_TICK = {close: v => num(v, 2), dv_yield: v => (v * 100).toFixed(2) + '%',
pe_ttm: v => num(v, 1), pb: v => num(v, 1), roe: v => num(v, 1) + '%',
drawdown: v => (v * 100).toFixed(0) + '%'};
// 提示框数值(带单位)
const SERIES_TEXT = {close: v => num(v, 2) + ' 元', dv_yield: v => pct(v, 2),
pe_ttm: v => num(v, 2), pb: v => num(v, 2), roe: v => num(v, 2) + '%',
drawdown: v => pct(v, 2)};
function mountBacktestStock(runId, symbol) {
const render = async () => {
const host = document.getElementById('c_stock');
if (!host) return;
const shown = [...document.querySelectorAll('.st-ser:checked')]
.map(x => x.value)
.sort((a, b) => SERIES_ORDER.indexOf(a) - SERIES_ORDER.indexOf(b));
if (!shown.length) {
host.style.height = '140px';
host.innerHTML = '<div class="empty">请至少勾选一个指标</div>';
const n0 = document.getElementById('c_stock_note');
if (n0) n0.innerHTML = '';
return;
}
host.innerHTML = '<div class="loading">加载中…</div>';
const d = await api(`backtests/${runId}/stocks/${encodeURIComponent(symbol)}`
+ `?series=${shown.join(',')}`);
stState.data = d;
host.innerHTML = '';
paintStockPanels(host, d, shown);
};
render().catch(e => {
const host = document.getElementById('c_stock');
if (host) host.innerHTML = `<div class="callout fail">图表加载失败:${esc(e.message)}</div>`;
const note = document.getElementById('c_stock_note');
if (note) note.innerHTML = '';
});
return render;
}
/** 把选中的指标画成 N 联图:每个指标一个 grid,共用一个时间轴与缩放。 */
function paintStockPanels(host, d, shown) {
const N = shown.length;
const TOP = 46; // 顶部:买卖点图例 + 首个面板标题
const GAP = 34; // 面板间距(容纳下一个面板标题)
const BOTTOM = 54; // 末面板的 x 轴标签 + dataZoom 滑条
const PANEL_H = N <= 2 ? 170 : (N <= 4 ? 132 : 112);
const gridTop = i => TOP + i * (PANEL_H + GAP);
host.style.height = (gridTop(N - 1) + PANEL_H + BOTTOM) + 'px';
const dateIndex = new Map(d.dates.map((dt, i) => [dt, i]));
const idxOf = date => (dateIndex.has(date) ? dateIndex.get(date) : -1);
const tradesOn = new Map();
d.trades.forEach(t => {
if (!tradesOn.has(t.execution_date)) tradesOn.set(t.execution_date, []);
tradesOn.get(t.execution_date).push(t);
});
// 成交日可能落在价格区间之外(实测 000338.SZ 的卖出在区间最后一天之后一天),
// 直接用日期当类目会把这笔成交整笔丢掉。这里把这些日期按序补进横轴,
// 让股价面板仍能按成交价标出买卖点。
const extraDates = [...new Set(d.trades.map(t => t.execution_date))]
.filter(dt => !dateIndex.has(dt)).sort();
const axisDates = d.dates.slice();
extraDates.forEach(dt => {
let lo = 0, hi = axisDates.length;
while (lo < hi) {
const mid = (lo + hi) >> 1;
if (axisDates[mid] < dt) lo = mid + 1; else hi = mid;
}
axisDates.splice(lo, 0, dt);
});
const titles = [], grids = [], xAxis = [], yAxis = [], series = [];
shown.forEach((k, i) => {
const color = COLORS[i % COLORS.length];
const vals = d.series[k] || [];
const top = gridTop(i);
grids.push({left: 76, right: 26, top, height: PANEL_H});
xAxis.push({
type: 'category', data: axisDates, gridIndex: i,
// 两端留 1% 空隙:首/末成交日的三角标不会被画到 grid 外面切掉
boundaryGap: ['1%', '1%'],
axisTick: {show: false},
axisLine: {show: i === N - 1, lineStyle: {color: '#DBEAFE'}},
// 只有最下面的面板显示日期,其余靠十字光标对齐读取
axisLabel: {show: i === N - 1, color: '#64748B', fontSize: 11,
formatter: v => String(v).slice(0, 7)},
axisPointer: {label: {show: i === N - 1}},
});
yAxis.push({
// scale:true 让轴随数据取值;splitNumber 不能太小 —— 取 3 时
// 「nice」刻度会把价格轴整到 0~120,趋势被压扁(实测过)。
type: 'value', gridIndex: i, scale: true, splitNumber: 4,
axisLabel: {color: '#64748B', fontSize: 11, formatter: SERIES_TICK[k]},
splitLine: {lineStyle: {color: '#E9EEF6'}},
axisPointer: {label: {formatter: p => SERIES_TICK[k](p.value)}},
});
titles.push({
left: 76, top: top - 20,
text: `{n|${SERIES_LABEL[k] || k}}`
+ (SERIES_UNIT[k] ? `{u|(${SERIES_UNIT[k]})}` : '')
+ `{v|最新 ${SERIES_TEXT[k](vals[vals.length - 1])}}`,
textStyle: {rich: {
n: {fontSize: 12, fontWeight: 600, color},
u: {fontSize: 11, color: '#94A3B8'},
v: {fontSize: 11, color: '#94A3B8', padding: [0, 0, 0, 10]},
}},
});
series.push({
name: SERIES_LABEL[k] || k, type: 'line', xAxisIndex: i, yAxisIndex: i,
// 指标序列按补过日期的横轴对齐(多出来的位置为 null,折线自然断开)
data: k === 'close' && !extraDates.length
? vals : axisDates.map(dt => {
const j = dateIndex.get(dt);
return j === undefined ? null : vals[j];
}),
showSymbol: false, sampling: 'lttb', z: 5,
lineStyle: {width: k === 'close' ? 1.6 : 1.3, color},
itemStyle: {color},
...(k === 'dv_yield' ? {areaStyle: {opacity: 0.08, color}} : {}),
});
// 买卖点画在**每个**面板上:取该指标在成交日的取值,
// 这样能直接看出「买在多少股息率 / 多少 PE」。
// 股价面板用成交价(含滑点),与「▲▼ 标在成交价上」一致;
// 区间外的成交日只有成交价、没有指标值,因此只画在股价面板。
[['BUY', '买入', COLORS[4], 'triangle', k === 'close' ? 12 : 8],
['SELL', '卖出', COLORS[3], 'diamond', k === 'close' ? 12 : 8]]
.forEach(([side, cn, c, sym, size]) => {
const pts = d.trades.filter(t => t.side === side).map(t => {
if (k === 'close') return t.price == null ? null : [t.execution_date, t.price];
const j = dateIndex.get(t.execution_date);
const v = j === undefined ? null : vals[j];
return v == null ? null : [t.execution_date, v];
}).filter(Boolean);
if (!pts.length) return;
series.push({
name: cn, type: 'scatter', xAxisIndex: i, yAxisIndex: i, z: 20,
symbol: sym, symbolSize: size, itemStyle: {color: c},
data: pts, tooltip: {show: false}, // 统一由 axis 提示框呈现
});
});
});
const xIdx = shown.map((_, i) => i);
chart('c_stock', {
animation: false,
// 任意面板悬停都弹同一份「全指标 + 当日成交」读数
tooltip: {
trigger: 'axis', confine: true,
axisPointer: {type: 'cross', label: {backgroundColor: '#475569'}},
formatter: params => {
const p = Array.isArray(params) ? params[0] : params;
if (!p) return '';
const date = p.axisValue, idx = idxOf(date);
const head = `<div style="font-weight:600;margin-bottom:4px">${esc(date)}</div>`;
const rows = idx < 0
? `<div style="color:#94A3B8;font-size:12px">该日超出指标数据区间,仅此处的成交记录</div>`
: shown.map((k, i) => `
<div style="display:flex;gap:16px;justify-content:space-between;line-height:1.7">
<span style="color:${COLORS[i % COLORS.length]}">● ${esc(SERIES_LABEL[k] || k)}</span>
<b style="font-family:monospace">${esc(SERIES_TEXT[k]((d.series[k] || [])[idx]))}</b>
</div>`).join('');
const trs = (tradesOn.get(date) || []).map(t => `
<div style="margin-top:6px;padding-top:6px;border-top:1px dashed #CBD5E1">
<b style="color:${t.side === 'BUY' ? COLORS[4] : COLORS[3]}">
${t.side === 'BUY' ? '▲ 买入' : '▼ 卖出'}</b>
<span style="font-family:monospace">${num(t.price, 3)} 元</span> ·
${num(t.quantity, 0)} 股 · ${num(t.amount, 0)} 元<br/>
<span style="color:#64748B;font-size:12px">${esc(t.reason_text)}</span>
</div>`).join('');
return `<div style="max-width:340px;white-space:normal">${head}${rows}${trs}</div>`;
},
},
legend: {data: ['买入', '卖出'], top: 2, right: 8, itemWidth: 12,
itemHeight: 8, itemGap: 14,
textStyle: {color: '#64748B', fontSize: 11}},
axisPointer: {link: [{xAxisIndex: 'all'}]}, // 十字光标跨面板对齐
title: titles, grid: grids, xAxis, yAxis,
dataZoom: [{type: 'inside', xAxisIndex: xIdx},
{type: 'slider', xAxisIndex: xIdx, height: 16, bottom: 10,
borderColor: '#DBEAFE', fillerColor: 'rgba(30,64,175,.08)',
handleStyle: {color: COLORS[0]},
textStyle: {color: '#64748B', fontSize: 10}}],
series,
});
// 区间外的成交只画得出股价面板,明确说明,避免读者以为图上没卖点就是没卖过
const note = document.getElementById('c_stock_note');
if (note) {
const items = extraDates.map(dt => (tradesOn.get(dt) || []).map(t =>
`${dt} ${t.side === 'BUY' ? '买入' : '卖出'} ${num(t.price, 3)} 元`).join('、'))
.filter(Boolean);
note.innerHTML = items.length ? `<div class="callout warn" style="margin-top:12px">
有 ${items.length} 笔成交发生在指标区间(${esc(d.range.start)} ~ ${esc(d.range.end)})之外:
${esc(items.join(';'))}。<br/>
这些成交只能按<b>成交价</b>标在「股价」面板上,
股息率 / PE 等面板没有对应日期的取值,因此不标注(悬停对应日期仍可看到成交信息)。
</div>` : '';
}
}
/* ---------------- 视图:Walk-forward ---------------- */
async function viewWalkforwards() {
const d = await api('walkforwards');
return `
<h1 class="page">Walk-forward 样本外验证</h1>
<div class="page-sub">每个窗口用训练段校准阈值、测试段冻结参数,是判断策略是否真正有效的核心依据。</div>
<div class="callout warn">
<b>判读要点</b>:单条路径的全期回测会系统性高估策略。
请以<b>样本外均值</b>与<b>稳定性</b>(均值/标准差,&lt;1 表示窗口间差异大于均值本身)为准。
</div>
${d.items.length ? `<div class="rec-list">${d.items.map(wfRec).join('')}</div>`
: `<div class="empty">还没有 Walk-forward 记录。<br>
运行 <code class="mono">python -m hdiv backtest --mode walkforward</code>(约 25 分钟)。</div>`}`;
}
function wfRec(w) {
const o = w.oos || {};
const m = o.mean_return, wr = o.win_rate, dd = o.worst_drawdown;
return `<div class="rec">
<div class="rec-main">
<div class="rec-title">
<a href="#/walkforwards/${esc(w.wf_id)}">${esc(w.title)}</a>
<span class="badge info">${esc(w.status || 'OK')}</span>
</div>
<div class="rec-meta">
<span class="mono">${esc((w.created_at || '').slice(0, 16))}</span>
<span>步进 ${w.step_months} 月</span>
<span>数据版本 <span class="mono">${esc((w.data_version || '').slice(0, 10))}</span></span>
</div>
${m != null ? `<div class="rec-metrics">
<span class="rec-metric">样本外均值<b class="${sign(m)}">${pct(m)}</b></span>
<span class="rec-metric">样本外胜率<b>${pct(wr, 1)}</b></span>
<span class="rec-metric">最差回撤<b class="loss">${pct(dd)}</b></span>
<span class="rec-metric">有效窗口<b>${o.sample_count ?? '—'}/${o.window_count ?? '—'}</b></span>
</div>` : ''}
</div>
<div class="rec-actions">
<button class="primary" data-act="open" data-id="${esc(w.wf_id)}" data-prefix="walkforwards">
打开逐窗口结果</button>
</div>
</div>`;
}
async function viewWalkforwardDetail(wfId) {
let d;
try { d = await api(`walkforwards/${wfId}`); }
catch (e) {
return `<div class="crumb"><a href="#/walkforwards">样本外</a></div>
<div class="callout fail"><b>无法加载:</b>${esc(e.message)}</div>`;
}
const s = d.summary;
const rows = d.windows.map(w => {
const i = w.in_sample, o = w.out_of_sample;
const excess = (i.total_return != null && o.total_return != null) ? null : null;
return `<tr>
<td class="num">#${w.window_index}</td>
<td class="num">${esc(w.train_start)} ~ ${esc(w.train_end)}</td>
<td class="num">${esc(w.test_start)} ~ ${esc(w.test_end)}</td>
<td class="num">${num(i.total_return != null ? i.total_return * 100 : null, 2)}%</td>
<td class="num"><b>${num(i.cagr != null ? i.cagr * 100 : null, 2)}%</b></td>
<td class="num">${num(i.sharpe, 2)}</td>
<td class="num ${sign(o.total_return)}"><b>${num(o.total_return != null ? o.total_return * 100 : null, 2)}%</b></td>
<td class="num ${sign(o.cagr)}">${num(o.cagr != null ? o.cagr * 100 : null, 2)}%</td>
<td class="num">${num(w.benchmark_return != null ? w.benchmark_return * 100 : null, 2)}%</td>
<td class="num ${sign(w.excess_return)}"><b>${num(w.excess_return != null ? w.excess_return * 100 : null, 2)}pp</b></td>
<td class="num loss">${num(o.max_drawdown != null ? o.max_drawdown * 100 : null, 2)}%</td>
<td class="num">${num(o.sharpe, 2)}</td>
<td class="num">${num(o.trade_count, 0)}</td>
<td class="num">P${num(w.frozen_params.entry_yield_percentile, 0)}</td>
</tr>`;
}).join('');
return `
<div class="crumb"><a href="#/walkforwards">样本外</a><span>/</span>${esc(d.strategy_id)}</div>
<h1 class="page">${esc(d.title)}</h1>
<div class="page-sub">
${esc(d.scheme)} · 训练 ${d.train_years} 年 / 测试 ${d.test_years} 年 · 步进 ${d.step_months} 月 ·
<span class="mono">${esc(d.wf_id)}</span>
</div>
<div class="kpi-grid">
<div class="kpi"><div class="label">样本外收益均值</div>
<div class="value ${sign(s.oos_mean)}">${pct(s.oos_mean)}</div>
<div class="note">中位数 ${pct(s.oos_median)}</div></div>
<div class="kpi"><div class="label">样本外胜率</div>
<div class="value">${pct(s.oos_win_rate, 1)}</div>
<div class="note">${s.oos_returns.filter(x => x > 0).length} / ${s.oos_returns.length} 个窗口为正</div></div>
<div class="kpi"><div class="label">基准均值(沪深300)</div>
<div class="value">${pct(s.benchmark_mean)}</div>
<div class="note">同期被动持有</div></div>
<div class="kpi"><div class="label">超额收益均值</div>
<div class="value ${sign(s.excess_mean)}">${num(s.excess_mean != null ? s.excess_mean * 100 : null, 2)}pp</div>
<div class="note">超额胜率 ${pct(s.excess_win_rate, 1)}</div></div>
<div class="kpi"><div class="label">稳定性</div>
<div class="value ${s.oos_stability != null && s.oos_stability < 0 ? 'loss' : ''}">${num(s.oos_stability, 2)}</div>
<div class="note">均值/标准差,&lt;1 表示结论不稳</div></div>
<div class="kpi"><div class="label">最差回撤</div>
<div class="value loss">${pct(s.oos_worst_drawdown)}</div>
<div class="note">样本外最深</div></div>
<div class="kpi"><div class="label">窗口数</div>
<div class="value">${s.window_count}</div>
<div class="note">${d.scheme} 滚动</div></div>
</div>
<div class="card">
<h2>样本内 vs 样本外</h2>
<div id="c_wf" class="chart"></div>
<div class="callout" style="margin-top:12px">
若<b>样本内收益明显高于样本外</b>,说明阈值是在训练段「拟合」出来的,
样本外无法复现 —— 这就是过拟合的直接证据。<br>
<b>超额 = 策略 − 基准(沪深300)</b>。本策略的典型形态是
<b>牛市跑输、熊市跑赢</b>:请结合当年的市场环境判读,不要只看均值。<br>
<b>注意区间长度</b>:训练段 5 年、测试段 1 年,因此上图统一用<b>年化</b>口径。
直接比两者的累计收益会得出「样本内远高于样本外」的错误印象。
</div>
</div>
<div class="card">
<h2>逐窗口明细</h2>
<div class="tw"><table class="data">
<thead>
<tr><th colspan="6" class="l" style="text-align:center">训练段(5 年 · 校准参照分布)</th>
<th colspan="7" class="l" style="text-align:center;border-left:2px solid #DBEAFE">测试段(1 年 · 冻结参数)</th></tr>
<tr><th>窗口</th><th>训练区间</th><th>测试区间</th>
<th>收益</th><th>CAGR</th><th>Sharpe</th>
<th style="border-left:2px solid #DBEAFE">收益</th><th>年化</th><th>基准</th>
<th>超额</th><th>最大回撤</th>
<th>Sharpe</th><th>成交</th><th>冻结阈值</th></tr>
</thead>
<tbody>${rows}</tbody>
</table></div>
<div class="callout" style="margin-top:12px">
<b>冻结阈值</b>是该窗口在训练段校准出、并在测试段强制沿用的买入分位 ——
各窗口若差异很大,说明策略对参数不稳定。
</div>
</div>
<div class="card">
<h2>可复现性</h2>
<div class="tw"><table class="data"><tbody>
<tr><td class="l">策略</td><td class="l mono">${esc(d.strategy_id)} v${esc(d.strategy_version)}</td></tr>
<tr><td class="l">配置指纹</td><td class="l mono">${esc(d.config_hash || '—')}</td></tr>
<tr><td class="l">数据版本</td><td class="l mono">${esc(d.data_version || '—')}</td></tr>
<tr><td class="l">代码版本</td><td class="l mono">${esc(d.code_version || '—')}</td></tr>
</tbody></table></div>
</div>`;
}
function mountWalkforwardDetail(d) {
const w = d.windows || [];
if (!w.length) return;
chart('c_wf', {
tooltip: {trigger: 'axis', axisPointer: {type: 'shadow'}},
legend: {top: 0, textStyle: {color: '#64748B'}},
grid: {left: 64, right: 30, top: 38, bottom: 76},
xAxis: {type: 'category',
data: w.map(x => `#${x.window_index}\n${String(x.test_start).slice(0, 7)}`),
axisLabel: {color: '#64748B', fontSize: 10, lineHeight: 13}},
yAxis: {type: 'value', name: '年化收益 %',
axisLabel: {color: '#64748B', formatter: v => v + '%'},
splitLine: {lineStyle: {color: '#E9EEF6'}}},
series: [
// 用**年化**而非累计:训练段 5 年、测试段 1 年,
// 直接比累计收益是不同长度区间的比较,会得出误导性结论。
{name: `样本内年化(${w[0] ? '' : ''}5年)`, type: 'bar', barMaxWidth: 22,
itemStyle: {color: COLORS[2]},
data: w.map(x => x.in_sample.cagr != null
? +(x.in_sample.cagr * 100).toFixed(2) : null)},
{name: '样本外年化(1年)', type: 'bar', barMaxWidth: 22,
itemStyle: {color: COLORS[0]},
data: w.map(x => x.out_of_sample.cagr != null
? +(x.out_of_sample.cagr * 100).toFixed(2) : null)},
{name: '基准(年内)', type: 'bar', barMaxWidth: 22,
itemStyle: {color: COLORS[3]},
data: w.map(x => x.benchmark_return != null
? +(x.benchmark_return * 100).toFixed(2) : null)},
{name: '超额', type: 'line', symbolSize: 7, lineStyle: {width: 1.6, color: COLORS[1]},
itemStyle: {color: COLORS[1]},
data: w.map(x => x.excess_return != null
? +(x.excess_return * 100).toFixed(2) : null)},
{name: '零轴', type: 'line', data: [], markLine: {
silent: true, symbol: 'none',
lineStyle: {color: '#94A3B8', type: 'dashed', width: 1},
data: [{yAxis: 0}]}},
],
});
}
/* ---------------- 视图:归档 ---------------- */ /* ---------------- 视图:归档 ---------------- */
async function viewArchive() { async function viewArchive() {
const [us, bs] = await Promise.all([ const [us, bs] = await Promise.all([
@@ -778,6 +1473,7 @@ async function render() {
const on = (key === 'home' && !parts.length) || const on = (key === 'home' && !parts.length) ||
(key === 'universes' && parts[0] === 'universes') || (key === 'universes' && parts[0] === 'universes') ||
(key === 'backtests' && parts[0] === 'backtests') || (key === 'backtests' && parts[0] === 'backtests') ||
(key === 'walkforwards' && parts[0] === 'walkforwards') ||
(key === 'archive' && parts[0] === 'archive'); (key === 'archive' && parts[0] === 'archive');
a.classList.toggle('active', !!on); a.classList.toggle('active', !!on);
}); });
@@ -798,10 +1494,22 @@ async function render() {
if (d) after = () => mountStock(d); if (d) after = () => mountStock(d);
} }
else if (parts[0] === 'backtests' && parts.length === 1) html = await viewBacktests(q); else if (parts[0] === 'backtests' && parts.length === 1) html = await viewBacktests(q);
else if (parts[0] === 'backtests' && parts.length === 4 && parts[2] === 'stocks') {
html = await viewBacktestStock(parts[1], parts[3]);
after = () => { stState.runId = parts[1]; stState.symbol = parts[3];
mountBacktestStock(parts[1], parts[3]); };
}
else if (parts[0] === 'backtests' && parts.length === 2) { else if (parts[0] === 'backtests' && parts.length === 2) {
html = await viewBacktestDetail(parts[1]); html = await viewBacktestDetail(parts[1]);
after = () => mountBacktestDetail(parts[1]).catch(e => toast(e.message, true)); after = () => mountBacktestDetail(parts[1]).catch(e => toast(e.message, true));
} }
else if (parts[0] === 'walkforwards' && parts.length === 1)
html = await viewWalkforwards();
else if (parts[0] === 'walkforwards' && parts.length === 2) {
const d = await api(`walkforwards/${parts[1]}`);
html = await viewWalkforwardDetail(parts[1]);
after = () => mountWalkforwardDetail(d);
}
else if (parts[0] === 'archive') html = await viewArchive(); else if (parts[0] === 'archive') html = await viewArchive();
else html = `<div class="callout warn">未知页面:<span class="mono">${esc(path)}</span> else html = `<div class="callout warn">未知页面:<span class="mono">${esc(path)}</span>
<br><a href="#/">返回概览</a></div>`; <br><a href="#/">返回概览</a></div>`;
@@ -824,7 +1532,7 @@ document.addEventListener('click', async e => {
const base = scope === 'b' ? 'backtests' : 'universes'; const base = scope === 'b' ? 'backtests' : 'universes';
try { try {
if (act === 'open') { if (act === 'open') {
location.hash = `#/${base}/${id}`; return; location.hash = `#/${btn.dataset.prefix || base}/${id}`; return;
} }
if (act === 'rename') { if (act === 'rename') {
const cur = await api(`${base}/${id}`); const cur = await api(`${base}/${id}`);
@@ -854,6 +1562,21 @@ document.addEventListener('click', async e => {
toast(del ? '已删除(可从「归档」页恢复)' : '已恢复'); toast(del ? '已删除(可从「归档」页恢复)' : '已恢复');
currentPath = ''; render(); return; currentPath = ''; render(); return;
} }
if (act === 'pf-shift') {
shiftPortfolio(parseInt(btn.dataset.d, 10));
return;
}
if (act === 'st-apply') {
const host = document.getElementById('c_stock');
if (host) {
const shown = [...document.querySelectorAll('.st-ser:checked')].map(x => x.value);
if (!shown.length) { toast('请至少勾选一个指标', true); return; }
disposeCharts();
const {parts} = parseHash();
mountBacktestStock(parts[1], parts[3]);
}
return;
}
if (act === 'page') { if (act === 'page') {
const {parts, q} = parseHash(); const {parts, q} = parseHash();
q.set('page', btn.dataset.p); q.set('page', btn.dataset.p);
@@ -877,6 +1600,16 @@ document.addEventListener('click', async e => {
}); });
document.addEventListener('change', e => { document.addEventListener('change', e => {
if (e.target.id === 'pf-date') {
loadPortfolio(e.target.value);
return;
}
if (e.target.id === 'eq-index') {
eqState.index = e.target.value;
mountEquityChart(eqState.runId, eqState.index)
.catch(err => toast(err.message, true));
return;
}
if (e.target.id === 'incArch' || e.target.id === 'incDel') { if (e.target.id === 'incArch' || e.target.id === 'incDel') {
const {parts, q} = parseHash(); const {parts, q} = parseHash();
if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0'); if (e.target.id === 'incArch') q.set('archived', e.target.checked ? '1' : '0');
@@ -900,6 +1633,11 @@ window.addEventListener('hashchange', () => { currentPath = ''; render(); });
try { try {
await api('health'); await api('health');
st.textContent = 'API 正常'; st.className = 'badge ok'; st.textContent = 'API 正常'; st.className = 'badge ok';
try {
const dc = await api('config/display');
Object.assign(FMT, dc);
document.documentElement.style.setProperty('--max-width', (dc.max_width || 1440) + 'px');
} catch (e) { /* 取不到就用默认精度,不影响使用 */ }
} catch (e) { } catch (e) {
st.textContent = 'API 不可用'; st.className = 'badge fail'; st.textContent = 'API 不可用'; st.className = 'badge fail';
} }
+1
View File
@@ -15,6 +15,7 @@
<a href="#/" data-nav="home">概览</a> <a href="#/" data-nav="home">概览</a>
<a href="#/universes" data-nav="universes">股票池筛选</a> <a href="#/universes" data-nav="universes">股票池筛选</a>
<a href="#/backtests" data-nav="backtests">回测</a> <a href="#/backtests" data-nav="backtests">回测</a>
<a href="#/walkforwards" data-nav="walkforwards">样本外</a>
<a href="#/archive" data-nav="archive">归档</a> <a href="#/archive" data-nav="archive">归档</a>
</nav> </nav>
<div class="topbar-right"> <div class="topbar-right">