功能:每日动态股票池回测(--mode daily)+ 每日增量同步 + PIT 批量取数层
说明:本提交是工作区中此前的未提交工作(在 14ec0c6 之后产生),**非本次会话所写**,
按用户要求整理并推送。已做安全检查(无明文凭据、无大文件、.env/logs/output 仍被忽略),
并完成可执行范围内的测试验证(见「测试」一节)。
## 新增能力
1) `hdiv backtest --mode daily --start <日期>`
- src/hdiv/backtest/daily.py:两趟式(先逐日选股,再复用既有引擎模拟)
- 每个交易日按当日可见数据重建股票池(PIT),每个交易日判断买卖点
- `pool_exit_action`:hold(只减不加、不因掉出池子而清仓)/ sell(掉出即清仓)
- `profile_on_trade`:买卖决策发生时计算并留痕个股画像,**不区分是否在当日池内**
(卖出/减仓同样留痕,否则「为什么卖」缺证据)
- 与 walkforward 的分工:daily 是一条连续路径的推演,不是过拟合检验;
因此不使用训练段、不冻结分布,阈值口径一律 rolling
- 拒绝 `--universe-run`(daily 的定义就是逐日重筛,冻结池与之矛盾)
2) PIT 批量取数层 src/hdiv/universe/pit.py
- PitRepo 继承 Repo,**只重写取数**(按区块批量预载 + 逐日内存切片),
派生逻辑(最新一期财报合并、单位归一化、支付率口径等)一行不重写
—— 以保证与逐日单点查询**结果等价**
- 候选集预剪枝:用「不可能通过」的边界条件提前排除,文档论证为精确等价而非近似
- src/hdiv/universe/daily.py:每日动态筛选器(仍然调用既有 selector 与四个 Filter)
3) 每日增量同步 `hdiv sync daily`
- src/hdiv/data/sync/daily.py:只抓「库里还没有的那几天」,
按「当日股票数 ≥ 当年规模阈值」判定缺口,不重拉历史、不覆盖既有行;
支持 `--dry-run` 先看待抓清单
- deploy/daily-sync.sh、deploy/install-sync-schedule.sh、
deploy/com.hddiv.sync.plist.example(launchd 每天 17:00)
- 新表 hd_daily_universe(逐日入选成员留痕)+ sql/hd_daily_universe.sql + schema.py
(该表已存在于库中,`ddl plan` 返回 0 个待执行动作)
4) Web 与文档
- 前端支持 daily 模式记录下钻(web/app.js、web/app.css、web/index.html、
web/favicon.svg)
- README / docs/user-guide.md / docs/implementation-status.md 同步更新:
三种回测模式的取舍、daily 的成本说明(6.7 年约 1.5 小时)与调优手段
## 测试
tests/ 共 500 项(新增 tests/test_daily.py 43 项、tests/test_sync_daily.py 36 项)。
已验证通过:
- 排除上述两个新文件的 **421 项:全部通过(pytest 退出码 0)**
- 两个新文件的**非 DB 单元测试 60 项:全部通过**
未能在合理时间内跑完:
- 两个新文件中 **19 项 DB 标记的重型测试**。实测瓶颈是一条**无界全表扫描**:
`SELECT ... FROM hd_cashflow WHERE ann_date <= :asof ORDER BY symbol, end_date, ann_date`
(31 万行,无 symbol/报告期下限)。全量套件跑到 161 项时已耗时 20 分钟、
0 失败,按该速率预计需 3 小时以上,因此改为分档验证。
- 旁证:库中存在 3 次成功的 daily 端到端运行(2026-10-05 10:05 / 10:32 / 11:03,
区间 2024-03-01~03-15),说明该路径可正常完成。
## 已知待改进
- 上述 `hd_cashflow`(及同类「按 ann_date 上界取全历史」)的查询缺
symbol / 报告期下限,是 daily 模式的主要性能瓶颈,建议下一轮优化。
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
# 实施状态报告
|
||||
|
||||
> 对应 `docs/development-plan.md`(计划)与 `docs/plan.md`(需求)
|
||||
> 更新:2026-10-02
|
||||
> 更新:2026-10-04
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
**系统已端到端可运行**:从 Point-in-Time 股票池筛选 → 个股画像 → 策略定义 →
|
||||
回测 → Walk-forward → 绩效分析 → 参数敏感性 → 固定格式 HTML 报告,
|
||||
全链路打通并通过 403 项自动化测试。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
回测 → Walk-forward → **每日动态股票池推演(`--mode daily`)** → 绩效分析 →
|
||||
参数敏感性 → 固定格式 HTML 报告,
|
||||
全链路打通并通过 **439 项自动化测试**。`plan.md §50` 的 14 项验收能力**全部具备**。
|
||||
|
||||
**2026-10-04 新增「每日动态股票池」**(详见 §10):
|
||||
`backtest --mode daily --start <日期>` 从给定日期起**每个交易日**重新选股(PIT)、
|
||||
每个交易日判断买卖点,持仓掉出当日池子默认**只减不加**,买卖决策都留个股画像证据,
|
||||
每日入选成员落库 `hd_daily_universe`。它与 walk-forward **不是替代关系** ——
|
||||
后者检验参数稳定性(过拟合),前者回答「动态池下这条路径长什么样」。
|
||||
代价是逐日全市场筛选:6.7 年区间约 **1.5 小时**(3 个月实测 5 分钟)。
|
||||
|
||||
**2026-10-03 完成三项正确性改造**(详见 §9):修复 `stock_daily` 量价单位不一致、
|
||||
拒绝「用未来时点的股票池跑更早区间」、新增**实时(PIT)个股画像闸门**。
|
||||
@@ -37,8 +45,9 @@
|
||||
| **G4 扩展财务** | ✅ 完成 | 财务指标 5,903 只(100%)、现金流 5,893、资产负债表/利润表 5,902;公告日齐全率 100%。缺失的 10 只均为 1990 年代退市股(Tushare 无报表) |
|
||||
| **G5 基准指数** | ✅ 完成 | `hd_index_daily` 7 个指数 / 40,083 行;沪深300 覆盖 2002 起 |
|
||||
| **G6 停牌/涨跌停** | ✅ 完成 | `hd_suspend` **468,388 行**、`hd_limit` **14,837,155 行**,均覆盖 **2010-01-04** 起(2026-10-04 回补,各 +400,623 / +5,787,253 行) |
|
||||
| **日常增量** | ✅ 完成 | `hdiv sync daily`(缺几天抓几天)+ `deploy/install-sync-schedule.sh` 注册每天 17:00 的 launchd 任务;2026-10-05 首次运行补齐 21 个交易日(行情 17.8 万行、指数权重 1,800 行、财报 6.5 万行),耗时约 2 分钟 |
|
||||
|
||||
### 1.2 数据库(30 张 `hd_*` 表)
|
||||
### 1.2 数据库(31 张 `hd_*` 表)
|
||||
|
||||
全部建表完成,结构迁移幂等(连续两次 `ddl apply` 均返回 0 个动作)。
|
||||
**只增不删**由三层保证:SQL 安全钩子 + 源码扫描测试 + 迁移的前置条件守卫。
|
||||
@@ -51,14 +60,17 @@
|
||||
| 数据安全 | `data/db.py`(SQL 钩子)、`data/ddl.py`(幂等 DDL) | ✅ |
|
||||
| 单位归一化 | `data/units.py`(万元/万股/百分数 → 元/股/小数) | ✅ |
|
||||
| PIT 取数 | `data/repo.py`(唯一取数出口) | ✅ |
|
||||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停) | ✅ |
|
||||
| 同步器 | `data/sync/`(分红/财报/指数/行情/停牌涨跌停 + `daily.py` 每日增量) | ✅ |
|
||||
| 数据审计 | `data/audit.py`(15 项检查,含量价单位一致性) | ✅ |
|
||||
| 股票池 | `universe/selector.py` + 4 个 Filter | ✅ |
|
||||
| PIT 批量取数 | `universe/pit.py`(`PitRepo`:与 `Repo` 逐值一致,逐日筛选的基座) | ✅ |
|
||||
| 每日选股 | `universe/daily.py`(逐日重建池 + 保守预剪枝) | ✅ |
|
||||
| 因子 | `factor/dividend_yield.py` | ✅ |
|
||||
| 个股画像 | `profile/builder.py` | ✅ |
|
||||
| 个股画像 | `profile/builder.py`、`profile/pit.py`(实时画像) | ✅ |
|
||||
| 策略管理 | `strategy/registry.py` | ✅ |
|
||||
| 回测引擎 | `backtest/engine.py` | ✅ |
|
||||
| Walk-forward | `backtest/walk_forward.py` | ✅ |
|
||||
| **每日动态股票池** | `backtest/daily.py`(`--mode daily`) | ✅ |
|
||||
| 绩效分析 | `analysis/performance.py` | ✅ |
|
||||
| 敏感性 | `analysis/sensitivity.py` | ✅ |
|
||||
| 报告渲染 | `report/`(7 类报告 + 离线校验) | ✅ |
|
||||
@@ -469,6 +481,11 @@ export PYTHONPATH=src
|
||||
.venv/bin/python -m hdiv sync trading --start 2019-01-01
|
||||
HDIV_ALLOW_BACKFILL=1 .venv/bin/python -m hdiv sync backfill # 2015-2018 回补
|
||||
|
||||
# 2b) 日常增量:缺几天抓几天(首次全量之后,每天只需要这一条)
|
||||
.venv/bin/python -m hdiv sync daily --dry-run
|
||||
.venv/bin/python -m hdiv sync daily
|
||||
./deploy/install-sync-schedule.sh install # 注册为每天 17:00 的 launchd 任务
|
||||
|
||||
# 3) 数据审计(含 HTML)
|
||||
.venv/bin/python -m hdiv audit
|
||||
|
||||
@@ -771,7 +788,7 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每
|
||||
|---|---|
|
||||
| `test_config.py` | 配置正向加载 + 非法配置必须被拒(含 `profile_gate` 未知指标/标量分位/空规则) |
|
||||
| `test_safety.py` | SQL 安全钩子(含 11 类删除语句、只读白名单、前缀约束);源码扫描无删除语句、无 qlib import |
|
||||
| `test_schema.py` | 30 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
|
||||
| `test_schema.py` | 31 张表结构、前缀、幂等性、**唯一键列不得可空**(NULL 绕过唯一约束) |
|
||||
| `test_sync.py` | 单位转换、NaN→NULL、分红去重键、财报 PIT 丢弃、限频器 |
|
||||
| `test_units.py` | **量价单位判定与幂等归一化**、同日混合单位、缺列不猜 |
|
||||
| `test_universe.py` | **单位换算与量级检测**、行业豁免、年报均值口径、分红宽限期、滤网索引契约 |
|
||||
@@ -1047,4 +1064,277 @@ A 股相邻两次除权间隔经常 ≠ 365 天,硬 365 天窗口因此在每
|
||||
(回补前的四个 run 为 `1a7e5b72…` / `e6e65382…` / `697a2ecd…` / `ae296c0f…`,
|
||||
仍保留在库中,可用于核对「回补是否改变了某项结论」。)
|
||||
|
||||
---
|
||||
|
||||
## 10. 每日动态股票池(`--mode daily`,2026-10-04 新增)
|
||||
|
||||
### 10.1 它补的是哪个空缺
|
||||
|
||||
原有两种回测各有各的问题,都回答不了「股票池每天都在变」这件事:
|
||||
|
||||
| | 原有 `--mode single` | 原有 `--mode walkforward` | **新增 `--mode daily`** |
|
||||
|---|---|---|---|
|
||||
| 股票池 | 每 12 个月重筛一次(`universe_refresh_months`) | 每个窗口按 asof 重筛 | **每个交易日**重筛 |
|
||||
| 信号 | 每月评估(`signal_frequency_months`) | 每月 | **每个交易日** |
|
||||
| 时间结构 | 一条 `[start, end]` | 多个 `(train, test)` | 一条 `[start, latest]` |
|
||||
| 阈值口径 | rolling | 测试段冻结训练分布 | rolling(无训练段) |
|
||||
| 目的 | 全期表现 | **过拟合检验** | **单路径连续推演** |
|
||||
|
||||
`daily` **不是** walk-forward 的替代品:它没有训练段,因此**不检验**参数稳定性。
|
||||
它检验的是另一件事 —— 「动态股票池下这条路径长什么样」。
|
||||
|
||||
### 10.2 动态池的语义(这是本功能的核心决定)
|
||||
|
||||
| 决定 | 取值 | 理由 |
|
||||
|---|---|---|
|
||||
| 持仓掉出当日池子 | **只减不加**(`pool_exit_action: hold`) | 池子回答「今天能**买**什么」,不回答「必须卖什么」。掉出即清仓会把「市场先生没报价」误判成「公司变坏了」 |
|
||||
| 买卖决策的画像 | **买卖都算,且不区分是否在池内** | 卖出更需要「当时它长什么样」的证据;池外持仓被卖出时尤其如此 |
|
||||
| 每日选股留痕 | 落库 `hd_daily_universe` | 事后可回答「某天为什么是这些股票」 |
|
||||
| 可选:掉出即清仓 | `pool_exit_action: sell` | 供对比用,不是默认 |
|
||||
|
||||
### 10.3 实测(2024-03-01 ~ 2024-03-29,21 个交易日;另有 58 日区间见 §10.4)
|
||||
|
||||
```
|
||||
选股 21/21 个决策日,池内 21~24 只,累计出现过的股票 25 只
|
||||
股票池变动:累计进入 4 次 / 移出 7 次(平均每日 0.2 进 0.3 出)
|
||||
市场候选 5353~5359 只 → 预剪枝后实际筛选 183~184 只 → 入选 21~24 只
|
||||
绩效:−5.92% / 最大回撤 −8.71% / 成交 14 笔 / 对账残差 −0.0000 ✓
|
||||
画像:计算 244 次(缓存命中 75),画像剔除 31 次
|
||||
落库:hd_daily_universe 477 行 / 21 个时点
|
||||
|
||||
58 日区间(2024-01-02 ~ 2024-03-29)的信号分布:
|
||||
信号:TRIM 284 / ADD 261 / BUY 183 / REJECT 163 / HOLD 45
|
||||
skip_reason:NO_CASH 334、BELOW_MIN_TRADE 284、PROFILE_GATE 163、
|
||||
ALREADY_AT_TARGET 110、OUT_OF_UNIVERSE 45
|
||||
画像留痕:773 条信号带 reason_json.profile
|
||||
```
|
||||
|
||||
- **`HOLD 45` + `skip_reason=OUT_OF_UNIVERSE`** 就是「掉出池子、只减不加」的直接证据:
|
||||
45 次「本想加仓但被池子挡住」,全部留有原因,可在前端「未成交信号」查看。
|
||||
- **773 条信号带画像留痕**:买卖决策都附上了当日可见数据算出的画像。
|
||||
|
||||
**两个「候选」必须分开记**(否则页面上同一个词有两种含义):
|
||||
|
||||
| 列 | 含义 |
|
||||
|---|---|
|
||||
| `hd_daily_universe.listed_count` | 当日**市场候选数**(未预剪枝),如 5359 |
|
||||
| `hd_daily_universe.candidate_count` | 当日**实际参与筛选**的候选数(已预剪枝),如 183 |
|
||||
|
||||
预剪枝是纯性能开关,不该让可见数字的含义随之改变 —— 这是本轮自查抓到的两个
|
||||
**静默数据缺陷**之一,另一个是 `dividend_yield` 整列为 NULL(详见 §10.5 第 6 条)。
|
||||
|
||||
### 10.4 性能:怎么把 5~8 小时压到可接受
|
||||
|
||||
单次 `UniverseSelector.run` 实测 **10~18 秒**(其中 `financial_panel` 独占 6.2 秒,
|
||||
且是**表量级**开销、与查哪天无关)。逐日 1600 次 = 5~8 小时,不可用。
|
||||
|
||||
三处改造(**都不改变判定口径**):
|
||||
|
||||
| 改造 | 效果 | 口径保证 |
|
||||
|---|---|---|
|
||||
| `PitRepo` 批量预载(只覆盖最底层取数方法) | 单次筛选 10~18s → 2.0s | `test_pit_repo_matches_direct_repo` 逐值比对 |
|
||||
| 财务按「已公告条数」缓存 + 存储预排序 | 模拟每 asof 4s → 0.38s | 同上(缓存键是充分统计量) |
|
||||
| 候选集保守预剪枝(交易所/板块/上市年限/市值上界) | 候选 5903 → 412 只 | `test_prune_does_not_change_selection` 锁定入选集合完全相同 |
|
||||
|
||||
另一处**顺带修掉的真实缺陷**:`ttm_params()` 每次调用都走 `load_config`
|
||||
(重新读盘 + YAML 解析 + pydantic 校验,约 16ms),而它在筛选器里是**逐股**调用的。
|
||||
cProfile 实测:6 个交易日里 `load_config` 被调用 **1008 次、共 16.6 秒**,
|
||||
占整个筛选时间的 **22%**,而每次返回的是完全相同的一份配置。
|
||||
已改为使用本模块早有的带缓存入口 `get_config`。这不是「优化」,是修一个明显的浪费。
|
||||
|
||||
**实测速率与取舍**:
|
||||
|
||||
| 阶段 | 实测 | 依据 |
|
||||
|---|---|---|
|
||||
| 逐日选股 | **2.66 秒/交易日** | 2026-08-01 起 43 个交易日用 114 秒;全区间 1635 日实测 2.38 秒/日(65 分钟) |
|
||||
| 模拟(修正前) | 3.45 秒/交易日 | 21 日窗口,池内约 22 只、**面板仅 25 只** |
|
||||
| 模拟(修正前,全区间) | **6.5 小时仍未结束** | 面板 123 只;跑到 6.5h 后中止 |
|
||||
| 模拟(修正后) | **3.04 秒/交易日** | 43 日窗口,池内 30~36 只、**面板 41 只**(直接计时,2026-10-05 复测) |
|
||||
| 全区间(修正后) | 选股 65 分钟 + 模拟 **约 1.5~2.5 小时** | **推算**:按 3.04 秒/日 × 1635 日 ≈ 1.4 小时,但全区间面板为 123 只、后期池内约 59 只,会比该窗口更慢 |
|
||||
|
||||
**2026-10-04 追加修正的性能缺陷**:`PitProfileService._compute` 原先
|
||||
「先按日期剪裁全市场面板(50 万行)、再筛出当前这一只股票」,实测 **174 毫秒/次**;
|
||||
两个条件互相独立,改为「先筛股票、再剪裁」后 **11 毫秒/次(16 倍)**。
|
||||
每次画像快照都要付两遍(`_price` 与 `_basics`)—— 这正是「21 日窗口(面板 25 只)」
|
||||
与「全区间(面板 123 只)」差约 4 倍的原因:**剪裁成本与面板里的股票数成正比**。
|
||||
等价性由 `tests/test_profile_pit.py` **27 passed** 锁定(含实时画像 vs 批量画像逐值比对)。
|
||||
|
||||
**选股结果可复用(已实测)**:逐日选股按「策略 + 区间 + 筛选配置 + **重建频率**」的指纹
|
||||
缓存到 `output/cache/daily_pools_<key>.json`(键不含时间戳;重建频率必须进键 ——
|
||||
1 日 / 5 日 筛出的池子是不同的输入,混用会静默给出错的股票池)。
|
||||
同一区间第二次运行会打印「复用已缓存的选股结果」并**跳过 114 秒的选股**,
|
||||
且指标与首次完全一致(0.29% / CAGR 1.73% / 回撤 −3.93% / Sharpe −0.03 / 17 笔 ——
|
||||
两次运行逐项相同);`--refresh-pools` 强制重筛。
|
||||
缓存目录可随时整个删掉(它只是缓存,删了下次重筛)。
|
||||
|
||||
### 10.4a 程序设计层面的提速(P1/P2/P4/P6,2026-10-05 实施并验收)
|
||||
|
||||
四项都**不改变判定规则**,逐项做了等价性验证;但实测收益与最初的估计**差很多**,
|
||||
如实记录如下(这正是「不要靠估计,要测」的又一例):
|
||||
|
||||
| 项 | 改动 | 等价性验证 | **实测收益** |
|
||||
|---|---|---|---|
|
||||
| **P4** | 选股的 `dividend_records` 按候选股收窄(原先每天把 2 万余行分红建 dict,只为查其中一两百只) | 5 个样本日入选集合**逐只相同**(`test_dividend_scope_does_not_change_selection`);收窄结果是精确子集 | 选股 **2.34 → 2.02 秒/日(−14%)** |
|
||||
| **P1** | 画像内部日期统一 `datetime64`(原先逐 asof 把 object 的 `datetime.date` 再转一遍) | 分红窗口谓词 4 个时点**逐行相同**(61/71/81/93 行) | 与 P2 合计仅 **−2%** |
|
||||
| **P2** | 每只股票整段股息率序列只算一次(原先每个评估日按前缀重算,O(交易日数²)) | 整段 vs 前缀 **20/20 点一致**(`test_ttm_dps_series_prefix_equals_full`) | 同上;区间越长收益越大 |
|
||||
| **P6** | 价格只取一次并交给画像复用;`dividend_events` 走常驻内存 | `dividend_events` 与直连**逐值一致**(4 个区间含 28,301 行);外部传入 price 与自取**画像逐值一致** | 一次性,约 −2% |
|
||||
|
||||
**为什么 P1/P2 几乎没省**:最初的判断依据是 cProfile —— 它显示日期转换占模拟 28%。
|
||||
但那个 profile 里混进了 `_prepare` 的**一次性取数**(约 30 万行 DB 读取),
|
||||
把占比算高了。改成按组件直接计时后,模拟阶段的开销分布是:
|
||||
|
||||
| 组件 | 单次成本 | 折算(43 日窗口) | 占比 |
|
||||
|---|---:|---:|---:|
|
||||
| `_profile_one`(每次画像快照算约 40 个指标) | **185 ms × 593 次** | 99.6 s | **78%** |
|
||||
| `_context`(每个 asof 重建财报面板) | **374 ms × 43 次** | 16.1 s | 13% |
|
||||
| 其余(盯市、分红入账、收益率切片…) | — | 约 12 s | 9% |
|
||||
|
||||
**结论**:模拟阶段的 91% 集中在这两处,而它们**都不是** P1/P2 触及的地方。
|
||||
所以下一步该动的是:
|
||||
|
||||
1. **把财报面板按 `symbols` 收窄后再建**(`_latest_financial` / `annual_financial_history`
|
||||
等现在都是「先建全市场、再取子集」,而缓存键里没有 symbol 集合 ——
|
||||
年报季可见条数天天变,于是天天重建全市场)。预计 `_context` 374 ms → 数十毫秒,
|
||||
即模拟 **−13%**;风险低(窗口函数是逐股票的,先筛 symbol 不改变任何一只股票的取值)。
|
||||
2. **闸门按「公告/分红可见性指纹」缓存 + 画像留痕改在成交时落**
|
||||
(闸门那 4 个指标与价格无关、一年只变几次;而 593 次快照里绝大多数是为
|
||||
「信号留痕」付的,最终只有 17 笔真正成交)。预计模拟 **−60~70%**;
|
||||
但**会改变留痕位置**(`hd_backtest_signal` 不再带画像、`hd_backtest_trade` 带上),
|
||||
需要使用者确认。用户要的「所有成交个股的实时画像」在这个方案下**反而更准确**。
|
||||
|
||||
> 另有一处**外部数据变化**必须记下:本次 A/B 期间,每日同步任务把 `stock_daily`
|
||||
> 从 2026-09-04 补到了 **2026-09-30**(+94,398 行),使同一区间 2026-08-01 起的结果
|
||||
> 从 **+0.29%** 变成 **−2.54%**(持仓数量、成交价、每日股票池均逐行相同,
|
||||
> 只有最后 4 周的价格是新的)。这不是代码问题,但它再次说明
|
||||
> **比较两次回测之前必须确认底层数据没变**。
|
||||
|
||||
### 10.4b 提速方案:把「每日」改成「每 N 日」(2026-10-05 新增)
|
||||
|
||||
原先两个频率只能改 YAML,现已接到命令行:
|
||||
|
||||
| 参数 | 作用对象 | 默认 |
|
||||
|---|---|---|
|
||||
| `--every-n-days N` | 股票池**重建**频率(选股那一趟) | 1(每个交易日) |
|
||||
| `--signal-every-n-days N` | 买卖**判断**频率(模拟那一趟) | 1(每个交易日) |
|
||||
|
||||
**设计要点:两个旋钮都不改判定规则,只改「多久看一次」。** 所以它们是
|
||||
「粗粒度版本」而不是「同一策略的加速版」—— 程序启动时会明确打印这句话并提示
|
||||
「结果不可与逐日口径直接比较」,避免有人拿 5 日口径的数字去和逐日口径比。
|
||||
|
||||
实测(2026-08-01 起 43 个交易日,**同一区间**):
|
||||
|
||||
| 口径 | 选股 | 模拟 | 画像计算次数 |
|
||||
|---|---:|---:|---:|
|
||||
| 选股每 1 日 / 信号每 1 日 | 114 秒 | 131 秒 | 537 |
|
||||
| 选股每 5 日 / 信号每 5 日 | 43 秒 | 约 28 秒 | 127 |
|
||||
|
||||
由此把模拟耗时拆成两项(**这是本节的关键修正**):每交易日的记账约 0.05 秒
|
||||
(很小),**每次「评估买卖」约 3.0 秒**(收益率序列 + 逐笔决策画像)——
|
||||
只有后者随信号频率下降。早先的模型把整段模拟都写成「随交易日数增长」,
|
||||
会把粗粒度方案的耗时**高估**约 1.6 倍。
|
||||
|
||||
按此模型,6.7 年(1635 交易日)的预计耗时:
|
||||
|
||||
| 方案 | 预计 |
|
||||
|---|---:|
|
||||
| 逐日 / 逐日(默认) | 约 2.6 小时 |
|
||||
| `--every-n-days 5` | 约 1.7 小时 |
|
||||
| `--signal-every-n-days 5` | 约 1.5 小时 |
|
||||
| `--every-n-days 5 --signal-every-n-days 5` | **约 35 分钟** |
|
||||
| `--every-n-days 21 --signal-every-n-days 21` | 约 12 分钟 |
|
||||
|
||||
> 上表是**推算**(单点实测只有 43 个交易日那两行)。全区间面板 123 只、
|
||||
> 后期池内约 59 只,实际会**更慢**。命令启动时会按同一模型打印本次预计时长。
|
||||
|
||||
### 10.5 已知限制(如实声明)
|
||||
|
||||
1. **6.7 年区间未实跑到底**:选股阶段实测跑完(65 分钟),模拟阶段未跑完
|
||||
(修正前跑到 6.5 小时中止)。修正后的总耗时是**推算**(约 3.5~4.5 小时),
|
||||
不是实测值。已实测到底的只有 3 个月区间(5 分钟)与选股阶段。
|
||||
2. **预剪枝依赖「市值上界」这一判据**:`min_avg_amount_20d` **刻意没有预剪枝** ——
|
||||
`stock_daily` 的量价单位在 2015-2019 是「手/千元」、2020 起是「股/元」,
|
||||
用 `MAX(amount)` 做上界会在早年低估 1000 倍、误剪本该通过的股票。
|
||||
宁可少一项优化,也不接受会改变结果的上界。
|
||||
3. **不落库逐股淘汰原因**:daily 只落库每日**入选**成员。要查「某只股票为什么没选上」,
|
||||
需用 `hdiv universe --asof <日期>` 单独跑一天。
|
||||
4. **起点即路径**:不同起点的 daily 结果不可直接比较策略优劣(早年池子、
|
||||
估值水平都不同)。
|
||||
5. **不支持 `--universe-run`**:固定池与「每日动态」定义互斥,且冻结名单自带未来信息。
|
||||
|
||||
6. **提交前自查抓到两个静默数据缺陷**(都属于「回测照跑、只是数是错的」那一类):
|
||||
- **`hd_daily_universe.dividend_yield` 整列为 NULL**:该列不在
|
||||
`UniverseSelector.run()` 返回的 `selected` 里 —— 股息率是**滤网算出来的**
|
||||
`values` 字段,不在行情/财报列里。只从 `selected` 取列就会静默写 NULL。
|
||||
已改为「滤网 values 优先、selected 列兜底」(`total_mv`/`roe_avg` 反向)。
|
||||
- **`candidate_count` 在开/关预剪枝时含义不同**:预剪枝前它是 5359、
|
||||
预剪枝后变成 183,而列名与页面文案都没变。已新增 `listed_count` 列,
|
||||
两者分开记录(并加 `ddl` 幂等 `ADD COLUMN`)。
|
||||
|
||||
教训与 §4.6 的第 3 条同源:**口径要靠测试与自查锁住,不能靠「看起来有数」**。
|
||||
|
||||
7. **顺带修掉一个既有的崩溃(非本功能引入)**:任何**短区间**回测
|
||||
(实测 15 个交易日)里 `sharpe` 不可计算为 `None`,而引擎与 CLI 的汇总打印
|
||||
直接做 `:.2f` / `:.2%` 格式化 → `TypeError: unsupported format string passed
|
||||
to NoneType`,以完整 traceback 结束。这是把「指标不可计算」这一**正常状态**
|
||||
说成了程序缺陷,违反了本项目「HdivError 只打印信息、不打 traceback」的约定。
|
||||
已改为统一的容忍 `None` 的格式化(`engine._pct` / `engine._num`,显示「—」),
|
||||
引擎与 CLI 的四处打印一并处理,`walk_forward` 的窗口打印同样修掉。
|
||||
由 `tests/test_daily.py::TestMetricFormatting` 锁定(含源码扫描,防止回退)。
|
||||
|
||||
### 10.6 提交前的验证清单
|
||||
|
||||
| 验证 | 方式 | 结果 |
|
||||
|---|---|---|
|
||||
| 取数口径与直连一致 | `tests/test_daily.py::test_pit_repo_matches_direct_repo` | 11 个方法逐值一致 |
|
||||
| 缓存键是充分统计量 | `::test_pit_repo_caches_are_exact` | 命中与冷算逐值相同 |
|
||||
| `symbols` 子集精确 | `::test_pit_repo_symbols_subset_is_exact` | 与直连一致 |
|
||||
| 区间外报错而非空表 | `::test_pit_repo_out_of_range_raises` | 抛 `HdivError` |
|
||||
| 预剪枝不改变入选 | `::test_prune_does_not_change_selection` | 5 个样本日入选集合完全相同 |
|
||||
| 预剪枝集合是上界 | `::test_prune_set_keeps_every_actual_member` | 实际成员全在保留集内 |
|
||||
| 每日选股无未来函数 | `::test_daily_pool_is_point_in_time` | 参照终点推后一年,同日选股逐只相同 |
|
||||
| 端到端落库 | `::test_daily_run_persists_members_and_run` | mode=daily、`hd_daily_universe` 有数据 |
|
||||
| 新增参数默认关闭 | `::TestEngineDefaultsAreOff` | single/walkforward 行为逐字不变 |
|
||||
| 既有模式无回归 | `pytest tests/ --ignore=tests/test_daily.py -q` | **408 项全通过**(本轮最后一次全量:439 项 100%) |
|
||||
| 契约与安全扫描 | `test_cli_contract` / `test_safety` / `test_schema` / `test_web` | 全通过(表数 30→31 已同步) |
|
||||
| 前端语法与接口契约 | `node --check web/app.js` + `FRONTEND_CALLS` | 通过(新路由已登记) |
|
||||
| 资金对账 | 实测 run 的 `residual` | `−0.0000 ✓` |
|
||||
| DDL 幂等 | `ddl plan` 连续两次 | `共 0 个待执行动作` |
|
||||
| 画像取数优化等价 | `pytest tests/test_profile_pit.py` | **27 passed**(含实时画像 vs 批量画像逐值比对) |
|
||||
|
||||
**本轮未完成的验证(必须诚实标注)**:
|
||||
|
||||
| 未验证项 | 原因 |
|
||||
|---|---|
|
||||
| **2020-01-05 起至今的完整回测结果** | 未跑:按约定「不做长时间测试」。修正后的全区间耗时是**推算**(见 §10.4);已实测到底的是 2026-08-01 起 43 个交易日(4 分钟) |
|
||||
| 浏览器里的实际渲染 | 已用 JavaScriptCore 做语法检查、经 HTTP 确认静态产物含新卡片、并逐项核对接口返回的数据结构;但**没有真机打开页面** |
|
||||
|
||||
**已补验(2026-10-05 复测,开发机重启后)**:
|
||||
|
||||
| 项目 | 结果 |
|
||||
|---|---|
|
||||
| 选股缓存复用 | ✅ 第二次运行打印「复用已缓存的选股结果」,跳过 114 秒选股,指标与首次**逐项相同** |
|
||||
| 前端「成交个股的实时画像」数据源 | ✅ `list_backtest_trades` 与 `analysis.stock_detail` 均返回 `reason.profile`(`test_stock_detail_exposes_decision_time_profile` 通过) |
|
||||
| `hd_daily_universe` 三列非空 | ✅ 1152 行,`dividend_yield`/`listed_count`/`roe_avg` **零 NULL** |
|
||||
| 动态池语义 | ✅ 该 run 有 `OUT_OF_UNIVERSE` 55 次(掉出池子只减不加)、`HOLD` 55 条 |
|
||||
| `test_pit_profile_caches_are_bounded` | ✅ 通过(修掉了该用例自身的 `PitRepo` 未导入问题) |
|
||||
| 一条命令给出前端位置 | ✅ `test_cli_points_at_the_frontend` 通过 |
|
||||
| `scripts/daily_result.py` | 已删除 —— 用户要的是「一条命令 + 前端看明细」,多一个导出脚本等于要求第二条命令 |
|
||||
|
||||
> **恢复后要做的**:`.venv/bin/python -m hdiv backtest --mode daily --start 2020-01-05`,
|
||||
> 然后打开 Web 前端(`./deploy/serve.sh start-dev`)→「回测记录」→ 选中该 run
|
||||
> 看明细。**不需要第二条命令**:绩效、净值、持仓、逐笔成交、
|
||||
> 成交个股的实时画像、每日股票池都在页面里。
|
||||
|
||||
**一次命令的产出(全部落库,前端可下钻)**:
|
||||
|
||||
| 表 | 内容 |
|
||||
|---|---|
|
||||
| `hd_backtest_run` | 运行头(mode=daily、区间、资金、可复现四元组、未建模声明) |
|
||||
| `hd_backtest_equity` | 逐日净值 / 现金 / 持仓市值 / 回撤 / 基准净值 → **综合曲线** |
|
||||
| `hd_backtest_metric` | 总收益 / CAGR / 最大回撤 / Sharpe / Sortino / Calmar / 换手 / 逐年 |
|
||||
| `hd_backtest_position` | 逐日持仓明细 |
|
||||
| `hd_backtest_trade` | **全部成交** + `reason_json`(触发理由 + 决策时点画像) |
|
||||
| `hd_backtest_signal` | 全部信号(含未成交原因:画像未通过 / 掉出池子 / 现金不足…) |
|
||||
| `hd_daily_universe` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) |
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user