Files
qlib/docs/USAGE.md
T
Simon 36fe018075 docs+chore: 同步操作说明与端到端自检(字段库/单位换算、因子参数化)
- docs/USAGE.md:
  · 因子层一行改为「代码注册表投影 + 参数化实例,参数写在名字里以冻结口径」;
  · 新增 `GET/POST/PATCH /api/factors`、`GET /api/factors/templates` 与
    `/api/condition-fields`、`/fields` 的说明;
  · 新增「参数化因子(2026-10)」块:受控范围、键必须写全参数(缺项就靠可改的
    默认值兜底 = 追溯改义,所以拒绝)、口径文案按代码收敛、停用 ≠ 删除、
    参数化因子也能当过滤条件;
  · 自检清单一并更新(pytest 500 条;verify_strategy_workspace 145 项 skip-job;
    verify_ui_alignment 8 页 160 项;新增 verify_unit_conversion、verify_factor_params)。
- scripts/:新增 verify_unit_conversion.py(单位只能在给定范围里选 + 界面单位⇄
  基准单位换算)、verify_factor_params.py(参数暴露/界面新建/越界拒绝/停用语义,
  跑完自动清掉临时因子);verify_strategy_workspace.py 加 [5.7b] 因子参数化一节,
  临时因子的清理挪进 finally(断言中途失败也不给真人库留垃圾)。
- .gitignore:docs/screenshots/ 是临时验证证据,不入库(文件留在磁盘)。
2026-10-01 16:38:54 +08:00

764 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# qlib-platform 使用说明
> 适用代码版本:M0–M5 + M-DB + **M6 选股系统 / M7 因子层 / M8(Signal·Portfolio·Strategy·Agent·Web)**
> 全部完成(数据库已切换 MySQL);若文档与代码不一致,以代码与
> [ROADMAP.md](./ROADMAP.md) / [DEV_PLAN_v2.md](./DEV_PLAN_v2.md) 为准。
>
> 本文覆盖:安装配置、数据同步、启动前后端、研究 API、研究引擎与 Qlib 接入、
> AI Agent、测试门禁、已知限制。
---
## 1. 项目概览(当前完成度)
个人 A 股中低频量化研究平台:
| 模块 | 位置 / 说明 |
|---|---|
| 数据层 | `backend/app/domain`、`infrastructure/data_sources`:Tushare 首选 + 新浪备用(Failover 审计);**默认 MySQL**(`config.yaml database.mysql`)+ 日线 Parquet 导出 |
| 选股系统 | `quant/selection.py` + `application/services/selection_service.py`:条件选股(A)/ 因子评分 TopN(B),`as_of` 当前/历史一致;结果落库可复现、可解释 |
| 因子层 | `factor_definition` 入库(`/api/factors` 读库:代码注册表投影 + **参数化实例**,参数写在名字里以冻结口径)+ Composite Engine(`quant/composite.py`,组合落库 `/api/composites`)+ 行情口径显式化(`price_adjustment`) |
| 交易信号 | `quant/signal.py`:评分排名 + 趋势规则 → BUY/WATCH/SELL + 理由,落库 `/api/signals` |
| 研究/回测 | `backend/app/quant`:ResearchSpec → 因子 → IC/RankIC/分层 → TopK 低频回测 → 标准化 `BacktestResult`;**双引擎**:LocalEngine(默认)与 QlibEngine v1 |
| 策略 | `strategy` 落库 + `/api/strategies`(命名策略,可展开为回测 spec) |
| Web | `frontend/web`:总览 / 股票池 / **股票筛选** / 因子研究 / 因子组合 / **交易信号** / 选股回测 / 实验 |
| 异步与归档 | 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 · **TradingView Lightweight Charts 4.2.3**(唯一图表基座) · Redis(本机 127.0.0.1:6379 已就绪,触发时接入)
---
## 2. 环境准备
```bash
# 工具(本机已装则跳过)
python3 -m pip install uv
# Node 18+ 与 pnpm: https://pnpm.io/installation
# 1) 后端依赖(backend/.python-version 固定 3.12)
cd backend
uv sync # 基础依赖(含 Qlib/qlib 源码安装依赖)
uv sync --extra datasource-tushare # 数据同步需要 Tushare SDK(首次)
```
前端:
```bash
cd frontend/web
pnpm install # 依赖使用 npmmirror 镜像时较快
cp .env.local.example .env.local # NEXT_PUBLIC_API_BASE 默认 http://127.0.0.1:8000/api
```
> Qlib 安装说明:本机(Linux aarch64 + CPython 3.12)经源码 git 安装并固定 commit,
> 见 `backend/pyproject.toml` 与 [docs/QLIB_VERIFICATION.md](./QLIB_VERIFICATION.md)。
---
## 3. 配置
### 3.1 根目录 `.env`(密钥,不入库)
```bash
cp .env.example .env
```
| 变量 | 必填 | 说明 |
|---|---|---|
| `TUSHARE_TOKEN` | 同步数据时必填 | Tushare Pro token |
| `LLM_API_KEY` | 使用 Agent 时必填 | 大模型 API Key(URL/模型名在 config.yaml) |
| `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` | 否 | 应用密钥(未接登录,可暂不改) |
> 约定:**密钥只放 `.env`**;URL、模型名等可配置项放 `config.yaml`。`.env` 已被 gitignore,严禁提交。
### 3.2 根目录 `config.yaml`(可配置项,可入库)
常用字段(节选,完整见仓库根 config.yaml):
```yaml
app: {name, version, debug, secret_key_env}
api: {prefix: "/api"}
database:
url_env: "DATABASE_URL"
migrations_dir: ...
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"}
storage: {parquet_dir: "data/parquet", qlib_dir: "data/qlib", ...} # 相对项目根
agent:
llm: # ← AI Agent 模型接入
base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1" # URL 在这里配
model: "qwen-plus" # 模型名在这里配
api_key_env: "LLM_API_KEY" # Key 仍从 .env 读
```
- **换模型**:改 `config.yaml → agent.llm.model`(如 `qwen-max` / `deepseek-chat` 等
OpenAI 兼容接口模型),并按平台同步 `base_url`。
- 也可用 `.env` 的 `LLM_BASE_URL` / `LLM_MODEL` 覆盖 yaml(env 优先)。
### 3.3 数据库迁移
```bash
cd backend
uv run alembic upgrade head # 在 config 指定库上建表(默认 MySQL qlib;SQLite 兜底库为 data/quant.db)
# 开发中改 Model 后:
uv run alembic revision --autogenerate -m "desc"
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;随后默认库切换到
> **本机 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` 可复查一致性)。
---
## 4. 数据同步与导出
```bash
cd backend
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 股披露节奏推算)的股票直接跳过;
逐只落库,中断/限速后重跑同一命令即可续传补齐(只丢当前一只)。
- 新浪兜底带「两边一致」真实性校验:Tushare 报错时,只有当该股票本地历史与新浪
返回数据**重叠部分一致**,才把新浪**新数据**(本地缺失键)导入,并标记
`source=sina`(日线另标记 `adjust=qfq` 前复权;新浪不提供复权因子)。
校验口径:财务为重叠报告期的 eps / 销售毛利率逐期一致(ROE 两端口径不同,
不作依据);日线为最近重叠交易日的前复权价一致(老交易日在除权后不可比)。
本地无历史可对照或校验不一致 → 拒绝导入并告警,留待 Tushare 恢复后重跑补齐。
- 限速时可加 `--sleep 秒数` 加大请求间隔;`financial --full` 重拉全部历史并覆盖既有行。
- 新浪(`verify`)仅交叉验证,返回**前复权**口径,不会并入不复权主库。
- 财务指标带 `announce_date`(公告日)与 `source`(tushare/sina);研究侧只允许
使用已公告数据(防未来函数),对同一报告期优先消费 `source=tushare` 的行。
- `export`:`data/parquet/stock_daily/<year>.parquet`(列:symbol/trade_date/ohlc/volume/amount),
由 pyarrow 写出,可直接用 pandas 读取分析,或作为 Qlib 等引擎的后续数据源。
---
## 5. 启动
**一键脚本(后端 + 前端同时管理)**:
```bash
scripts/dev.sh start # 启动后端(8000) + 前端(3000)
scripts/dev.sh status # 查看状态
scripts/dev.sh restart # 重启
scripts/dev.sh stop # 停止
scripts/dev.sh logs api # 跟踪后端日志(web 同理)
# 局域网访问前端时(例如 http://192.168.1.160:3000):
# BACKEND_API_URL=http://192.168.1.160:8000 scripts/dev.sh start
```
手动分别启动见下:
```bash
# 后端(端口 8000)
cd backend
uv run uvicorn app.main:app --reload --port 8000
# 交互文档:http://127.0.0.1:8000/docs 健康检查:/api/health
# 前端(端口 3000)
cd frontend/web
pnpm dev
# 打开 http://127.0.0.1:3000
```
页面:`总览` → `股票池`(搜索/列表)→ `因子研究`(目录 + 单因子 IC/RankIC/分层)→
`回测`(参数 → 净值/回撤/月度/持仓/未建模标注)→ `实验`(归档列表 / 详情 / 一键复跑)。
---
## 6. 研究 API 与研究引擎
### 6.0 引擎分层(M6–M8)
研究/选股链路自 M6 起分层:`Composite Engine`(因子复合分)→ `Selection Engine`
(条件/评分选股)→ `Signal Engine`(买卖信号)→ `Portfolio`(组合权重)→ 回测。
**当前选股与历史回测共用同一评分引擎**(`quant/composite.build_score_panel`),
保证 v2 §25「历史回测与当前选股用同一套逻辑」(一致性由测试锁定)。
- 选股:`POST /api/selections`,body 为 `SelectionQuery`(universe / factors / top_n /
as_of / method);结果候选含 `factor_values` 与 `selection_reason`(为什么选它)。
- 信号:`POST /api/signals`,body `{query, rules}`;rules 含买入排名阈值/趋势 MA/卖出区间。
- 策略:`POST /api/strategies` 保存命名策略 → `POST /{id}/expand` 补 period 展开为 spec 提交回测。
### 6.1 研究引擎:LocalEngine(默认)与 QlibEngine v1
服务通过 `QuantEngine` Protocol 注入引擎(`backend/app/quant/engine.py`):
- **LocalEngine**(默认,研究服务与 API 使用):纯 pandas —— 因子注册表计算 → 截面
z-score 复合 → TopK 月/周调仓回测。无需 Qlib 数据。
- **QlibEngine v1**(`quant/qlib_adapter/engine.py`):同一套因子与记账规则,但行情经
**QlibDataset** 供给 —— 把本地行情按 qlib 官方二进制格式落盘(默认 `data/qlib`,
可注入 `QlibEngine(qlib_dir=...)`)→ `qlib.init` + `D.features` 读取 close → 回测。
切换示例(代码内):
```python
from app.quant.engine import LocalEngine # 默认
from app.quant.qlib_adapter.engine import QlibEngine # Qlib 数据管线引擎
from app.quant.service import ResearchService
engine = LocalEngine() # 或 QlibEngine()
service = ResearchService(stock_repo, daily_repo, engine)
```
注意:Qlib bin 以 float32 存储,QlibEngine 结果与 LocalEngine 存在极小数值差
(真实 20 股对照:-12.81% vs -12.97%),属预期精度差异,不影响结论方向。
### 6.2 API(curl 示例)
研究输入统一为 ResearchSpec(前端 / API / Agent 同构):
```bash
curl -X POST http://127.0.0.1:8000/api/backtests \
-H 'Content-Type: application/json' \
-d '{
"type": "backtest",
"universe": {"exclude_st": true, "min_listing_days": 0},
"factors": [{"name": "momentum_60", "weight": 1.0}],
"selection": {"top_n": 5},
"rebalance": "monthly",
"period": ["2024-03-01", "2024-12-31"]
}'
```
**高股息案例(两级截断 + 双周期 + 复权 + 条件过滤)**——同一 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` | 股票列表/搜索(单页上限 500) |
| `GET /api/stocks/names` | **`{symbol: name}` 全市场名称映射**(5,900+ 条,前端启动一次性拉取并缓存,避免表格逐行查名称) |
| `GET /api/stocks/{symbol}` | 单只股票详情 |
| `GET /api/factors` | 因子目录:**代码注册表 + 参数化实例**(读 `factor_definition` 表)。每行带 `template` / `params` / `param_specs`(可编辑参数与允许范围)/ `label`(中文名含参数)/ `source` / `enabled` / `resolvable`。读取时按「引擎口径字段」做差集同步(代码里新增的因子当场补进来,能算出来的行口径按代码纠正,手改会被改回);算不出来的手登记行保留但标 `resolvable=false`(引用即 `FactorError`)。稳态零写入 |
| `GET /api/factors/templates` | 因子模板:可编辑参数(`param_specs`:类型/范围/枚举/默认值/说明)、公式、依赖列、已有内置实例 —— 「新建参数化因子」表单的数据源 |
| `POST /api/factors` | **新建参数化因子**,body `{template, params}`(缺省项取模板默认值)→ 201 返回新因子(名字里含全部参数,如 `momentum(window=90,direction=higher_is_better)`)。越界/未知参数/未知模板/重复参数组合 → **422**(detail 说明允许范围);同参数不会重复创建 |
| `PATCH /api/factors` | **启用/停用**因子,body `{name, enabled}`(名字含括号/等号,放 body 不放路径)。停用只影响能否被选中,历史策略/归档照旧解析;内置实例 → **422**,不存在 → **404** |
| `GET /api/condition-fields` | **字段库**(过滤条件可用字段,读 `condition_field` 表;首次读取自动 seed,`?include_disabled=false` 只返回启用项) |
| `GET /api/condition-fields/available` | 引擎支持但尚未入库的字段(「新增字段」的可选项) |
| `POST /api/condition-fields` | 新增自定义字段;字段引擎算不出来 → 422(不假装支持) |
| `PUT /api/condition-fields/{name}` | 改中文名 / 含义 / 分组 / **界面单位** / 启用状态(`name`/`kind`/`source` 不可改;单位只能取该字段 `units` 里列出的值,否则 422) |
| `DELETE /api/condition-fields/{name}` | 删除自定义字段;内置字段 → 400(只能停用) |
| `POST /api/composites` / `GET /api/composites` | 因子组合保存 / 列表(方向由注册表填充) |
| `POST /api/selections` | 执行选股(`method=score` 评分 TopN / `condition` 条件)→ `SelectionResult` 并落库 |
| `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` | 保存(`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 + 初始资金) |
| `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` | **归档列表**:支持 `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/成交)/明细 |
| `POST /api/factor-correlations` | 多因子横截面 Spearman 相关矩阵 |
| `POST /api/replays` | Bar Replay 线性重放(as_of 逐日,白名单≤40/区间≤90) |
| `GET /api/jobs` / `POST /api/jobs/{id}/cancel` | Job 列表 / 取消(含 stage 字段上报) |
| `POST /api/selections/jobs` | 全市场/长任务选股异步 Job(Web 页「异步」按钮) |
| `POST /api/agent/chat` | AI 研究助手(见 §7,14 个受控工具) |
**防未来函数与真实性**:回测在调仓日收盘成交、自次日起计收益;财务数据只使用
`announce_date` 之前已公告内容;成本(佣金/印花税/滑点)、涨跌停、停牌已建模;
未建模约束(如开盘一字路径)会写入结果的 `unimplemented` 数组,前端如实展示。
### 6.3 Web 研究工作台:一条闭环走到底
> **2026-09 重构**:把原来「一个策略 = 全套参数」拆成三件独立的事 ——
> **① 公共配置**(`/settings`,全局唯一):佣金 / 印花税 / 滑点 / 最低佣金 / 复权口径 / 基准;
> **② 选股策略**(`/strategies`):只剩「怎么选」(股票池 + 因子 + 过滤条件),
> 不含资金 / 持仓数 / 持仓时间 / 调仓 / 费率 / 区间;
> **③ 回测组合**(`/backtest`):引用若干选股策略 + 回测时才定的参数
> (起始资金、持仓数 N、持仓天数区间 [Tmin, Tmax]、调仓时机 日/周/月、区间)。
> 多策略取**并集后 Borda 秩和**统一打分;Tmax **每个交易日**强制了结,Tmin 防频繁换手。
> 运行时公共配置的成本/复权会**快照**进归档 `config_snapshot`,保证可复现。
> **2026-10 过滤条件字段库**(`/fields`):过滤条件的字段不再是手填的裸字段名。
> 字段有**中文名 + 含义/口径 + 单位 + 类型**,在策略表单里按分组(股票基础 / 行情 /
> 技术指标 / 每日指标 / 财务指标 / 因子)下拉选择,选中即显示口径说明;
> 比较符按类型收窄(文本字段只能 等号/属于/不属于,数值字段才能比大小),
> 取值支持「和固定值比」或「和另一个字段比」(如 `close > ma60`)。
> 字段库由代码注册表 `quant/condition_fields.py` 与引擎域校验,DB 表 `condition_field`
> 承接目录(seed **只补不删**,改过的中文名/含义不会被覆盖);
> 可改文案、可停用、可新增「引擎支持但默认不入库」的字段、可删自定义字段,
> 内置字段不可删除(删了下次读取会自动补回)。新增时若字段引擎算不出来 → **422 拒绝**
> —— 否则会建出一条「永远选不出股票」的条件,这是 AGENT.md 禁止的静默失败。
> **单位:可选,但只在注册表给的范围内选**(2026-10)——字段库里的单位分两层,
> 改错任何一层都会让策略**静默算错**,所以分开:
> **基准单位**(`base_unit`:总市值=万元、成交额=元、成交量=股…)来自数据源落库口径,
> 是引擎**存储与比较**用的单位,**不可改**;策略 JSON、归档 `spec`、引擎求值一律只用它,
> 所以归档永远复现得出来。
> **界面单位**(`unit`)只影响「输入 / 显示」,可在字段库里从注册表给的阶梯里选
> (总市值 万元⇄亿元、成交额 元⇄万元⇄亿元、成交量 股⇄手⇄万手、股本 万股⇄亿股),
> 提交前 ×系数、回显时 ÷系数。于是「把总市值改成亿元」立刻生效:策略表单输入 `5`
> 存进库里是 `50000`(万元),卡片与说明里显示「总市值 ≥ 5 亿元」——语义一致、口径不乱。
> 自由文本单位(如 `亿亿元`)与不在该字段阶梯里的单位(如给总市值选 `元`)一律 **422 拒绝**,
> 因为换算必须按固定系数做,标签与实际口径不一致就是静默错误。
> 百分数(%)与倍数(倍)不提供备选:换个说法只会制造误读。
> **打分因子 与 过滤条件 的关系**(策略表单内也有同样的说明卡):
> **条件 = 准入**(全部 AND 通过才有资格,不做排序);**因子 = 优先级**(横截面 z-score
> 加权成复合分后排序,不筛掉任何人)。执行顺序:股票池 → 过滤条件 → 因子打分 → 取 TopN;
> 其中 **N 在回测组合里定**,不属于选股策略。同一个字段两种用法都行:高股息策略里
> `dv_ratio ≤ 30` 当条件用(剔掉股息率异常偏高、多为一次性分红或股价暴跌的「高股息陷阱」样本),
> `dividend_yield` 当因子用(让股息率高的排前面)。
> **因子怎么配**(`dividend_yield` 就是这样一个内置因子):因子由**模板 + 参数**两段构成,
> 都在代码注册表(`quant/factors.py`)里声明。模板是算法家族(动量 / 波动率 / 量比 /
> 乖离 / 反转 / 接近新高 / 股息率…),给出公式、依赖列(`requires`)与**可编辑参数**的
> 允许范围;参数是每个实例的取值(窗口、方向)。
> **参数化因子(2026-10)**:参数可以改,改的方式是**从模板新建一个参数化因子**。
> 例如把动量窗口从 20 改成 90、方向改成越低越好,就新建
> `momentum(window=90,direction=lower_is_better)` —— **参数写在因子的名字里**,
> 所以它是一个**新身份**:旧因子、既有策略、已归档的实验都按各自名字里的参数计算,
> **不会被后来的修改改义**。同一个模板的不同参数版本可以并存(`momentum_20`、
> `momentum_60`、`momentum(window=90,…)` 同时可用),在策略里各自配自己的权重。
>
> - **受控**:窗口是 `2 ~ 500` 的整数,方向只有「越高越好 / 越低越好」两个选项;
> **只在给定范围内选/填**,越界、未知参数、乱造模板一律 **422**(不静默截断、不悄悄取默认值)。
> - **键要写全参数**:`momentum(window=90)` 这种「漏一个参数」的写法会被拒绝,必须写成
> `momentum(window=90,direction=higher_is_better)`。原因是缺项就得靠模板默认值补齐,
> 而默认值是可以改的代码细节 —— 一旦改了,库里老策略的含义会**追溯性地变掉**。
> 界面里你只选参数,规范键由系统生成(目录页会实时预览)。
> - **口径文案仍按代码收敛**:描述 / 公式 / 方向 / 回看 / 依赖列以注册表为准,手改会被下次
> 读取纠正回来(「文档写一套、代码跑另一套」是禁止的)。依赖列更是事实而不是配置:
> 它决定引擎装配哪些数据列。
> - **停用 / 启用**:停用只是把因子从选择列表里拿掉,**既有策略/归档仍按名字解析**;
> 想彻底换算法就改代码。**内置实例(`momentum_20` 这类历史名)的开关由代码决定**,
> 不能在目录里停用(要不同参数就新建一个参数化因子)。
>
> 在选股策略里引用因子时,**可配置的是「用哪个参数版本」+ 权重**
> (`score = Σ 权重 × 截面 z-score`,方向由该版本的参数决定,`lower_is_better` 由引擎自动取负号)。
> 新增的内置因子会在下次读取 `/api/factors` 时自动补进目录;目录里多出来的手登记行会保留,
> 但标 `resolvable=false` —— 引擎算不出来就用不了,不会假装支持。
> 同一个字段「当条件」还是「当因子」是两种用法,不是两种字段:条件在过滤阶段筛掉,
> 因子在打分阶段排序。例如 `dv_ratio`(每日指标列)与 `dividend_yield`(因子)
> 今天算的是同一个数,区别只在「筛掉」还是「排序」。**参数化因子也能当过滤条件**:
> `momentum(window=90,direction=higher_is_better) > 0`(因子是无量纲量,不带单位)。
侧栏「策略」分组把研究闭环串成一条线,顺序即引导:
| 环节 | 页面 | 做什么 | 关键改进 |
|---|---|---|---|
| ① 出候选 | `/selection` | 因子评分 TopN / 条件选股(可指定历史时点) | 候选表**代码 + 名称**且可点击进个股页 |
| ② 定规则 | `/strategies` | 命名保存**选股条件组合**(股票池+因子+条件),增删改查 | 每个策略展示后端推导的**一句话说明 + 选股口径**;卡片可直接「**加入回测组合**」;过滤条件的字段从**字段库**分组下拉选择并显示口径 |
| ②″ 管字段 | `/fields` | 维护过滤条件的**字段库**:中文名 / 含义口径 / **界面单位** / 启用状态,新增与删除自定义字段 | 字段来自引擎注册表(保证真能算);比较符按类型收窄;单位下拉只列注册表给的档(如 万元/亿元)并注明基准单位;页内说明「因子 vs 条件」关系 |
| ②′ 设成本 | `/settings` | 维护全局唯一的费率 / 滑点 / 复权口径 / 基准 | 所有回测组合共用;改动只影响之后的回测,已归档按各自快照复现 |
| ③ 验规则 | `/backtest` | 勾选 ≥1 个选股策略 + 填回测参数 → **保存为组合 / 直接运行**;下方「**已保存的回测组合**」库可载入 / 直接运行 / 删除 | 公共配置只读展示;持仓天数区间 [Tmin,Tmax] + 调仓 日/周/月;组合库让保存过的组合能找回来;结果分区锚点导航 |
| ④ 复盘 | `/experiments` | 勾选 2~3 次回测对比;**搜索/按类型筛选**后「打开归档」 | 归一化净值曲线叠加 + 指标差值表(✅/⚠️ 标注改善方向)+ `config_snapshot` **参数 diff** |
| ⑤ 存档 | `/experiments/{id}` | **只读归档快照**:任意时候都能翻回来看 | 单策略回测显示「选股条件 + 交易执行依据」;**组合回测**显示「引用的策略 + 回测参数 + 运行时配置快照」+ 完整结果 + 元数据 + 导出 JSON |
几条「直通」链路(都可分享 URL、刷新后仍生效):
```
/strategies ──「加入回测组合」──▶ /backtest?strategy=STG-xxxx 预先把该选股策略勾进组合
/experiments ──「以此参数再跑」─▶ /backtest?from_experiment=EXP-xxxx 复用该实验的参数快照
/backtest 组合库 ─「载入到表单」▶ /backtest 回填组合名/说明/策略/参数
/backtest 组合库 ─「直接运行」──▶ POST /api/combos/CMB-xxxx/run 用库里那份参数跑(不受表单草稿影响)
/backtest?combo=CMB-xxxx URL 直达某个已保存组合
回测跑完 ──「打开归档(完整快照)」─▶ /experiments/EXP-xxxx 直接查看这次结果的冻结快照
```
> 归档是**只读**的:`/experiments/{id}` 用 URL 表达「这就是那次回测」,刷新/换设备/分享链接都能看。
> - **单策略回测**(旧 ResearchSpec 路径,`/api/backtests`)归档顶部两块表:**① 选股条件**
> (股票池 / 因子与权重及方向 / 过滤条件 / 两级截断 n→x / 择股周期 m / 调仓周期 y / 买不进时怎么办)
> **② 交易执行依据**(成交时点=调仓日收盘、复权口径、滑点后的买/卖价、佣金/印花税/最低佣金、
> 涨停/跌停/停牌如何拦单、顺延规则、期末是否平仓、对照基准)。
> - **组合回测**归档顶部改为「**回测组合**」卡:引用的选股策略 + 回测参数(N / [Tmin,Tmax] /
> 调仓时机 / 资金 / 区间)+ **交易执行依据(来自运行时的公共配置快照)**,并明确标注
> 「成本与复权是运行那一刻从公共配置快照下来的,事后改公共配置不影响本归档」。
#### 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
```
#### 6.3.2 界面规范与对齐自检(控件尺寸 / 输入友好)
界面不是"能看就行":控件错位、尺寸与内容不匹配、点不中、报错说不清,都会直接变成操作错误。
因此把可度量的部分**写成规范 + 自检脚本**,而不是靠肉眼。
**① 控件高度只有三档令牌**(`app/globals.css` 的 `--ctl-h-sm/md/lg` = 28 / 34 / 38px):
| 令牌 | 用途 | 典型控件 |
|---|---|---|
| `--ctl-h-sm` | 密集行 | 小按钮、图标按钮、可点击 chip |
| `--ctl-h-md` | 标准 | 输入框、下拉、按钮(默认) |
| `--ctl-h-lg` | 主操作 | 顶栏图标按钮 |
规则:**同一行内的控件必须等高**。做法是给 `.input / select / .btn / .icon-btn` 显式
`height`,而不是靠上下 padding 拼高度 —— 后者会得到 34 / 36.8 / 38.8 三种结果
(Chrome 的原生 `date` 输入还会多 2px),同一行就肉眼可见地参差。
**② 尺寸与内容匹配 + 整体收束**:参数表单用 `.params-form` 约束到 **~1100px**
(否则在宽屏上铺满 ~1900px、5 列各 ~360px,控件拉得太开且数字框与下拉宽度不一);
限宽后数值/日期输入直接**填满单元格**,与同行下拉等宽 → 左右边缘严格对齐
(早先在未限宽时用 `max-width:180px` 防数字框过宽,限宽后该上限反而让数字框比
相邻下拉窄一截,故移除)。多行文本(如「一句话说明」,最长 300 字)用
**整宽 `<textarea>`** 而非单行 input —— 单行框必然截断长文案(截图实测
「例:全市场股息率最高的 2」被切掉)。选项文案长的选择器给更宽的类
(`.input--picker`);工具条检索/筛选用 `.input--search` / `.input--filter`,
**不在 JSX 里写内联像素宽度**;重复行(因子、条件)用"表头 + CSS 栅格"成列对齐
且**首列封顶**(因子名 ≤460px),避免一个下拉撑满整行。
**③ 点击目标**:控件 ≥28px;16px 的勾选框/单选按钮由外层 `label`(`.check` / `.radio-row`)
撑开热区,表格里的勾选框用 `.check--cell` 让整个单元可点。
**④ 标签与无障碍名**:每个输入都有可见标签或 `aria-label`;只有图标的按钮必须有
`aria-label`/`title`(否则读屏只会念"按钮")。
**⑤ 输入友好**:校验错误**失焦或提交后**才提示(清空重填的瞬间不标红);
提交被拦下时**一次展开全部**行内错误并**聚焦到第一个问题字段**;
运行/保存按钮**不因参数非法而置灰**(灰按钮不说原因属于"看起来不可点却无响应"),
而是可点击并讲清原因。
**对齐自检**(用系统 Chrome + 原生 CDP,仅标准库;会独占随机端口与临时 profile):
```bash
python3 scripts/verify_ui_alignment.py # 8 个页面 × 1500/375px,共 160 项检查(含 /fields、/factors)
python3 scripts/verify_ui_alignment.py --dump # 打印每行控件的宽高与字号明细
python3 scripts/verify_ui_alignment.py --pages /backtest --width 375
```
检查项:同排控件等高、控件高度取值归一(≤3 种)、点击目标、标签/无障碍名、
字号与圆角一致、尺寸匹配内容、无横向滚动、提示文本不被裁切。
> 说明:本平台为**深色单主题**(`color-scheme: dark`),因此"深浅两套主题都测对比度"
> 这一条不适用;对比度按深色主题实测(WCAG 相对亮度公式,正文对背景):
> 正文 `--text-1` 对 `--bg-0` **17.09:1**、对卡片 `--surface-1` **15.26:1**;
> 标签 `--text-2` 对卡片 **8.42:1**;提示 `--text-3` 对卡片 **6.12:1**;
> 错误 `--neg` 对卡片 **7.04:1** —— 均高于正文 4.5:1 / 次要文字 3:1 的门槛。
---
## 7. AI Agent
前置:`config.yaml → agent.llm`(URL/模型名默认百炼兼容端点 + qwen-plus),
`.env` 中设置 `LLM_API_KEY`(仅需 Key)。
```bash
curl -X POST http://127.0.0.1:8000/api/agent/chat \
-H 'Content-Type: application/json' \
-d '{"message": "帮我测试 momentum_60 因子并跑一次 Top5 月度回测,评估是否值得深入"}'
```
返回:
```json
{
"reply": "(最终结论,中文)",
"actions": [
{"tool": "test_factor", "args": {...}, "output": "因子测试完成(Experiment EXP-…)..."},
{"tool": "run_backtest", "args": {...}, "output": "回测完成(Experiment EXP-…)..."}
]
}
```
Agent 能力边界(10 个内置受控工具,只读 + 受控写库):
- `search_stocks` / `get_market_data`:查询
- `test_factor` / `run_backtest`:研究并**自动归档 Experiment**
- `screen_stocks` / `explain_selection`:按因子评分选股(传 `symbols` 白名单避免全市场长任务)/ 解释某次选股理由
- `generate_signals`:生成 BUY/WATCH/SELL 信号
- `create_strategy`:保存命名策略
- `get_experiment` / `compare_experiments`:读取/对比归档
**不提供** shell / 任意代码执行 / 删改数据 / 修改配置与凭证。未配置 Key 时接口返回
400 引导信息。研究纪律写入系统提示:禁止仅凭单次样本内高收益判定策略有效,需说明
样本外、过拟合、look-ahead bias、成本、参数敏感性等(未验证项要明说)。
---
## 8. 测试与质量门禁
```bash
cd backend
uv run ruff check app tests && uv run ruff format --check app tests
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 500 条)
cd frontend/web
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 # 选股策略/字段库/因子目录+参数化/公共配置/回测组合库/归档链路(145 项 skip-job,含真实组合回测更多,约 5 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(8 个页面 × 2 宽度 = 160 项,约 3 分钟)
python3 ../scripts/verify_unit_conversion.py # 字段库单位:只能在给定范围内选 + 界面单位⇄基准单位换算(约 2 分钟)
python3 ../scripts/verify_factor_params.py # 因子参数化:暴露真实参数/界面新建参数化因子/越界拒绝/停用不影响历史解析(约 3 分钟)
```
覆盖重点:Provider 归一化与 Failover 审计、Repository 幂等与「未来函数阻断」
(`as_of_date`/`announce_date`)、Alembic 迁移、因子/IC/分层、回测记账与成本/涨跌停、
**数据库目标守卫**(`192.168.1.10` 被拒 / 本机放行 / 列表可用环境变量覆盖)、
**QlibEngine 数据管线**(bin 落盘格式/roundtrip/端到端回测)、Job 状态机与 Experiment
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
(`describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)、
**UI 对齐与控件一致性**(同排等高 / 尺寸归一 / 点击目标 / 标签 / 无障碍名 / 横向溢出,
7 个页面 × 1500 与 375px 两种视口,见 §6.3.2)。
---
## 9. 已知限制与说明
1. **Qlib 模型选股(Selection 模式 C)按需延后**(v3 §14.1C):当前选股为条件(A)/因子评分(B);
模型预测选股(Alpha158 + LightGBM walk-forward,M8.4)为「按需」延后项
(见 docs/DEV_PLAN_v2.md §6.4)。
2. **全市场选股为同步请求**:无白名单的全市场选股约需 60s+;Agent 工具要求传 `symbols`
白名单,全市场请在 Web 页执行(后续可迁异步 Job)。
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. **幸存者偏差(已修复)**:
- 退市股:`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 <JOB> --code-version <rev> --apply`
按原 id 重建(spec/result 逐字复制、摘要按归档同口径重算、`data_version` 留空不伪造);
新归档没有副本,工具会明确拒绝。操作细节见 §6.3.1。
9. **Agent 结论质量取决于模型**:编排只保证「经受控工具 + 归档留痕」,研究有效性判断
需要人复核;接不同厂商模型请核对 `config.yaml` 的 `base_url` 与 `model` 命名。
10. **SSE 与 Job 为单进程本地执行**:重启进程后未完成 Job 需重新提交(第一阶段刻意
不引入 Redis/Celery;本机 Redis 已就绪,出现排队/长任务需求时按 DEV_PLAN §7 接入)。
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 线、基本信息、复权切换不受影响)。
---
## 10. 常见问题(FAQ)
- **同步报「缺少 TUSHARE_TOKEN」**:把 token 填入根目录 `.env` 的 `TUSHARE_TOKEN`。
- **同步报「未安装 tushare 客户端」**:`cd backend && uv sync --extra datasource-tushare`。
- **Agent 报 400「未配置 LLM」**:`.env` 设置 `LLM_API_KEY`;换模型改 `config.yaml` 的
`agent.llm.model` / `base_url`。
- **回测默认用哪个引擎?怎么切 Qlib 引擎**:默认 LocalEngine(纯 pandas)。需要在代码
中向 `ResearchService` 注入 `QlibEngine()` 启用 QlibDataset 数据管线(见 §6.1)。
- **Parquet 导出文件在哪、怎么读**:`data/parquet/stock_daily/<year>.parquet`;
`pd.read_parquet(...)` 直接读取(pyarrow 已随依赖安装)。
- **前端连不上后端 / NetworkError**:前端默认经 Next **同源代理**访问 `/api/*`
(next.config.ts rewrites → `127.0.0.1:8000`,可用 `BACKEND_API_URL` 覆盖),
后端 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 库无此问题。
- **数据库被改动想重置**:默认库为本机 MariaDB(qlib@127.0.0.1)时重建库后
`uv run alembic upgrade head`(行情需重新同步或从备份恢复);若切回 SQLite 兜底库则删除
`data/quant.db` 后重建。
- **默认库已是 MySQL**(config.yaml `database.mysql`,密码在 `.env` 的 `MYSQL_PASSWORD`);
**想临时切回 SQLite**:`.env` 设 `DATABASE_URL=sqlite:///./data/quant.db`。
业务层均无需改动(Repository / SQLAlchemy 已隔离方言差异)。