- 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 图表选型
1059 lines
73 KiB
Markdown
1059 lines
73 KiB
Markdown
# 高股息 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 读取。
|