功能:每日动态股票池回测(--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:
+365
-14
@@ -36,6 +36,11 @@
|
||||
cd ~/project/高股息回测
|
||||
export PYTHONPATH=src
|
||||
|
||||
# ⓪ 数据:首次全量同步见 §2.3;此后每天只需要这一条(缺几天抓几天)
|
||||
.venv/bin/python -m hdiv sync daily --dry-run # 先看待抓清单
|
||||
.venv/bin/python -m hdiv sync daily # 真抓(只补缺口)
|
||||
./deploy/install-sync-schedule.sh install # 注册为每天 17:00 自动执行
|
||||
|
||||
# ① 选股:按 2025-01-01 当时可见的数据筛选(实际落到交易日 2024-12-31)
|
||||
.venv/bin/python -m hdiv universe --asof 2025-01-01
|
||||
# → 记下打印的 run_id,例如 02485801b2805cbae0e02db66c7bc946
|
||||
@@ -193,7 +198,8 @@ CLI 会打印覆盖率,例如:
|
||||
若闸门启用:另按最长窗口预载画像面板
|
||||
④ 逐日循环 ↓
|
||||
④a 开盘 → 执行**昨日**收盘产生的信号,成交价 = 次日开盘价 ± 滑点
|
||||
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送转股增加股数
|
||||
④b 盘中 → 除权除息:现金分红入账(按持股期限扣红利税)、送股/转增调整股数
|
||||
(纯送转无现金分红也照常调股数,不会被丢弃)
|
||||
④c 收盘 → 每月一次评估信号(signal_frequency_months=1):
|
||||
· 算当日股息率 = TTM 每股分红 ÷ 不复权收盘价
|
||||
· 算历史分位 = 当前值在「(当日−5年, 当日]」分布中的占比
|
||||
@@ -228,6 +234,7 @@ CLI 会打印覆盖率,例如:
|
||||
|---|---|
|
||||
| `--universe-run` 且**股票池 asof > 回测首个交易日** | **拒绝执行**,退出码 1,错误信息给出三种正确做法 |
|
||||
| `--universe-run` 用在 `--mode walkforward` | **拒绝执行**(训练窗口比股票池时点更早) |
|
||||
| `--universe-run` 用在 `--mode daily` | **拒绝执行**(固定池与「每日动态」定义互斥,且冻结名单自带未来信息) |
|
||||
| `--universe-run` 且 asof ≤ 起点 | 正常执行(股票池属于**事前信息**) |
|
||||
| 确需复现带未来信息的旧结果 | 显式加 `--allow-lookahead-universe`;偏差会写入 `unimplemented_json` |
|
||||
|
||||
@@ -245,9 +252,9 @@ CLI 会打印覆盖率,例如:
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板 → 该信号**跳过**(记 `skip_reason`) |
|
||||
| 停牌 | 该信号**跳过**(`backtest.yml` 写的 `defer` **未实现**,不会顺延) |
|
||||
| 分红 | 除权日入账,**留存为现金**(`cash_mode: reinvest` 未实现),下次调仓按目标权重再配置 |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 分红 | 除权日入账(税后),进的是**与初始资金同一个可投资现金池**,下次调仓按目标权重再配置(`reinvest` + `portfolio_rebalance`,已实现;`hold`/`cash_out` 未实现) |
|
||||
| 送股 / 转增 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降)。**纯送转**(如 10 送 10:股价腰斩、股数翻倍,无现金分红)同样处理,不会被丢弃 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效;库里也没有配股价/比例数据) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
> 以上「未实现」的项都会**逐条写入 `hd_backtest_run.unimplemented_json`**,
|
||||
@@ -298,8 +305,9 @@ CLI 也会直接打印对账残差与 `✓`。
|
||||
|---|---|---|---|
|
||||
| ① 选股 | `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 [--universe-run <id>] [--start]` | `hd_backtest_run` / `_equity` / `_position` / `_trade` / `_signal` / `_metric` | **股票池 asof 晚于起点会被拒绝**;`defer`、配股等未实现项在 `unimplemented_json` 里 |
|
||||
| ④ 验证 | `hdiv backtest --mode walkforward` | `hd_walkforward_run` / `_window` | 必须与 `--universe-run` 分开用;约 25~80 分钟 |
|
||||
| ④b 动态池推演 | `hdiv backtest --mode daily --start <日期>` | `hd_backtest_run(mode=daily)` / `_equity` / `_trade` / `_signal` + **`hd_daily_universe`** | **每个交易日全市场筛选**,6.7 年约 1.5 小时;不支持 `--universe-run`;见 §5.7b |
|
||||
| ⑤ 调参 | `hdiv sensitivity --sweep "..."` | `hd_sensitivity_run` / `_point` | 样本不足时噪声会被误读为过拟合 |
|
||||
|
||||
---
|
||||
@@ -828,7 +836,11 @@ period: { start: 2015-01-01, end: latest }
|
||||
| `fill.price` | `next_open` | 信号次日开盘成交 |
|
||||
| `fill.limit_up_down_rule` | `skip` | 涨跌停时跳过(`defer` 分支**未实现**) |
|
||||
| `fill.suspended_rule` | `defer` | ⚠️ **未实现**:实际行为是**跳过**,不会顺延(见 §0.3) |
|
||||
| `dividend.cash_mode` | `reinvest` | ⚠️ **未实现**:实际行为是**留存为现金**(等价 `hold`),见 §0.3 |
|
||||
| `dividend.cash_mode` | `reinvest` | 分红现金**回落到可投资现金池**(与初始资金同一个 `cash` 变量),下次调仓按目标权重再配置。`hold`/`cash_out` **未实现** |
|
||||
| `dividend.reinvest_rule` | `portfolio_rebalance` | 已实现:调仓时按目标权重再配置。`same_stock_next_open`(同股再投)**未实现** |
|
||||
| `dividend.apply_dividend_tax` | `true` | 红利税**总闸**(分档税率在 `cost.yml` 的 `dividend_tax`;两者同时为真才计税) |
|
||||
| `dividend.handle_stock_dividend` | `true` | 送股/转增按 `stk_div` 调整股数、总成本不变(`false` 会写入 `unimplemented`) |
|
||||
| `dividend.handle_rights_issue` | `true` | ⚠️ **未实现**:配股缴款/股数变动不入账(见 §0.3) |
|
||||
|
||||
---
|
||||
|
||||
@@ -892,6 +904,9 @@ hdiv ddl verify # 校验库中表结构是否符合代码定义
|
||||
## 5.2 `sync` — 数据同步
|
||||
|
||||
```bash
|
||||
hdiv sync daily [--dry-run] [--asof YYYY-MM-DD] [--lookback-days 45] \
|
||||
[--only price trading index dividend financial] \
|
||||
[--no-financial] [--financial-limit 500] [--json]
|
||||
hdiv sync dividend [--symbols ...] [--only-missing] [--limit N]
|
||||
hdiv sync financial [--interleaved] [--only-missing] [--apis ...] [--limit N]
|
||||
hdiv sync index [--no-weight] [--start YYYYMMDD]
|
||||
@@ -903,6 +918,7 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
|
||||
|
||||
| 目标 | 说明 | 首次耗时 |
|
||||
|---|---|---:|
|
||||
| `daily` | **日常增量:缺几天就抓几天**(下方 §5.2.1) | ~1 分钟 |
|
||||
| `dividend` | 分红送转全明细(逐只股票) | ~35 分钟 |
|
||||
| `financial` | 四张财务报表;**加 `--interleaved` 按股票交错拉取**(推荐) | ~3 小时 |
|
||||
| `index` | 基准指数行情 + 成分股权重 | ~2 分钟 |
|
||||
@@ -918,6 +934,81 @@ hdiv sync backfill [--start 2015-01-01] [--end 2018-12-31] \
|
||||
> **`daily_basic` 仍停在 2015** —— 画像里的 PE/PB/股息率照样拿不到早年数据。
|
||||
> 现在 `--basic-start` 缺省时跟随 `--start`。
|
||||
|
||||
### 5.2.1 `sync daily` — 每日增量(只补缺口)
|
||||
|
||||
首次全量同步是一次性的事;**此后每天该跑的只有这一条命令**。
|
||||
它只抓「库里还没有的那几天」,已完整的历史一天都不重拉。
|
||||
|
||||
```bash
|
||||
hdiv sync daily --dry-run # 只看待抓清单,不调用接口、不写库
|
||||
hdiv sync daily # 真抓
|
||||
hdiv sync daily --only price # 只补行情三表
|
||||
hdiv sync daily --json # 机器可读结果(给监控/告警用)
|
||||
```
|
||||
|
||||
**外部数据源与本地表的对应关系**(只有这些表是「抓来的」,其余 `hd_*`
|
||||
(`hd_universe_*` / `hd_profile_*` / `hd_backtest_*` / `hd_strategy*` / `hd_report`
|
||||
/ `hd_data_audit` / `hd_sync_log`)都是本项目自己算出来或记的账):
|
||||
|
||||
| 本地表 | Tushare 接口 | 分区方式 | 缺口判定 |
|
||||
|---|---|---|---|
|
||||
| `stock_daily` | `daily` | `trade_date` | 当日股票数 ≥ 当年规模阈值 |
|
||||
| `adjust_factor` | `adj_factor` | `trade_date` | 同上 |
|
||||
| `daily_basic` | `daily_basic` | `trade_date` | 同上 |
|
||||
| `hd_suspend` | `suspend_d` | `trade_date` | 有行即视为已同步 |
|
||||
| `hd_limit` | `stk_limit` | `trade_date` | 当日股票数 ≥ 当年规模阈值 |
|
||||
| `hd_index_daily` | `index_daily` | `(指数, trade_date)` | 每个指数各自的最后一天 |
|
||||
| `hd_dividend` | `dividend` | `ann/imp_ann/ex/record_date` | 四个日期列都查过才算同步 |
|
||||
| `hd_fina_indicator` | `fina_indicator` | `ts_code` | 缺股票 / 报告期滞后 |
|
||||
| `hd_cashflow` | `cashflow` | `ts_code` | 同上 |
|
||||
| `hd_balancesheet` | `balancesheet` | `ts_code` | 同上 |
|
||||
| `hd_income` | `income` | `ts_code` | 同上 |
|
||||
| `index_weight` | `index_weight` | 月度区间 | 最后一个权重日之后 |
|
||||
|
||||
> **只读表的写入**:`stock_daily` / `adjust_factor` / `daily_basic` 是 qlib 的既有表,
|
||||
> 写入受 `StatementGuard` 保护。`sync daily` 会在进程内自动打开 `HDIV_ALLOW_BACKFILL`
|
||||
> 并向 `hd_sync_log` 记账;写入一律 `INSERT IGNORE`,**冲突行完全不改动**。
|
||||
|
||||
> **为什么财报四表不能按天补**:实测 `fina_indicator` / `income` / `balancesheet` /
|
||||
> `cashflow` 只传 `period` / `ann_date` / `start_date` 而不传 `ts_code` 时,
|
||||
> 服务端一律返回 `50101 必填参数, ts_code` —— 只能按股票拉。所以这四张表改为
|
||||
> 「先补完全没数据的股票,再按**报告期水位**补滞后股票」,单次有上限
|
||||
> (`--financial-limit`,默认 500 只,按市值降序),积压会在随后每天自动排空。
|
||||
> 报告期水位按披露截止日推算:年报/一季报 4-30、半年报 8-31、三季报 10-31。
|
||||
|
||||
> **为什么分红可以按天补**:`dividend` 接口支持 `ann_date` / `imp_ann_date` /
|
||||
> `ex_date` / `record_date` 四种日期参数(实测可用)。因此不必像早期实现那样
|
||||
> 逐只股票重拉全历史(5,900 次调用),每天最多 4 次调用;四个日期列都查,
|
||||
> 避免漏掉「预案日已过、除权日未到」的记录。
|
||||
|
||||
> **回溯窗口**:`--lookback-days`(默认 45)决定「往前找多少天的缺口」。
|
||||
> 更早的历史空洞属于**回补**而不是每日增量,用 `hdiv audit` 发现、
|
||||
> 用 `hdiv sync backfill` 处理。窗口存在是为了「昨夜失败今晨自愈」。
|
||||
|
||||
#### 定时执行(每天 17:00)
|
||||
|
||||
```bash
|
||||
./deploy/install-sync-schedule.sh install # 注册 launchd 定时任务
|
||||
./deploy/install-sync-schedule.sh status # 状态 + 最近日志
|
||||
./deploy/install-sync-schedule.sh dry-run # 立刻跑一次「只看清单」
|
||||
./deploy/install-sync-schedule.sh uninstall # 移除
|
||||
```
|
||||
|
||||
- 计划模板:`deploy/com.hddiv.sync.plist.example`(`StartCalendarInterval` = 17:00);
|
||||
- 执行包装:`deploy/daily-sync.sh`(单实例锁 + 日志轮转 + 退出码);
|
||||
- 日志:`logs/daily-sync.log`(脚本自身)与 `logs/daily-sync.launchd.log`(启动失败兜底)。
|
||||
|
||||
> **为什么用 launchd 而不是 cron**:macOS 上 cron 睡眠期间错过的任务**不会补跑**,
|
||||
> 而 launchd 的 `StartCalendarInterval` 会在唤醒后补跑一次 —— 对「每天补缺口」
|
||||
> 的任务来说补跑是刚需(漏一天就多一天缺口,且缺口会一直留着)。
|
||||
> 本机 web 服务已经用 launchd 托管,同一套机制更好排查。
|
||||
>
|
||||
> **非 launchd 环境**(Linux 服务器)等价的一行 crontab:
|
||||
> ```
|
||||
> 0 17 * * * cd <项目根> && PYTHONPATH=src .venv/bin/python -m hdiv sync daily >> logs/daily-sync.log 2>&1
|
||||
> ```
|
||||
|
||||
|
||||
## 5.3 `audit` — 数据审计
|
||||
|
||||
```bash
|
||||
@@ -1022,6 +1113,225 @@ hdiv backtest --universe-run <run_id> # 用指定股票池(冻结)并建
|
||||
> 「画像剔除 1027 次」= 有多少个买入信号被实时画像拦下。它们全部以
|
||||
> `REJECT` 记录在库,可在前端「未成交信号」里逐条查看每条规则的实际值。
|
||||
|
||||
### 5.7b `--mode daily` — 每日动态股票池
|
||||
|
||||
```bash
|
||||
# 逐日口径(最细):从 2020-01-05 起,每个交易日重新选股、每个交易日判断买卖点
|
||||
hdiv backtest --mode daily --start 2020-01-05
|
||||
# → { 落库 hd_backtest_run(mode='daily') + 逐日 hd_daily_universe + 完整回测明细 }
|
||||
|
||||
# 提速(推荐先跑这个,约 35 分钟):股票池与买卖都按「每周」口径
|
||||
hdiv backtest --mode daily --start 2020-01-05 \
|
||||
--every-n-days 5 --signal-every-n-days 5
|
||||
```
|
||||
|
||||
> 两个 `--*-every-n-days` 只改变**多久看一次**(选股 / 判断买卖),
|
||||
> 不改变判定规则;调大它们得到的是**粗粒度版本**,结果不可与逐日口径直接比较。
|
||||
> 完整的可选方案与预计耗时见本节末尾「提速方案」。
|
||||
|
||||
**它和 `--mode walkforward` 是两种不同的检验,不能互相替代**:
|
||||
|
||||
| | `--mode walkforward` | `--mode daily` |
|
||||
|---|---|---|
|
||||
| 回答的问题 | 参数在样本外能否复现(**过拟合检验**) | 从某天起连续推演**会怎样** |
|
||||
| 时间结构 | 多个 `(train, test)` 滚动窗口 | 一条连续的 `[start, latest]` |
|
||||
| 阈值口径 | 测试段**冻结**训练段分布 | 一律 rolling(PIT 滚动窗口) |
|
||||
| 股票池 | 每个窗口/调仓日按 asof 重筛 | **每个交易日**按 asof 重筛 |
|
||||
| 持仓掉出股票池 | ——(每窗口独立重来) | `pool_exit_action` 决定(默认只减不加) |
|
||||
| 产出 | 多窗口样本外统计 | 一条净值 + **逐日选股** + 逐笔信号 |
|
||||
|
||||
> **两个都要看**:walkforward 说「这套参数在未知未来是否站得住」;
|
||||
> daily 说「动态股票池下这条路径长什么样」。只看其中一个都会误判。
|
||||
|
||||
**动态股票池的语义**(`config/backtest.yml: daily`):
|
||||
|
||||
| 配置 | 默认 | 含义 |
|
||||
|---|---|---|
|
||||
| `universe_refresh_days` | `1` | 每 N 个交易日重建股票池。`1` = 每个交易日 |
|
||||
| `signal_frequency_days` | `1` | 每 N 个交易日评估买卖点 |
|
||||
| `pool_exit_action` | `hold` | 持仓掉出当日池子:`hold` = **只减不加**(不清仓,仍按分位卖出);`sell` = 清仓 |
|
||||
| `profile_on_trade` | `true` | 买卖决策发生时计算并留痕个股画像(**不区分是否在池内**) |
|
||||
| `persist_daily_universe` | `true` | 把每日入选成员写入 `hd_daily_universe` |
|
||||
|
||||
**输出示例**(2024-03-01 ~ 2024-03-29,21 个交易日):
|
||||
|
||||
```
|
||||
每日动态股票池回测 HD_MR_V1 v1.0:2024-03-01 ~ 2024-03-29(21 个交易日)
|
||||
选股频率:每 1 个交易日(共 21 次筛选);信号频率:每 1 个交易日;池外持仓:hold
|
||||
已载入参照数据(财报/分红/交易日历):17.2s
|
||||
候选集预剪枝:5903 → 409 只(剔除 交易所 349、板块 0、上市年限 2958、市值 2187)
|
||||
区块 1/1 2024-03-01 ~ 2024-03-29(21 个交易日,已载入行情 533,109 行 / 19.6s)
|
||||
选股 21/21 56s(2.65s/日,预计剩余 0.0 分钟)2024-03-29 池内 21 只
|
||||
选股完成:21/21 个决策日选出非空股票池,成员数 21~24,累计出现过的股票 25 只
|
||||
实时画像:计算 244 次(缓存命中 75),涉及 21 个决策时点 | 画像剔除 31 次
|
||||
期初 1,000,000 → 期末 940,815 | 总收益 -5.92% | 最大回撤 -8.71% | 成交 14 笔
|
||||
累计现金分红 0(已扣红利税 0) | 对账残差 -0.0000 ✓
|
||||
每日选股留痕:hd_daily_universe 477 行(21 个时点)
|
||||
股票池变动:累计进入 4 次 / 移出 7 次(平均每日 0.2 进 0.3 出)
|
||||
2024-03-05: +0 -1 出 000538.SZ
|
||||
2024-03-07: +1 -0 进 000538.SZ
|
||||
2024-03-08: +0 -1 出 000538.SZ
|
||||
```
|
||||
|
||||
**耗时与取舍(★ 必读)**:逐日全市场筛选 + 逐笔决策画像,是本系统最重的计算。
|
||||
|
||||
| 阶段 | 实测 | 说明 |
|
||||
|---|---|---|
|
||||
| 逐日选股 | **约 2.0 秒/交易日** | 2026-08 窗口实测 2.02 秒/日(P4 优化后;优化前 2.34 秒/日)。全区间 1635 日曾实测 2.38 秒/日(65 分钟)—— 不含 P4 |
|
||||
| 模拟 | **约 3.0 秒/交易日** | 43 个交易日、池内 30~36 只、面板 41 只(直接计时)。其中 **78% 是逐笔决策的画像快照**(每次约 185 ms)、13% 是逐时点重建财报面板 |
|
||||
| 加起来 | **约 2~3 小时**(6.7 年,推算) | 选股 65 分钟 + 模拟约 1.5~2.5 小时(推算,非实测) |
|
||||
|
||||
模拟的耗时**与股票池规模、面板股票数都成正比**:2020 年池内约 15 只、
|
||||
2026 年约 50 只,所以后半段明显更慢。
|
||||
|
||||
**已修掉的性能缺陷(2026-10-04)**:`PitProfileService._compute` 原先
|
||||
「先按日期剪裁 50 万行的全市场面板、再筛出这一只股票」,实测 **174 毫秒/次**;
|
||||
两个过滤条件互相独立,交换顺序后只要 **11 毫秒/次(16 倍)**。
|
||||
它每次画像快照都要付两遍(价格面板 + 每日指标面板)。等价性由
|
||||
`tests/test_profile_pit.py::test_pit_profile_matches_batch_builder`(实时画像 vs 批量画像
|
||||
逐值比对)与 27 项 PIT 用例锁定。
|
||||
|
||||
> **请注意**:全区间(6.7 年)修正后的总耗时是**推算**,不是实测 ——
|
||||
> 已实测到底的是 43 个交易日(4 分钟)。命令启动时会按实测速率给出预计值,
|
||||
> 运行中每 20 个交易日打印实测速率与 ETA。
|
||||
|
||||
### 提速方案(按「收益 / 失真代价」排序,可任选或叠加)
|
||||
|
||||
两个**频率旋钮**已接到命令行上(此前只能改 YAML):
|
||||
|
||||
| 参数 | 含义 | 默认 |
|
||||
|---|---|---|
|
||||
| `--every-n-days N` | 股票池每 N 个交易日**重建**一次 | `1`(每个交易日)= config 的 `daily.universe_refresh_days` |
|
||||
| `--signal-every-n-days N` | 买卖每 N 个交易日**判断**一次 | `1`(每个交易日)= config 的 `daily.signal_frequency_days` |
|
||||
|
||||
**它们不改变任何判定规则**,只改变「多久看一次」。所以调大它们得到的是
|
||||
**粗粒度版本**,不是同一策略的加速版 —— 交易机会与换手都会下降,
|
||||
结果**不可**与逐日口径直接比较(程序启动时会明确提示这一点)。
|
||||
|
||||
下表是 6.7 年区间(1635 个交易日)的**预计**耗时,按 2026-08-01 起 43 个交易日的
|
||||
实测速率推算(选股 2.6 秒/次、每次信号评估 3.0 秒、每交易日记账 0.05 秒):
|
||||
|
||||
| 方案 | 在 `--start 2020-01-05` 基础上加 | 预计耗时 | 失真代价 |
|
||||
|---|---|---:|---|
|
||||
| 逐日(当前默认) | — | **约 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 分钟** | 每周口径(建议先跑这个看结论) |
|
||||
| 每 10 日 | `--every-n-days 10 --signal-every-n-days 10` | 约 20 分钟 | 双周口径 |
|
||||
| ≈ 原月频 | `--every-n-days 21 --signal-every-n-days 21` | 约 12 分钟 | 与改造前 `--mode single` 的月频同量级,但股票池**动态重建**(这正是新功能的价值) |
|
||||
| 季频 | `--every-n-days 63 --signal-every-n-days 63` | 约 7 分钟 | 最粗,只适合快速看方向 |
|
||||
|
||||
> **这些数是推算不是实测**:单点实测是 43 个交易日(逐日 114+131 秒;
|
||||
> 每 5 日 43+约 28 秒)。全区间面板为 123 只(测试窗口 41 只)、后期池内约 59 只,
|
||||
> 所以实际会**更慢**。命令启动时会按同一模型打印本次的预计时长。
|
||||
|
||||
**建议**:先用 `--every-n-days 5 --signal-every-n-days 5`(约 35 分钟)拿结论;
|
||||
若某条结论对频率敏感,再把**信号**频率收紧回 1(逐日判断、每周选股,约 1.5 小时)
|
||||
做对照 —— 「多久判断一次买卖」才是真正改变收益路径的那个旋钮。
|
||||
|
||||
**其他可选方案**(不动上面两个频率):
|
||||
|
||||
| 做法 | 省多少 | 代价 |
|
||||
|---|---|---|
|
||||
| **复用选股缓存**(默认行为) | 重跑同区间**省掉整个选股阶段**(实测 43 日省 114 秒;全区间约 65 分钟) | 无。键只含输入、不含时间戳;缓存损坏会自动退回重筛 |
|
||||
| 缩短区间 `--start 2023-01-01` | 近似按比例下降 | 只看到那一段;起点不同结果本就不可比 |
|
||||
| `daily.profile_on_trade: false` | 模拟阶段约降 1/4(只保留闸门触发时的画像) | **前端「成交个股的实时画像」会变空** —— 与「所有成交个股实时画像」直接冲突 |
|
||||
| 策略文件 `entry.profile_gate.enabled: false` | 模拟阶段最大的一项开销消失(每次信号评估约 3.0 秒 → 约 0.6 秒) | 去掉一层风险控制,**改变了策略本身**;README 的样本外结论基于「闸门开」 |
|
||||
| `--refresh-pools` | 只会**更慢**(强制丢弃缓存重筛) | 无(用途是怀疑缓存时强制重算) |
|
||||
|
||||
**为什么能跑得动**:单次 `UniverseSelector.run` 约 10~18 秒,逐日 1600 次就是
|
||||
5~8 小时。daily 模式做了三件事把它降到可接受范围,且**都不改变判定口径**:
|
||||
|
||||
1. **批量预载**(`hdiv/universe/pit.py`):行情/每日指标按年分块一次性取回,
|
||||
逐日在内存切片;财务四表与分红常驻。``PitRepo`` 继承 ``Repo``,
|
||||
**只覆盖最底层的取数方法**,所有派生逻辑(最新一期财报合并、ROE 年化、
|
||||
单位归一化、支付率)一行未改 —— 口径由
|
||||
`tests/test_daily.py::test_pit_repo_matches_direct_repo` 逐值锁定;
|
||||
2. **可见性缓存**:财务面板按「已公告财报条数」缓存 —— 条数相同则可见集合相同,
|
||||
因此这是**精确**键。年报季几乎每天失效,靠预排序把每次重建压到 0.4 秒;
|
||||
3. **候选集预剪枝**:只剔除「在整个区间内**不可能**通过市场滤网」的股票
|
||||
(交易所/板块/上市年限/市值上界)。被剔除者在原流程里必然在第一个滤网被淘汰,
|
||||
所以最终入选逐只相同 —— 由
|
||||
`tests/test_daily.py::test_prune_does_not_change_selection` 锁定。
|
||||
|
||||
> **流动性刻意没有预剪枝**:`stock_daily` 的量价单位在 2015-2019 是「手/千元」、
|
||||
> 2020 起是「股/元」,用 `MAX(amount)` 做上界会在早年低估 1000 倍,**误剪掉
|
||||
> 本该通过的股票**。宁可少一项优化,也不接受一个会改变结果的上界。
|
||||
|
||||
**看结果:一条命令,然后打开前端 —— 不需要第二条命令**
|
||||
|
||||
这条命令自己会打印 `run_id`,并给出前端位置。结果全部落库,页面里直接下钻:
|
||||
|
||||
| 页面位置 | 能看到什么 |
|
||||
|---|---|
|
||||
| 回测记录 → 该条(模式 daily) | 绩效 KPI(总收益 / CAGR / 最大回撤 / Sharpe)、**净值曲线与基准**、资金对账、回测条件、可复现性 |
|
||||
| ↳ 持仓明细 | 任意交易日的持仓(可翻上/下一交易日) |
|
||||
| ↳ 逐笔成交与理由 | **全部成交** + 每笔的触发理由 |
|
||||
| ↳ **成交个股的实时画像** | 每一笔成交当天的画像:股息率、股息率分位、PE、PB、5 年 ROE、连续分红年数、支付率、FCF 覆盖,并标注该股**当日是否仍在池内** |
|
||||
| ↳ 未成交信号与原因 | 想买没买到 / 画像未通过 / 掉出池子 / 现金不足 |
|
||||
| ↳ **每日动态股票池** | 逐日选股留痕:决策日下拉、池内成员与入选时因子 |
|
||||
| ↳ 点任一成交个股 | 趋势与买卖点(N 联图)+ 该股**逐笔决策的实时画像**(可展开全部指标) |
|
||||
|
||||
「成交个股的实时画像」与个股页的「决策时点实时画像」是 `--mode daily` 的核心新增:
|
||||
它们把**每个买卖决策当天、只用当时可见数据**算出的画像列出来,
|
||||
回答的是「当时凭什么买/卖」,而不是「今天回头看它长什么样」。
|
||||
数据来自成交流水的 `reason_json.profile`,可用 SQL 逐条复核。
|
||||
|
||||
**同一批数据也可以用 SQL 直接查**(页面上的每个数字都可追溯到 SQL):
|
||||
|
||||
```sql
|
||||
-- 某一天的动态股票池(dividend_yield 是**筛选口径**的股息率)
|
||||
SELECT symbol, name, industry, dividend_yield, total_mv, roe_avg
|
||||
FROM hd_daily_universe
|
||||
WHERE run_id = '<run_id>' AND trade_date = '2024-03-29'
|
||||
ORDER BY dividend_yield DESC;
|
||||
|
||||
-- 池子的规模变化。注意两个「候选」含义不同:
|
||||
-- listed_count = 当日市场候选数(未预剪枝)
|
||||
-- candidate_count = 当日**实际参与筛选**的候选数(已预剪枝)
|
||||
SELECT trade_date, MAX(listed_count) AS 市场候选, MAX(candidate_count) AS 实际筛选,
|
||||
COUNT(*) AS 入选
|
||||
FROM hd_daily_universe WHERE run_id = '<run_id>'
|
||||
GROUP BY trade_date ORDER BY trade_date;
|
||||
|
||||
-- 「想加仓但已掉出池子」的记录(pool_exit_action=hold 的直接证据)
|
||||
SELECT symbol, signal_date, JSON_UNQUOTE(JSON_EXTRACT(reason_json,'$.rule')) AS why
|
||||
FROM hd_backtest_signal
|
||||
WHERE run_id = '<run_id>' AND skip_reason = 'OUT_OF_UNIVERSE';
|
||||
|
||||
-- 买卖决策时的个股画像留痕
|
||||
SELECT symbol, signal_date, signal_type,
|
||||
JSON_EXTRACT(reason_json, '$.profile.values.roe_avg') AS roe_avg,
|
||||
JSON_EXTRACT(reason_json, '$.profile.percentiles.dv_yield') AS dv_pct
|
||||
FROM hd_backtest_signal
|
||||
WHERE run_id = '<run_id>' AND JSON_EXTRACT(reason_json,'$.profile') IS NOT NULL LIMIT 20;
|
||||
```
|
||||
|
||||
**相关接口**(前端已经在用,供二次开发参考):
|
||||
|
||||
| 接口 | 内容 |
|
||||
|---|---|
|
||||
| `GET /api/backtests/<run_id>` | 运行头(mode、区间、资金、未建模声明) |
|
||||
| `GET /api/backtests/<run_id>/metrics` | 绩效指标(含逐年、基准对比) |
|
||||
| `GET /api/backtests/<run_id>/equity` | 逐日净值 / 回撤 / 基准净值 |
|
||||
| `GET /api/backtests/<run_id>/trades` | 全部成交 + `reason`(含决策时点画像) |
|
||||
| `GET /api/backtests/<run_id>/signals` | 未成交信号与原因 |
|
||||
| `GET /api/backtests/<run_id>/portfolio?date=` | 任意日持仓 |
|
||||
| `GET /api/backtests/<run_id>/stocks/<symbol>` | 个股买卖点 + 曲线 + 该股全部成交(含画像) |
|
||||
| `GET /api/backtests/<run_id>/daily-universe[?date=]` | 每日股票池时间线 / 某日成员 |
|
||||
|
||||
**已知限制**:
|
||||
|
||||
- **不支持 `--universe-run`**(会报错说明原因)。固定股票池与「每日动态」在定义上
|
||||
互斥,且冻结名单自带未来信息。
|
||||
- **daily 没有「训练段」**,因此也没有冻结分布。它**不检验**参数稳定性 ——
|
||||
要检验参数稳定性请用 `--mode walkforward` 与 `sensitivity`。
|
||||
- **候选集预剪枝会少留逐股淘汰原因**:daily 模式只落库每日**入选**成员;
|
||||
被剪掉的股票不会出现在 `hd_universe_member` 里(它本来也不落库 daily 的候选)。
|
||||
需要「某只股票为什么没选上」时,用 `hdiv universe --asof <日期>` 单独跑一天。
|
||||
- **区间起点即起点**:daily 是单条路径。起点不同,路径就不同(早年的股票池、
|
||||
估值水平都不一样)。不要拿两个不同起点的 daily 结果直接比较策略优劣。
|
||||
|
||||
## 5.8 `sensitivity` — 参数敏感性
|
||||
|
||||
```bash
|
||||
@@ -1050,7 +1360,7 @@ hdiv report validate # 校验全部报告的离线合规性与 JS 语法
|
||||
```bash
|
||||
export PYTHONPATH=src
|
||||
.venv/bin/python -m hdiv ddl apply
|
||||
.venv/bin/python -m hdiv ddl verify # 应输出「OK:30 张 hd_* 表结构全部符合 schema 定义」
|
||||
.venv/bin/python -m hdiv ddl verify # 应输出「OK:31 张 hd_* 表结构全部符合 schema 定义」
|
||||
```
|
||||
|
||||
## 6.2 数据同步(首次约 3~4 小时)
|
||||
@@ -1408,7 +1718,7 @@ http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1MB)
|
||||
|
||||
# 8. 数据库
|
||||
|
||||
## 8.1 表清单(30 张,全部 `hd_` 前缀)
|
||||
## 8.1 表清单(31 张,全部 `hd_` 前缀)
|
||||
|
||||
### 数据同步层
|
||||
|
||||
@@ -1427,6 +1737,7 @@ http://<host>:8080/ggx/assets/echarts.min.js ← 图表库(约 1MB)
|
||||
| 表 | 用途 |
|
||||
|---|---|
|
||||
| `hd_universe_run` / `hd_universe_member` | 股票池运行头 / 成员与逐滤网留痕 |
|
||||
| `hd_daily_universe` | **每日动态股票池**成员(`--mode daily` 的逐日选股留痕) |
|
||||
| `hd_factor_snapshot` | 决策时点因子值 |
|
||||
| `hd_profile_run` / `hd_profile_stat` / `hd_profile_series` / `hd_profile_score` | 画像头 / 分布统计 / 时间序列 / 安全边际得分 |
|
||||
| `hd_strategy` / `hd_strategy_param` | 策略版本 / 参数扁平表 |
|
||||
@@ -1615,15 +1926,22 @@ Tushare 各接口单位不统一,且从列名看不出来。系统在 `data/un
|
||||
| 整手 | 买入按 100 股取整 |
|
||||
| 涨跌停 | 开盘即封板则该信号**当日跳过**,记录 `skip_reason`(`defer` 未实现) |
|
||||
| 停牌 | **当日跳过**(⚠️ `fill.suspended_rule: defer` **未实现**,不会顺延;见 §0.3) |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、成本不变 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效) |
|
||||
| 送转股 | 已实现:股数按 `stk_div` 增加、**总成本不变**(每股成本随之下降);纯送转(如 10 送 10,无现金分红)同样处理 |
|
||||
| 配股 | **未实现**(`handle_rights_issue` 不生效;且库里没有配股价/配股比例数据) |
|
||||
| 部分成交 / 成交量占比 | **未实现**(按信号全额成交,受资金与权重上限约束) |
|
||||
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税),
|
||||
**留存为现金**,在下次调仓时按目标权重重新配置
|
||||
(⚠️ `cash_mode: reinvest` / `reinvest_rule` **未实现**)。
|
||||
**分红处理**:持仓市值用**不复权价**,现金分红在除权日**单独入账**(按持股期限扣红利税)。
|
||||
入账的税后现金进的是**与初始资金同一个现金池**(`cash_mode: reinvest` +
|
||||
`reinvest_rule: portfolio_rebalance`,已实现)—— 它不是被隔离的「不可投资资金」,
|
||||
下次调仓时按目标权重再配置;`hold`(永久留存)与 `cash_out`(移出组合)才是未实现分支。
|
||||
用不复权价 + 独立现金流,从根上避免了「复权收益 + 分红」的重复计算。
|
||||
|
||||
> **两处已知口径简化**(不产生 `unimplemented` 声明,因为这是模型假设而非漏实现):
|
||||
> ① 红利税在**除权日**一次性按「买入→除权日」的持有期扣除,而 A 股实际是**卖出时**
|
||||
> 按「买入→卖出」的实际持有期补缴,且按分笔 FIFO;② **送股**(`stk_bo_rate`)
|
||||
> 按面值 1 元计入红利所得的个税未建模(转增 `stk_co_rate` 本就不征,故对以转增为主的
|
||||
> 样本无影响;持股 > 1 年者该税为 0)。
|
||||
|
||||
> 以上每一项未实现都会逐条写入 `hd_backtest_run.unimplemented_json` —— 可直接查库核对。
|
||||
|
||||
**资金对账**(每次回测都会校验):
|
||||
@@ -1985,6 +2303,39 @@ PYTHONPATH=src .venv/bin/python -c \
|
||||
**记住这条规则:改 `src/` 或 `config/` 之后,一律 `restart` 一次。**
|
||||
只改 `web/`、`templates/` 这类静态产物不需要——它们由 nginx 每次请求重新读盘。
|
||||
|
||||
### 11.2.2 页面一直停在「加载中…」,或页面没有样式(裸 HTML)
|
||||
|
||||
这两个症状都是**前端产物**的问题,后端其实是好的。分别定位:
|
||||
|
||||
**① 一直停在「加载中…」(概览页打不开,其他页正常)**
|
||||
|
||||
`index.html` 里预置了一句 `<div class="loading">加载中…</div>`,由前端路由
|
||||
`render()` 用真实内容替换掉。前端有一个「已渲染路径」缓存来避免重复渲染,
|
||||
而**首页的路径恰好是空串 `''`**:一旦这个缓存的初值也用 `''`,`render()`
|
||||
在首页一进门就命中 `path === currentPath` 提前返回 —— 占位符永远不被替换。
|
||||
哨兵值必须用 `null`(`web/app.js` 里 `let currentPath = null`),这种不一致
|
||||
才是病根,不是接口慢。
|
||||
|
||||
**② 页面裸奔(HTML 打得开,CSS/JS 403)**
|
||||
|
||||
nginx 的 master 是 root、**worker 是 nobody**,所以站点文件必须 world-readable:
|
||||
|
||||
```bash
|
||||
# 从 nginx 的视角看某个文件是否读得到(403 = 权限,404 = 路径/发布没做)
|
||||
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/ggx/app/app.css
|
||||
grep 'Permission denied' /usr/local/var/log/nginx/ggx.error.log | tail -3
|
||||
find output -type f ! -perm -o=r # 列出 nobody 读不到的文件
|
||||
```
|
||||
|
||||
根因通常是 `shutil.copy2` **连权限一起复制**:`web/app.css` 若被编辑器以
|
||||
umask 077 存成 `600`,发布后 `output/app/app.css` 也是 `600` → nginx 403。
|
||||
`hdiv site build` 现在会在发布末尾统一把站点树收敛为「目录 755 / 文件 644」,
|
||||
所以正确做法是改完前端后重跑一次发布,而不是手工 chmod:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m hdiv site build # 同步 web/ → output/ 并修正权限
|
||||
```
|
||||
|
||||
## 11.3 股票池为空或很少
|
||||
|
||||
**按顺序排查**:
|
||||
@@ -2074,7 +2425,7 @@ market.min_market_capp
|
||||
| 4 | 未实现部分成交 | 按信号全额成交,受资金与权重上限约束 |
|
||||
| 5 | 大股东质押、重大诉讼过滤**无数据源** | 配置项存在但恒不生效 |
|
||||
| 6 | AI Agent 层(P8)未实现 | 属 `plan.md` 第四版扩展 |
|
||||
| 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`**,可直接从库里查 |
|
||||
| 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` 的 `hold`/`cash_out`** 与 **`dividend.reinvest_rule=same_stock_next_open`**(实际行为一律是「分红现金回落到可投资现金池,下次调仓按目标权重再配置」,即 `reinvest` + `portfolio_rebalance`)、**`dividend.handle_rights_issue`**(配股不入账)、**`execution.signal_to_execution`**(固定次日开盘成交)。<br>**这些都会逐条写入 `hd_backtest_run.unimplemented_json`**,可直接从库里查 |
|
||||
| 8 | `stock_daily` 2015–2019 的存量行仍是 Tushare 原始单位 | 读取层已兜底换算(结果正确),审计 `UNIT-OHLCV` 报 WARN;重刷数据可消除 |
|
||||
| 9 | ~~行情/每日指标只到 2015-01-05~~ → **已修复(2026-10-04 回补到 2005-01-04)** | 曾使「过去 5 年画像」在 2019 年前只有 0.2~4 年数据(覆盖率 20%~67%);回补后 5 年窗口覆盖率为 **98.7%~100%**,残差经逐日核实为真实停牌。另:**修好数据后全期收益从 +117.36% 降到 +92.73%**,因为 2015 年(牛市顶 + 股灾)从「被数据缺口挡住」变成被真实交易。见 §6.6 与 implementation-status §9.3c |
|
||||
| 10 | `config/profile.yml: sufficiency` 三个阈值**尚未被任何代码使用** | `min_history_years_dividend` / `min_history_years_price` / `min_dividend_records` 目前是死配置。真正的充分性判定由 `profile_gate.min_window_coverage` + `on_unverifiable` 承担 |
|
||||
|
||||
Reference in New Issue
Block a user