Files
qlib/docs/USAGE.md
T
Simon bb48c91853 docs: 同步「买卖理由 / 因子曲线 / 曲线放大 / 作业反馈」与门禁条数
- §6.3 新增两块说明:①回测结果的买卖理由(封闭词表、成交与未成交都覆盖、
  区分跌出 TopN / 不在候选池 / 全量换仓 / Tmin / Tmax / 涨跌停 / 现金不足)、
  因子曲线口径(持仓市值加权原始值、空仓不落点、带方向与单位)、
  `/charts/{id}?s=...` 放大页(Server Component,数据从归档直出,未归档不给假按钮);
  ②全站统一的作业反馈(useJobRunner + JobProgress:提交瞬间有字、已用每秒自增、
  可取消、失败给后端原文、成功给归档入口、自动滚入视野)。
- 门禁条数更新为 524;回测结果契约脚本补充「理由词表/名次/因子值/因子曲线」断言说明;
  列出新增的两个理由测试文件。
2026-10-01 18:10:29 +08:00

57 KiB
Raw Blame History

qlib-platform 使用说明

适用代码版本:M0–M5 + M-DB + M6 选股系统 / M7 因子层 / M8(Signal·Portfolio·Strategy·Agent·Web) 全部完成(数据库已切换 MySQL);若文档与代码不一致,以代码与 ROADMAP.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. 环境准备

# 工具(本机已装则跳过)
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(首次)

前端:

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。


3. 配置

3.1 根目录 .env(密钥,不入库)

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):

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 数据库迁移

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. 数据同步与导出

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. 启动

一键脚本(后端 + 前端同时管理):

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

手动分别启动见下:

# 后端(端口 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 → 回测。

切换示例(代码内):

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 同构):

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/脚本一致:

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 链路):

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 不能,工具明确拒绝,不假装能救
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)。

图表:全站统一使用 TradingView Lightweight Charts(components/charts/LwChart.tsx), ECharts 已从依赖中移除。买卖点标记(▲绿=买入 / ▼红=卖出)只落在该 series 真实存在的交易日上, 并按时间升序提交(Lightweight Charts 的硬约束),因此不会出现标记丢失或错位。

股票名称:后端在 SelectionCandidate / SymbolCurve / ActionRecord / RankedPick / Position / Trade 上都填充 name(由 GET /api/stocks/names 一次性映射), 前端 SymbolLink 保证「有代码必有名称、且可点击进入个股页(基本信息 + 走势图)」; 名称确实缺失时显示灰色「—」而不猜测、不臆造。

自检脚本(接口 + 页面契约,约 2 分钟含一次真实回测):

cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py
# 只验接口与页面(跳过回测 Job):加 --skip-job

回测结果契约(跑一次真实区间,约 3 分钟;校验页面实际读取的每个字段):

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):

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)。

curl -X POST http://127.0.0.1:8000/api/agent/chat \
  -H 'Content-Type: application/json' \
  -d '{"message": "帮我测试 momentum_60 因子并跑一次 Top5 月度回测,评估是否值得深入"}'

返回:

{
  "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. 测试与质量门禁

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,用于验证「页面实现」与「后端字段」不漂移):

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 已隔离方言差异)。