diff --git a/AGENT.md b/AGENT.md index 8b30e3f..acbd337 100644 --- a/AGENT.md +++ b/AGENT.md @@ -40,6 +40,17 @@ wget -e use_proxy=yes -e http_proxy=http://192.168.1.160:3128 - 代理 IP 属于局域网配置,不写入 `.env` / `config.yaml` / 任何提交进 Git 的文件。 - 直接下载成功时不要多此一举走代理。 +### 0.1 数据库目标:只允许本机 MariaDB(硬约束) + +- 数据库一律指向**本机 MariaDB 10.11**(`127.0.0.1:3306/qlib`)。 +- **禁止把 `192.168.1.10` 作为数据库目标**(该地址在本文档中仅作外网代理白名单/SSH 提及, + 不是数据库目标)。远端旧库只作为历史数据来源,已不再写入。 +- 该约束不是「靠自觉」:`app/core/config.py::assert_db_target_allowed` 在 `get_settings()` + 解析出 URL 后立刻校验,命中禁用主机**直接抛错**(应用起不来),不允许「起得来但连错库」。 + 禁用列表默认 `192.168.1.10`,可用环境变量 `QLIB_FORBIDDEN_DB_HOSTS` 覆盖(空串=不限制)。 +- 理由:连错库属于最危险的静默错误 —— 不报错、界面正常,回测/归档/策略却写到了另一台机器上。 +- 新增脚本/文档/示例配置时,DB 主机一律写 `127.0.0.1`,禁止出现远端库地址作为**默认值**。 + --- # 1. 项目定位 diff --git a/README.md b/README.md index d0f269f..0706431 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ 详见 [AGENT.md](./AGENT.md)(开发约束)与 [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md)(架构文档),**改代码前必须阅读**。 ```text -Web 前端 (frontend/web:总览 / 股票池 / 股票筛选 / 因子研究 / 因子组合 / 交易信号 / 选股回测 / 实验) +Web 前端 (frontend/web:总览 / 股票池 / 股票筛选 / 因子研究 / 因子组合 / 交易信号 / 选股回测 / 实验 / 归档详情) ↓ REST / SSE(异步 Job 状态机) FastAPI (backend:业务对象 API,见下「核心能力」) ↓ Research Specification(统一研究契约) @@ -72,7 +72,12 @@ cd backend uv sync # 含 pyqlib(GitHub 源码依赖,固定 commit)。若网络下载困难/超时,按 AGENT.md §0 设置代理 192.168.1.160:3128 后重试 # 3. 运行测试 -uv run pytest +uv run pytest # 全量 341 条 +uv run ruff check app tests + +# 3b. 端到端契约自检(真实提交回测 Job,验证页面↔后端字段不漂移) +PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路 +PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约 # 4. 启动开发服务 uv run uvicorn app.main:app --reload --port 8000 @@ -101,8 +106,14 @@ uv run alembic revision --autogenerate -m "add xxx table" | 因子研究 | Job 异步 IC / RankIC / 分层测试(`/api/jobs`) | | 交易信号 | `POST /api/signals`:评分排名 + 趋势规则 → BUY/WATCH/SELL + 理由;Web `/signals` | | 选股回测 | `POST /api/backtests`(成本/涨跌停近似;与当前选股共用同一评分引擎,v2 §25 一致性) | -| 策略资产 | `strategy` 落库 + `/api/strategies`(命名配置,可展开为回测 spec) | -| Experiment | 每次研究自动归档 + 一键复跑 + SSE 进度 | +| 定期调仓回测 | **两级截断(候选池 n → 持仓 x)+ 双周期(每 m 月择股 / 每 y 月调仓)+ 复权口径(none/qfq/hfq)+ 组合条件过滤 + 最低佣金**;输出净值/个股曲线并标注买卖点;Web `/backtest`(含高股息案例预设)、`scripts/run_dividend_case.py` | +| 每日指标 | `daily_basic`(股息率 `dv_ratio`/`dv_ttm`、PE/PB/市值)+ `dividend_yield` 因子;`sync daily_basic` 回补 | +| 退市股与时点股票池 | `sync basic --include-delisted` 入库已退市/暂停上市(`status`/`delist_date`)+ 定向 `sync daily` 补行情;`filter_stocks` 按 `as_of` 正确纳入/排除(实测 338 只) | +| 时点 ST / 名称历史 | `sync namechange` 入库名称生效区间(14,213 行);`exclude_st` 在**每个择股日**按当时名称判定(回测与 `/api/selections` 同口径),消除「曾高股息后 ST」的股息陷阱隐藏偏差(实测 3.70pp) | +| 策略库 | `strategy` 落库 + `/api/strategies` CRUD/`PUT` 原地更新/展开为回测 spec;**每个策略有后端推导的「一句话说明 + 计算公式 + 执行步骤 + 注意事项」**(`describe_strategy`,与引擎实执行规则同源);一键回测;Web `/strategies` | +| Experiment / 归档 | **完整存档**:每次回测(异步 Job 与同步接口都算)把结果 + spec + `code_version` + `data_version` 数据快照指纹写入 `experiment` 表,个股收益曲线默认**全量保存**(超出体积预算才裁剪并显式标注);列表支持 `kind`/`q` 过滤且总数经 `X-Total-Count` 暴露(不再静默截断);`DELETE /api/experiments/{id}` + `python -m app.cli.prune_experiments --keep N`(默认 dry-run,删除即失去结果,建议先导出)治理体积;**实验对比**:勾选 2~3 个 → 归一化净值曲线叠加 + 指标差值表 + `config_snapshot` 参数 diff;**归档详情页 `/experiments/{id}`**(Server Component)只读复看完整结果,并明确回答「**选股条件**」与「**交易执行依据**」;归档页可**导出完整 JSON / 以此参数再跑 / 删除**(确认框写明后果);体积用 `app.cli.prune_experiments`(默认 dry-run)治理,删错的历史归档可用 `app.cli.restore_experiment_from_job` 从 Job 副本按原 id 重建 | +| 图表基座 | **统一 TradingView Lightweight Charts 4.2.3**:`components/charts/LwChart.tsx`(折线/面积/柱状 + 买卖点标记 + tooltip + 可点击图例)与 `components/StockChart/CandleChartLW.tsx`(个股 K 线 + 成交量 + MA);ECharts 已完全下线(`package.json`、`pnpm-lock.yaml`、`node_modules`、文档与架构图标注均已清除)。实测个股页图表根节点是 Lightweight Charts 自身的 `div.tv-lightweight-charts`,页面 canvas 无一来自其它图表库 | +| 代码即名称 | 任何出现股票代码的位置都成对显示股票名称且可点击进入个股页(基本信息 + 走势图);后端在 `SelectionCandidate/SymbolCurve/ActionRecord/RankedPick/Position/Trade` 上填充 `name`,前端 `GET /api/stocks/names` 一次性缓存兜底 | | AI Agent | `POST /api/agent/chat`,**14 个受控 Tool**(v3 §25 全清单) | ## 里程碑(详见 [ROADMAP.md](./docs/ROADMAP.md) 与 [docs/DEV_PLAN_v2.md](./docs/DEV_PLAN_v2.md)) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 77847bf..45605c0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -3,8 +3,10 @@ > 版本:v1.0 > 定位:个人开发、A股、中低频选股/回测、AI Agent 二次开发 > 数据源优先级:Tushare > 新浪财经 -> 当前数据库:SQLite -> 未来数据库:MySQL +> 当前数据库:**本机 MariaDB 10.11(`127.0.0.1:3306/qlib`)** +> 数据库目标约束:**只允许本机**;`192.168.1.10` 已禁止作为 DB 目标,由 +> `app/core/config.py::assert_db_target_allowed` 在解析配置时硬拦截(见 `AGENT.md` §0.1) +> 历史:SQLite(`data/quant.db`)→ 远端 MySQL → 本机 MariaDB(逐表一致副本) > 核心量化引擎:Qlib > 前端:React / Next.js > 后端:FastAPI + Python @@ -269,6 +271,13 @@ TushareClient # 6. 数据库设计 +> **目标库约束(硬性)**:一律 `127.0.0.1:3306/qlib`(本机 MariaDB 10.11)。 +> `192.168.1.10` 禁止作为数据库目标 —— 连错库不会报错、界面也正常,却会把回测/归档/策略 +> 静默写到另一台机器上,属于最难发现的一类故障。守卫在 `get_settings()` 里执行: +> 命中禁用主机直接抛错(应用起不来),禁用列表可用 `QLIB_FORBIDDEN_DB_HOSTS` 覆盖。 +> 启动日志会打印 `数据库目标:mysql qlib@127.0.0.1:3306/qlib`(不含密码),便于随时确认。 + + ## 6.1 SQLite 定位 SQLite 用于: @@ -385,11 +394,16 @@ index index_daily trading_calendar -suspend_data -stock_st_data +suspend_data # ⚠️ 未实现(停牌以「当日无行情」近似,结果页如实标注) +stock_st_data # → 由 stock_name_history(tushare namechange)落地时点 ST 判定 financial_indicator income_statement + +# ---- 后续增量(已落地,见 docs/DEV_PLAN_DIVIDEND_BACKTEST.md §10)---- +daily_basic # 每日指标:dv_ratio/dv_ttm 股息率、PE/PB、市值(高股息选股) +stock_name_history # 名称生效区间:exclude_st 的**时点**口径(股息陷阱可见) + balance_sheet cashflow_statement @@ -552,17 +566,35 @@ Qlib Adapter # 12. 前端结构 -第一阶段: +第一阶段(**已实现**,侧栏按研究闭环分组): ```text -Dashboard -股票池 -因子研究 -选股 -回测 -Experiment +研究 总览 / 股票池 / 股票筛选 +策略 策略库 / 选股回测 / 实验对比 +因子与信号 因子研究 / 因子组合 / 交易信号 +数据 数据同步 / 数据管理 ``` +## 12.1 表现层约定(本轮定型) + +| 约定 | 取值 | 理由 | +|---|---|---| +| 图表库 | **TradingView Lightweight Charts**(唯一) | 轻量(~45KB)、原生支持「在 series 上打买卖点标记」,金融图表交互(十字光标/缩放)开箱可用;ECharts 已下线(依赖已移除),避免两套图表基座带来的样式与交互分裂 | +| 图表基座 | `components/charts/LwChart.tsx` | 折线/面积/柱状 + 标记 + tooltip + 可点击图例由一个组件承载;**chart 实例只在结构变化时重建**,数据变化只 `setData`,避免每次刷新重建 canvas | +| 主题 | `components/charts/theme.ts` | 颜色/数值格式集中定义(涨绿跌红按 A 股习惯),组件内不出现硬编码色值 | +| 标记约束 | 标记时间必须存在于对应 series 且**升序** | Lightweight Charts 硬约束:不满足会整组标记丢失。`LwChart` 内统一做「过滤到已有时间点 + 排序」,页面只负责给语义(BUY/SELL) | +| 股票标识 | `SymbolLink`(`lib/symbols.tsx`) | 「有代码必有名称」是产品要求:后端填充 `name`(权威),前端 `GET /api/stocks/names` 缓存兜底;名称缺失显示「—」,**不猜不造**(§7 无静默行为) | +| 参数模型 | `components/StrategyParamsForm.tsx` 单一 `StrategyParams` | 策略库/回测/直通入口共用同一参数模型与校验,避免三处各自维护字段(§6 依赖抽象) | +| 策略说明 | `StrategyDoc`(后端 `describe_strategy` 推导) | 说明与公式**由 spec 真实推导**而非前端手写模板:参数一改,公式同步变;引擎未建模处进 `warnings` 如实暴露(§7/§24) | +| 结果视图 | `components/BacktestResultView.tsx`(回测页与归档页**共用**) | 「刚跑完」和「翻回来看」必须是同一套图表与表格;两处各写一套必然漂移成「归档里少一张图」 | +| 归档页 | `/experiments/{id}` 为 **Server Component**(`app/experiments/[id]/page.tsx`) | 归档是只读内容:选股条件/执行依据/元数据必须服务端直出(客户端渲染时 SSR HTML 里连「选股条件」都搜不到);交互(删除/导出/折叠 spec)隔离在 `components/ArchiveActions.tsx` | +| 归档口径说明 | 归档页顶部「选股条件」+「交易执行依据」两块,取自**归档内 spec** | 说明必须与那次执行一致,不能读当前页面状态(否则「看归档」会看到今天的参数);字段逐项对应引擎实执行语义,不写泛泛的模板话 | +| 归档列表分页 | body 保持 `list[...]`,总数放 `X-Total-Count` 响应头 | 不破坏既有前端契约,同时消除「硬编码 limit=50 静默截断」(§7):前端据此显示「显示 N 条 / 共 M 条」 | + +> 前端**只**依赖 `/api/*` 的 Domain 结构(§3.2):`name` 由后端填充而不是前端反查数据库, +> 图表库可替换而不影响领域层,`/backtest` 的三种入口(策略/选股/实验)都只是把 URL 参数 +> 映射成同一个 `StrategyParams`。 + 第二阶段: ```text diff --git a/docs/DEV_PLAN_DIVIDEND_BACKTEST.md b/docs/DEV_PLAN_DIVIDEND_BACKTEST.md new file mode 100644 index 0000000..2a8492f --- /dev/null +++ b/docs/DEV_PLAN_DIVIDEND_BACKTEST.md @@ -0,0 +1,1058 @@ +# 高股息 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 起。
⚠️ 这 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 --code-version [--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 读取。 diff --git a/docs/DEV_PLAN_v2.md b/docs/DEV_PLAN_v2.md index ec67d3e..ea669c5 100644 --- a/docs/DEV_PLAN_v2.md +++ b/docs/DEV_PLAN_v2.md @@ -44,6 +44,7 @@ AI Agent(6 个受控 Tool)。**数据库已于 2026-09 从 SQLite 全量迁 **已交付(2026-09)**: - MySQL(192.168.1.10:3306,MariaDB 10.11,库 `qlib`,utf8mb4)建 8 张业务表 + alembic_version, + > ⚠️ 历史记录:该地址已**禁止作为 DB 目标**,现为本机 MariaDB(`127.0.0.1:3306/qlib`),见 `AGENT.md` §0.1。 全部经 `alembic upgrade head`(迁移 e4d18825…53113c80…91c4e27a…d3f6c9a2),结构由既有 ORM 模型保证。 - `scripts/migrate_sqlite_to_mysql.py`:一次性工具 —— sqlite 直连 + pymysql chunk 多值 INSERT + keyset 分页续传 + 幂等(已迁则跳过)+ `--verify-only` 逐表 COUNT 与抽样比对;支持 `--only <表>` 并行大表、 diff --git a/docs/DEV_PLAN_v3.md b/docs/DEV_PLAN_v3.md index b6ab475..65d001e 100644 --- a/docs/DEV_PLAN_v3.md +++ b/docs/DEV_PLAN_v3.md @@ -9,6 +9,9 @@ ## 0. 现状 vs v3(已完成清单) **数据库**:MySQL(qlib@192.168.1.10)默认,15 张业务表,Alembic head `e1f2a3b4c5d6`; +> ⚠️ **历史记录,勿照抄**:当前 DB 目标为本机 MariaDB 10.11(`127.0.0.1:3306/qlib`), +> 远端 `192.168.1.10` **已被禁止作为 DB 目标**(代码层 `assert_db_target_allowed` 硬拦截, +> 见 `AGENT.md` §0.1)。以下涉及该地址的描述均为当时状态。 SQLite→MySQL 迁移数据 16,057,265 行校验一致(M-DB)。 **研究/选股主线(M6–M8 已完成)**: diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4d7484a..d1e70c7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -121,7 +121,8 @@ > 目标:业务导向 UI(股票池/因子/选股/回测/结果),不暴露 Qlib 内部概念。 -- `frontend/web`:Next.js(TS) + ECharts(按 README 与架构 §12/§13/§26 初始化) +- `frontend/web`:Next.js(TS) + **TradingView Lightweight Charts**(按 README 与架构 §12/§13/§26 初始化) + > 注:M3 期曾用 ECharts,后已下线并移除依赖(含锁文件),全站唯一图表基座为 `components/charts/LwChart.tsx`。 - 页面:Dashboard → 股票池 → 因子研究 → 选股 → 回测 → 结果(Experiment 视角) - API 对齐业务对象(AGENT.md §17):`/api/stocks /api/universes /api/factors /api/factor-tests /api/strategies /api/backtests /api/experiments /api/jobs` - 交互:参数清晰、结果可视化;耗时任务显示 job 进度(轮询起步,SSE 视需要) diff --git a/docs/USAGE.md b/docs/USAGE.md index df742a2..bf50f0d 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -25,7 +25,7 @@ | 异步与归档 | Job 状态机 + Experiment 自动归档 + 一键复跑 + SSE 进度 | | AI Agent | **14 个受控工具**(v3 §25 全清单)+ LLM 编排 | -**技术栈**:Python 3.12(uv) · FastAPI · SQLAlchemy 2 · Alembic · **MySQL(pymysql)**(SQLite 兜底)· pandas · pyarrow(Parquet) · pyqlib 0.9.8.dev32(源码安装) · LightGBM · Next.js 15 · ECharts · Redis(本机 127.0.0.1:6379 已就绪,触发时接入) +**技术栈**:Python 3.12(uv) · FastAPI · SQLAlchemy 2 · Alembic · **MySQL(pymysql)**(SQLite 兜底)· pandas · pyarrow(Parquet) · pyqlib 0.9.8.dev32(源码安装) · LightGBM · Next.js 15 · **TradingView Lightweight Charts 4.2.3**(唯一图表基座) · Redis(本机 127.0.0.1:6379 已就绪,触发时接入) --- @@ -67,7 +67,7 @@ cp .env.example .env |---|---|---| | `TUSHARE_TOKEN` | 同步数据时必填 | Tushare Pro token | | `LLM_API_KEY` | 使用 Agent 时必填 | 大模型 API Key(URL/模型名在 config.yaml) | -| `DATABASE_URL` | 否 | 默认库 = `config.yaml → database.mysql`(MySQL `192.168.1.10/qlib`);设此项可覆盖(如切回 SQLite) | +| `DATABASE_URL` | 否 | 默认库 = `config.yaml → database.mysql`(本机 MariaDB `127.0.0.1/qlib`);设此项可覆盖(如切回 SQLite) | | `MYSQL_PASSWORD` | 使用 MySQL 默认库时必填 | MySQL 密码(`config.yaml database.mysql.password_env` 引用;host/db/user 在 config.yaml) | | `APP_SECRET_KEY` | 否 | 应用密钥(未接登录,可暂不改) | @@ -83,7 +83,7 @@ api: {prefix: "/api"} database: url_env: "DATABASE_URL" migrations_dir: ... - mysql: {enabled: true, host: "192.168.1.10", port: 3306, + mysql: {enabled: true, host: "127.0.0.1", port: 3306, db: "qlib", user: "qlib", password_env: "MYSQL_PASSWORD", charset: "utf8mb4"} # URL 优先级:DATABASE_URL 环境变量 > database.mysql 组装 > sqlite:///./data/quant.db 兜底 data_source: {primary: "tushare", fallback: "sina", tushare_token_env: "TUSHARE_TOKEN"} @@ -111,7 +111,14 @@ uv run alembic upgrade head DATABASE_URL='mysql+pymysql://user:pass@host/db' uv run alembic upgrade head ``` -> 2026-09 已把数据从 SQLite(`data/quant.db`)全量迁移至 MySQL(`192.168.1.10:3306/qlib`), +> 2026-09 已把数据从 SQLite(`data/quant.db`)全量迁移至 MySQL;随后默认库切换到 +> **本机 MariaDB 10.11(`127.0.0.1:3306/qlib`)**(服务器主机名 `summer`,socket +`/opt/local/var/run/mariadb-10.11/mysqld.sock`,账号 `qlib@localhost`;实测后端进程只有 +`127.0.0.1:3306` 连接),与远端 `192.168.1.10/qlib` 为逐表一致副本(**远端已禁止作为 DB 目标**: +`assert_db_target_allowed` 硬拦截,见 `AGENT.md` §0.1) +> (迁移当时 stock_daily 7,792,869 / adjust_factor 7,898,717;**当前 Alembic head +> `a3f8c21d9b47`** = 迁移链 `f5e0d1c2b3a4 → 22d7380706f7(daily_basic) → +> 7b1c4e9a52d8(result_json→MEDIUMTEXT) → a3f8c21d9b47(stock_name_history)`)。 > 迁移工具与校验见 `scripts/migrate_sqlite_to_mysql.py`(`--verify-only` 可复查一致性)。 --- @@ -120,16 +127,37 @@ DATABASE_URL='mysql+pymysql://user:pass@host/db' uv run alembic upgrade head ```bash cd backend -uv run python -m app.cli.sync basic # 股票基础信息(全市场) +uv run python -m app.cli.sync basic # 股票基础信息(在市 L) +uv run python -m app.cli.sync basic --include-delisted # 追加已退市 D / 暂停上市 P(含 delist_date) +uv run python -m app.cli.sync namechange # 名称变更历史(时点 ST 判定,按年分片) uv run python -m app.cli.sync calendar --start 20240101 --end 20241231 uv run python -m app.cli.sync daily --symbols 600519.SH,000858.SZ --start 20230101 uv run python -m app.cli.sync daily --all --start 20240101 --resume # 全市场 + 断点续传 uv run python -m app.cli.sync financial --all # 财务指标(默认增量) uv run python -m app.cli.sync financial --all --full # 财务指标(强制全量重拉) +uv run python -m app.cli.sync daily_basic --start 20200101 # 每日指标(股息率/PE/PB/市值) uv run python -m app.cli.sync verify --symbol 600519.SH # 新浪交叉验证 uv run python -m app.cli.sync export [--years 2023,2024] # 日线按年导出 Parquet ``` +- `basic --include-delisted`:Tushare `stock_basic` 默认只返回**在市**股票,因此 + `delist_date` 恒为空、退市股整体缺失(回测幸存者偏差的根因)。加该参数后按 + `list_status=L/P/D` 分别拉取并写入 `status`/`delist_date`(实测 2019-12 之后退市 230 只)。 + ⚠️ **退市股的历史行情必须另行同步**,否则池子里有票、没行情: + `sync daily --symbols <退市代码> --start 20190101`。时点股票池语义由 + `filter_stocks` 保证(`delist_date < as_of` 排除),无需额外配置。 +- `namechange`:Tushare `namechange` 的**名称生效区间**(实测 14,213 行,覆盖 1990-12 起)。 + 用途:把 `universe.exclude_st` 从「最新名称快照」升级为**时点名称** —— + `stock.name` 是最新快照,用它判定会把「曾为高股息、后来才变 ST/退市」的标的 + 在整段历史里排除,而那正是**股息陷阱**样本(实测影响约 3.70pp 收益)。 + 回测在**每个择股日**按当时名称重判,与 `/api/selections` 的单时点口径一致; + 未同步时自动回退最新名称,并在结果 `config_snapshot.price_basis.name_basis` + 标注 `point_in_time=false`(不静默)。 +- `daily_basic`:Tushare `daily_basic` 接口的每日指标(`dv_ratio` 股息率 / `dv_ttm` / + `pe` / `pb` / `total_mv` 等),是高股息类策略的数据基础。**按缺失交易日续跑**, + 可中断重跑;每 20 个交易日提交一次进度。仅 Tushare 提供,新浪不支持 + (`DataSourceNotSupported`,不做假兜底)。 + - 每次拉取写入 `sync_log` 审计(来源 / 成功与否 / 行数 / 区间),禁止静默切换数据源。 - `daily` 同时写入日线与复权因子;`--resume` 从本地最新交易日续传。`financial` 默认增量:本地已含「最新应披露报告期」(按 A 股披露节奏推算)的股票直接跳过; @@ -238,10 +266,58 @@ curl -X POST http://127.0.0.1:8000/api/backtests \ }' ``` +**高股息案例(两级截断 + 双周期 + 复权 + 条件过滤)**——同一 spec 在页面/API/脚本一致: + +```bash +curl -X POST http://127.0.0.1:8000/api/jobs \ + -H 'Content-Type: application/json' \ + -d '{ + "type": "backtest", + "universe": {"exclude_st": true, "min_listing_days": 250}, + "price_adjustment": "hfq", + "factors": [{"name": "dividend_yield", "weight": 1.0}], + "conditions": [{"field": "dv_ratio", "op": "lte", "value": 30}], + "selection": {"top_n": 20, "hold_top_x": 20, + "allow_substitute": false, "defer_buy": true}, + "rebalance": "monthly", + "selection_interval_months": 6, + "rebalance_interval_months": 6, + "costs": {"commission_rate": 0.0003, "stamp_tax_rate": 0.0005, + "slippage_rate": 0.001, "min_commission": 5.0}, + "initial_capital": 1000000, + "period": ["2020-01-01", "2026-09-04"] + }' +``` + +一键脚本(含指标打印与结果落盘,走真实 Job 链路): + +```bash +cd backend && PYTHONPATH=. .venv/bin/python ../scripts/run_dividend_case.py --help +``` + +新增 spec 字段说明: + +| 字段 | 语义 | 默认 | +|---|---|---| +| `selection.top_n`(n) | 择股条件/因子排序后**候选池**大小 | 30 | +| `selection.hold_top_x`(x) | 实际持仓数,**必须 ≤ n**(超出直接校验失败) | = n | +| `selection.allow_substitute` | 买不进时是否往 n 名之外替补 | `true`(向后兼容) | +| `selection.defer_buy` | 买不进时**顺延到之后首个不涨停交易日**买入 | `false` | +| `selection_interval_months`(m) | 每 m 个月择股一次(锚定起始月) | 空 = 每次调仓都择股 | +| `rebalance_interval_months`(y) | 每 y 个月调仓一次 | = m | +| `conditions[]` | 选股过滤条件(AND,universe 之后、排序之前) | 空 | +| `price_adjustment` | `none` / `qfq` / `hfq`(回测与成交价同源折算) | `none` | +| `costs.min_commission` | 单笔最低佣金(元) | `0` | + +> `allow_substitute` 与 `defer_buy` **互斥**(同时为 `true` 会被拒绝): +> 「换一只买」与「等它不涨停」是两种互斥的补位哲学。 +> 顺延单只在两次调仓之间有效,到下一次调仓仍未成交则作废(资金留作现金)。 + | 端点 | 说明 | |---|---| | `GET /api/health` | 健康检查 | -| `GET /api/stocks?q=600519&limit=20` | 股票列表/搜索 | +| `GET /api/stocks?q=600519&limit=20` | 股票列表/搜索(单页上限 500) | +| `GET /api/stocks/names` | **`{symbol: name}` 全市场名称映射**(5,900+ 条,前端启动一次性拉取并缓存,避免表格逐行查名称) | | `GET /api/stocks/{symbol}` | 单只股票详情 | | `GET /api/factors` | 因子目录(读 `factor_definition` 表;空表自动 seed) | | `POST /api/composites` / `GET /api/composites` | 因子组合保存 / 列表(方向由注册表填充) | @@ -249,16 +325,20 @@ curl -X POST http://127.0.0.1:8000/api/backtests \ | `GET /api/selections/{id}` / `GET /api/selections` | 读回 / 历史选股(`as_of`、`method` 过滤) | | `POST /api/signals` | 生成 BUY/WATCH/SELL 信号(评分+规则)→ 落库 | | `GET /api/signals/{id}` / `GET /api/signals` | 读回 / 历史信号 | -| `POST /api/strategies` / `GET /api/strategies` | 保存 / 列表命名策略 | +| `POST /api/strategies` / `GET /api/strategies` | 保存(`description` 留空时自动用推导出的一句话说明补全)/ 列表命名策略 | +| `GET /api/strategies/{id}` / `PUT /api/strategies/{id}` / `DELETE /api/strategies/{id}` | 读取 / **原地更新**(保留 `id` 与 `created_at`,重名 → 400,不存在 → 404)/ 删除 | | `POST /api/strategies/{id}/expand` | 展开为回测 ResearchSpec(period + 初始资金) | -| `POST /api/factor-tests` | 同步单因子测试 → `FactorTestReport` | -| `POST /api/backtests` | 同步回测 → `BacktestResult`(默认 LocalEngine) | +| `GET /api/strategies/{id}/describe` | **策略说明**:`{summary, formula, steps, warnings}`(由已保存 spec 推导) | +| `POST /api/strategies/describe` | 同上,但直接传 `ResearchSpec` —— 用于**未保存参数**的实时预览 | +| `POST /api/factor-tests` | 同步单因子测试 → `FactorTestReport`;**结果同样写入归档**(kind=`factor_test`),归档 id 见响应头 `X-Experiment-Id` | +| `POST /api/backtests` | 同步回测 → `BacktestResult`(默认 LocalEngine);**结果同样写入归档**,归档 id 见响应头 `X-Experiment-Id` | | `GET /api/backtests/last` | 最近一次同步回测 | | `POST /api/jobs` | 创建异步 Job(同 spec),返回 `{"job_id","status":"queued"}` | | `GET /api/jobs/{id}` | 查询状态;成功时内嵌 `result` | | `GET /api/jobs/{id}/events` | SSE 进度(`curl -N ...`) | -| `GET /api/experiments` | 实验归档列表(收益摘要/代码版本) | -| `GET /api/experiments/{id}` | 实验详情(spec + 完整结果) | +| `GET /api/experiments` | **归档列表**:支持 `kind`(backtest/factor_test/selection)与 `q`(匹配 id/因子/摘要)过滤、`limit`/`offset`;**过滤后总数放在 `X-Total-Count` 响应头**(body 保持数组形状) | +| `GET /api/experiments/{id}` | 归档详情:`spec`(复现依据)+ 完整 `result` + `code_version` / **`data_version`** / `job_id` / `result_bytes` | +| `DELETE /api/experiments/{id}` | 删除一条归档(不存在 → 404)。**同时等于删掉这次回测的结果**:完整结果只存归档一份,Job 记录仍在但 `GET /api/jobs/{id}` 的 `result` 会变成 `null`(并给 `result_unavailable_reason` 如实说明)。要留底请先导出 JSON | | `POST /api/experiments/{id}/rerun` | 一键复跑 → 新 Job | | `GET /api/stocks/{symbol}/chart\|signals\|selections` | Chart API:K线/量/MA/复权显示 + 该股历史标记 | | `GET /api/backtests/{experiment_id}/stocks/{symbol}/chart\|trades\|positions` | 回测个股图(fills/成交)/明细 | @@ -272,6 +352,93 @@ curl -X POST http://127.0.0.1:8000/api/backtests \ `announce_date` 之前已公告内容;成本(佣金/印花税/滑点)、涨跌停、停牌已建模; 未建模约束(如开盘一字路径)会写入结果的 `unimplemented` 数组,前端如实展示。 +### 6.3 Web 研究工作台:一条闭环走到底 + +侧栏「策略」分组把研究闭环的四个环节串成一条线,顺序即引导: + +| 环节 | 页面 | 做什么 | 关键改进 | +|---|---|---|---| +| ① 出候选 | `/selection` | 因子评分 TopN / 条件选股(可指定历史时点) | 候选表**代码 + 名称**且可点击进个股页;结果卡带「**按此条件回测**」 | +| ② 定规则 | `/strategies` | 命名保存选股规则,管理增删改查 | 每个策略展示**一句话说明 + 计算公式 + 执行步骤 + 注意事项**(后端由 spec 真实推导),列表内可直接**一键回测**,编辑为**原地更新** | +| ③ 验规则 | `/backtest` | 两级截断(候选池 n → 持仓 x)+ 双周期(每 m 月择股 / 每 y 月调仓)回测 | 净值/个股曲线标买卖点;参数区与「策略说明」同屏实时联动;**保存为策略**;**载入上次结果**(免重跑);作业显示**真实阶段**与已用时间;结果分区锚点导航 | +| ④ 复盘 | `/experiments` | 勾选 2~3 次回测对比;**搜索/按类型筛选**后「打开归档」 | 归一化净值曲线叠加 + 指标差值表(✅/⚠️ 标注改善方向)+ `config_snapshot` **参数 diff**(默认只显示有差异项)| +| ⑤ 存档 | `/experiments/{id}` | **只读归档快照**:任意时候都能翻回来看 | 明确回答「**选股条件**」与「**交易执行依据**」+ 完整结果(与刚跑完时同一套图表)+ 归档元数据(代码版本/数据版本)+ 导出 JSON | + +三条「直通」链路(都可分享 URL、刷新后仍生效): + +``` +/selection ──「按此条件回测」──▶ /backtest?from_selection=SEL-xxxx 预填条件/因子/TopN/复权口径 +/strategies ──「一键回测」────▶ /backtest 展开 spec → 提交 Job(页内出指标) +/experiments ──「以此参数再跑」─▶ /backtest?from_experiment=EXP-xxxx 复用该实验的参数快照 +回测跑完 ──「打开归档(完整快照)」─▶ /experiments/EXP-xxxx 直接查看这次结果的冻结快照 +``` + +> 归档是**只读**的:`/experiments/{id}` 用 URL 表达「这就是那次回测」,刷新/换设备/分享链接都能看。 +> 页面顶部两块表明确定义了这次回测的口径: +> **① 选股条件**(股票池 / 因子与权重及方向 / 过滤条件 / 两级截断 n→x / 择股周期 m / 调仓周期 y / 买不进时怎么办) +> **② 交易执行依据**(成交时点=调仓日收盘、复权口径、滑点后的买/卖价、佣金与印花税与最低佣金、 +> 涨停/跌停/停牌如何拦单、顺延规则、期末是否平仓、对照基准), +> 并附后端按归档 `spec` 推导的**一句话说明 + 计算公式 + 执行步骤 + 注意事项**(与引擎实执行规则同源)。 + +> ⚠️ 口径提醒(页面上也有同样提示):从选股结果进入回测,预填的是**规则**(条件/因子/TopN/口径), +> 回测会在**每个择股日按同一规则重新选股**,不是固定持有那一次选出的股票。 + +#### 6.3.1 归档的日常操作(查看 / 导出 / 删除 / 恢复) + +| 想做什么 | 在哪做 | 说明 | +|---|---|---| +| **查看** | `/experiments` 每行「打开归档」,或回测跑完提示条「打开归档(完整快照)」 | 进入 `/experiments/{id}`:**选股条件** + **交易执行依据** + 完整结果 + 归档元数据。URL 可分享、刷新不丢 | +| **筛选找归档** | `/experiments` 顶部搜索框 + 类型下拉 | 条件是 `q`(匹配 id/因子/摘要)与 `kind`;**筛选会回写 URL**(`/experiments?kind=backtest&q=dividend`),可分享、刷新保持;总数显示「显示 N 条 / 共 M 条」,不静默截断 | +| **导出留底** | 归档页「**导出完整 JSON**」 | 把该归档的 `spec` + `result` 原样下载(字节级一致),是最可靠的留底方式 | +| **看原始 spec** | 归档页「查看原始 spec(JSON)」 | 展开后端实际收到的请求体,逐字核对参数 | +| **复用参数再跑** | 归档页「**以此参数再跑**」→ `/backtest?from_experiment=EXP-xxxx` | 参数预填但**不自动执行**(长区间一次 3~5 分钟,由你决定何时跑) | +| **参与对比** | 归档页「加入对比」/ 列表勾选 | 最多 3 条,跳到 `/experiments` 的对比视图 | +| **删除** | 归档页「**删除归档**」(带确认框) | 调 `DELETE /api/experiments/{id}`,成功后回到列表 | + +**删除前必须知道的事**(确认框里也写了同样的内容): + +- 删除后这份快照(结果 + spec)**无法再查看,也不可恢复**; +- **完整结果只存归档一份**:删除后连该次执行的作业记录也读不回结果 + (`GET /api/jobs/{id}` 的 `result` 变 `null`,并给出 `result_unavailable_reason`); +- 想留底请先点「导出完整 JSON」。 + +**但是历史归档能救回来**(`job` 表另有副本),边界如下: + +| 归档 | `job.result_json` | 删除后能否重建 | +|---|---|---| +| 完整存档上线**前**的历史归档 | 有副本(双写遗留) | **能**,按原 id 重建 | +| 完整存档上线**后**的新归档 | `NULL` | **不能**,工具明确拒绝,不假装能救 | + +```bash +cd backend +# 保留策略:默认 dry-run,只打印将要删的清单;确认后加 --apply +PYTHONPATH=. .venv/bin/python -m app.cli.prune_experiments --keep 10 +PYTHONPATH=. .venv/bin/python -m app.cli.prune_experiments --keep 10 --kind backtest --apply + +# 从 Job 副本重建某条已删除的历史归档(默认 dry-run;code-version 必须显式给,不猜) +PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job \ + --job-id JOB-XXXXXXXX --code-version 92627f5 # 加 --apply 才写库 +``` + +**归档完整度会明示**:归档页顶部标注「归档完整 N/N」;若曲线因体积预算被裁剪,或该快照产生于 +完整存档上线之前(个股曲线只有 60 只),页面直接显示「**归档不完整**」并给出差异与「以此参数重跑」入口。 + +**图表**:全站统一使用 **TradingView Lightweight Charts**(`components/charts/LwChart.tsx`), +ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上, +并按时间升序提交(Lightweight Charts 的硬约束),因此不会出现标记丢失或错位。 + +**股票名称**:后端在 `SelectionCandidate` / `SymbolCurve` / `ActionRecord` / `RankedPick` / +`Position` / `Trade` 上都填充 `name`(由 `GET /api/stocks/names` 一次性映射), +前端 `SymbolLink` 保证「有代码必有名称、且可点击进入个股页(基本信息 + 走势图)」; +名称确实缺失时显示灰色「—」而**不猜测、不臆造**。 + +**自检脚本**(接口 + 页面契约,约 2 分钟含一次真实回测): + +```bash +cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py +# 只验接口与页面(跳过回测 Job):加 --skip-job +``` + --- ## 7. AI Agent @@ -317,17 +484,28 @@ Agent 能力边界(10 个内置受控工具,只读 + 受控写库): ```bash cd backend uv run ruff check app tests && uv run ruff format --check app tests -uv run pytest # 全量测试(每个里程碑提交前均须通过) +uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 388 条) cd frontend/web -pnpm run typecheck -pnpm run build +pnpm run typecheck # tsc --noEmit,0 error +pnpm run test:charts # 图表标记逻辑单测(7 条,node --test,无需额外依赖) +pnpm run build # 13 条路由,含 /strategies /backtest /experiments /experiments/[id] +``` + +**端到端契约脚本**(会真实提交回测 Job,用于验证「页面实现」与「后端字段」不漂移): + +```bash +cd backend +PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路(56 项,约 4 分钟) +PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟) ``` 覆盖重点:Provider 归一化与 Failover 审计、Repository 幂等与「未来函数阻断」 (`as_of_date`/`announce_date`)、Alembic 迁移、因子/IC/分层、回测记账与成本/涨跌停、 +**数据库目标守卫**(`192.168.1.10` 被拒 / 本机放行 / 列表可用环境变量覆盖)、 **QlibEngine 数据管线**(bin 落盘格式/roundtrip/端到端回测)、Job 状态机与 Experiment -归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端。 +归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导** +(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)。 --- @@ -338,17 +516,78 @@ pnpm run build (见 docs/DEV_PLAN_v2.md §6.4)。 2. **全市场选股为同步请求**:无白名单的全市场选股约需 60s+;Agent 工具要求传 `symbols` 白名单,全市场请在 Web 页执行(后续可迁异步 Job)。 -3. **数据规模**:当前 MySQL 已同步全市场(stock 5556 只、stock_daily 约 780 万行、 - adjust_factor 约 790 万行,2026-09 由 SQLite 迁移并校验一致)。 +3. **数据规模**:当前 MySQL 已同步全市场 + 退市股(stock 5903 只 = 在市 5565 + 退市 338、 + stock_daily 约 805 万行/5786 只、adjust_factor 约 819 万行、daily_basic 约 800 万行 + /1629 个交易日,2026-09 由 SQLite 迁移并校验一致)。 4. **回测为近似建模**:涨跌停按收盘相对上一有效收盘判定、成交假设调仓日收盘, Portfolio v1 仅等权(单股/行业上限约束字段已预留但未建模,设置后会在结果 `unimplemented` 如实标注)(详见结果 `unimplemented`)。 -5. **Agent 结论质量取决于模型**:编排只保证「经受控工具 + 归档留痕」,研究有效性判断 +5. **幸存者偏差(已修复)**: + - 退市股:`sync basic --include-delisted` + 定向 `sync daily`,实测 338 只、24.0 万根; + 时点股票池按 `delist_date` 正确纳入/排除(实测 `000005.SZ` 2024-04-26 退市: + 2024-01-02 纳入 / 2024-06-03 排除)。 + - ST/风险警示:`sync namechange`(14,213 行名称生效区间,覆盖 1990-12 起), + `exclude_st` 在**每个择股日**按当时名称重判,与 `/api/selections` 同口径。 + 实测 2020-01-02 时点口径比快照口径**多纳入 236 只**(当时尚未 ST 的标的)。 + 案例全区间收益因此经历三次修正:+35.71%(最新名称快照)→ +32.01%(仅在池子基准日过滤) + → **+24.86%(逐择股日时点 ST,最终)**;即初始口径高估 **10.85pp**。 + 窗口内真实反例:`000961.SZ` 阳光城 2023-05-05 起 ST、`600466.SH` 中南建设 + 2024-04-24 起 ST,只按起始日过滤会让它们在变 ST 后仍被选中。 + - 残余(如实标注):股票池的 `min_listing_days`/`delist_date` 仍以**回测起始日**为基准 + (池子不逐日重算),故「起始日为 ST、之后摘帽」的标的不会进入该次回测候选池; + 名称历史未同步时自动回退最新名称并在 `name_basis` 标注 `point_in_time=false`。 + +6. **老牌蓝筹缺 2020–2022 行情(已修复)**:实测缺口 **20 只**(含 `600028.SH` 中石化、 + `600900.SH` 长江电力、`601398.SH` 工商银行等高股息主力),本地日线原自 2023-01-03 起; + 已回补 19,419 根、数据现自 2019-01-02 起。另有 **5 只**(盈方微/盐湖股份/皇台酒业/ + 深深房A/中毅达)是该区间的**真实长期停牌**(停牌期本就无行情),非数据缺失。 +7. **停牌无独立数据表**:以「当日无行情」近似停牌(不可买不可卖),未接入 + tushare `suspend_d` 明细(`ARCHITECTURE.md` §8 的 `suspend_data` 尚未落地), + `exclude_suspended` 仍未实现(结果 `unimplemented` 已标注)。 +8. **结果体积与完整存档**:全市场多年回测的结果 JSON 可达数 MB, + `experiment.result_json` 为 `MEDIUMTEXT`(16MB)以容纳。 + - **个股收益曲线默认全量保存**(此前只存收益绝对值最大的 60 只,是个真实缺陷): + 实测案例 2020-01-01~2026-09-04 回测期内持有 **135 只**,归档 `symbol_curves` 为 + **135 条**、结果 **1.86 MB**,`archive_meta` 记录 + `{curves_stored:135, curves_total:135, truncated:false, budget_chars:12000000}`。 + - 上限可用 `research.archive_curve_limit` 配置(默认不限制);只有当结果超出 + 体积预算(12,000,000 字符,为 MEDIUMTEXT 16MB 留余量)时才会按收益绝对值裁剪, + 并**同时**写入 `archive_meta.truncated=true` 与 `unimplemented` 说明 —— + 归档页会把「归档完整 / 归档不完整」直接标出来,**不静默丢数据**。 + - **老归档(完整存档上线前)确实不完整**:归档页对这类快照显示 + 「个股曲线只有 60 只(期内共持有 N 只)」并给出「以此参数重跑」入口。 + - 归档列表保留策略:`python -m app.cli.prune_experiments --keep N`(**默认 dry-run**, + 加 `--apply` 才删除)。注意删除即失去结果(结果只存归档一份),建议先导出要紧的归档; + 早期归档在 `job` 表另有副本,但新记录没有(`job.result_json` 为 NULL)。 + - 删除后的恢复路径:历史归档可用 + `python -m app.cli.restore_experiment_from_job --job-id --code-version --apply` + 按原 id 重建(spec/result 逐字复制、摘要按归档同口径重算、`data_version` 留空不伪造); + 新归档没有副本,工具会明确拒绝。操作细节见 §6.3.1。 +9. **Agent 结论质量取决于模型**:编排只保证「经受控工具 + 归档留痕」,研究有效性判断 需要人复核;接不同厂商模型请核对 `config.yaml` 的 `base_url` 与 `model` 命名。 -6. **SSE 与 Job 为单进程本地执行**:重启进程后未完成 Job 需重新提交(第一阶段刻意 +10. **SSE 与 Job 为单进程本地执行**:重启进程后未完成 Job 需重新提交(第一阶段刻意 不引入 Redis/Celery;本机 Redis 已就绪,出现排队/长任务需求时按 DEV_PLAN §7 接入)。 -7. **Qlib 数据为进程内单例初始化**:`qlib.init` 以 provider_uri 为键幂等,切换不同 - qlib_dir 会重新初始化;测试环境建议注入独立临时目录。 +11. **Qlib 数据为进程内单例初始化**:`qlib.init` 以 provider_uri 为键幂等,切换不同 + qlib_dir 会重新初始化;测试环境建议注入独立临时目录。 +12. **策略说明列宽 300(未加迁移)**:`strategy.description` 列是 `String(300)`,而 + `describe_strategy` 生成的自动说明最长可达 313 字符 → 后端按 300 截断并加显式 `…`, + 前端表单同步 `maxLength=300` 并实时计数。要让长说明完整落库,需要单独做一次 + `Model → Alembic`(把列改 `Text`)的 schema 变更。 +13. **异步选股不落库**:`POST /api/selections/jobs` 只建 Job、不写 `selection_snapshot`, + 因此异步路径下 `/selection` 不渲染「按此条件回测」按钮(不给坏链接);同步 + `POST /api/selections` 会落库并返回 `selection_id`,可正常直通回测。 +14. **同步接口也会写归档**:`POST /api/backtests` 与 `POST /api/factor-tests` 每次调用都会 + 新增一条归档(归档 id 见响应头 `X-Experiment-Id`)。批量试参数会在库里留下大量归档, + 用 `prune_experiments` 按 `--keep` 清理。归档失败不会吞掉计算结果(接口仍返回结果, + 失败原因经响应头 `X-Archive-Error` 与日志如实暴露)。 +15. **归档的「数据版本」只对上线后产生的归档有效**:老归档 `data_version` 为空, + 归档页显示「未记录(该归档早于数据指纹上线)」,并提示不要当作可严格复现的依据; + 指纹形如 `d20260904;n≈7688k;a≈7922k;b≈7718k`(`d`=最新交易日、`n`=stock_daily、 + `a`=adjust_factor、`b`=daily_basic,`≈` 表示取自 `information_schema` 的近似行数)。 +16. **个股页「回测成交与信号」卡的数据边界**:`GET /api/stocks/{symbol}/chart` 的 + `fills/signals` 来自信号表与选股命中;**回测成交**要经 + `/api/backtests/{experiment_id}/stocks/{symbol}/chart` 才有,因此在没有信号/命中记录时 + 该卡不渲染(个股页的 K 线、基本信息、复权切换不受影响)。 --- @@ -364,11 +603,13 @@ pnpm run build `pd.read_parquet(...)` 直接读取(pyarrow 已随依赖安装)。 - **前端连不上后端 / NetworkError**:前端默认经 Next **同源代理**访问 `/api/*` (next.config.ts rewrites → `127.0.0.1:8000`,可用 `BACKEND_API_URL` 覆盖), - 任意 IP 访问 `:3000` 都不需要 CORS 或硬编码后端地址;后端 CORS 开发期为 `*`。 + 后端 CORS 开发期为 `*`。前端 dev server 监听 `0.0.0.0:3000`(`dev.sh` 传 `-H 0.0.0.0`), + 但**访问来源必须在 `next.config.ts` 的 `allowedDevOrigins` 白名单内**,否则其 + `/_next/*` 请求会被 Next 15.5+ 拒绝(表现为页面白屏/资源 403);换机器访问时按需追加来源 IP。 若使用 SQLite 兜底库且日志出现 `database is locked`,多半是正在跑全市场数据 同步(长写事务),同步结束后自动恢复(SQLite 连接已加 busy_timeout); 默认 MySQL 库无此问题。 -- **数据库被改动想重置**:默认 MySQL(qlib@192.168.1.10)时在远端重建后 +- **数据库被改动想重置**:默认库为本机 MariaDB(qlib@127.0.0.1)时重建库后 `uv run alembic upgrade head`(行情需重新同步或从备份恢复);若切回 SQLite 兜底库则删除 `data/quant.db` 后重建。 - **默认库已是 MySQL**(config.yaml `database.mysql`,密码在 `.env` 的 `MYSQL_PASSWORD`); diff --git a/docs/diagrams/qlib-architecture.html b/docs/diagrams/qlib-architecture.html index c546d34..6737706 100644 --- a/docs/diagrams/qlib-architecture.html +++ b/docs/diagrams/qlib-architecture.html @@ -4924,8 +4924,8 @@ - - Web UI · Next.js · ECharts · Architecture component + + Web UI · Next.js · Lightweight Charts · Architecture component Web UI - Next.js · ECharts + Next.js · Lightweight Charts diff --git a/docs/diagrams/qlib-architecture.json b/docs/diagrams/qlib-architecture.json index 29571bf..c3d94ac 100644 --- a/docs/diagrams/qlib-architecture.json +++ b/docs/diagrams/qlib-architecture.json @@ -12,7 +12,7 @@ ] }, "components": [ - { "id": "web-ui", "type": "frontend", "label": "Web UI", "sublabel": "Next.js · ECharts", "pos": [460, 16], "size": [320, 64] }, + { "id": "web-ui", "type": "frontend", "label": "Web UI", "sublabel": "Next.js · Lightweight Charts", "pos": [460, 16], "size": [320, 64] }, { "id": "api", "type": "backend", "label": "FastAPI API", "sublabel": "Pydantic DTO · REST / SSE", "pos": [460, 118], "size": [320, 64] }, { "id": "services", "type": "backend", "label": "Application Services", "sublabel": "数据 / 因子 / 策略 / 回测 / 实验", "pos": [400, 232], "size": [440, 108] }, { "id": "domain", "type": "backend", "label": "Domain · Repository", "sublabel": "实体 + Protocol(禁 SQL)", "pos": [460, 392], "size": [320, 80] }, diff --git a/docs/diagrams/qlib-dataflow.html b/docs/diagrams/qlib-dataflow.html index dcb34b1..73ac4e1 100644 --- a/docs/diagrams/qlib-dataflow.html +++ b/docs/diagrams/qlib-dataflow.html @@ -5079,8 +5079,8 @@ REST / SSE - - Web UI · Next.js · ECharts · 05 / 回测 · 实验 · 消费 · 结果可视化 + + Web UI · Next.js · Lightweight Charts · 05 / 回测 · 实验 · 消费 · 结果可视化 Web UI - Next.js · ECharts + Next.js · Lightweight Charts 结果可视化 diff --git a/docs/diagrams/qlib-dataflow.json b/docs/diagrams/qlib-dataflow.json index c814c42..fc0b3d6 100644 --- a/docs/diagrams/qlib-dataflow.json +++ b/docs/diagrams/qlib-dataflow.json @@ -192,7 +192,7 @@ "id": "web", "type": "frontend", "label": "Web UI", - "sublabel": "Next.js · ECharts", + "sublabel": "Next.js · Lightweight Charts", "tag": "结果可视化", "stage": 4, "row": 4,