@@ -25,7 +25,7 @@
| 异步与归档 | 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 · ECharts · Redis(本机 127.0.0.1:6379 已就绪,触发时接入)
**技术栈 ** : 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 已就绪,触发时接入)
---
@@ -67,7 +67,7 @@ cp .env.example .env
|---|---|---|
| `TUSHARE_TOKEN` | 同步数据时必填 | Tushare Pro token |
| `LLM_API_KEY` | 使用 Agent 时必填 | 大模型 API Key( URL/模型名在 config.yaml) |
| `DATABASE_URL` | 否 | 默认库 = `config.yaml → database.mysql` ( MySQL `192.168.1 .10 /qlib` );设此项可覆盖(如切回 SQLite) |
| `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` | 否 | 应用密钥(未接登录,可暂不改) |
@@ -83,7 +83,7 @@ api: {prefix: "/api"}
database :
url_env : "DATABASE_URL"
migrations_dir : ...
mysql : {enabled : true, host : "192.168.1 .10 " , port : 3306 ,
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" }
@@ -111,7 +111,14 @@ 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( `192.168.1.10:3306/qlib`),
> 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` 可复查一致性)。
---
@@ -120,16 +127,37 @@ DATABASE_URL='mysql+pymysql://user:pass@host/db' uv run alembic upgrade head
``` bash
cd backend
uv run python -m app.cli.sync basic # 股票基础信息(全市场 )
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 股披露节奏推算)的股票直接跳过;
@@ -238,10 +266,58 @@ curl -X POST http://127.0.0.1:8000/api/backtests \
}'
```
**高股息案例(两级截断 + 双周期 + 复权 + 条件过滤) ** ——同一 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` | 股票列表/搜索 |
| `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` | 因子组合保存 / 列表(方向由注册表填充) |
@@ -249,16 +325,20 @@ curl -X POST http://127.0.0.1:8000/api/backtests \
| `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` | 保存 / 列表命名策略 |
| `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 + 初始资金) |
| `POS T /api/factor-tests` | 同步单因子测试 → `FactorTe stR eport` |
| `POST /api/backtests` | 同步回测 → `BacktestResult` (默认 LocalEngine) |
| `GE T /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` | 实验 归档列表(收益摘要/代码版本 ) |
| `GET /api/experiments/{id}` | 实验详情(spec + 完整结果) |
| `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/成交)/明细 |
@@ -272,6 +352,93 @@ curl -X POST http://127.0.0.1:8000/api/backtests \
`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
```
---
## 7. AI Agent
@@ -317,17 +484,28 @@ Agent 能力边界(10 个内置受控工具,只读 + 受控写库):
``` bash
cd backend
uv run ruff check app tests && uv run ruff format --check app tests
uv run pytest # 全量测试(每个里程碑提交前均须通过)
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 388 条 )
cd frontend/web
pnpm run typecheck
pnpm run build
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 # 策略库/说明/名称/选股直通/归档链路(56 项,约 4 分钟)
PYTHONPATH = . .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
```
覆盖重点:Provider 归一化与 Failover 审计、Repository 幂等与「未来函数阻断」
( `as_of_date` /`announce_date` )、Alembic 迁移、因子/IC/分层、回测记账与成本/涨跌停、
**数据库目标守卫 ** ( `192.168.1.10` 被拒 / 本机放行 / 列表可用环境变量覆盖)、
**QlibEngine 数据管线 ** ( bin 落盘格式/roundtrip/端到端回测)、Job 状态机与 Experiment
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端。
归档、Agent 工具白名单与编排、LLM 配置加载、API 端到端、**策略说明推导**
( `describe_strategy` 的分支/互斥/缺失值语义)、**股票名称回填**(回测与选股两侧)。
---
@@ -338,17 +516,78 @@ pnpm run build
(见 docs/DEV_PLAN_v2.md §6.4)。
2. **全市场选股为同步请求 ** :无白名单的全市场选股约需 60s+;Agent 工具要求传 `symbols`
白名单,全市场请在 Web 页执行(后续可迁异步 Job)。
3. **数据规模 ** :当前 MySQL 已同步全市场(stock 5556 只、stock_daily 约 780 万行 、
adjust_factor 约 790 万行,2026-09 由 SQLite 迁移并校验一致)。
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. **Agent 结论质量取决于模型 ** :编排只保证「经受控工具 + 归档留痕」,研究有效性判断
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` 命名。
6. **SSE 与 Job 为单进程本地执行 ** :重启进程后未完成 Job 需重新提交(第一阶段刻意
10. **SSE 与 Job 为单进程本地执行 ** :重启进程后未完成 Job 需重新提交(第一阶段刻意
不引入 Redis/Celery;本机 Redis 已就绪,出现排队/长任务需求时按 DEV_PLAN §7 接入)。
7. **Qlib 数据为进程内单例初始化 ** : `qlib.init` 以 provider_uri 为键幂等,切换不同
qlib_dir 会重新初始化;测试环境建议注入独立临时目录。
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 线、基本信息、复权切换不受影响)。
---
@@ -364,11 +603,13 @@ pnpm run build
`pd.read_parquet(...)` 直接读取(pyarrow 已随依赖安装)。
- **前端连不上后端 / NetworkError**:前端默认经 Next **同源代理**访问 `/api/*`
( next.config.ts rewrites → `127.0.0.1:8000` ,可用 `BACKEND_API_URL` 覆盖),
任意 IP 访问 `:3000` 都不需要 CORS 或硬编码后端地址; 后端 CORS 开发期为 `*` 。
后端 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 库无此问题。
- **数据库被改动想重置**:默认 MySQL( qlib @192 .168.1 .10 )时在远端 重建后
- **数据库被改动想重置**:默认库为本机 MariaDB( qlib @127 .0.0 .1)时重建库 后
`uv run alembic upgrade head` (行情需重新同步或从备份恢复);若切回 SQLite 兜底库则删除
`data/quant.db` 后重建。
- **默认库已是 MySQL**( config.yaml `database.mysql` ,密码在 `.env` 的 `MYSQL_PASSWORD` );