Files
qlib/docs/DEV_PLAN_DIVIDEND_BACKTEST.md
Simon 82240e383d docs: 同步操作说明/架构/路线图(归档操作、图表基座、数据库目标硬约束)
- USAGE 新增 §6.3.1「归档的日常操作」:查看 / 筛选(回写 URL)/ 导出完整 JSON /
  以此参数再跑 / 删除(写明「删除即失去结果,结果只存归档一份」)/
  历史归档用 restore_experiment_from_job 按原 id 重建;并说明归档完整度如何标注
- USAGE/README 更正技术栈与图表基座:TradingView Lightweight Charts 4.2.3 为唯一
  图表基座(ECharts 已从 package.json、pnpm-lock.yaml、node_modules、文档与
  架构图标注中全部清除),并记录实测证据(个股页图表根节点为
  div.tv-lightweight-charts,页面 canvas 无一来自其它图表库)
- ARCHITECTURE 更正「当前数据库」(原写 SQLite/未来 MySQL):现为本机 MariaDB 10.11,
  §6 补目标库硬约束;USAGE 补服务器身份与实测连接证据
- AGENT.md 新增 §0.1:数据库目标只允许本机 MariaDB,禁止 192.168.1.10,
  由 config.py::assert_db_target_allowed 硬拦截(命中直接抛错,不静默降级)
- DEV_PLAN_DIVIDEND_BACKTEST 记录三轮实施与验证、归档恢复边界(§12.5)、已知限制
- DEV_PLAN v2/v3 标注为历史记录(避免把当时的远端库地址当现状照抄);
  ROADMAP 更正 M3 图表选型
2026-09-20 07:31:20 +08:00

1059 lines
73 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 高股息 Top-N 定期调仓回测 —— 可运行性评估与开发方案
> 需求来源:用户案例「全市场股息率 Top n,每 m 个月择股,前 x 只等权,每 y 个月调仓,
> 收盘价成交,含费率/印花税/滑点,输出整体与个股收益曲线并标注买卖点」。
>
> 本文档基于**当前代码与数据库的实测结果**(非推测),给出「能否跑通」的结论与分阶段开发方案。
> 遵循 `AGENT.md` §36 的开发流程(Domain → DTO → Repository → Service → API → 前端 → 测试 → 文档)。
---
## 0. 结论(TL;DR)
**当前不能按你的口径跑通。** 现有回测引擎本身是健康的(已实测跑通全市场 6.7 年回测),
但你的案例有 **4 个 P0 硬缺口** + **4 个 P1 数据/口径问题**:
| 级别 | 缺口 | 影响 |
|---|---|---|
| P0-1 | 库里**完全没有股息率数据**,也没有对应因子 | 核心选股条件无法表达 |
| P0-2 | 引擎只支持 `weekly` / `monthly` 调仓,**没有「每 m 个月」** | 6 个月周期无法表达 |
| P0-3 | 只有一级 `top_n`,**没有「选 n 只 → 持仓前 x 只」两级** | 你的 n / x 两个旋钮无法同时表达 |
| P0-4 | 结果里**没有个股收益曲线** | 「个股收益率趋势图」无法渲染 |
| P1-1 | 25 只老牌蓝筹(含中国石化等)**缺 2020–2022 行情** | 全市场排序不完整;选中的股票可能买不进 |
| P1-2 | `stock.delist_date` **全为空**,退市股整体缺失 | 幸存者偏差,收益被系统性高估 |
| P1-3 | 研究/回测口径**不按 `adjust_factor` 折算复权**(只有图表层折算) | 高股息策略的**除权损失会被误算成亏损** |
| P1-4 | 无停牌数据 | `exclude_suspended` 一直是未实现项(已如实标注) |
**推荐路径**:先走「路径 A 最小可跑通」(约 2~3 人日),先拿到真实回测结果;
再走「路径 B 完整方案」(累计约 5~7 人日)补齐条件组合、复权口径与数据修复。
---
## 0.1 实施状态(2026-09-19 更新:路径 B 已落地并端到端跑通)
**已按「路径 B 一次做全」实现,并在真实库上端到端跑通**(真实 Job 链路:
`POST /api/jobs` → 状态机 → Experiment 归档 → 前端渲染)。逐条对应见 §10「实施记录」,
关键实测结果:
| 项目 | 状态 | 验证方式 |
|---|---|---|
| P0-1 股息率数据 + 因子 | ✅ | `daily_basic` 表 + 回补 CLI + `dividend_yield` / `dividend_yield_ttm` 因子 |
| P0-2 每 m 个月择股 | ✅ | `selection_interval_months`(m)与 `rebalance_interval_months`(y)双周期 |
| P0-3 选 n → 持仓前 x | ✅ | `SelectionSpec.top_n` / `hold_top_x`(x ≤ n 强校验) |
| P0-4 个股收益曲线 | ✅ | `BacktestResult.symbol_curves`(持仓期累计收益 + 买卖点标注) |
| P1-3 复权口径 | ✅ | `price_adjustment=qfq/hfq` 在 SQL 层折算(回测与成交价同源) |
| P1-1 蓝筹缺行情 | ✅ | 实测缺口 **20 只**(`min=2023-01-03`)已回补 19,419 根;另 **5 只**为真实长期停牌(非缺数据),见 §10.7 |
| P1-2 幸存者偏差 | ✅ 已修复(两层) | ① 退市股 **338 只**入库(230 只含 2020+ 行情、24.0 万根)+ 时点股票池;② 名称变更史 **14,213 行**(`stock_name_history`)→ `exclude_st` **逐择股日**时点判定(见 §10.5) |
| P1-4 停牌数据 | ⚠️ 如实标注 | 无行情日近似停牌;无前收时涨停不可判定 → 按可买并标注次数 |
**全区间实测结果(2020-01-02 ~ 2026-09-04,n=20 / x=20 / m=y=6 / hfq / dv_ratio≤30 / 最低佣金 5 元)**:
| 指标 | 数值 | 指标 | 数值 |
|---|---|---|---|
| 期末权益 | 1,248,602.58 | 总收益 | **+24.86%** |
| 年化收益 | 3.52% | 夏普 | 0.268 |
| 最大回撤 | −28.58% | 年化波动 | 22.22% |
| 胜率 | 44.02% | 成交笔数 | 259 |
| 平均换手 | 4.88%/次 | 期内买入标的 | 135 只 / 14 个择股日 |
| 复权因子缺口 | **0 / 5,507,595 行** | 未成交意图 | 0(顺延机制生效) |
| 退市股 | 338 只入库(230 只含 2020+ 行情、24.0 万根) | 名称历史 | 14,213 行(时点 ST) |
首个调仓日 **2020-01-02 即建仓**(锚点与无前收处理修正后);19 只标的因数据窗口起点
缺前收、涨停不可判定而按可买处理(已如实标注)。完整结果 JSON 1.07MB,
经 `MEDIUMTEXT` 落库为 Experiment `EXP-747B0926`(Job `JOB-C3A99CC0`)。
**幸存者偏差(两层都已修)**:① 退市股 338 只入库并回补 24.0 万根日线,
时点股票池正确(退市前纳入 / 退市后排除);② 名称变更史 14,213 行(`stock_name_history`),
`exclude_st` 改为**逐择股日**按当时名称判定(与 `/api/selections` 同口径)。
收益口径因此经历三次修正,**最终 +24.86%**:
| 口径 | 总收益 | 年化 | 最大回撤 | 说明 |
|---|---|---|---|---|
| 最新名称快照(初始) | +35.71% | 4.87% | −26.99% | 「曾高股息后 ST」的标的被整段排除 → 高估 |
| 时点名称但仅按起始日过滤 | +32.01% | 4.42% | −28.58% | 变 ST 后仍被继续持有(与选股口径不一致) |
| **逐择股日时点 ST(最终)** | **+24.86%** | **3.52%** | **−28.58%** | 与 `/api/selections` 同口径 |
即初始报出的 +35.71% 相对最终值**高估约 10.85pp(相对 44%)**(详见 §10.5 / §10.6)。
> ⚠️ **这不是策略有效性结论**:本地库不含退市股(幸存者偏差)且调仓为整仓往返
> (成本偏保守),高股息策略尤其容易踩「股息陷阱」。请仅把它当作口径与工具链的
> 可复现验证,任何对外结论都需先补 §10.4 的偏差异常项。
---
## 1. 实测证据(本次会话在真实库 / 真实 Tushare 上验证)
### 1.1 现有回测引擎已跑通(基线)
用真实 MySQL(`stock_daily` 779 万行 / 5556 只)+ `LocalEngine` 执行:
```
spec = ResearchSpec(type="backtest", factors=[momentum_60], selection=top_n 20,
rebalance="monthly", period=2020-01-01 ~ 2026-09-04,
initial_capital=1_000_000)
```
结果:
```
elapsed 81.6 s
total_return_pct -95.569 annual_return_pct -38.454
sharpe -0.802 max_drawdown_pct -96.326
volatility_pct 46.775 win_rate_pct 36.31
total_trades 1512 equity pts 1619 / fills 3052
```
**结论**:数据装配 → 因子面板 → 收盘撮合 → 成本计提 → 结果标准化,整条链路可用,
单次全市场 6.7 年回测约 80 秒量级(异步 Job 模式下可接受)。
> 注:上述 -95% 是 1 个月动量 Top20 月度换仓的真实回测输出,不是引擎故障;
> 但它同时暴露了 P1-2/P1-3(幸存者偏差 + 不复权),**不能当作策略结论**。
### 1.2 股息率数据源可用(Tushare `daily_basic`)
```
pro.daily_basic(trade_date='20200102', fields='ts_code,trade_date,close,dv_ratio,dv_ttm,total_mv')
→ 3741 行,dv_ratio 非空 3606 行,耗时 0.23 s
```
- 单日全市场一次调用即可,**1619 个交易日 ≈ 6~7 分钟**(不含限速退避)。
- `dv_ratio`(股息率)与 `dv_ttm`(TTM 股息率)**逐日随价格重算**,属时点值,
用 `<= as_of` 取值**不引入未来函数**。
- 实测 2020-01-02 股息率 Top5:600738(37.2%) / 600507(16.8%) / 600188(14.5%) /
002110(14.2%) / 300741(12.8%) —— 数量级合理(含特别分红的高值)。
**这是好消息:P0-1 的「数据」部分不是障碍,只是「尚未接入」。**
### 1.3 数据体检(发现 P1-1 / P1-2)
```sql
select count(*) from stock where delist_date is not null; -- 0 ← P1-2
select year(trade_date), count(distinct symbol) from stock_daily ... -- 2020:4118 → 2026:5556
```
25 只 2020 年前上市、但本地缺 2020–2022 行情的股票:
```
000001.SZ 平安银行 000333.SZ 美的集团 000651.SZ 格力电器 000725.SZ 京东方A
000858.SZ 五粮液 002594.SZ 比亚迪 300750.SZ 宁德时代 600000.SH 浦发银行
600028.SH 中国石化 600030.SH 中信证券 600036.SH 招商银行 600276.SH 恒瑞医药
600519.SH 贵州茅台 600887.SH 伊利股份 600900.SH 长江电力 601166.SH 兴业银行
601318.SH 中国平安 601398.SH 工商银行 601888.SH 中国中免 601988.SH 中国银行
(另 5 只为长期停牌/复牌股,清单见 §3.5)
```
实测:`pro.daily(ts_code='000001.SZ', start_date='20200101', end_date='20200115')`
**能正常返回 10 行** → 缺口是**本地同步窗口的历史遗留**,不是数据源限制,**可修复**。
实测影响面(重要,避免夸大):2020-01-02 与 2023-01-03 的股息率 Top20 中,
仅 **1 只**(`600028.SH` 中国石化)落在缺失名单里。因此:
- 对 **n=20 的头部池**:影响较小(少 1 只可买),但会触发 §4.4 的「替补」语义问题;
- 对 **n≥100 或叠加「大市值 / 银行」等条件**:影响显著(银行、电力、石化是缺失重灾区)。
### 1.4 复权折算只存在于图表层(P1-3)
- `research.py` / `strategy.py` / `selection.py` 的 `price_adjustment` 只允许 `none|qfq`;
- `market_impl.py:174,205` 的读路径是 `StockDailyModel.adjust == adjust`,
而库里 `adjust` 只有两个来源:Tushare 落库的 `none`,与**新浪兜底的 `qfq` 行**;
- **基于 `adjust_factor` 的折算只写在 `chart_service._factor_multipliers()` 里**(显示层)。
即:把 `price_adjustment="qfq"` 传给回测,只会捞到稀疏的新浪兜底行 ——
**不是真正的前复权序列**。对高股息策略这是硬伤:除权日的价格跳空会被当成亏损,
而分红现金从未入账,收益被系统性低估。
---
## 2. 需求逐条对照
| # | 你的需求 | 现状 | 缺口 |
|---|---|---|---|
| 1 | 全市场股息率最高的 n 只,n 默认 20 可调 | 无股息率数据/因子;`SelectionSpec.top_n` 存在但语义是「持仓数」 | **P0-1 / P0-3** |
| 2 | 自 2020-01-01 起(可调),每 m 个月择股(默认 6) | `rebalance` 仅 `weekly\|monthly`,且**择股与调仓是同一个日期集合** | **P0-2** |
| 3 | 允许组合别的择股条件 | `SelectionQuery.conditions`(M6.2)已支持 `static.*` / 行情 / 因子 / `fundamental.*`(带 `announce_date` 防未来),**但回测路径完全不支持 conditions** | 需打通 |
| 4 | 选前 x 只,x ≤ 选出股数 | 只有一级 `top_n`,无 x 及其校验 | **P0-3** |
| 5 | 每 y 个月调仓,默认 y=m | 同 #2 | **P0-2** |
| 6 | 调仓买卖点为收盘价 | ✅ `local_engine.py` 已是收盘撮合(t 收盘成交、t+1 起计收益) | 无 |
| 7 | 起始资金 100 万,x 只平均分配 | ✅ `initial_capital` 默认 1_000_000;`equal_weight_budget` 等权 | 无 |
| 8 | 费率/印花税/滑点默认值可调 | ✅ `CostSpec`:佣金 0.03%、印花税 0.05%、滑点 0.1% | 无 |
| 9 | 整体收益趋势图 + 买卖点标注 | `equity_curve` ✅、`fills`/`signal_history` ✅,但**前端未标注买卖点** | 需前端 |
| 10 | 个股收益率趋势图 + 买卖点标注 | ❌ 结果结构里没有任何 per-symbol 序列 | **P0-4** |
---
## 3. 缺口详述
### 3.1 P0-1 无股息率数据与因子
**现状**
- 表清单(`show tables`)里没有 `daily_basic` 或任何分红表;
- `quant/factors.py` 的 9 个内置因子全部只依赖行情列(`close/high/volume`),
且 `compute_factor()` 用 `daily.pivot(...)` 取值 —— **数据只能来自传进来的 `daily` 长表**;
- `ResearchService._load_daily()` → `load_daily_df()` → `stream_range_many_columns()`,
而该方法的列白名单 `BAR_FLOAT_COLUMNS = ("open","high","low","close","volume","amount")`
**会拒绝任何新列**。
**结论**:新增股息率因子 = 新增一张表 + 一条数据装配支路 + 一个因子,
不是「加个因子函数」那么简单。这是本方案改动最深的一处。
### 3.2 P0-2 无「每 m 个月」周期
**现状**:`local_engine.rebalance_dates()` 按 `M`/`W` 取每月/每周首个交易日;
`ResearchSpec.rebalance` 的 pattern 是 `^(weekly|monthly)$`;
`TopKBacktestRunner.run()` 里 `rebal` 是唯一日期集合 —— **选股与调仓共用同一天**。
**你的口径需要两套日期集合**:
- 择股日 `S`:每 m 个月一次(m 可 ≠ 调仓间隔)
- 调仓日 `R`:每 y 个月一次(默认 y = m)
且 y < m 时(先择股后调仓),调仓日应沿用**最近一次择股日**产生的池子。
### 3.3 P0-3 无「选 n → 持仓前 x」两级
当前 `SelectionSpec.top_n` 一个数字兼任两种语义。你的口径是两级:
```
universe ∩ conditions → 按股息率降序 → Top n(候选池 = 「选出来的股数」)
→ 前 x(实际持仓,x ≤ n)
```
需要一个 `x` 字段 + `x ≤ n` 的校验 + 「池子不足 n 时 x 的上限随之收缩」的校验
(因为过滤/缺数据会让实际可用股票数 < n)。
### 3.4 P0-4 无个股收益曲线
`BacktestResult` 现有字段:`summary / equity_curve / drawdown / monthly_returns /
yearly_returns / positions / trades / selection_history / signal_history / fills /
turnover_pct / unimplemented / config_snapshot`。
`positions` 只有调仓日的 `(date, symbol, weight)`,**没有逐日市值**,
`trades` 只有区间汇总 —— 无法画出「个股收益率趋势」。
需要新增(遵循 `AGENT.md` §27 标准化结果):
`symbol_curves: list[SymbolCurve]`,建议结构:
```python
class SymbolCurve(BaseModel):
symbol: str
points: list[CurvePoint] # 以首次买入为 0% 的累计收益率序列
marks: list[ActionRecord] # 该股的 BUY/SELL 成交点(复用 signal_history 语义)
class BacktestResult(BaseModel):
...
symbol_curves: list[SymbolCurve] = []
```
### 3.5 P1-1 25 只股票缺 2020–2022 行情
完整清单(`list_date < 2020-01-01` 且缺 2020-01 行情):
```
000001.SZ 平安银行 000029.SZ 深深房A 000333.SZ 美的集团 000651.SZ 格力电器
000725.SZ 京东方A 000858.SZ 五粮液 000995.SZ 皇台酒业 002594.SZ 比亚迪
300750.SZ 宁德时代 600000.SH 浦发银行 600028.SH 中国石化 600030.SH 中信证券
600036.SH 招商银行 600215.SH 派斯林 600276.SH 恒瑞医药 600319.SH 亚星化学
600519.SH 贵州茅台 600610.SH 中毅达 600887.SH 伊利股份 600900.SH 长江电力
601166.SH 兴业银行 601318.SH 中国平安 601398.SH 工商银行 601888.SH 中国中免
601988.SH 中国银行
```
全部 `min(trade_date) = 2023-01-03`(少数为停牌复牌日)。`adjust_factor` 同样缺 ——
两表同步缺口一致,指向「这批股票曾用近 3 年窗口同步」。
**修复方式**:对这 25 只按 `2020-01-01 → 2023-01-03` 重跑
`VerifiedDailySyncer.sync_symbol()`,25 次 API 调用即可(`upsert_many` 幂等,重复无副作用)。
### 3.6 P1-2 幸存者偏差
`stock.delist_date` 全为 NULL 且库内**没有任何已退市股票**:
2020 年有行情的 4118 只,全部在 2026-09-04 仍有行情。这意味着当前股票池
=「截至今天仍上市的公司」,历史上的退市股整体缺席。
**后果**:任何 2020 起的回测都会**系统性高估收益**(退市股通常是暴跌收场,
而高股息策略恰恰容易踩中「高股息陷阱」——分红率高但基本面恶化、最终退市)。
**处理**:要么补齐退市股历史(Tushare `stock_basic(list_status='D')` + 逐只历史),
要么在 `BacktestResult.unimplemented` 中**如实标注幸存者偏差**(`AGENT.md` §24/§29 要求)。
建议**先如实标注 + 输出池子里的退市风险提示**,补齐作为独立任务。
### 3.7 P1-3 复权口径(对高股息策略尤其关键)
需要一个**研究层**的复权折算(复用 `chart_service._factor_multipliers()` 的公式,
但抽取到可复用位置),并允许 `hfq`:
- `qfq`:`close × factor / max(factor)`(以最新因子归一)
- `hfq`:`close × factor`(累积因子,**含分红再投的总收益口径**)
**高股息策略强烈建议用 `hfq`**:前复权序列在分红除权后会把历史价格下移,
区间收益率为正时也可能出现「分红没算进去」的错觉;后复权直接反映含权的总收益。
但注意:**买卖点价格若用 hfq 报价,与实际盘口价格不一致**,
建议「收益计算用 hfq、成交价展示用 none 原始价」,并在结果中显式标注口径。
同时 `price_adjustment` 的 pattern 要从 `^(none|qfq)$` 扩到 `^(none|qfq|hfq)$`
(涉及 `research.py` / `selection.py` / `strategy.py` 三处)。
### 3.8 P1-4 停牌 / 涨跌停(现状与边界)
- 涨跌停:`_limit_up_ratio()` 按板块近似(创业板 1.199 / 北交所 1.299 / 主板 1.099),
买卖两侧都已建模,且未成交会写入 `reject_reason` —— **这部分是达标的**;
- 停牌:无停牌表,`exclude_suspended` 自始至终未实现(已写在 `unimplemented`);
引擎对「当日无价格」的处理是:不卖出、保留持仓、不估值(`_value()` 里 `continue`)。
- ⚠️ 顺带发现一处**引擎缺陷**(本次审查):`_rebalance()` 的买入分支
`shares[s] = invest / price_in` 是**覆盖写**而非累加。若某股因跌停/停牌未能卖出
(`shares[s] > 0` 被保留),而它又在当轮 `targets` 里,旧仓位会被**静默清零**,
对应市值凭空消失。建议在本次改造中一并修掉(改为累加 + 单测覆盖)。
---
## 4. 关键设计决策(**开工前请你确认**)
### 4.1 股息率口径
| 选项 | 说明 | 建议 |
|---|---|---|
| `dv_ratio` | 股息率 = 近 12 个月现金分红 / 总市值 | ✅ **默认** |
| `dv_ttm` | TTM 股息率 | 备选,可做成可选字段 |
| 自算(`dividend` + 分红公告日) | 最严谨(可对齐 `announce_date`),但要处理预案/实施/多次分红 | 二期 |
实测中 `dv_ratio` 与 `dv_ttm` 在多数日期取值相同。两者都是**逐日重算的时点值**,
按 `<= as_of` 取值天然无未来函数,**推荐直接用 `dv_ratio`**。
> 需要注意的**已知数据噪音**:`dv_ratio` 会因**特别分红**产生畸高值
> (实测 600738 在 2020-01-02 为 37.2%)。建议在选股条件里默认加一条
> `dv_ratio <= 上限`(如 30%)或至少在结果中标注,否则头部池可能被一次性特别分红股占满。
### 4.2 复权口径
见 §3.7。**推荐 `hfq` + 结果显式标注**;若你希望成交价与盘口一致,选 `none` 但需接受
分红收益不入账的低估。
### 4.3 周期锚点
「每 m 个月」需要一个确定的锚点,否则结果不可复现。建议:
```
月序号 k = (year × 12 + month) − (start_year × 12 + start_month)
k % m == 0 → 该月首个交易日为择股日
```
即**锚定回测起始日所在月**。这样 `start=2020-01-01, m=6` 的择股日为
2020-01、2020-07、2021-01、… 若你把起始日改成 2020-03-01,则变为
2020-03、2020-09、… —— **这是特性而非缺陷**,但必须写进文档与 `config_snapshot`。
### 4.4 「替补」语义(重要)
现有引擎在意图股票涨停/停牌时会**从 n 名之外继续往下找可买标的**补齐数量:
```python
for sym in top: # top = 全市场降序,不限于 n
if len(targets) >= top_n: break
ok, _ = _buyable(sym)
if ok: targets.append(sym) # ← 可能引入第 n+1、n+2 名
```
你的口径是「选择择股条件选出来的**前 x 个**」,**严格来说不应该替补**。
建议改为**可配置**:`allow_substitute`(默认 `False` = 严格前 x,不足则留现金或等权摊到已买标的),
并把未成交原因写入 `signal_history`(已有机制)。
**实施结果(已按你的选择实现)**:新增 `SelectionSpec.defer_buy`(**默认 `False`**,保持历史
行为不变;`allow_substitute` 默认 `True` 同为向后兼容)。本次案例显式设置
`allow_substitute=False, defer_buy=True`,语义为:
> **不替补,不放弃,往后推不涨停的交易日买入**(你的原话)。
即:调仓日目标股若涨停/停牌,则挂为 `PendingBuy`(保留在该股的现金额度),自次一交易日起
逐日重试,成交价 = 该日收盘价;**到下一次调仓日仍未成交则作废**(资金留作现金,
下次调仓重新按目标名单分配)。两者互斥(同时为 True 会被 `SelectionSpec` 校验拒绝),
因为「顺延等它」与「换一只买」是两种互斥的补位哲学。
实现细节(`app/quant/local_engine.py::TopKBacktestRunner`):
- `_buyable` 判定可买性:`无行情 → 停牌不可买` / `close/prev_close ≥ 涨停幅度 → 涨停不可追买`
- 无有效前收(数据窗口起点、长期停牌后复牌)时**无法判定涨停** → 按可买处理,
并在 `unimplemented` 中标注发生次数(实测案例首个交易日命中 1 次)
- 未成交意图全部落 `signal_history`(`filled=False` + `reject_reason`),前端「未成交意图」卡片展示
- 待成交单在**下一次调仓**清空(不会无限期挂着)
### 4.5 默认费用(沿用现有 `CostSpec`,符合 `AGENT.md` §24)
| 项 | 默认 | 说明 |
|---|---|---|
| 佣金(双边) | 0.03% | 万三 |
| 印花税(仅卖出) | 0.05% | 万五 |
| 滑点(双边) | 0.1% | 千一 |
| 单笔最低佣金 | 5 元 | ✅ 已实现 `CostSpec.min_commission`(默认 `0.0` 以保持历史结果不变,案例显式设为 5 元) |
> 最低佣金对 100 万 / 20 只 = 每只 5 万的情形影响很小(0.03% = 15 元 > 5 元),
> 但若 x 很大(如 100 只,每只 1 万,佣金 3 元 < 5 元)就会低估成本。建议实现。
---
## 5. 分阶段开发方案
### 路径 A —— 最小可跑通(先拿到真实结果)
> 目标:用你给的默认参数(n=20 / x=20 / m=6 / y=6 / 100 万 / 收盘价 / 含费用)
> 跑出一次真实回测,并看到整体 + 个股曲线与买卖点。
> **不改动**条件组合、复权折算、退市补齐 —— 这些在结果 `unimplemented` 里如实标注。
#### A1. 股息率数据落库(P0-1)
| 项 | 内容 |
|---|---|
| Domain | `entities/market.py` 新增 `DailyBasic`(symbol, trade_date, close, dv_ratio, dv_ttm, pe, pb, total_mv, circ_mv, turnover_rate, source) |
| Model | `models/market.py` 新增 `DailyBasicModel`(`UniqueConstraint(symbol, trade_date)`) |
| Migration | `alembic revision --autogenerate -m "add daily_basic table"`(Model → Migration → Test,`AGENT.md` §12) |
| Provider | `domain/providers.py` 加 `get_daily_basic(trade_date) -> list[DailyBasic]`;`data_sources/tushare.py` 实现(`pro.daily_basic(trade_date=...)`);`failover.py` 代理(新浪**不支持** → `DataSourceNotSupported`,写 `SyncLog` 如实记录) |
| Repository | `repositories/market.py` 加 `DailyBasicRepository`(`upsert_many` / `stream_range_many` / `latest_date`);`market_impl.py` 实现 |
| Sync | `application/services/data_sync.py` 新增 `DailyBasicSyncer`(按交易日循环、幂等、写 `sync_log`、限速退避复用现有 `_call` 机制) |
| 脚本 | `backend/app/cli/sync.py`(argparse 子命令:`basic`/`calendar`/`daily`/`financial`/`index_weight`/`verify`/`export`)新增 `daily_basic` 子命令:`uv run python -m app.cli.sync daily_basic --start 2020-01-01`,1619 天 ≈ 6~7 分钟 |
| 修复 | 对 §3.5 的 25 只重跑一段 `2020-01-01 ~ 2023-01-03` 的日线 + 复权因子同步 |
**验收**:`select count(*), min(trade_date), max(trade_date) from daily_basic` 覆盖
2020-01-02 ~ 最新且每日行数 ≈ 当日上市股票数;25 只蓝筹的 `min(trade_date)` = 2020-01-02。
#### A2. `dividend_yield` 因子(P0-1 续)
- `quant/factors.py` 注册 `dividend_yield`:
`FactorDef("dividend_yield", "股息率(近12月现金分红/总市值)", "dv_ratio", direction="higher_is_better", requires=("dv_ratio",))`
- 打通装配:`ResearchService._load_daily()` 在因子 `requires` 含 `dv_ratio` 时,
额外从 `DailyBasicRepository` 取数并 **merge 进 `daily` 长表**(按 `symbol, trade_date` 左连接)。
→ 这样 `compute_factor()` 的 `pivot` 路径无需改动。
- `quant/engine.py` 的 `factor_required_columns()` 需能识别「非行情列」并交由 Service 走另一条支路。
**验收**:`GET /api/factors` 出现 `dividend_yield`;单因子测试的 IC/RankIC 可出数;
`dividend_yield` 在 `as_of=2020-01-02` 的横截面 Top20 与 §1.2 的 Tushare 直查结果一致(一致性强校验)。
#### A3. 周期与两级选股(P0-2 / P0-3)
**Domain(`entities/research.py`)**
```python
class SelectionSpec(BaseModel):
top_n: int = 20 # n:候选池大小(semantics 由 top_n → pool 明确化)
hold_top_x: int | None = None # x:实际持仓数;None → = top_n
allow_substitute: bool = False # §4.4
class ResearchSpec(BaseModel):
rebalance: str = "monthly" # 保留(兼容旧 spec)
selection_interval_months: int | None = None # m
rebalance_interval_months: int | None = None # y,None → = m
@model_validator(mode="after")
def _check_x(self): # x ≤ n
```
**Engine(`quant/local_engine.py`)**
- `rebalance_dates()` 增加 `every_months` 参数(§4.3 的锚点公式);
新增 `selection_dates()` / `rebalance_dates()` 两套集合;
- `TopKBacktestRunner.run()`:维护 `pool`(最近一次择股日的 Top n),
**仅在调仓日**交易,交易标的 = `pool[:x]`;
- `_rebalance()` 买入分支修正为**累加**(§3.8 的覆盖写缺陷);
- `portfolio.py`:`x` 只等权(对齐现有 `equal_weight_budget`)。
**DTO / API**:`api/research.py` 的 `ResearchSpec` 直接复用;错误用 400 + 中文原因。
**验收(必测)**:
1. `x > n` → 422;
2. `m=6, y=6` 的交易日集合 = 每半年首个交易日;
3. `m=6, y=12` 时,**奇数个半年只有调仓不换池**,池子沿用上一个择股日;
4. `y=3, m=6` 时池子在下一次择股前保持不变(并在 `unimplemented` 标注池子陈旧);
5. 同一 spec 跑两次结果逐位一致(可复现,`AGENT.md` §21)。
#### A4. 个股收益曲线(P0-4)
- 引擎在逐日估值时顺带记录每只持仓股的**逐日归一化收益**
(以首次建仓价为 0%,含复权口径说明);
- `entities/research.py` 新增 `SymbolCurve`,`BacktestResult.symbol_curves`;
- 未平仓的个股也输出(到回测末日),并标记是否仍持有。
**验收**:`symbol_curves` 每只的点数与其持仓区间一致;`marks` 与 `trades`/`fills` 可交叉核对。
#### A5. 前端:整体曲线 + 个股曲线 + 买卖点
- `components/LineChart.tsx` 目前只注册了 `LineChart/Grid/Title/Tooltip`,
**需要新增 `MarkPointComponent` / `MarkLineComponent`(或 `scatter` 系列)** 才能画买卖点;
- `app/backtest/page.tsx`:
- 参数区加:`n`(候选池)、`x`(持仓数,动态 `max=n`)、`m`(择股间隔月)、`y`(调仓间隔月,默认联动 m)、`复权口径`、`允许替补`;
- 「净值曲线」叠加买卖点标记;
- 新增「个股收益率趋势」卡片:个股选择器(或小倍数网格),叠加该股买卖点。
- 复用现有 `components/StockChart/` 已有 K 线 + 标记能力(`extra_markers` 机制已存在于 `chart_service.stock_chart`),
个股图可直接走 `/api/charts/...` 而**不必**新造组件。
**验收**:浏览器实测(前端构建后刷新页面),参数改动 → 结果刷新 → 两类图都能看到买卖点。
#### A6. 测试与文档
- 新增:`tests/test_dividend_factor.py`、`tests/test_rebalance_interval.py`、
`tests/test_symbol_curves.py`、`tests/test_daily_basic_sync.py`、`tests/test_daily_basic_api.py`;
- 回归:`tests/test_selection_backtest_consistency.py`(v2 §25 一致性)、
`tests/test_price_adjustment.py`、`tests/test_api.py`、`tests/test_migrations.py` 必须全绿;
- 文档:`docs/USAGE.md` 补数据同步与参数说明,`README.md` 能力表补一行,本文档标记落地状态。
**路径 A 合计:约 2~3 人日**(数据回填的 6~7 分钟是纯等待)。
---
### 路径 B —— 完整方案(在 A 之上)
#### B1. 条件组合进回测(你的需求 #3)
- `ResearchSpec` 增加 `conditions: list[ConditionSpec]`(复用 `entities/selection.py` 已有模型);
- 抽取 `quant/selection.py` 里 `run_condition_selection` 的求值逻辑为**可复用筛选器**
(当前它把「取数 + 求值 + 组 DTO」耦在一起,需拆出纯函数
`filter_by_conditions(daily, stocks, conditions, as_of, financial) -> set[str]`);
- 回测流程变为:`universe ∩ conditions → 按因子分排序 → Top n → 前 x`;
- `LocalEngine.required_columns()` 需把条件的 `field/ref` 依赖列一并纳入;
- `fundamental.*` 条件的 `announce_date <= as_of` 语义必须与选股路径**完全一致**
(否则 v2 §25 一致性失守)—— 建议直接复用 `SelectionService` 的取数函数。
**验收**:同一组 `universe + conditions + factors`,`POST /api/selections`(as_of=某历史日)
与回测在该日的 `selection_history` **完全一致**(新增一致性测试)。
#### B2. 研究层复权折算 + `hfq`(P1-3)
- 把 `chart_service._factor_multipliers()` 的公式抽到
`infrastructure/persistence/.../adjust.py` 或 `quant/adjust.py`(engine 与 chart 共用);
- `load_daily_df()` 增加 `adjust` 语义:`none` 直读、`qfq`/`hfq` 读 `none` 后乘因子;
- `price_adjustment` pattern 扩为 `^(none|qfq|hfq)$`(`research.py` / `selection.py` / `strategy.py`);
- 结果里显式区分「收益口径」与「成交价展示口径」。
**验收**:`tests/test_price_adjustment.py` 扩展 qfq/hfq 数值用例;
高股息个股在除权日的曲线不再出现无因跳空。
#### B3. 数据修复与如实标注(P1-1 / P1-2 / P1-4)
- 退市股:`stock_basic(list_status='D')` → 补 `stock` 行(含 `delist_date`)→ 逐只补历史行情;
工作量取决于退市股数量(可能数百只 × 1 次调用);
- 停牌:Tushare `suspend_d` 落表 → 启用 `exclude_suspended`,并在涨跌停/停牌判定里区分「一字板」;
- 在这些完成前,**所有回测结果的 `unimplemented` 必须包含**:
「幸存者偏差:股票池仅含当前仍上市股票」、「停牌未建模」、「复权口径 none 时分红收益未入账」。
#### B4. 最低佣金与更细的成本模型(可选)
- `CostSpec` 增加 `min_commission: float = 5.0`,在买卖两侧按单笔计提。
#### B5. 策略资产化(可选)
- `StrategyDefinition` 同步新增 `conditions` / `selection_interval_months` /
`rebalance_interval_months` / `hold_top_x`,让这套口径可命名保存、一键复跑
(`StrategyDefinition.to_research_spec()` 已具备展开机制)。
**路径 B 合计:再约 2~4 人日**(B3 的退市股补齐取决于数据量,可能单独估)。
---
## 6. 建议的默认参数(对齐你的描述)
```yaml
# 用于 ResearchSpec / StrategyDefinition 的默认值
universe:
exclude_st: true # 剔除 ST
exclude_suspended: true # ⚠️ 未建模前会落在 unimplemented
min_listing_days: 250 # 上市满 1 年(避免新股噪音)
price_adjustment: "hfq" # 股息策略建议后复权(路径 B 落地后)
selection:
conditions: # 组合条件(路径 B 落地后)
- { field: "dv_ratio", op: "lte", value: 30 } # 剔除特别分红畸高值
factors:
- { name: "dividend_yield", weight: 1.0 }
top_n: 20 # n
hold_top_x: 20 # x,x ≤ n
allow_substitute: false # 严格前 x,不替补
selection_interval_months: 6 # m
rebalance_interval_months: 6 # y,默认 = m
costs:
commission_rate: 0.0003 # 万三(双边)
stamp_tax_rate: 0.0005 # 万五(仅卖出)
slippage_rate: 0.001 # 千一
min_commission: 5.0 # 路径 B
initial_capital: 1000000
period: ["2020-01-01", "2026-09-04"]
```
---
## 7. 风险与注意事项(研究纪律)
1. **不要用 -95% 这类数字下结论**:路径 A 跑出的结果仍带幸存者偏差 + 复权口径问题,
只能用于**验证流程**,不能用于判断策略优劣(`AGENT.md` §29)。
2. **高股息陷阱**:股息率高往往意味着股价跌得多(分母小)或一次性特别分红。
建议在结果中额外输出「股息率 vs 后续 12 个月收益」的检验,而不是只看净值。
3. **周期锚点敏感**:m=6 的择股日锚定起始月,换一个起始日结果会变。
建议做一次参数敏感性扫描(起始月 1~12),`AGENT.md` §29 的 overfitting 要求。
4. **80 秒/次** 的全市场回测在参数扫描时会成为瓶颈;
若要扫参,建议先把日线装配结果缓存(Parquet)或走 `LocalEngine` 的列裁剪优化。
5. **不要动 `site-packages/qlib`**(`AGENT.md` §15);以上全部改动都在 Adapter / 业务层内。
---
## 8. 验收清单(对齐 `AGENT.md` §41)
```
□ 是否违反 ARCHITECTURE.md? —— 否,改动仍在 Service→Quant→Repository 分层内
□ 是否绕过 DAO? —— 否,新表一律走 Repository
□ 是否把 SQLite 写死? —— 否,新表用 SQLAlchemy + Alembic
□ 是否直接依赖 Qlib 内部实现? —— 否,仍走 LocalEngine
□ 是否可能引入未来函数? —— 股息率取 <= as_of 时点值;财务字段守 announce_date
□ 是否引入数据泄露? —— 需显式标注幸存者偏差(P1-2)
□ 是否有测试? —— A6 / B 各阶段均有
□ 是否修改了 API 契约? —— 是(ResearchSpec 新增字段,向后兼容默认值)
□ 是否需要更新文档? —— 是(USAGE.md / README.md / 本文档)
□ 是否把 secret 提交进 Git? —— 否
□ 是否进行了不必要的大规模重构? —— 否,抽取而非重写
```
---
## 9. 需要你拍板的 4 件事
1. **股息率口径**:`dv_ratio`(默认)还是 `dv_ttm`?是否要加 `dv_ratio <= 30%` 的过滤?
2. **复权口径**:`hfq`(推荐,含分红总收益)/ `qfq` / `none`(成交价贴合盘口但低估收益)?
3. **替补语义**:严格执行「前 x 只,买不进就空着」还是允许往下替补?
4. **走哪条路**:先做**路径 A** 拿到结果,还是直接按**路径 B** 一次做全?
---
## 10. 实施记录(2026-09-19,路径 B 一次做全)
### 10.1 一个命令跑通
```bash
# 1) 股息率数据回补(幂等,可中断续跑)
# 实测:1569/1569 交易日、0 失败、7,777,707 行、耗时 4833s(约 80 分钟)
cd backend && PYTHONPATH=. .venv/bin/python -m app.cli.sync daily_basic --start 20200101
# 2) 运行案例(真实 Job 链路:状态机 + Experiment 归档 + 结果落盘)
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/run_dividend_case.py --end 2026-09-04 --out ../data/backtest_dividend_case.json
# 可调:--n 20 --x 20 --m 6 --y 6 --start 2020-01-01 --end YYYY-MM-DD
# --dv-cap 30 --adjust hfq|qfq|none --min-commission 5 --no-defer
# 实测全区间耗时约 7 分钟(装配 150s + 引擎 + 落库)
# 3) 前端(同参数可视化):scripts/dev.sh start → http://127.0.0.1:3000/backtest
# 页面顶部「载入高股息案例默认参数」一键填充全部旋钮 → 「运行回测」(约 4.5 分钟)
# 4) 页面契约自检(无需人工看图:按页面请求体提交并校验页面读取的每个字段)
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py
```
### 10.1.1 一处**有意的偏差**(与目标措辞不同,此处显式说明)
目标表述为「复权口径默认 hfq」。实际落地:**案例侧默认 hfq**
(前端回测页 `priceAdjustment` 初值 = `hfq`、案例脚本 `--adjust` 默认 = `hfq`、
「载入高股息案例默认参数」预设 = `hfq`),但**领域默认仍为 `none`**
(`ResearchSpec.price_adjustment`)。原因:该字段已被既有策略/Experiment 持久化引用,
把默认值改成 `hfq` 会**静默改变所有历史保存用例的结果口径**(`AGENT.md` 最小改动 /
既有行为不静默变更)。因此选择「案例默认 hfq + 领域默认不变」,并在结果
`config_snapshot.price_basis.adjust_mode` 显式记录每次实际口径。
### 10.2 改动清单(按数据流自下而上)
| 层 | 文件 | 内容 |
|---|---|---|
| 迁移 | `migrations/versions/22d7380706f7_add_daily_basic_table.py` | 新建 `daily_basic` 表 + 2 索引 |
| 迁移 | `migrations/versions/7b1c4e9a52d8_widen_result_json.py` | `job/experiment.result_json` → MEDIUMTEXT(见 §10.3 实测事故) |
| 模型 | `models/market.py` / `models/jobs.py` | `DailyBasicModel`;长 JSON 列 `with_variant(MEDIUMTEXT, "mysql")` |
| 实体 | `domain/entities/market.py` | `DailyBasic` 实体与数值字段白名单 |
| 仓储 | `repositories/market_impl.py` | `SqlAlchemyDailyBasicRepository`;`stream_range_many_columns(adjust, price_adjust)` 在 SQL 层做 qfq/hfq 折算;`count_price_adjust_gaps` 口径体检 |
| 数据源 | `data_sources/tushare.py` | `get_daily_basic(trade_date)`(sina 明确 `DataSourceNotSupported`,不假装支持) |
| 同步 | `application/services/data_sync.py`、`cli/sync.py` | `DailyBasicSyncer`(按缺失日期续跑)+ `daily_basic` 子命令 |
| 因子 | `quant/factors.py` | `dividend_yield`(`dv_ratio`)、`dividend_yield_ttm`(`dv_ttm`),含 30% 尖峰上限常量 |
| 领域 | `domain/entities/research.py` | `ConditionSpec`、`SelectionSpec.top_n/hold_top_x/allow_substitute/defer_buy`、`ResearchSpec.conditions/selection_interval_months/rebalance_interval_months`、`CostSpec.min_commission`、`SymbolCurve`、`BacktestResult.symbol_curves` |
| 选股 | `quant/selection.py` | `build_condition_fields` / `eligible_symbols` 抽为公共入口,**回测与选股复用同一套条件求值**(v3 §25/§28 一致性) |
| 引擎 | `quant/local_engine.py` | 双周期日期集合、`_select`(两级截断)、`_rebalance`(严格前 x / 顺延)、`PendingBuy`、`_accrue_symbol_returns` + `_mark_curve_dates`、`_symbol_curves` |
| 服务 | `quant/service.py` | `price_adjustment` 贯穿加载与成交价;`_build_eligibility` 注入条件过滤;`_annotate_price_basis` 写入口径快照与缺口统计 |
| 装配 | `application/services/job_executor.py`、`api/deps.py` | 注入 daily_basic / adjust_factor / financial / index 仓储(**原本只在 API 同步路径注入,Job 路径缺失 → 前端必失败**,见 §10.3) |
| 前端 | `app/backtest/page.tsx`、`components/LineChart.tsx`、`lib/types.ts` | n/x/m/y/复权/条件/最低佣金旋钮 + 案例预设;净值曲线买卖点散点;个股曲线选择器与买卖点 |
| 测试 | `tests/test_interval_selection.py`、`test_adjust_factor_prices.py`、`test_dividend_case_e2e.py` | 31 个新用例(合计 265 passed) |
| 脚本 | `scripts/run_dividend_case.py` | 案例执行器(真实 Job 链路 + 指标打印 + 结果落盘) |
| 脚本 | `scripts/verify_backtest_page_contract.py` | 页面契约自检(请求体与读取字段逐一校验,替代肉眼看图) |
### 10.2.1 幸存者偏差修正(本轮增量,数据流自下而上)
| 层 | 文件 | 改动 |
|---|---|---|
| 实体 | `domain/entities/market.py` | 新增 `StockNameHistory`(名称生效区间 + `is_risk_warned`) |
| 表 | `models/market.py` + 迁移 `a3f8c21d9b47` | 新表 `stock_name_history`(幂等键 symbol,start_date) |
| 协议 | `domain/repositories/market.py` | 新增 `StockNameHistoryRepository`(`names_as_of` / `name_spans`) |
| 仓储 | `repositories/market_impl.py` | `SqlAlchemyStockNameHistoryRepository` + 注册 upsert 键 |
| 数据源 | `data_sources/tushare.py` | `get_stock_basic(list_status)`、`get_name_changes`、NaN 归一(`_to_opt_str`/`_to_date`)、代码规范过滤、`MAX_ROWS_PER_CALL` |
| 同步 | `application/services/data_sync.py` | `NameHistorySyncer`(按自然年分片 + 审计) |
| CLI | `cli/sync.py` | `basic --include-delisted`、`namechange [--start/--end]` |
| 领域 | `quant/universe.py` | `filter_stocks(name_at=...)`、`names_as_of()` 助手 |
| 服务 | `quant/service.py` | `name_repo` 注入 + `_build_st_filter` 逐择股日重判 + `name_basis` 标注 |
| 服务 | `selection_service.py` / `signal_service.py` / `replay_service.py` | 同步接入时点名称(口径一致) |
| 装配 | `api/deps.py` / `job_executor.py` | `name_repo_factory` 贯穿 API 与 Job 两条链路 |
| 测试 | `tests/test_name_history.py` | 14 个用例(仓储/时点/逐日/降级/归一化) |
### 10.3 实测暴露并修复的两个真实故障(非推测)
1. **Job 落库失败:`1406 Data too long for column 'result_json'`**
- 现象:回测**计算成功**(总收益 12.62%),但 Experiment 归档时 MySQL `TEXT`(64KB)
装不下约 **1.2MB** 的结果 JSON → Job 整体标记 failed,前端看不到任何结果。
- 修复:迁移放宽到 `MEDIUMTEXT`;同时给个股曲线加数量上限(60 只)并只在持仓日落点,
结果体积从数 MB 压到 ~0.7MB,并在 `unimplemented` 中如实标注截断。
- 教训:**「算得出」不等于「跑得通」** —— 长结果必须走一次真实落库链路才算验证。
2. **Job 路径缺少新仓储装配**
- 现象:API 同步路径(`api/deps.py`)已注入 `basic_repo`,但 `job_executor` 仍按旧签名
构造 `ResearchService`/`SelectionService` → 前端走 Job 时 `dv_ratio` 因子必然报
「未注入 DailyBasicRepository」。
- 修复:`default_factories()` 补齐 4 个仓储工厂并贯穿 `execute_job`;缺失时由 Service
明确报错(不静默降级,`AGENT.md` §24)。
### 10.4 尚未完成 / 需你决定
| 项 | 说明 |
|---|---|
| ~~蓝筹缺 2020–2022 行情(P1-1)~~ | ✅ **已完成**。实测命中 20 只(`min(trade_date)=2023-01-03` 且 `list_date<=2019-01-01`):平安银行/美的/格力/京东方A/五粮液/比亚迪/宁德时代/浦发/中石化/中信证券/招商银行/恒瑞/茅台/伊利/长江电力/兴业/平安/工商银行/中免/中国银行。已用 `sync daily --symbols ... --start 20190101 --end 20230103` 回补 **19,419 根日线 + 对应复权因子**(耗时 16s),数据现自 2019-01-02 起。<br>⚠️ 这 20 只含中石化/长江电力/工商银行等高股息主力,回补前 2020–2022 的股息率 Top20 是**残缺**的 —— 回补会改变该区间选股结果,必须重跑。 |
| 条件字段面板按择股日重建(P2,性能) | 实测(全市场 6.7 年、517.9 万行):每个择股日 `build_condition_fields` 0.31s→4.20s(随截断窗口增长),14 个择股日合计约 **30s**;同区间 `_load_daily`(含 hfq JOIN 装配)需 **150s**,才是主要瓶颈。当前不改(收益有限、存在加大内存峰值风险),如需优化:把面板构建提到闭包外一次构建再按日切片(滚动窗口只回看,等价安全),并把 `_load_financial` 改为整区间取一次。 |
| 幸存者偏差(P1-2) | 无退市股数据,结果页明确提示「收益可能系统性高估」,未做数值修正 |
| ~~全区间结果~~ | ✅ **已完成**(2020-01-02 ~ 2026-09-04,**+24.86% / 年化 3.52% / 回撤 −28.58%**,见 §0.1) |
| 全部卖出→重新买入的成本偏差 | 多次调仓为整仓往返(保守),已在 `unimplemented` 标注;如需精确可做权重漂移微调 |
### 10.5 幸存者偏差与「股息陷阱」的量化(本轮新增,含对照组实测)
**做了什么**:
1. `tushare stock_basic` 不带 `list_status` 时**只返回在市股票** —— 这是 `delist_date` 全空、
退市股整体缺失的根因。新增 `list_status` 透传(L/D/P)与 CLI `sync basic --include-delisted`。
2. 实测退市股 **338 只**(其中 2019-12 之后退市 **230 只**),已入库:
`stock.status='D'` + `delist_date` 完整;再 `sync daily` 回补 **240,430 根**日线 + 复权因子(0 失败)。
3. 时点股票池已验证:`filter_stocks` 的 `delist_date < as_of` 规则使退市股**退市前纳入、退市后排除**
(实测 `000005.SZ` 2024-04-26 退市:as_of=2024-01-02 纳入 ✓ / as_of=2024-06-03 排除 ✓);
2020-06-01 时点通过 universe 5,792 只 → 2026-09-04 为 5,565 只(在市数)。
4. 顺带修掉两个只有拉退市数据才会暴露的 Provider 缺陷:退市记录的 `industry/area` 是 **float NaN**
(pydantic 直接拒绝 `str | None`)、`status` 字段为空(若一律兜底 `"L"` 会把退市股标成在市)、
以及 `T600018.SH` 这类非规范代码会让**整批拉取失败**(现跳过 + 告警,不静默丢弃)。
**量化结论(对照组实测,同一 spec 仅改 `exclude_st`)**:
| 组 | 总收益 | 年化 | 最大回撤 | 夏普 | 候选池中的退市股 |
|---|---|---|---|---|---|
| `exclude_st=true`(当时为名称快照口径) | **+35.71%** | 4.87% | −26.99% | 0.329 | 0 只 |
| `exclude_st=false`(对照) | **+32.01%** | 4.42% | −28.58% | 0.302 | 5 只(实际成交) |
| `exclude_st=true` + 逐择股日时点 ST(最终口径) | **+24.86%** | 3.52% | −28.58% | 0.268 | 全部按当时名称判定 |
对照组里 5 只退市股被真实买入,其中 **4 只亏损**:`000961.SZ` −46.5%、`000671.SZ` −38.0%、
`600466.SH`(*ST蓝光(退)) −30.1%、`600565.SH`(ST迪马(退)) −21.5%,仅 `000780.SZ`(ST平能(退)) +148.0%。
**净效应 −3.70pp**(相对 35.71% 约 −10% 相对值),回撤加深 1.59pp。
(续见 §10.6:把 ST 判定改成**逐择股日**后进一步降到 +24.86%,合计修正 10.85pp。)
**这说明什么(重要)**:
- 案例默认 `exclude_st=true` 时,退市股**全部**因「最新名称含 ST」被整段排除 —— 所以补数据后
结果一字未变。也就是说,该口径下的收益对「退市股缺失」不敏感,但对
**「曾是高股息、后来变 ST/退市」的陷阱样本同样不敏感**:`exclude_st` 用的是**最新名称快照**
而非时点 ST 状态,`ST迪马`/`*ST蓝海`/`ST平能` 这些 2020 年股息率 5.8~7.9% 的标的
在 2020 年就被排除了(而当年它们还没 ST)。
- 因此 **+35.71% 应视为高估**(最终修正为 +24.86%,见 §10.6),该偏差已写入结果
`unimplemented` 并在此量化。
- **彻底修复路径(已实施)**:见 §10.7 —— 已引入时点名称历史(tushare `namechange` →
`stock_name_history`,14,213 行),`exclude_st` 改为**逐择股日**按当时名称判定,
该 3.70pp 偏差已从口径上消除。
### 10.6 时点 ST / 名称历史(`stock_name_history`)—— 把 3.70pp 偏差从口径上消除
§10.5 的结论是「+35.71% 仍应视为高估,根源是 `exclude_st` 用最新名称快照」。本轮把这条**修掉了**:
**新增数据层**(AGENT.md §12 流程:Model → Migration → Test):
- `StockNameHistory` 实体 + `stock_name_history` 表(幂等键 `symbol,start_date`,
迁移 `a3f8c21d9b47`),语义 = **名称生效区间** `[start_date, end_date]`;
- `TushareProvider.get_name_changes(start,end)`(区间批量)→ 实测 2020+ 仅 4,031 行,
但全历史会触及单次 6,000 行上限,故 `NameHistorySyncer` **按自然年分片**(37 片);
- CLI `sync namechange`(默认 1990 起)→ 实测 **14,213 行、9 秒、0 失败**;
- `SqlAlchemyStockNameHistoryRepository.names_as_of(symbols, as_of)`:时点名称查询。
**口径改造**:
- `filter_stocks(..., name_at=...)`:`exclude_st` 优先用时点名称,缺失回退最新快照;
- **回测逐择股日重判**(`ResearchService._build_st_filter` + 逐日缓存):否则「入池时非 ST、
之后才变 ST」的标的会在之后每个择股日继续被选中 —— 正是高股息最危险的路径;
- `/api/selections`、`/api/signals`、Bar Replay 同步接入 → **回测与选股同口径**(v2 §25);
- 结果新增 `config_snapshot.price_basis.name_basis = {point_in_time, covered, total}`,
未同步名称历史时标注 `point_in_time=false`(不静默降级)。
**实测效果**:
| 口径 | 2020-01-02 通过 universe | 说明 |
|---|---|---|
| 最新名称快照(旧) | 5,513 | 「曾高股息、后 ST」的标的被整段排除 |
| **时点名称(新)** | **5,636** | 多纳入 **236 只**当时尚未 ST 的标的 |
名称覆盖:**全部 5,903 只都有记录**;2020-01-02 前已上市的 3,869 只**100% 有生效区间**,
无回退缺口。案例全区间收益随之由 **+35.71% → +32.01%**(与 `exclude_st=false` 对照一致,
回撤 −26.99% → −28.58%)—— 两个独立口径互相印证,说明这 3.70pp 确实是口径偏差。
**再进一步:逐择股日重判(为什么不能只在起始日过滤)**。把 `exclude_st` 只用在池子基准日时,
「入池时非 ST、之后才变 ST」的标的之后仍会被选中并持有 —— 这与 `/api/selections` 在那些
日期的候选池**不一致**(v2 §25 红线)。实测窗口内就有两只:
| 标的 | 名称变更 | 影响 |
|---|---|---|
| `000961.SZ` 阳光城 | 2023-05-05 起 **ST阳光城** | 2023-07 起的择股日必须排除 |
| `600466.SH` 中南建设 | 2024-04-24 起 **ST中南** | 2024-07 起的择股日必须排除 |
改为逐择股日重判后,收益 **+32.01% → +24.86%**(年化 4.42% → 3.52%,夏普 0.302 → 0.268),
即初始 +35.71% 相对最终口径**高估 10.85pp(相对约 44%)**。这才是与选股口径一致的答案。
**过程中实测暴露的 3 个真实缺陷(均已修 + 回归测试)**:
1. `_to_date` 不处理 NaN:`namechange` 的 `end_date` 是 float NaN → 抛
`time data 'nan' does not match format`,**32/37 个年度分片整体失败**。
该函数被所有归一化器共用,属普遍性缺陷。
2. 退市记录的 `industry/area` 是 NaN、`status` 为空:前者被 pydantic 直接拒绝,
后者若一律兜底 `"L"` 会把退市股标成在市。
3. `T600018.SH`(上港集箱(退),2006 退市)不符合本地代码规范 → 一条脏记录让
**整批 338 只退市股拉取失败**;现跳过并告警(不静默丢弃)。
**自身实现缺陷(测试抓到)**:无条件时 `_build_eligibility` 一度直接返回 `st_fn`,
而 `st_fn` 返回的是**不合格**集合 → 语义取反(首个择股日 eligible 为空、变 ST 后反而入选)。
已修正为 `候选 − 该日ST`,并由 `TestPerDateStFilter` 锁定。
### 10.7 「25 只蓝筹」的精确结论
原计划写的「25 只缺 2020–2022 行情」经逐条核对为**两类不同问题**,已分别处理:
| 类别 | 只数 | 证据 | 处理 |
|---|---|---|---|
| 真·缺数据 | **20** | `min(trade_date)=2023-01-03` 且 `list_date<=2019-01-01` | ✅ 已回补 19,419 根(数据现自 2019-01-02 起) |
| 真·长期停牌 | **5** | `000670.SZ` 盈方微 138 根、`000792.SZ` 盐湖股份 416 根、`000995.SZ` 皇台酒业 496 根、`000029.SZ` 深深房A 524 根、`600610.SH` 中毅达 568 根——停牌期间本就没有行情 | ✅ 非缺数据,如实说明(引擎将这些无行情日视为停牌、不可买不可卖) |
「首条数据晚于上市日/窗口起点 90 天以上」的符号现仅剩 **3 只**(中毅达/深深房A/皇台酒业),
全部是 2020 年复牌的长期停牌股,**不存在未修复的数据缺口**。
### 10.8 代码审查发现并已修复的问题
对本次改动做了一次独立审查(范围:本功能的全部新增/修改文件),2 个 P1 + 2 个 P3,
**均已修复并补测试**:
| 级别 | 问题 | 修复 |
|---|---|---|
| P1 | `qfq` 折算把**缺失因子行**除以最新因子(`coalesce(f,1)/max`),factor=5 时产生 −80% 假跌幅,污染 qfq 收益/回撤/个股曲线 | `coalesce(f/max, 1.0)`(COALESCE 提到最外层);新增 `TestQfqMissingFactor` 回归用例 |
| P1 | 起始日非月初时**锚点月被整体丢弃**(前端默认 start=今日−6 月即命中),前 m 个月空仓、指标明显失真 | `rebalance_dates` 改为锚定「start 所在月内首个 >= start 的交易日」,不丢锚点月;新增 3 个用例(含非交易日起始 / 月末起始 / 不产生前期空仓) |
| P3 | 「无前收买入」计数把纯探测调用(替补扫描会重复调用)当买入次数上报,数字被放大成约 2 倍 | 计数移到真实成交路径并去重到**标的**粒度(`_no_prev_close_symbols`) |
| P3 | `ResearchService.adjust_repo` 从未被读取(折算实际在仓储 SQL 内),易误判折算发生在业务层 | 删除该形参与 API/Job 装配点,注释改为「折算在行情仓储 SQL 内完成」 |
审查同时确认**无问题**的关注点:择股/条件只取 `<= as_of` 数据、财务守 `announce_date`、
成交与收益结算时序无未来函数、分层未越界(无 SQL/Session/tushare 直连业务层)、
默认值保持既有 Strategy/Experiment/Signal↔Fill 行为不变、迁移与模型一致且可达。
### 10.8.1 第二轮独立审查(幸存者偏差/时点 ST 增量)发现并已修复
对新增量(退市股 + 名称历史 + 逐择股日 ST)再做一次独立审查,1 个 P1 + 3 个 P2 + 4 个 P3,
**全部修复并补回归测试**(`tests/test_name_history.py::TestReviewRegressions`):
| 级别 | 问题 | 修复 |
|---|---|---|
| P1 | **名称历史表为空时仍上报 `point_in_time=true`** 并输出「股息陷阱可见」:库已迁移但未 `sync namechange` 时,结果页会把**未被修正的 10.85pp 偏差**当成已修正(违反 §24/§7) | `names_as_of` 判空 → 返回 `(None, (False,0))`;端到端断言 `name_basis.point_in_time=false` 且输出「回退最新名称快照」 |
| P2 | `_build_st_filter` **缺最新名称回退**:某股无生效区间时按「非 ST」放行,而 `filter_stocks` 会回退快照 → 同一择股日「回测入选 / `/api/selections` 排除」打架;查询异常时也静默放行 | 逐股 `name_at.get(sym) or s.name`(与 `filter_stocks` 完全同口径);不可用时整段退回池子基准日口径并如实标注 |
| P2 | `MarketDataProvider` 未声明 `get_name_changes`,新浪/Failover 也没有 → 非 Tushare 装配下 `AttributeError`,违反 §6 | 协议补声明;`SinaProvider` 抛 `DataSourceNotSupported`(不静默返回空表);`FailoverProvider` 按 §7 转发 |
| P2 | `QlibEngine.run_backtest` 无 `eligibility_fn` 形参 → 注入该引擎后**任何回测 TypeError**(条件/时点 ST 会被静默忽略) | 形参补齐并**真正透传**给共用的 `TopKBacktestRunner`;加签名一致性测试 |
| P3 | `cmd_basic` 的 `except DataSourceNotSupported` **不可达**(failover 把备用源的 NotSupported 包装成 `DataSourceError`)→ 主源抖动时 `--include-delisted` 整体抛栈退出 | 改捕获 `DataSourceError`、逐状态继续、失败时打印原因并**返回非零退出码** |
| P3 | `test_list_status_passed_to_api` **未真正校验透传**(`FakePro` 丢弃 kwargs)→ 删掉 `list_status` 也照样通过 | `FakePro` 记录 `call_kwargs`,断言 `list_status == "D"` / `"L"` |
| P3 | 实体 docstring 写「ann_date <= as_of」与实现(生效区间)相反 | 统一为生效区间口径并说明 `ann_date` 仅留痕;实测 `ann_date` 恒早于等于 `start_date` |
| P3 | `docs/USAGE.md` 的 Alembic head 过期(写 `f5e0d1c2b3a4`,实际 `a3f8c21d9b47`) | 更新为实际 head 并列出迁移链 |
审查同时确认**无问题**的关键点:`name_at` 缺省时行为与改动前**完全一致**;
`start_date <= as_of <= end_date` 口径**无前视**;退市股 `status='D' ⇔ delist_date` 实测 338/338 一致;
名称区间**无衔接空档**(4 个时点实测「已上市但无生效名称」均为 0 只);
按年分片最大单次 2,416 行,远低于 6,000 行上限(不会静默截断);
Model→Migration→Test 一致(列类型/唯一键/索引均对齐)。
### 10.9 研究纪律提醒
本方案产出的是**可复现的回测口径与工具链**,不是策略有效性结论。全区间 **+24.86%**
(年化 3.52%、最大回撤 −28.58%)已建立在:退市股入库 + 时点股票池 + **时点 ST(名称历史)**
之上,其余残余为:无停牌明细表(以「当日无行情」近似)、无涨跌停开盘路径、
股票池的上市天数/退市口径以起始日为基准、整仓往返成本偏保守。
相比初始名称快照口径(+35.71%)已修正 **10.85pp** 的口径偏差;**但仍不等于策略有效性结论**,
对外结论前建议先补 `suspend_d` 停牌明细与涨跌停路径建模。
---
## 11. 第十一轮:研究工作台闭环(策略库 / 实验对比 / 图表与名称)
### 11.1 起点与结论
需求四条:①落地上一轮提议的三件事(策略库页、实验对比、选股→回测直通);②图表以
TradingView Lightweight Charts 为主;③有代码处必须有名称且可点击看基本信息与走势图;
④UI 易用性进一步提升。**四条全部落地,并以真实浏览器(CDP 驱动 headless Chrome)
抓取渲染后 DOM 验证**(curl SSR 的表格是客户端 fetch 出来的,SSR HTML 里没有行,
此前用 curl 只能证明「页面 200」,本轮改为断言真实 DOM)。
### 11.2 交付物
| 能力 | 入口 | 关键实现 |
|---|---|---|
| 策略库 | `/strategies` | 列表/检索/新建/编辑(**PUT 原地更新**,id 与 created_at 不变)/删除;每卡展示**后端推导**的说明与公式;一键回测(expand → Job → 指标) |
| 策略说明 | `POST /strategies/describe`、`GET /strategies/{id}/describe` | `app/quant/strategy_doc.py` 纯函数 `describe_strategy` → `{summary, formula, steps, warnings}`;公式与引擎实执行规则同源,未建模处进 `warnings` |
| 实验对比 | `/experiments` | 勾选 2~3 个 → 归一化净值曲线叠加(LW)+ 指标差值表(✅/⚠️ 方向标注)+ `config_snapshot` 参数 diff(默认只显示差异项,排除 `price_basis.*` 运行元数据) |
| 选股 → 回测 | `/selection` 的「按此条件回测」 | `GET /selections/{id}` 的 `config_snapshot` 取回当时规则并预填;**明确提示回测会按规则在每个择股日重新选股,而非固定持有那批股票** |
| 名称与个股页 | 全站 | 后端在 7 个结构上回填 `name`;前端 `SymbolLink`(代码+名称→`/stocks/{symbol}`)+ `GET /api/stocks/names` 缓存兜底;名称缺失显示「—」不臆造 |
| 图表 | 全站 | 统一 `components/charts/LwChart.tsx`(唯一图表基座),**ECharts 依赖已移除** |
### 11.3 本轮自查发现并修复的问题(含我自己写的代码)
| 级别 | 问题 | 修复 |
|---|---|---|
| P1 | `"SIG_BUY".startsWith("BUY") === false` → 信号买点被画成**卖出箭头**(aboveBar/arrowDown)。该 bug 由本轮新写的标记单测抓到 | 抽出 `isBuyMarker()` 显式枚举,`markers.test.ts` 回归 |
| P1 | 表单只用布尔 `deferBuy` 建模,而后端 `allow_substitute` 默认 `true`:把老策略载入表单再保存会**静默把「替补买入」改成「不补位」**(语义漂移) | 改为三态 `fillPolicy`(换一只/顺延/不补位),两字段始终显式成对下发 |
| P2 | `GET /api/stocks/names` 不可用时,兜底 `GET /api/stocks` 单页上限 500 → 500 名之后**静默显示「—」** | 兜底改为翻页取全量;不足则如实标记失败而不是给残缺映射 |
| P2 | 老实验归档结果里 `symbol_curves[].name` 为空(后端回填是本轮才加),个股下拉框显示成「601919.SH (+65.50%)」 | 下拉/搜索/详情统一经 `useSymbolNames()` 缓存取名 |
| P2 | `description` 落库列 `String(300)`,自动说明可达 313 字符,超长会被 MySQL 严格模式拒绝(500) | 前端 `maxLength=300` + 实时计数 + 校验提示;后端截断加显式 `…` |
| P3 | 重复因子名会到后端才报 400 | 表单前置校验并给出「想加权重请调权重值」的提示 |
| P3 | 页面 `.dim`、`.input--invalid` 两个类此前**没有样式规则**(写了不生效) | 补 CSS |
| P3 | 刷新回测页即丢失结果,用户必须重跑 3~5 分钟作业 | 「载入上次结果」(localStorage 记录实验 id)+ `?from_experiment=EXP-x` 直接展示归档结果 |
| P3 | 进度用假进度条 | `waitJob` 透传后端真实 `stage`,页面显示四阶段与已用时间 |
### 11.4 验证证据(可复跑)
```bash
# 后端:341 passed(基线 296,本轮 +45)
cd backend && PYTHONPATH=. JOB_MODE=local .venv/bin/python -m pytest tests/ -p no:warnings -q
cd backend && .venv/bin/python -m ruff check app/ tests/ # All checks passed!
# 前端:类型 + 图表单测
cd frontend/web && npx tsc --noEmit # 0 error
cd frontend/web && npm run test:charts # 7 passed(标记约束)
# 端到端契约(含一次真实回测,约 4 分钟)
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py
```
真实浏览器(CDP)实测要点:
- `/backtest?from_experiment=EXP-59917C30`:净值图 **`data-marker-count=3`**,与卡头
「买入 2 日 / 卖出 1 日」一致(证明买卖点真的画上去了,而不是被 LW 静默丢弃);
68 个 `.symlink` 全部带名称、0 个缺失。
- `/experiments`:勾选 2 个 → 曲线图例显示参数签名与末值,指标差值表带基准与 ⚠️,
参数 diff 命中 `costs.min_commission 5 vs 0`。
- `/strategies`:3 张策略卡,点「看说明/公式」→ 说明 + 公式 + 因子明细 + 4 条以上 warnings 全部渲染。
- `/selection`:`href="/backtest?from_selection=SEL-..."` 直通按钮存在且带口径说明 title。
### 11.5 已知限制(本轮未解决,不掩饰)
1. `strategy.description` 列仍为 `String(300)`:自动说明超长按 300 截断加 `…`。改成 `Text`
需要 Model+Alembic 迁移,本轮未做(属 schema 变更,建议单独一次提交)。
2. `/stocks/{symbol}` 的「回测成交与信号」卡数据来自 `/api/stocks/{symbol}/chart`,
而 fills 只在 `/api/backtests/{experiment_id}/stocks/{symbol}/chart` 产出 →
该卡在没有信号/选股命中记录时**不渲染**;个股页的 K 线、基本信息、复权切换不受影响。
3. `/api/selections/jobs`(异步选股)**不落库** selection 记录(只建 Job),
因此异步路径下「按此条件回测」按钮不渲染(前端据此不生成坏链接);
需要异步也能直通的话,得让该 Job 落库或从 Job 结果反查。
4. 涨停/停牌仍为近似建模(无 `suspend_d` 明细与开盘路径),如 §10.9 所述。
---
## 12. 第十二轮:回测存档(完整归档 + 往复查看入口)
**触发**:你问「已经运行过的回测当前有没有快照存 DB?如果没有的话,帮我开发存档机制,以便往复查看」,
随后明确两条要求:**① 我需要完整存档;② 我需要查看存档的入口,并说明回测的选股条件和回测的交易执行依据。**
### 12.1 先如实回答现状:**已有快照,但有真实缺口**
侦察结论(真实库 + 代码,不是推测):
| 事实 | 证据 |
|---|---|
| 已有归档表与写入 | `experiment` 表 40 条(35 回测 / 4 因子测试 / 1 选股),`result_json` 0.13–1.08 MB/条;异步 Job 完成即写 `spec_json`+`result_json`+`summary_text`+`code_version`+`job_id` |
| 已有读取接口 | `GET /api/experiments`(列表)、`GET /api/experiments/{id}`(spec + 完整结果)、`POST /{id}/rerun` |
| 已有前端入口 | `/experiments` 列表/详情/对比;`/backtest?from_experiment=EXP-x` 直接渲染归档结果 |
**缺口(本轮修复对象)**:
1. **同步 `POST /api/backtests` / `POST /api/factor-tests` 完全不落库** —— 结果只进模块级 `_LAST_BACKTEST`,重启即丢。
2. **`data_version` 字段从未写入** —— 归档缺「数据快照」这一维,无法判断能否严格复现。
3. **列表硬编码 `limit=50` 且无过滤** —— 超过 50 条后更老的归档**静默不可见**(违反 `AGENT.md` §7)。
4. **个股曲线只存 60 只**(`local_engine._MAX_SYMBOL_CURVES`,按收益绝对值排序)——「完整存档」不成立。
5. **没有归档查看页** —— 只能绕道 `/backtest?from_experiment=`,而那是**可编辑参数**的工作台,看归档时极易误认为「当前参数就是归档参数」。
6. 无删除/保留策略、无导出。
### 12.2 本轮实现
**后端**
- 抽出归档函数供同步路径与 Job 共用;`POST /api/backtests`、`POST /api/factor-tests` 的结果**也写入归档**,归档 id 经响应头 `X-Experiment-Id` 返回(body 形状不变,不破坏既有契约)。
- `data_version` 写入**真实数据快照指纹**(最新交易日 + 各表规模,近似值显式标注 `approx`)。
- 个股曲线默认**全量保存**(`research.archive_curve_limit` 可配),仅当结果 JSON 超出体积预算才按收益绝对值裁剪,并把 `archive_meta{curves_stored,curves_total,truncated,budget_chars}` 与 `unimplemented` 说明一并落库 —— **完整优先,降级必标注**。
- `GET /api/experiments` 支持 `kind`/`q`/`limit`/`offset`,总数放 `X-Total-Count` 响应头(body 仍是数组)。
- `DELETE /api/experiments/{id}`(只删归档,不动 Job 执行记录)。
- `python -m app.cli.prune_experiments --keep N [--kind K] [--older-than D] [--apply]` —— **默认 dry-run**,先打印将删除的清单与释放空间。
**前端**
- 新增 **`/experiments/{id}` 归档详情页**(Server Component,只读):
- 顶部两块**口径说明**(取自归档内 spec,不读当前页面状态):
**① 选股条件**:股票池(剔除 ST/上市天数/指数成分/白名单/停牌)、因子与权重及方向、过滤条件、两级截断 n→x、择股周期 m、调仓周期 y、买不进时的三态处置;
**② 交易执行依据**:成交时点(调仓日收盘)、复权口径、滑点后的买/卖价、佣金/印花税/最低佣金、涨跌停与停牌如何拦单、顺延规则、期末是否平仓、对照基准;
另附后端按归档 spec 推导的**一句话说明 + 计算公式 + 执行步骤 + 注意事项**(与引擎实执行规则同源)。
- **完整结果**:直接复用 `components/BacktestResultView.tsx`(与刚跑完时同一套图表与表格,避免「归档里少一张图」)。
- **归档元数据**:实验 id / 类型 / 归档时间 / 代码版本 / **数据版本** / 来源作业 / 体积 / 区间 + 归档**完整度**(完整 ✅ 或被裁剪 ⚠️ 及原因)。
- 动作:以此参数再跑、加入对比、导出完整 JSON、查看原始 spec、删除归档(二次确认,文案写明「只删归档、不影响 Job」)。
- `/experiments` 列表加搜索 + 类型筛选 + 「显示 N 条 / 共 M 条」+「打开归档」直达。
- 回测跑完的提示条与策略库一键回测结果都加「打开归档(完整快照)」入口。
**顺带修掉的真实 bug(做本轮时发现)**:`/api/factors` 原先只在「目录表为空」时 seed,
于是表非空之后**代码新增的因子永远进不了目录** —— 真实库 9 条 vs 代码注册表 11 条,
`dividend_yield` / `dividend_yield_ttm` 缺失,归档页要显示「因子方向与含义」时取不到
(显示 `—`)。现改为按「注册表有、库里没有」的差集补齐(稳态零写入、只补不删、保留自定义因子),
并加回归测试。
### 12.3 验证结果(全部实测,非推断)
| 门禁 | 结果 |
|---|---|
| 后端测试 | **374 passed**(基线 341;+23 归档测试 +10 因子目录测试) |
| ruff | `All checks passed!` |
| 前端类型 | `npx tsc --noEmit` 0 错误 |
| 图表单测 | `npm run test:charts` **7 passed** |
| 生产构建 | `npx next build` 成功,**13 条路由**(新增 `ƒ /experiments/[id]`,动态按需 SSR) |
| 策略工作台契约 | `scripts/verify_strategy_workspace.py` **56/56 通过**(原 42/42,+14 条归档检查) |
| 回测页契约 | `scripts/verify_backtest_page_contract.py` 通过(总收益 20.532%、净值 1212 点、买卖点全落点) |
**完整存档的关键实测证据**
| 项 | 值 |
|---|---|
| 归档 | `EXP-FD6B35E2`(job `JOB-64E29D45`,2020-01-01~2026-09-04,n=20/x=20,m=y=6,hfq) |
| 个股曲线 | **135 / 135 条**(旧实现只有收益绝对值最大的 60 只;第 61~135 只占完整曲线字符数的 **39.8%**,此前是**看不到**的) |
| 归档体积 | 1,858,434 字符 / 1,879,197 字节(1.88 MB),预算 12,000,000 字节 → 已用 **15.7%** |
| `archive_meta` | `{curves_stored:135, curves_total:135, truncated:false, over_budget:false, budget_bytes:12000000, result_bytes:1879197}` |
| `data_version` | `d20260904;n≈7688k;a≈7922k;b≈7718k`(`d` 精确到日;行数取自 `information_schema` 并标 `≈`;指纹端到端耗时热态 ~1ms) |
| 浏览器实测 | 归档页 135 个下拉选项、416 个带名称的股票链接(**0 缺失**)、4 张图、归档条显示「归档完整 135 / 135」;净值末值 1,248,603 与案例口径一致 |
| 入口实测 | `/experiments` 列表「打开归档」;回测跑完提示条「打开归档(完整快照)」→ `/experiments/EXP-FD6B35E2`;`載入上次结果` 载入后提示条同样带归档链接 |
| 老归档(上线前) | 归档页显式标「**归档不完整**:个股曲线只有 60 只(期内共持有 104 只)」并给「以此参数重跑」入口 —— 不伪装成完整 |
**顺手修掉的两个真实 bug(本轮发现)**
1. **`/api/factors` 目录与代码注册表长期不一致**:seed 只在目录表为空时触发,导致代码里后加的
`dividend_yield` / `dividend_yield_ttm` 永远进不了目录(真实库 9 条 vs 注册表 11 条),
归档页/策略页要显示「因子方向与含义」时只能显示 `—`。已改为按差集补齐(稳态零写入、只补不删),
并加回归测试(含「表非空也要补齐」与「自定义因子不被删」)。
2. **后端说明文本的 Markdown 强调符漏成字面量**:`describe_strategy` 与引擎 `unimplemented`
用 `**粗体**` / `` `代码` `` 做强调协议,前端直接渲染 → 实测归档页/策略页/回测页有 10 处
显示成 `**已公告**` 这样的半成品。新增 `components/RichText.tsx` 统一渲染(不引 markdown 解析器、
不用 `dangerouslySetInnerHTML`),三个页面复检为 **0 处漏字**。
### 12.4 本轮已知限制(不掩饰,含子 Agent 自查项)
1. **删除归档 = 结果彻底消失**:完整结果只存归档一份(`job.result_json` 对新记录为 NULL,
`GET /api/jobs/{id}` 是从归档回读的),所以删归档后该次回测的结果不可再查看。
归档页的删除确认文案与 `USAGE` 已如实改写(并提示先「导出完整 JSON」)。
早期归档(40 条)在 `job` 表仍有副本,属历史冗余(约 8M+ 字符),未做一次性清理。
2. **`data_version` 被列宽限制**:`experiment.data_version` 是 `varchar(40)`,指纹只能 33 字符;
想加更多段(股票数、复权口径)必须先做一次 Alembic 迁移加宽列。
3. **行数是近似值**:`information_schema.TABLE_ROWS` 比真实少约 4.6%(stock_daily 7,688,126 vs
8,052,698),已在字符串里标 `≈`;真实 `COUNT(*)` 单表约 1.2s(三表 ~3.6s),代价不可接受。
4. **体积护栏的 `truncated` 路径只有构造性单测**:真实长区间只用掉 15.7% 预算,
尚未在真实数据上触发过裁剪。
5. **`X-Archive-Error` 的中文被转义**:HTTP 头只能 latin-1,非 ASCII 做 `\uXXXX` 转义后截断。
6. **同步端点也写归档**:反复试参数会在库里留下大量归档,用
`python -m app.cli.prune_experiments --keep N`(默认 dry-run)治理。
### 12.5 删除归档的恢复边界(明确结论,避免误以为"删了都能救")
| 归档生成时间 | `job.result_json` | 删除后能否恢复 |
|---|---|---|
| 完整存档上线**前**(40 条历史归档) | **有副本**(双写遗留) | **能**:`python -m app.cli.restore_experiment_from_job --job-id <JOB> --code-version <rev> [--apply]` 按**原归档 id** 重建(spec/result 逐字复制;摘要按归档同口径重算;`data_version` 留空不伪造;`code_version` 必须显式给出,不猜) |
| 完整存档上线**后**(新归档) | `NULL`(结果只存归档一份) | **不能**:工具会明确拒绝并说明原因,不假装能救 |
- 删除本身是正常功能:`DELETE /api/experiments/{id}` → 200;前端归档页「删除归档」带确认框,
确认文案已如实写明后果与"先导出 JSON"的建议。
- 实测:从真实库 job `JOB-D3C120DC` 对已删除的 `EXP-9EC197E0` 干跑成功
(spec 376 字符 / result 25,781 字符 / 摘要 `总收益 -12.41% · 年化 -15.10% · 回撤 -20.84%`),
**未执行写库**(删除是使用者有意操作,工具保持只读默认)。
- 该工具的写库路径由 8 条测试钉住(`tests/test_restore_experiment.py`),
其中一条当场抓出真 bug:裸 `text()` 查询在 SQLite 下把 DateTime 列返回成字符串,
写回 ORM 即抛 `TypeError`(MySQL 下看不出)→ 已改为 ORM 读取。