# 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/.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 字)用 **整宽 `