Files
qlib/docs/USAGE.md
T
Simon 50a1030afa fix(web): 参数表单收束 + 一句话说明改多行(按截图实测修两处失调)
用户给了真实截图,指出两处肉眼可见的不协调,逐项定位后修复:

1. 「一句话说明」是单行 input 被塞进 ~360px 的格子,placeholder
   「例:全市场股息率最高的 2」直接截断 —— 内容太多、框太小。
   → 改为整宽多行 <textarea>(rows=2、min-height 64px、可纵向拉伸),
     与定宽 340px 的策略名并排(.params-meta flex),窄屏自动上下堆叠;
     实时字数 (N/300) 已在提示里。

2. 「太多地方没有对齐」的根因:表单铺满 ~1900px 内容区,5 列各 ~360px,
   控件拉得太开;因子下拉用 1fr 撑到 ~1500px 而权重框才 118px,比例失调;
   数字输入还有 max-width:180px 上限,比同行下拉窄一截。
   → .params-form 约束到 ~1100px(每列 ~210px,所有控件按 1fr 等宽,
     左右边缘严格对齐);移除已多余的 max-width:180px(限宽后它反而
     制造新的不等宽),数值/日期改为填满单元格;因子行首列封顶 460px、
     整行限宽 720px,条件行同理(首列 ≤360px、限宽 760px);
     复权口径选项去掉括号里的「股息策略推荐」(已在 hint 说明),不再截断。

验证方式也升级了:这次是**截图后用 read_image 看渲染像素**确认,而不只量几何。
对齐自检同步把 textarea(高度随行数变、本就该比单行高)排除出「同排等高 /
高度归一」比较,并把数字过宽阈值调到 240px(限宽后 ~210px 属正常)。

门禁:tsc 0 错误、图表单测 7/7、next build 成功、页面 200、
对齐自检 140/140、契约自检 59/59;/backtest 与 /strategies 截图复核通过。
2026-09-30 19:56:40 +08:00

677 lines
43 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` 表;空表自动 seed) |
| `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 研究工作台:一条闭环走到底
侧栏「策略」分组把研究闭环的四个环节串成一条线,顺序即引导:
| 环节 | 页面 | 做什么 | 关键改进 |
|---|---|---|---|
| ① 出候选 | `/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
```
#### 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 # 7 个页面 × 1500/375px,共 140 项检查
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 # 全量测试(每个里程碑提交前均须通过,当前 388 条)
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 # 策略库/说明/名称/选股直通/归档链路(59 项,约 4 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(140 项,约 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 已隔离方言差异)。