接着 /backtest 的那次改造,把其余会跑异步 Job 的页面也切到同一套
`useJobRunner` + `JobProgress`(用户原话:包括因子测试等所有测试都帮我完善用户反馈):
- **/factors**(一个因子一个 Job 的批量场景):删掉 `running/processed/current/jobId` 与
按「已处理数 ÷ 总数」自算的 `Progress` **假百分比**;每轮把 `第 i/共 n 个` 交给反馈条,
阶段/作业号/已用秒数全部来自后端。取消 = 用户明确意图 → 保留已出的报告、不再跑后续因子,
不报错;单个因子失败仍继续跑其余因子,并把后端原文累积成**批级清单**(多因子时反馈条
只能显示最后一个作业,前几个失败不能丢)。
- **/factors/compose**:删掉 `value={30}` 的假进度条;`archiveId` / 复用 `BacktestResultView` /
「新页面放大」全部保留;failed/cancelled 交给反馈条,页面 error 只留参数与目录错误
(同一失败不在两处各说一遍)。
- **/selection**:异步选股切反馈条;**同步**的「执行选股」保留原 loading(`POST /selections`
没有 job_id,套上会去 `GET /jobs/{signal}` 撞 404)。
- **/signals**:`POST /api/signals` 是同步接口,**不套**作业反馈(不编作业号、不编阶段),
改为点击即现的 `role="status"` 提示,如实写明「同步请求、请求期间不能关页、无阶段无取消、
出错显示后端原文」。
- **阶段圆点按作业类型区分**(修掉一个真实缺陷):原来全站共用一张含 `queued/done` 的
`STAGE_ORDER`,因子测试页实测出现过**裸英文** `factor_calculation` 且 4 个圆点全灰
(`indexOf` = -1),还画出了因子测试根本不存在的「逐择股日选股 / 撮合与净值结算」。
现在 `STAGE_PIPELINES = { factor_test: [加载→计算因子值→汇总], backtest: [加载→撮合→汇总],
selection: [逐择股日选股] }`(阶段序列**不含 queued/done**,那是作业状态不是阶段),
未知阶段显示「执行中(stage)」并保留已推进的圆点,不整排灰、不露裸枚举。
验证(真实浏览器 CDP,读数原文已记录):
- /factors:点击后 0.2s 内 `submitting`→`queued` + 作业号;+30s Pill「计算因子值」、
圆点 `✓ 加载行情与因子数据 / ● 计算因子值 / ○ 汇总指标与曲线`(无「选股/撮合」、无英文枚举);
成功态给「去对比 / 打开归档」。
- /selection:圆点只有 `逐择股日选股` 一段;取消 → 「已取消,没有归档」;
另实测撞并发上限时如实显示后端原文「系统繁忙:并发研究任务已达上限」。
- /factors/compose:`submitting→queued→撮合与净值结算→success(EXP-…)`,业务结果与 5 处放大入口照旧。
- /backtest 回归:圆点由 4 个变 3 个(去掉后端**从不上报**的 selection 阶段),取消仍「已取消 + 没归档」。
- 自检产生的 12 个实验归档已全部删除(bulk-delete count:12,复查无残留)。
810 lines
58 KiB
Markdown
810 lines
58 KiB
Markdown
# 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 只),页面直接显示「**归档不完整**」并给出差异与「以此参数重跑」入口。
|
||
|
||
> **买卖理由 + 因子曲线 + 曲线放大**(2026-10):回测结果不再只给「成交了哪些」,
|
||
> 而是回答「**为什么买 / 为什么卖**」,且数字全部来自引擎当时的计算:
|
||
> - `ActionRecord.reason`(`TradeReason{code,text,data}`)覆盖**成交与未成交**的每个买卖点:
|
||
> 名次 / 候选数 / 综合分 / 各因子**原始值** / 持有交易日 / 现金预算 / 涨停比值…;
|
||
> 原因分类是封闭词表(`quant/trade_reasons.py`),组合引擎与单策略引擎共用同一套构造器,
|
||
> 两个引擎对同一件事不会写出两种说法。
|
||
> - 区分「**跌出 TopN**」「**不在候选池**(被股票池/条件过滤,如转 ST)」「**全量换仓**」
|
||
> (单策略引擎每次调仓先清仓再建仓,被卖出的股票可能仍排在 TopN 内,这时不能写「跌出 TopN」)
|
||
> 「**Tmin 保护暂留**」「**超 Tmax 强制了结**」「涨停/停牌/现金不足/不足最低佣金」。
|
||
> - `result.factor_curves`:每个策略因子一条曲线 = 当日**持仓按市值加权平均的原始值**
|
||
> (不做 z-score、不按方向取反,**空仓日不落点、不插值、不用 0 填充**),带
|
||
> `label / direction / unit`,界面据此写明口径(如「股息率 %」。收益曲线对照成交日,
|
||
> 一眼看出「买在什么水平、卖在什么水平」)。
|
||
> - **每条曲线都能新页面放大**:`/charts/{归档id}?s={equity|drawdown|factor:<name>|sym:<code>|monthly}`。
|
||
> 放大页是 Server Component,数据从**归档**直出(URL 可分享、刷新还原同一张图);
|
||
> 结果没归档时不显示假按钮,而是写明「未归档,无法放大」。
|
||
> 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双),避免同一个综合分在理由原文与
|
||
> 数字标签里显示成两个数。
|
||
|
||
> **作业反馈(全站统一)**(2026-10):`lib/jobs.ts` 的 `useJobRunner` + `components/JobProgress.tsx`。
|
||
> 解决的问题是用户原话「点了回测没有任何反馈,不清楚是不是已经开始」:
|
||
> **提交瞬间**就显示「排队中 + 作业号 + 发起时间」,**已用时间每秒自增**(不等后端报新阶段),
|
||
> 阶段取后端真实 `stage`,排队/运行中可**取消任务**(`POST /jobs/{id}/cancel`),
|
||
> 失败显示后端原文、成功给「打开归档 / 去对比」;反馈条出现时自动滚入视野
|
||
> (运行按钮常在长表单底部,不滚过去等于没显示),并带 `role="status" aria-live="polite"`。
|
||
> 各页多个作业互不干扰(各自 `anchorId`)。
|
||
> 阶段小圆点**按作业类型分别定义**(`STAGE_PIPELINES`):因子测试是
|
||
> 「加载行情与因子数据 → 计算因子值 → 汇总指标与曲线」,回测/回测组合是
|
||
> 「加载行情与因子数据 → 撮合与净值结算 → 汇总指标与曲线」,异步选股只有
|
||
> 「逐择股日选股」一段 —— 后端没上报的阶段不会画成「○ 待开始」(那是看似有数据的假象),
|
||
> 后端新阶段的文案没跟上时也不会露出裸英文枚举(显示「执行中(stage)」并保留已推进的圆点)。
|
||
> `POST /api/signals` 是**同步**接口,页面如实写明「同步请求、没有作业号与阶段、不能取消」,
|
||
> 而不是给它编一个作业号。
|
||
|
||
**图表**:全站统一使用 **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
|
||
```
|
||
|
||
**回测结果契约**(跑一次真实区间,约 3 分钟;校验页面实际读取的每个字段):
|
||
|
||
```bash
|
||
cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py --end 2023-12-31
|
||
```
|
||
|
||
它除了净值/个股曲线/买卖点落点,还会断言:**每个买卖点都有结构化理由且原因代码在词表内**、
|
||
成交类理由带名次/候选数/综合分/因子原始值、`trades` 两端理由齐全、`factor_curves` 非空且
|
||
日期升序无重复 —— 也就是「买了什么原因、图上的因子曲线」这些字段真的在。
|
||
|
||
#### 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 # 全量测试(每个里程碑提交前均须通过,当前 524 条)
|
||
|
||
cd frontend/web
|
||
pnpm run typecheck # tsc --noEmit,0 error
|
||
pnpm run test:charts # 图表标记逻辑单测(7 条,node --test,无需额外依赖)
|
||
pnpm run build # 路由含 /strategies /backtest /experiments /experiments/[id] /charts/[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 # 回测结果结构契约:字段 + 买卖理由词表/名次/因子值 + 因子曲线(约 3 分钟)
|
||
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)、**买卖理由与因子曲线**
|
||
(理由词表封闭、数字来自引擎、因子曲线持仓市值加权且空仓不落点,见
|
||
`tests/test_trade_reasons.py` 8 条 + `tests/test_local_engine_reasons.py` 14 条)。
|
||
|
||
---
|
||
|
||
## 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 已隔离方言差异)。
|