功能:每日动态股票池回测(--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` | **每日选股**留痕(逐日入选成员 + 入选时因子快照) |
|
||||
|
||||
|
||||
|
||||
+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