docs+chore: 同步操作说明与端到端自检(字段库/单位换算、因子参数化)

- docs/USAGE.md:
  · 因子层一行改为「代码注册表投影 + 参数化实例,参数写在名字里以冻结口径」;
  · 新增 `GET/POST/PATCH /api/factors`、`GET /api/factors/templates` 与
    `/api/condition-fields`、`/fields` 的说明;
  · 新增「参数化因子(2026-10)」块:受控范围、键必须写全参数(缺项就靠可改的
    默认值兜底 = 追溯改义,所以拒绝)、口径文案按代码收敛、停用 ≠ 删除、
    参数化因子也能当过滤条件;
  · 自检清单一并更新(pytest 500 条;verify_strategy_workspace 145 项 skip-job;
    verify_ui_alignment 8 页 160 项;新增 verify_unit_conversion、verify_factor_params)。
- scripts/:新增 verify_unit_conversion.py(单位只能在给定范围里选 + 界面单位⇄
  基准单位换算)、verify_factor_params.py(参数暴露/界面新建/越界拒绝/停用语义,
  跑完自动清掉临时因子);verify_strategy_workspace.py 加 [5.7b] 因子参数化一节,
  临时因子的清理挪进 finally(断言中途失败也不给真人库留垃圾)。
- .gitignore:docs/screenshots/ 是临时验证证据,不入库(文件留在磁盘)。
This commit is contained in:
Simon
2026-10-01 16:38:54 +08:00
parent f13b34c59e
commit 36fe018075
6 changed files with 1317 additions and 62 deletions
+2
View File
@@ -53,6 +53,8 @@ experiments/*
# ================= archify 视觉验证副产物(重新生成即可) =================
docs/diagrams/*.visual-check.*
# 界面自检截图:临时证据,不入库(要看图直接打开磁盘上的文件)
docs/screenshots/
# ================= 运行期(dev.sh 管理) =================
.run/
+111 -24
View File
@@ -17,7 +17,7 @@
|---|---|
| 数据层 | `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`) |
| 因子层 | `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) |
@@ -319,7 +319,15 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/run_dividend_case.py --he
| `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) |
| `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` 过滤) |
@@ -354,34 +362,111 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/run_dividend_case.py --he
### 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` | 命名保存选股规则,管理增删改查 | 每个策略展示**一句话说明 + 计算公式 + 执行步骤 + 注意事项**(后端由 spec 真实推导),列表内可直接**一键回测**,编辑为**原地更新** |
| ③ 验规则 | `/backtest` | 两级截断(候选池 n → 持仓 x)+ 双周期(每 m 月择股 / 每 y 月调仓)回测 | 净值/个股曲线标买卖点;参数区与「策略说明」同屏实时联动;**保存为策略**;**载入上次结果**(免重跑);作业显示**真实阶段**与已用时间;结果分区锚点导航 |
| ④ 复盘 | `/experiments` | 勾选 2~3 次回测对比;**搜索/按类型筛选**后「打开归档」 | 归一化净值曲线叠加 + 指标差值表(✅/⚠️ 标注改善方向)+ `config_snapshot` **参数 diff**(默认只显示有差异项)|
| ⑤ 存档 | `/experiments/{id}` | **只读归档快照**:任意时候都能翻回来看 | 明确回答「**选股条件**」与「**交易执行依据**」+ 完整结果(与刚跑完时同一套图表)+ 归档元数据(代码版本/数据版本)+ 导出 JSON |
| ① 出候选 | `/selection` | 因子评分 TopN / 条件选股(可指定历史时点) | 候选表**代码 + 名称**且可点击进个股页 |
| ② 定规则 | `/strategies` | 命名保存**选股条件组合**(股票池+因子+条件),增删改查 | 每个策略展示后端推导的**一句话说明 + 选股口径**;卡片可直接「**加入回测组合**」;过滤条件的字段从**字段库**分组下拉选择并显示口径 |
| ②″ 管字段 | `/fields` | 维护过滤条件的**字段库**:中文名 / 含义口径 / **界面单位** / 启用状态,新增与删除自定义字段 | 字段来自引擎注册表(保证真能算);比较符按类型收窄;单位下拉只列注册表给的档(如 万元/亿元)并注明基准单位;页内说明「因子 vs 条件」关系 |
| ②′ 设成本 | `/settings` | 维护全局唯一的费率 / 滑点 / 复权口径 / 基准 | 所有回测组合共用;改动只影响之后的回测,已归档按各自快照复现 |
| ③ 验规则 | `/backtest` | 勾选 ≥1 个选股策略 + 填回测参数 → **保存为组合 / 直接运行**;下方「**已保存的回测组合**」库可载入 / 直接运行 / 删除 | 公共配置只读展示;持仓天数区间 [Tmin,Tmax] + 调仓 日/周/月;组合库让保存过的组合能找回来;结果分区锚点导航 |
| ④ 复盘 | `/experiments` | 勾选 2~3 次回测对比;**搜索/按类型筛选**后「打开归档」 | 归一化净值曲线叠加 + 指标差值表(✅/⚠️ 标注改善方向)+ `config_snapshot` **参数 diff** |
| ⑤ 存档 | `/experiments/{id}` | **只读归档快照**:任意时候都能翻回来看 | 单策略回测显示「选股条件 + 交易执行依据」;**组合回测**显示「引用的策略 + 回测参数 + 运行时配置快照」+ 完整结果 + 元数据 + 导出 JSON |
三条「直通」链路(都可分享 URL、刷新后仍生效):
几条「直通」链路(都可分享 URL、刷新后仍生效):
```
/selection ──「按此条件回测」──▶ /backtest?from_selection=SEL-xxxx 预填条件/因子/TopN/复权口径
/strategies ──「一键回测」────▶ /backtest 展开 spec → 提交 Job(页内出指标)
/experiments ──「以此参数再跑」─▶ /backtest?from_experiment=EXP-xxxx 复用该实验的参数快照
/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 表达「这就是那次回测」,刷新/换设备/分享链接都能看。
> 页面顶部两块表明确定义了这次回测的口径:
> **① 选股条件**(股票池 / 因子与权重及方向 / 过滤条件 / 两级截断 n→x / 择股周期 m / 调仓周期 y / 买不进时怎么办)
> **② 交易执行依据**(成交时点=调仓日收盘、复权口径、滑点后的买/卖价、佣金与印花税与最低佣金、
> 涨停/跌停/停牌如何拦单、顺延规则、期末是否平仓、对照基准),
> 并附后端按归档 `spec` 推导的**一句话说明 + 计算公式 + 执行步骤 + 注意事项**(与引擎实执行规则同源)。
> ⚠️ 口径提醒(页面上也有同样提示):从选股结果进入回测,预填的是**规则**(条件/因子/TopN/口径),
> 回测会在**每个择股日按同一规则重新选股**,不是固定持有那一次选出的股票。
> - **单策略回测**(旧 ResearchSpec 路径,`/api/backtests`)归档顶部两块表:**① 选股条件**
> (股票池 / 因子与权重及方向 / 过滤条件 / 两级截断 n→x / 择股周期 m / 调仓周期 y / 买不进时怎么办)
> **② 交易执行依据**(成交时点=调仓日收盘、复权口径、滑点后的买/卖价、佣金/印花税/最低佣金、
> 涨停/跌停/停牌如何拦单、顺延规则、期末是否平仓、对照基准)。
> - **组合回测**归档顶部改为「**回测组合**」卡:引用的选股策略 + 回测参数(N / [Tmin,Tmax] /
> 调仓时机 / 资金 / 区间)+ **交易执行依据(来自运行时的公共配置快照)**,并明确标注
> 「成本与复权是运行那一刻从公共配置快照下来的,事后改公共配置不影响本归档」。
#### 6.3.1 归档的日常操作(查看 / 导出 / 删除 / 恢复)
@@ -481,7 +566,7 @@ cd backend && PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace
**对齐自检**(用系统 Chrome + 原生 CDP,仅标准库;会独占随机端口与临时 profile):
```bash
python3 scripts/verify_ui_alignment.py # 7 个页面 × 1500/375px,共 140 项检查
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
```
@@ -540,7 +625,7 @@ Agent 能力边界(10 个内置受控工具,只读 + 受控写库):
```bash
cd backend
uv run ruff check app tests && uv run ruff format --check app tests
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 388 条)
uv run pytest # 全量测试(每个里程碑提交前均须通过,当前 500 条)
cd frontend/web
pnpm run typecheck # tsc --noEmit,0 error
@@ -552,9 +637,11 @@ pnpm run build # 13 条路由,含 /strategies /backtest /e
```bash
cd backend
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 策略库/说明/名称/选股直通/归档链路(59 项,约 4 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_strategy_workspace.py # 选股策略/字段库/因子目录+参数化/公共配置/回测组合库/归档链路(145 项 skip-job,含真实组合回测更多,约 5 分钟)
PYTHONPATH=. .venv/bin/python ../scripts/verify_backtest_page_contract.py # 回测结果结构契约(约 4 分钟)
python3 ../scripts/verify_ui_alignment.py # UI 对齐与控件一致性(140 项,约 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 幂等与「未来函数阻断」
+517
View File
@@ -0,0 +1,517 @@
"""端到端验证:因子参数化(暴露真实参数、可编辑、且真生效、真冻结)(2026-10)。
为什么单独一条契约脚本:因子参数写错**不会报错**,只会让策略按另一个口径选股
(「窗口 20」悄悄算成「窗口 90」= 另一个因子的结果)。所以这里从界面一路查到引擎:
1. `/factors`:目录暴露 template/params/param_specs(中文名含参数、允许范围 2~500、
方向二选一);展开行给出精确引擎键与来源;页面不再是「不可修改」的旧说法。
2. `/factors`:用界面新建参数化因子(窗口 90、越低越好)—— 预览键必须与后端规范键
逐字符一致;创建成功后 API 里出现该行,中文名/回看/方向全部由引擎投影。
3. `/factors`:越界参数(窗口 999)在界面上被拒,且**没有**留下任何垃圾行。
4. `/factors`:停用/启用真的写库,且停用后仍能被引擎解析(历史不变义)。
5. `/strategies`:条件字段下拉里出现该参数化因子(因子目录并入字段库),
因子下拉显示中文名(含参数)而不是又长又生的引擎键。
6. 375px:页面无横向溢出、几何检查全过。
7. 收尾:删掉验证期间创建的临时因子(目录没有删除接口,直接按主键清),回到 11 条内置。
需要浏览器可用的 Web(:3000)与 API(:8000)。复用 verify_ui_alignment 的 CDP 管线
(系统 Chrome,无需 playwright)。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, "/Users/summer/project/qlib/scripts")
from verify_ui_alignment import CDP, check_geometry, find_chrome, free_port, probe # noqa: E402
WEB = "http://127.0.0.1:3000"
API = "http://127.0.0.1:8000"
TMP_KEY = "momentum(window=90,direction=lower_is_better)"
BAD_KEY = "momentum(window=999,direction=lower_is_better)"
FACTORS_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const rows = [...document.querySelectorAll("table.tbl tbody tr")];
const find = (name) => rows.find((r) => (r.innerText || "").includes(name));
const mv = find("momentum_60");
const mvCells = mv ? [...mv.querySelectorAll("td")].map((td) => t(td)) : [];
const probe = find("__PROBE__");
const probeCells = probe ? [...probe.querySelectorAll("td")].map((td) => t(td)) : [];
const probeBtn = probe
? [...probe.querySelectorAll("button")].map((b) => t(b))
: [];
return JSON.stringify({
rowCount: rows.length,
headers: [...document.querySelectorAll("table.tbl thead th")].map((th) => t(th)),
momentumCells: mvCells,
probeCells,
probeButtons: probeBtn,
// 展开行的文本:主行的下一个兄弟 tr(class=expand-cell)就是展开内容
probeExpandedText: probe ? t(probe.nextElementSibling) : null,
bodyText: t(document.body),
});
})()
"""
PREVIEW_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const w = document.querySelector("#factor-new-window");
const d = document.querySelector("#factor-new-direction");
const all = t(document.body) || "";
// 预览键 = <b class="mono">模板名(参数=值,…)</b>。**不要**扫整页文本:
// 页面说明里也有一个示例键,扫文本会拿示例当预览(假通过)。
const previewKeys = [
...new Set(
[...document.querySelectorAll("b.mono")]
.map((el) => t(el))
.filter((x) => x && /^[a-z_][a-z0-9_]*\(/.test(x)),
),
];
return JSON.stringify({
windowValue: w ? w.value : null,
windowMin: w ? w.min : null,
windowMax: w ? w.max : null,
directionOptions: d ? [...d.options].map((o) => o.textContent.trim()) : [],
directionValue: d ? d.value : null,
templateOptions: [...(document.querySelectorAll("#factor-new-template option") || [])]
.map((o) => o.textContent.trim()),
previewKeys,
bodyText: all,
});
})()
"""
STRATEGY_FORM_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const factorSel = document.querySelector('select[aria-label="第 1 个因子的名称"]');
const condSel = document.querySelector('select[aria-label="第 1 个条件的字段"]');
// 注意:<option> 没有 innerText(它不被渲染),只能用 textContent
const opts = (sel) =>
sel
? [...sel.querySelectorAll("option")].map((o) =>
(o.textContent || "").replace(/\s+/g, " ").trim(),
)
: [];
// optgroup 的 label 是字符串(不是元素):别喂给只处理元素的 t()
const groups = (sel) =>
sel
? [...sel.querySelectorAll("optgroup")].map((g) =>
(g.getAttribute("label") || "").trim(),
)
: [];
return JSON.stringify({
hasFactorSelect: !!factorSel,
hasCondSelect: !!condSel,
factorOptions: opts(factorSel),
condOptions: opts(condSel),
condGroups: groups(condSel),
});
})()
"""
def api(method: str, path: str, body: dict | None = None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{API}{path}",
method=method,
data=data,
headers={"Content-Type": "application/json"} if data else {},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "{}")
def eval_json(cdp: CDP, expr: str, wait: float = 1.0):
time.sleep(wait)
r = cdp.call(
"Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True}
)
val = r.get("result", {}).get("value")
if isinstance(val, str):
try:
return json.loads(val)
except json.JSONDecodeError:
return val
return val
def click_by_text(cdp: CDP, text: str, nth: int = 0, exact: bool = True) -> str:
expr = (
"(() => { const bs=[...document.querySelectorAll('button,a')];"
f"const m=bs.filter(x=>x.innerText.replace(/\\s+/g,'').trim()"
f"{'===' if exact else '.includes('}'{text}'{')' if not exact else ''});"
f"const b=m[{nth}];"
"if(!b) return 'not-found'; b.click(); return 'clicked'; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def click_row(cdp: CDP, row_needle: str) -> str:
"""点整行展开定义(页面的展开热区是 <tr> 本身,不是按钮)。"""
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
f"const r=rows.find(x=>x.innerText.includes({row_needle!r}) && "
"!x.classList.contains('expand-cell'));"
"if(!r)return 'no-row';r.click();return 'clicked';})()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def click_in_row(cdp: CDP, row_needle: str, btn_text: str) -> str:
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
f"const r=rows.find(x=>x.innerText.includes({row_needle!r}));if(!r)return 'no-row';"
f"const b=[...r.querySelectorAll('button')].find(x=>x.innerText.includes({btn_text!r}));"
"if(!b)return 'no-btn';b.click();return 'clicked';})()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def set_input(cdp: CDP, selector: str, value: str) -> str:
expr = (
"(() => {"
f"const el=document.querySelector({selector!r});"
"if(!el) return 'not-found';"
"const proto=el.tagName==='SELECT'?HTMLSelectElement.prototype:HTMLInputElement.prototype;"
"const setter=Object.getOwnPropertyDescriptor(proto,'value').set;"
f"setter.call(el,{value!r});"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"el.dispatchEvent(new Event('change',{bubbles:true}));"
"return el.value; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value", f"<err {r.get('exceptionDetails', {}).get('text')}>")
BACKEND = "/Users/summer/project/qlib/backend"
PY = os.environ.get("QLIB_PY") or f"{BACKEND}/.venv/bin/python"
def cleanup_factor(key: str) -> bool:
"""删掉临时参数化因子:目录没有删除接口(设计如此),所以按主键直接清库。
走子进程 + 后端 venv 解释器:本脚本刻意只用标准库(CDP 管线不依赖第三方),
但删库要用 SQLAlchemy/ORM 配置,不能要求调用方先把 venv 激起来。
"""
code = (
"import sys; sys.path.insert(0, %r)\n"
"from sqlalchemy import delete\n"
"from app.infrastructure.persistence.sqlalchemy.models.factor import FactorDefinitionModel\n"
"from app.infrastructure.persistence.sqlalchemy.session import SessionLocal\n"
"with SessionLocal() as s:\n"
" n = s.execute(delete(FactorDefinitionModel).where(FactorDefinitionModel.name == %r)).rowcount\n"
" s.commit()\n"
"print(n)\n"
) % (BACKEND, key)
out = subprocess.run([PY, "-c", code], capture_output=True, text=True, timeout=120)
if out.returncode != 0:
raise RuntimeError(out.stderr.strip()[-200:])
return out.stdout.strip() == "1"
def main() -> int:
chrome = os.environ.get("QLIB_CHROME") or find_chrome()
if not chrome:
print("no chrome", file=sys.stderr)
return 2
port = free_port()
profile = tempfile.mkdtemp(prefix="qlib-factorparams-")
proc = subprocess.Popen(
[
chrome,
f"--remote-debugging-port={port}",
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--disable-extensions",
f"--user-data-dir={profile}",
"--window-size=1500,1200",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
failed = 0
created = False
original_enabled: bool | None = None
def report(good: bool, name: str, extra: str = "") -> None:
nonlocal failed
print(f" {'✅' if good else '❌'} {name}" + (f" — {extra}" if extra else ""), flush=True)
failed += 0 if good else 1
try:
ws = None
for _ in range(80):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for tab in json.loads(r.read()):
if tab.get("type") == "page":
ws = tab["webSocketDebuggerUrl"]
break
if ws:
break
except Exception:
pass
time.sleep(0.3)
cdp = CDP(ws)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
before = {f["name"]: f for f in api("GET", "/api/factors")[1]}
original_enabled = before.get(TMP_KEY, {}).get("enabled")
# ---------- 1. 目录暴露真实参数 ----------
print("\n=== /factors @1500px:真实参数与允许范围 ===", flush=True)
probe(cdp, f"{WEB}/factors", 1500, 1200, 3.2)
data = eval_json(cdp, FACTORS_READ.replace("__PROBE__", "momentum_60"), wait=0.5)
print(f" 表头:{data['headers']}", flush=True)
print(f" momentum_60 行:{data['momentumCells']}", flush=True)
report("参数" in " ".join(data["headers"]), "表格有「参数」列", str(data["headers"]))
joined = " ".join(data["momentumCells"])
report("窗口 60" in joined and "越高越好" in joined, "行内显示真实参数(窗口 60 · 越高越好)", joined)
report("动量" in joined, "因子列显示中文名(含参数)", joined)
body = data["bodyText"] or ""
report("不可修改" not in body, "旧的「不可修改」说法已下线(不再说谎)")
report(
("新身份" in body or "不变义" in body) and "参数" in body,
"页面说明「改参数 = 新身份,旧因子/既有策略不变义」",
)
# 展开行:精确键 + 参数范围 + 来源
exp = click_row(cdp, "momentum_60")
print(f" 展开 momentum_60 → {exp}", flush=True)
detail = eval_json(cdp, FACTORS_READ.replace("__PROBE__", "momentum_60"), wait=0.8)
dtext = " ".join(detail["momentumCells"]) + " " + (detail["probeExpandedText"] or "")
report("momentum_60" in dtext, "展开行给出精确引擎键", dtext[-90:])
report("2 ~ 500" in dtext or "2~500" in dtext, "展开行给出参数允许范围(2 ~ 500)")
report("越高越好" in dtext and ("内置" in dtext or "注册表" in dtext), "展开行标出方向与来源")
# ---------- 2. 界面新建参数化因子(预览键必须与后端一致) ----------
print("\n=== /factors:用界面新建参数化因子 ===", flush=True)
picked = set_input(cdp, "#factor-new-template", "momentum")
print(f" 选模板 momentum → {picked}", flush=True)
prev = eval_json(cdp, PREVIEW_READ, wait=1.0)
report("动量" in " ".join(prev["templateOptions"]), "模板下拉显示中文名与已有实例",
str(prev["templateOptions"][:2]))
report(prev["windowValue"] == "20", "窗口默认值来自模板(20)", str(prev["windowValue"]))
report(
prev["windowMin"] == "2" and prev["windowMax"] == "500",
"窗口输入框带 min/max(2/500,受控)",
f"min={prev['windowMin']} max={prev['windowMax']}",
)
report(
prev["directionOptions"] == ["越高越好", "越低越好"],
"方向下拉只有两个中文选项(不是自由文本)",
str(prev["directionOptions"]),
)
# 两次赋值之间要留一拍:受控 <select> 的原生赋值会被 React 的提交还原,
# 连着改两个控件时第二个改动会丢(这是无头驱动的时序,不是页面 bug)
set_input(cdp, "#factor-new-window", "90")
time.sleep(0.6)
set_input(cdp, "#factor-new-direction", "lower_is_better")
time.sleep(0.6)
prev2 = eval_json(cdp, PREVIEW_READ, wait=0.9)
keys = prev2["previewKeys"] or []
ptext = prev2["bodyText"] or ""
print(f" 预览键:{keys}", flush=True)
report(TMP_KEY in keys, "预览的规范键与后端逐字符一致", str(keys))
report("动量(窗口 90,越低越好)" in ptext, "预览中文名含参数与方向", ptext[:120])
print(f" 点「新建」→ {click_by_text(cdp, '新建参数化因子')}", flush=True)
made = eval_json(cdp, PREVIEW_READ, wait=2.4)
code, rows = api("GET", "/api/factors")
cat = {f["name"]: f for f in rows}
created = TMP_KEY in cat
report(created, "创建成功:API 目录里出现该参数化因子", str(sorted(cat)[-3:]))
if created:
row = cat[TMP_KEY]
report(
row.get("label") == "动量(窗口 90,越低越好)"
and row.get("lookback") == 90
and row.get("direction") == "lower_is_better"
and row.get("source") == "custom",
"新因子口径由引擎投影(中文名/回看/方向/来源)",
json.dumps({k: row.get(k) for k in ("label", "lookback", "direction", "source")},
ensure_ascii=False),
)
report(
"不变义" in (made["bodyText"] or "") or "新身份" in (made["bodyText"] or ""),
"成功提示里写明「旧因子不变义」",
)
# ---------- 3. 越界参数在界面被拒,且不留垃圾行 ----------
print("\n=== /factors:越界参数被拒(不静默截断) ===", flush=True)
probe(cdp, f"{WEB}/factors", 1500, 1200, 2.5)
set_input(cdp, "#factor-new-template", "momentum")
time.sleep(0.8)
set_input(cdp, "#factor-new-window", "999")
time.sleep(0.6)
click_by_text(cdp, "新建参数化因子")
bad = eval_json(cdp, PREVIEW_READ, wait=2.2)
btext = bad["bodyText"] or ""
report("2 ~ 500" in btext, "界面显示后端拒绝原因(含允许范围 2 ~ 500)", btext[:0])
_, rows2 = api("GET", "/api/factors")
report(BAD_KEY not in {f["name"] for f in rows2}, "越界请求没有在目录里留下垃圾行")
# 在目录里能看到新建的因子行(中文名含参数)
data3 = eval_json(cdp, FACTORS_READ.replace("__PROBE__", TMP_KEY), wait=1.0)
report(
bool(data3["probeCells"]) and "窗口 90" in " ".join(data3["probeCells"]),
"目录表格里出现新因子并显示参数",
str(data3["probeCells"])[:120],
)
# ---------- 4. 停用 / 启用(只影响能否被选中) ----------
print("\n=== /factors:停用/启用真的写库 ===", flush=True)
acts = data3["probeButtons"]
print(f" 该行按钮:{acts}", flush=True)
clicked = click_in_row(cdp, TMP_KEY, "停用")
time.sleep(1.6)
_, rows3 = api("GET", "/api/factors")
off = {f["name"]: f.get("enabled") for f in rows3}.get(TMP_KEY)
report(clicked == "clicked" and off is False, "界面停用 → 库里 enabled=false", f"{clicked}/{off}")
click_in_row(cdp, TMP_KEY, "启用")
time.sleep(1.6)
_, rows4 = api("GET", "/api/factors")
on = {f["name"]: f.get("enabled") for f in rows4}.get(TMP_KEY)
report(on is True, "界面启用 → 库里 enabled=true", str(on))
# 停用不影响引擎解析(历史不变义)
code, _ = api("GET", f"/api/factors?include_disabled=true")
report(code == 200, "目录接口在停用状态下仍正常(停用不是删除)")
# ---------- 5. 策略表单里能用(因子 + 条件两条路径) ----------
print("\n=== /strategies:参数化因子能当因子用,也能当条件用 ===", flush=True)
probe(cdp, f"{WEB}/strategies", 1500, 1200, 3.0)
print(f" 点「新建选股策略」→ {click_by_text(cdp, '新建选股策略')}", flush=True)
time.sleep(1.2)
click_by_text(cdp, "添加因子")
click_by_text(cdp, "添加条件")
time.sleep(1.2)
form = eval_json(cdp, STRATEGY_FORM_READ, wait=1.0)
fopts = form["factorOptions"]
print(f" 因子下拉选项:{fopts[:3]}", flush=True)
report(
any("动量(窗口 90,越低越好)" in o for o in fopts),
"因子下拉显示中文名(含参数)而不是裸键",
str([o for o in fopts if "90" in o][:1]),
)
report(
any(TMP_KEY not in o and "动量(窗口 20" in o for o in fopts),
"内置实例也显示中文名(含各自参数)",
str([o for o in fopts if "momentum_20" in o][:1]),
)
copts = form["condOptions"]
report(
any(TMP_KEY in o for o in copts),
"条件字段下拉并入参数化因子(因子目录 → 字段库)",
str([o for o in copts if "momentum" in o][:2]),
)
report("因子" in (form["condGroups"] or []), "条件字段下拉有「因子」分组",
str(form["condGroups"]))
# ---------- 5b. 停用后不该出现在任何「选因子」的地方 ----------
print("\n=== 停用 = 不出现在选择列表(各页一致,不是假开关) ===", flush=True)
api("PATCH", "/api/factors", {"name": TMP_KEY, "enabled": False})
time.sleep(0.6)
pages = [
("/selection", "select[aria-label=\"第 1 个因子的名称\"]", "添加因子"),
("/signals", "select[aria-label=\"评分因子\"]", None),
]
for path, selector, add_btn in pages:
probe(cdp, f"{WEB}{path}", 1500, 1200, 2.6)
if add_btn:
print(f" {path} 点「{add_btn}」→ {click_by_text(cdp, add_btn)}", flush=True)
time.sleep(0.8)
# /signals 的因子下拉是 <Field label> 包出来的(没有 aria-label),
# 所以按「选项里有 momentum_」来定位那一个 select —— 资产名比选择器稳。
finder = (
"(() => {const sels=[...document.querySelectorAll('select')];"
"const sel=sels.find(s=>[...s.options].some(o=>/momentum/.test(o.textContent||'')));"
"return JSON.stringify(sel?[...sel.options].map(o=>(o.textContent||'').trim()):[]);})()"
if add_btn is None
else "JSON.stringify([...document.querySelectorAll(%r + ' option')]"
".map((o) => (o.textContent || '').trim()))" % selector
)
opts = eval_json(cdp, finder, wait=0.8)
opts = opts or []
print(f" {path} 因子下拉 {len(opts)} 项", flush=True)
report(
not any(TMP_KEY in o for o in opts),
f"{path} 停用的因子不出现在候选里",
str([o for o in opts if "momentum" in o][:2]),
)
api("PATCH", "/api/factors", {"name": TMP_KEY, "enabled": True})
time.sleep(0.5)
probe(cdp, f"{WEB}/signals", 1500, 1200, 2.4)
# 这里连 value 一起读:显示文字是「中文名 · 短键」(direction 在提示里说),
# 但提交给引擎的 value 必须还是**完整键** —— 两者都要验,缺一个就是骗人
pairs = eval_json(
cdp,
"(() => {const sels=[...document.querySelectorAll('select')];"
"const sel=sels.find(s=>[...s.options].some(o=>/momentum/.test(o.textContent||'')));"
"return JSON.stringify(sel?[...sel.options].map(o=>[o.value,(o.textContent||'').trim()]):[]);})()",
wait=0.8,
) or []
hit = [t for v, t in pairs if v == TMP_KEY]
report(
bool(hit) and "动量(窗口 90,越低越好)" in hit[0] and "momentum(window=90)" in hit[0],
"启用后该页按「中文名 · 短键」显示,而 value 仍是完整引擎键",
str(hit[:1] or pairs[:1]),
)
# ---------- 6. 375px:无溢出 + 几何 ----------
print("\n=== /factors @375px:无横向溢出 ===", flush=True)
g = probe(cdp, f"{WEB}/factors", 375, 1200, 3.0)
small = eval_json(cdp, FACTORS_READ.replace("__PROBE__", TMP_KEY), wait=0.8)
over = [o for o in g["overflows"] if o["scrollW"] > o["clientW"] + 2]
print(f" 溢出元素:{over[:4]}", flush=True)
report(not over, "375px 无横向溢出", str(over[:2]))
report(bool(small["probeCells"]), "375px 表格仍渲染出因子行", str(len(small["probeCells"])))
geo = check_geometry(g)
bad_geo = [(n, d) for ok, n, d in geo if not ok]
report(not bad_geo, f"375px 几何检查 {len(geo)} 项全部通过", str(bad_geo[:2]))
finally:
# ---------- 7. 收尾:删掉临时因子,回到只有内置实例 ----------
print("\n=== 收尾 ===", flush=True)
try:
if created:
cleanup_factor(TMP_KEY)
print(f"[cleanup] 已删除临时参数化因子 {TMP_KEY}", flush=True)
_, rows = api("GET", "/api/factors")
names = [f["name"] for f in rows]
extra = [n for n in names if "(" in n]
report(not extra, "目录回到只有内置实例(无临时参数化行)", str(extra))
report(len(names) == 11, "目录行数回到 11(内置实例)", str(len(names)))
except Exception as e: # noqa: BLE001 - 清理失败要显式说出来
print(f" ⚠ 清理失败(请手工检查 factor_definition):{e}", flush=True)
failed += 1
proc.terminate()
print(f"\n{'✅ 全部通过' if failed == 0 else f'❌ {failed} 项失败'}", flush=True)
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+393 -36
View File
@@ -8,7 +8,7 @@
- `GET /api/strategies/{id}/describe`、`POST /api/strategies/describe`:说明 + 计算公式;
- 回测结果里 `symbol_curves/positions/trades` 必须带 `name`(前端「代码必须配名称」依赖);
- 选股结果 `candidates[].name` 与 `config_snapshot`(选股 → 回测直通依赖它取回当时的规则);
- 页面 SSR:/strategies、/backtest、/experiments 必须 200 且含关键区块;
- 页面 SSR:/strategies、/backtest、/fields、/experiments 必须 200 且含关键区块;
- **归档链路**:`GET /api/experiments` 的 `X-Total-Count` 与 kind/q 过滤、归档详情含
`data_version`/`job_id`、`/experiments/{id}` 归档页 SSR 能渲染、`DELETE` 语义正确。
@@ -70,6 +70,17 @@ def call(method: str, path: str, body: object | None = None, timeout: float = 60
return e.code, e.read().decode()[:300]
def detail_of(payload) -> str:
"""从响应里取人类可读的错误说明。
`call()` 在 HTTPError 分支返回的是**原文串**(不是 dict),所以这里必须兼容两种形态 ——
直接对 str 调 .get() 会把自检脚本自己搞崩(真实踩过)。
"""
if isinstance(payload, dict):
return str(payload.get("detail") or payload)
return str(payload)
def get_text(url: str, timeout: float = 120.0) -> tuple[int, str]:
try:
with urllib.request.urlopen(url, timeout=timeout) as r:
@@ -78,24 +89,25 @@ def get_text(url: str, timeout: float = 120.0) -> tuple[int, str]:
return e.code, e.read().decode("utf-8", "ignore")[:200]
# 选股策略(2026-09 重构后只含「怎么选」;资金/持仓/调仓/费率/区间移到回测组合与公共配置)
STRATEGY = {
"name": f"契约自检-高股息-{int(time.time())}",
"description": "", # 故意留空:验证后端自动补全
"spec_type": "backtest",
"universe": {"exclude_st": True, "min_listing_days": 250},
"price_adjustment": "hfq",
"factors": [{"name": "dividend_yield", "weight": 1}],
"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,
},
}
# 回测组合(引用上面的选股策略 + 回测参数)—— 用于 [5] 的组合回测 Job
COMBO = {
"name": f"契约自检-组合-{int(time.time())}",
"strategy_ids": [], # 创建策略后回填
"initial_capital": 1000000,
"hold_count": 15,
"hold_min_days": 0,
"hold_max_days": 20,
"rebalance_freq": "monthly",
"period": ["2024-01-02", "2024-12-31"],
}
@@ -130,26 +142,32 @@ def main() -> int:
"说明为空时被自动补全(策略必须有说明)",
(saved.get("description") or "")[:80],
)
check(
saved.get("selection_interval_months") == 6 and saved.get("rebalance_interval_months") == 6,
"m/y 字段持久化(策略 JSON blob,无需迁移)",
f"m={saved.get('selection_interval_months')} y={saved.get('rebalance_interval_months')}",
)
# 选股策略不应持久化任何回测执行参数(重构核心约束)
for forbidden in ("selection_interval_months", "rebalance_interval_months", "selection", "costs", "rebalance"):
check(
forbidden not in saved,
f"选股策略不存回测参数字段 {forbidden}",
str(saved.get(forbidden)),
)
created_at = str(saved.get("created_at") or "")
# ---------- 3. 原地更新 ----------
print("[3] 原地更新(策略库「编辑」依赖)", flush=True)
upd = dict(STRATEGY)
upd["selection"] = {**STRATEGY["selection"], "top_n": 15, "hold_top_x": 10}
upd["conditions"] = [{"field": "dv_ratio", "op": "lt", "value": 15}]
st, after = call("PUT", f"/api/strategies/{created_id}", upd)
check(st == 200, "PUT /api/strategies/{id}", f"HTTP {st}")
if isinstance(after, dict):
check(after.get("id") == created_id, "id 不变", str(after.get("id")))
conds = after.get("conditions") or []
check(
(after.get("selection") or {}).get("top_n") == 15,
"参数已更新(n=15)",
str((after.get("selection") or {}).get("top_n")),
len(conds) == 1 and conds[0].get("value") == 15,
"条件已更新(dv_ratio < 15)",
str(conds),
)
# 选股策略不应再带回测执行参数字段(重构核心约束)
for forbidden in ("selection", "costs", "rebalance", "price_adjustment"):
check(forbidden not in after, f"选股策略不含回测参数字段 {forbidden}", str(after.get(forbidden)))
check(
str(after.get("created_at") or "") == created_at,
"created_at 未被刷新(避免「改一下就排最前」)",
@@ -176,10 +194,15 @@ def main() -> int:
check(bool(summary), "summary 非空(一句话说明)", summary[:90])
check("dividend_yield" in formula, "公式含因子名", "dividend_yield")
check("dv_ratio" in formula, "公式含过滤条件字段", "dv_ratio")
check("30" in formula, "公式含条件阈值", "30")
check(
any(k in formula for k in ("佣金", "印花", "滑点", "commission")),
"公式含成本项",
"dv_ratio" in formula or "≤" in formula or "<=" in formula or "30" in formula,
"说明体现过滤条件",
formula[:80].replace("\n", " "),
)
check(
"选股" in summary or "因子" in formula,
"说明体现选股口径(选股策略不讲成本/成交,那些在回测组合里)",
summary[:60],
)
check(bool(doc.get("steps")), "steps 非空(执行步骤)", f"{len(doc.get('steps') or [])} 步")
spec_probe = {
@@ -206,20 +229,339 @@ def main() -> int:
st, _ = call("POST", "/api/strategies/describe", bad)
check(st == 422, "allow_substitute 与 defer_buy 同真被拒(前端用三态单选规避)", f"HTTP {st}")
# ---------- 5. 展开 + 回测 ----------
print("[5] 展开为 Spec" + ("" if args.skip_job else " + 真实回测 Job"), flush=True)
st, spec = call(
# ---------- 5. 回测组合(取代旧的 /expand) ----------
print("[5] 回测组合" + ("" if args.skip_job else " + 真实组合回测 Job"), flush=True)
# /expand 已随重构移除(回测改由组合驱动)
st, _ = call(
"POST",
f"/api/strategies/{created_id}/expand",
{"period": ["2024-01-02", args.end], "initial_capital": 1000000},
)
check(st == 200 and isinstance(spec, dict), "POST /strategies/{id}/expand", f"HTTP {st}")
if isinstance(spec, dict):
check(spec.get("selection", {}).get("top_n") == 15, "展开沿用了更新后的参数", str(spec.get("selection")))
check(st in (404, 405), "/strategies/{id}/expand 已移除(回测改走 /api/combos)", f"HTTP {st}")
combo = dict(COMBO)
combo["strategy_ids"] = [created_id]
combo["period"] = ["2024-01-02", args.end]
st, saved_combo = call("POST", "/api/combos", combo)
check(st == 200 and isinstance(saved_combo, dict), "POST /api/combos(保存组合)", f"HTTP {st}")
combo_id = (saved_combo or {}).get("id") if isinstance(saved_combo, dict) else None
check(bool(combo_id), "返回组合 id", str(combo_id))
if not args.skip_job and isinstance(spec, dict):
st, job = call("POST", "/api/jobs", spec)
check(st == 200, "POST /api/jobs(策略 → 回测)", f"HTTP {st}")
# 组合库(列表 / 详情 / 原地更新)—— 前端「已保存的回测组合」卡片依赖这些读路径
st, combo_list = call("GET", "/api/combos")
check(st == 200 and isinstance(combo_list, list), "GET /api/combos(组合库列表)", f"HTTP {st}")
if isinstance(combo_list, list) and combo_id:
check(any(c.get("id") == combo_id for c in combo_list), "列表含刚保存的组合", str(combo_id))
st, detail = call("GET", f"/api/combos/{combo_id}")
check(
st == 200 and (detail or {}).get("hold_count") == COMBO["hold_count"],
"GET /api/combos/{id} 回读参数一致",
f"HTTP {st}",
)
# 原地更新:description 往返(前端「载入到表单 → 改 → 更新组合」链路)
upd_combo = {**combo, "description": "契约自检:更新后的组合说明", "hold_count": 8}
st, updated = call("PUT", f"/api/combos/{combo_id}", upd_combo)
check(
st == 200 and (updated or {}).get("description") == "契约自检:更新后的组合说明",
"PUT /api/combos/{id} 说明可往返(组合库编辑依赖)",
f"HTTP {st}",
)
check(
(updated or {}).get("hold_count") == 8,
"PUT 更新持仓数生效(不是静默忽略)",
str((updated or {}).get("hold_count")),
)
# 未知字段必须 422:拼错键名被静默忽略 = 用户以为设上了、其实没生效(AGENT 禁止降级)
st, _ = call("POST", "/api/combos/run", {**combo, "capital": 1})
check(st == 422, "组合含未知字段 → 422(不静默降级)", f"HTTP {st}")
st, _ = call("POST", "/api/strategies", {**STRATEGY, "costs": {"commission_rate": 0.001}})
check(st == 422, "策略含旧版回测参数 → 422(不静默丢弃)", f"HTTP {st}")
# ---------- 5.5 公共配置(全局唯一) ----------
print("[5.5] 公共配置(费率/印花税/滑点/复权,回测组合运行时快照)", flush=True)
st, cfg = call("GET", "/api/config")
check(st == 200 and isinstance(cfg, dict), "GET /api/config", f"HTTP {st}")
if isinstance(cfg, dict):
check(cfg.get("id") == "default", "公共配置是单例(id=default)", str(cfg.get("id")))
for key in (
"commission_rate", "stamp_tax_rate", "slippage_rate",
"min_commission", "price_adjustment", "benchmark",
):
check(key in cfg, f"公共配置含 {key}", str(cfg.get(key)))
st, _ = call("PUT", "/api/config", {"slippage": 0.001}) # 故意写错键名:必须被拒
check(st == 422, "公共配置含未知字段 → 422(拼错键名不静默忽略)", f"HTTP {st}")
# 策略库说明不得再出现回测执行词:旧自动文案(「每 6 个月调仓…含佣金…」)已被
# c5d6e7f8a9b0 重算,这里做数据层回归守卫,防止再出现自相矛盾的策略说明。
_, all_st = call("GET", "/api/strategies")
stale_desc = [
(s or {}).get("id")
for s in (all_st if isinstance(all_st, list) else [])
if any(
m in ((s or {}).get("description") or "")
for m in ("佣金", "印花税", "滑点", "调仓", "复权口径")
)
]
check(not stale_desc, "策略库无陈旧说明(不含调仓/成本词)", str(stale_desc))
# ---------- 5.6 字段库(过滤条件的字段目录,2026-10) ----------
print("[5.6] 字段库(条件字段目录:中文名/含义/类型收窄/自定义增删)", flush=True)
st, fields = call("GET", "/api/condition-fields")
check(st == 200 and isinstance(fields, list) and fields, "GET /api/condition-fields(首次读取自动 seed)", f"HTTP {st},{len(fields) if isinstance(fields, list) else '?'} 条")
by_name = {f.get("name"): f for f in (fields if isinstance(fields, list) else [])}
check("dv_ratio" in by_name, "字段库含 dv_ratio", str(sorted(by_name)[:5]))
for key in ("close", "ma60", "static.industry", "fundamental.roe", "dividend_yield"):
check(key in by_name, f"字段库含 {key}", "缺失")
dv = by_name.get("dv_ratio") or {}
check(bool(dv.get("label")) and bool(dv.get("description")), "字段有中文名与含义(下拉要显示)", f"{dv.get('label')} / {(dv.get('description') or '')[:24]}")
check(dv.get("kind") == "num" and dv.get("unit") == "%", "字段带类型与单位", f"{dv.get('kind')} {dv.get('unit')}")
ind = by_name.get("static.industry") or {}
check(
set(ind.get("ops") or []) == {"eq", "ne", "in", "not_in"},
"文本字段比较符收窄为 等值/集合(不摆出恒为假的 >)",
str(ind.get("ops")),
)
check(
set(dv.get("ops") or []) == {"gt", "gte", "lt", "lte", "eq", "ne"},
"数值字段比较符合法集合",
str(dv.get("ops")),
)
st, avail = call("GET", "/api/condition-fields/available")
check(st == 200 and isinstance(avail, list), "GET /api/condition-fields/available", f"HTTP {st},{len(avail) if isinstance(avail, list) else '?'} 条")
# 单位:只能从注册表给的阶梯里选(界面单位),基准单位不可改 —— 换算在界面层做,
# 库里/引擎里永远是基准单位,所以改单位不会让历史策略变义。
mv = by_name.get("total_mv") or {}
check(mv.get("base_unit") == "万元", "字段响应带基准单位 base_unit(引擎口径)", str(mv.get("base_unit")))
check(
[(u.get("unit"), u.get("factor")) for u in (mv.get("units") or [])] == [("万元", 1.0), ("亿元", 10000.0)],
"总市值的可选界面单位 = 万元/亿元(系数 1/10000)",
str(mv.get("units")),
)
check(
[(u.get("unit"), u.get("factor")) for u in (by_name.get("close") or {}).get("units") or []]
== [("元", 1.0)],
"没有备选单位的字段只有基准单位一项(界面不给选择)",
str((by_name.get("close") or {}).get("units")),
)
st, _ = call("PUT", "/api/condition-fields/total_mv", {"unit": "亿亿元"})
check(st == 422, "自由文本单位 → 422(单位只在你给的范围内选)", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/total_mv", {"unit": "元"})
check(st == 422, "不在该字段阶梯里的单位 → 422(总市值没有「元」这一档)", f"HTTP {st}")
st, unit_case = call("PUT", "/api/condition-fields/total_mv", {"unit": "亿元"})
check(
st == 200 and (unit_case or {}).get("unit") == "亿元" and (unit_case or {}).get("base_unit") == "万元",
"选界面单位 亿元:生效,且基准单位仍是 万元",
f"HTTP {st} unit={(unit_case or {}).get('unit')} base={(unit_case or {}).get('base_unit')}",
)
st, _ = call("PUT", "/api/condition-fields/total_mv", {"unit": "万元"})
check(st == 200, "单位改回基准单位 万元(自检收尾,不留痕)", f"HTTP {st}")
st, _ = call("POST", "/api/condition-fields", {"name": "static.list_date"})
check(st == 422, "新增不可计算的字段 → 422(拒绝伪字段,避免永远选不出股票)", f"HTTP {st}")
st, made = call("POST", "/api/condition-fields", {"name": "ps", "label": "自检-市销率"})
check(st == 200 and (made or {}).get("source") == "custom", "新增自定义字段(引擎支持的少用字段)", f"HTTP {st}")
st, _ = call("POST", "/api/condition-fields", {"name": "ps"})
check(st == 422, "重复新增 → 422(不静默覆盖)", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/ps", {"label": "自检-改名"})
check(st == 200, "编辑中文名/含义", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/ps", {"kind": "str"})
check(st == 422, "改 kind → 422(类型是引擎事实,不许改)", f"HTTP {st}")
st, _ = call("PUT", "/api/condition-fields/ps", {"enabled": False})
check(st == 200, "停用字段", f"HTTP {st}")
_, picker = call("GET", "/api/condition-fields?include_disabled=false")
check(
"ps" not in {f.get("name") for f in (picker if isinstance(picker, list) else [])},
"停用后不出现在条件选择器里",
"",
)
st, _ = call("DELETE", "/api/condition-fields/close")
check(st == 400, "删除内置字段 → 400(只能停用,否则下次读取又补回来)", f"HTTP {st}")
st, _ = call("DELETE", "/api/condition-fields/ps")
check(st == 200, "删除自定义字段", f"HTTP {st}")
# ---------- 5.7 因子目录(必须是代码注册表的投影,不是可编辑配置) ----------
print("[5.7] 因子目录(注册表投影:口径/方向/回看/依赖列与代码一致)", flush=True)
st, factors = call("GET", "/api/factors")
check(st == 200 and isinstance(factors, list) and factors, "GET /api/factors", f"HTTP {st}")
cat = {f.get("name"): f for f in (factors if isinstance(factors, list) else [])}
check("dividend_yield" in cat, "目录含股息率因子(历史 bug:后加的因子曾长期缺失)", str(sorted(cat)))
try:
from app.quant.factors import (
list_factors as _registry, # noqa: PLC0415 - 自检可用时才导入
)
drift = []
for d in _registry():
row = cat.get(d.name)
if row is None:
drift.append(f"{d.name}: 缺失")
continue
for k in ("description", "formula", "brief", "frequency", "lookback", "direction", "requires"):
want, got = getattr(d, k), row.get(k)
# requires 在库里是 JSON 数组、代码里是 tuple:比语义,不比容器类型
same = list(got or []) == list(want or []) if k == "requires" else got == want
if not same:
drift.append(f"{d.name}.{k}: 库={got!r} 代码={want!r}")
check(not drift, "目录与代码注册表逐字段一致(方向/口径/依赖列不许漂移)", "; ".join(drift[:3]))
except ImportError as e: # 没装后端依赖时如实跳过,不假装通过
print(f" ⚠ 跳过注册表比对({e}):请用 PYTHONPATH=. 在 backend 下运行", flush=True)
# ---------- 5.7b 因子参数化(窗口/方向可编辑,且真生效、可冻结) ----------
print("[5.7b] 因子参数化(模板 + 受控参数 + 参数化键 = 冻结口径)", flush=True)
mv = cat.get("momentum_60") or {}
check(
mv.get("label") == "动量(窗口 60,越高越好)",
"目录暴露中文名(含真实参数)",
str(mv.get("label")),
)
check(
(mv.get("params") or {}).get("window") == 60 and mv.get("template") == "momentum",
"目录暴露 template 与冻结参数",
f"template={mv.get('template')} params={mv.get('params')}",
)
specs = {s.get("name"): s for s in (mv.get("param_specs") or [])}
check(
specs.get("window", {}).get("maximum") == 500
and specs.get("window", {}).get("minimum") == 2
and specs.get("direction", {}).get("choices") == ["higher_is_better", "lower_is_better"],
"暴露可编辑参数与允许范围(窗口 2~500、方向二选一)",
f"window={specs.get('window')} direction={specs.get('direction')}",
)
probe_key = "momentum(window=91,direction=lower_is_better)"
try:
st, tpls = call("GET", "/api/factors/templates")
tpl_names = {t.get("name") for t in (tpls or [])} if isinstance(tpls, list) else set()
check(
st == 200 and {"momentum", "volatility", "volume_ratio", "dividend_yield"} <= tpl_names,
"GET /api/factors/templates(模板:可编辑参数与默认值)",
f"HTTP {st} {sorted(tpl_names)[:4]}",
)
st, made = call("POST", "/api/factors", {"template": "momentum", "params": {"window": 91, "direction": "lower_is_better"}})
created_key = (made or {}).get("name") if isinstance(made, dict) else None
check(st == 201 and created_key == probe_key, "POST /api/factors 新建参数化因子(参数写进名字)", f"HTTP {st} {created_key}")
check(
(made or {}).get("label") == "动量(窗口 91,越低越好)"
and (made or {}).get("lookback") == 91
and (made or {}).get("source") == "custom",
"新建行带回中文名/回看/来源(口径由引擎投影)",
f"{made.get('label') if isinstance(made, dict) else made}",
)
st, dup = call("POST", "/api/factors", {"template": "momentum", "params": {"window": 91, "direction": "lower_is_better"}})
check(st == 422 and "已存在" in detail_of(dup), "同参数不重复创建(422 + 已存在的名字)", f"HTTP {st} {detail_of(dup)[:60]}")
for bad_params, why in (
({"window": 999, "direction": "higher_is_better"}, "窗口越界"),
({"window": 90, "direction": "upper"}, "方向不在枚举里"),
({"window": 90, "direction": "higher_is_better", "foo": 1}, "多给了参数"),
):
st, bad = call("POST", "/api/factors", {"template": "momentum", "params": bad_params})
check(st == 422, f"越界/非法参数被拒:{why}", f"HTTP {st} {detail_of(bad)[:50]}")
st, bad_tpl = call("POST", "/api/factors", {"template": "no_such", "params": {}})
check(st == 422 and "未知因子模板" in detail_of(bad_tpl), "未知模板被拒", f"HTTP {st}")
# 参数真的进了引擎:lookback 与方向都要跟着变
try:
# 别名不要叫 _ok:本脚本的通过计数器就叫 _ok,遮蔽它会打印出函数对象
from app.quant.factors import get_factor as _gf # noqa: PLC0415
from app.quant.factors import is_resolvable as _resolvable # noqa: PLC0415
d91, _ = _gf(probe_key)
check(
d91.lookback == 91 and d91.direction == "lower_is_better" and "91" in d91.formula,
"参数化键在引擎里真解析(回看/方向/公式都带上了 91)",
f"lookback={d91.lookback} dir={d91.direction}",
)
check(
_resolvable("momentum(window=91)") is False,
"缺参数的短键被拒(不许靠模板默认值兜底)",
)
check(
_resolvable("momentum_20(window=5,direction=higher_is_better)") is False,
"实例名不能再带参数",
)
except ImportError as e:
print(f" ⚠ 跳过引擎解析比对({e})", flush=True)
# 参数化因子能当过滤条件(字段库取得到、无单位),并能被策略引用后回读
try:
from app.quant.condition_fields import get_field as _getf # noqa: PLC0415
fdef = _getf(probe_key)
check(
fdef is not None and fdef.kind == "num" and fdef.unit == "",
"参数化因子可当过滤条件(kind=num 且不瞎挂单位)",
f"{fdef}",
)
except ImportError as e:
print(f" ⚠ 跳过条件字段比对({e})", flush=True)
st, probe_st2 = call("POST", "/api/strategies", {
**STRATEGY,
"name": f"契约自检-参数化因子-{int(time.time())}",
"factors": [{"name": probe_key, "weight": 1}],
"conditions": [],
})
probe2_id = (probe_st2 or {}).get("id") if isinstance(probe_st2, dict) else None
check(st == 200 and probe2_id, "参数化因子能被策略引用并落库", f"HTTP {st}")
if probe2_id:
_, back = call("GET", f"/api/strategies/{probe2_id}")
got_f = ((back or {}).get("factors") or [{}])[0].get("name")
check(got_f == probe_key, "回读策略:因子名(含参数)原样存回(历史不变义)", str(got_f))
_, doc = call("GET", f"/api/strategies/{probe2_id}/describe")
doc_text = "\n".join(
[str((doc or {}).get("formula") or "")]
+ [str(x) for x in ((doc or {}).get("steps") or [])]
)
check(probe_key in doc_text, "策略说明书写明所用参数版本(读者要知道窗口是多少)", doc_text[:80])
pd2, _ = call("DELETE", f"/api/strategies/{probe2_id}")
print(f"[cleanup] 删除参数化因子自检策略 {probe2_id} → HTTP {pd2}", flush=True)
st, off = call("PATCH", "/api/factors", {"name": probe_key, "enabled": False})
check(st == 200 and (off or {}).get("enabled") is False, "PATCH 停用参数化因子", f"HTTP {st}")
_, again = call("GET", "/api/factors")
still = {f.get("name"): f.get("enabled") for f in (again or []) if isinstance(f, dict)}
check(still.get(probe_key) is False, "停用状态不被幂等同步冲掉", str(still.get(probe_key)))
st, on = call("PATCH", "/api/factors", {"name": probe_key, "enabled": True})
check(st == 200 and (on or {}).get("enabled") is True, "PATCH 启用(复原)", f"HTTP {st}")
st, bi = call("PATCH", "/api/factors", {"name": "momentum_60", "enabled": False})
check(st == 422, "内置实例不能停用(开关由代码决定)", f"HTTP {st}")
# (清理统一放在 finally 里,见下方:断言中途失败也不会留垃圾)
finally:
# 清理:目录没有删除接口(设计如此),按主键直接清;放在 finally 里,
# 即使上面的断言抛错也不会给真人库留下「自检因子」
try:
from app.infrastructure.persistence.sqlalchemy.models.factor import ( # noqa: PLC0415
FactorDefinitionModel as _M,
)
from app.infrastructure.persistence.sqlalchemy.session import ( # noqa: PLC0415
SessionLocal as _SL,
)
from sqlalchemy import delete as _delete # noqa: PLC0415
with _SL() as _s:
_s.execute(_delete(_M).where(_M.name == probe_key))
_s.commit()
print(f"[cleanup] 删除临时参数化因子 {probe_key}", flush=True)
except Exception as e: # noqa: BLE001 - 清理失败要显式说出来,别静默留着
print(f" ⚠ 清理临时因子失败(请手工检查 factor_definition):{e}", flush=True)
# 条件payload的两种形态必须能被服务端接受(ref 字段比较 / in 文本集合)
st, probe_st = call("POST", "/api/strategies", {
**STRATEGY,
"name": f"契约自检-条件形态-{int(time.time())}",
"conditions": [{"field": "close", "op": "gt", "ref": "ma60"},
{"field": "static.industry", "op": "in", "value": ["银行"]}],
})
check(st == 200, "条件支持「字段 vs 字段」(close>ma60) 与「属于」文本集合", f"HTTP {st}")
if st == 200 and isinstance(probe_st, dict) and probe_st.get("id"):
pd, _ = call("DELETE", f"/api/strategies/{probe_st['id']}")
print(f"[cleanup] 删除条件形态自检策略 {probe_st.get('id')} → HTTP {pd}", flush=True)
if not args.skip_job and combo_id:
st, job = call("POST", f"/api/combos/{combo_id}/run")
check(st == 200, "POST /api/combos/{id}/run(组合 → 回测 Job)", f"HTTP {st}")
job_id = (job or {}).get("job_id") if isinstance(job, dict) else None
if job_id:
t0 = time.monotonic()
@@ -342,8 +684,16 @@ def main() -> int:
# ---------- 7. 页面 SSR ----------
print("[7] 页面可访问性与关键区块", flush=True)
pages = [
("/strategies", ["策略库", "计算公式", "一句话说明"]),
("/backtest", ["选股回测", "保存为策略", "计算公式"]),
("/strategies", ["选股策略库", "新建选股策略", "股票池"]),
# 注意:只验 SSR 就能看到的静态文案。「直接运行/载入到表单」是组合列表
# 非空时客户端渲染出来的按钮,SSR 阶段列表还在加载,断言它们会假失败。
("/backtest", ["回测组合", "选择选股策略", "持仓数 N", "已保存的回测组合"]),
("/settings", ["公共配置", "交易成本与行情口径", "全局唯一"]),
# 字段库:说明卡是 SSR 静态内容,可断言;字段表格由客户端拉取后渲染
("/fields", ["字段库", "打分因子 与 过滤条件 是什么关系", "新增字段"]),
# 因子研究:说明「参数可改(新建参数化因子)、口径按代码收敛」必须是 SSR 文案 ——
# 参数化之前这里写的是「不可修改」,那句话现在已经不成立(不能留假话)
("/factors", ["因子目录", "投影", "参数", "新建参数化因子"]),
("/experiments", ["实验对比", "参数"]),
]
if exp_id:
@@ -394,6 +744,13 @@ def main() -> int:
)
finally:
if created_id:
# 先删引用它的组合(若有),再删策略
stc, combos = call("GET", "/api/combos")
if isinstance(combos, list):
for cb in combos:
if created_id in (cb.get("strategy_ids") or []):
sd, _ = call("DELETE", f"/api/combos/{cb.get('id')}")
print(f"[cleanup] 删除自检组合 {cb.get('id')} → HTTP {sd}", flush=True)
st, _ = call("DELETE", f"/api/strategies/{created_id}")
print(f"[cleanup] 删除自检策略 {created_id} → HTTP {st}", flush=True)
+11 -2
View File
@@ -56,7 +56,7 @@ import urllib.request
WEB = os.environ.get("QLIB_WEB", "http://127.0.0.1:3000")
DEFAULT_PAGES = ["/", "/backtest", "/strategies", "/selection", "/experiments", "/stocks", "/factors"]
DEFAULT_PAGES = ["/", "/backtest", "/strategies", "/selection", "/fields", "/experiments", "/stocks", "/factors"]
CHROME_CANDIDATES = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
@@ -216,6 +216,8 @@ PROBE = r"""
// 多行文本域:高度随行数变化,是「内容决定高度」的控件,
// 不参与「同排等高」(它本来就该比单行输入高)与「高度取值归一」比较
isTextarea: e.tagName === "TEXTAREA",
// 卡片式多选项(整张卡可点):高度由文案决定,不计入「控件高度归一」
cardPick: !!(e.closest && e.closest(".strategy-pick__item")),
x: r.x, y: r.y, w: r.w, h: r.h, fs: parseFloat(s.fontSize),
radius: s.borderRadius, padL: parseFloat(s.paddingLeft), padR: parseFloat(s.paddingRight),
placeholder: txt(e.getAttribute("placeholder")).slice(0, 30),
@@ -230,9 +232,15 @@ PROBE = r"""
required: e.hasAttribute("required"), disabled: e.hasAttribute("disabled") || e.disabled === true,
// 勾选/单选控件的真实点击目标是外层 label(.check / .radio-row),
// 16px 的方块永远达不到 28px;量 label 才是量「用户能不能点到」。
// 但「卡片式」多选 label(.strategy-pick__item:内含名称+说明+因子,
// 高度由文案决定,可达上百 px)不是「控件高度」概念,排除出归一比较。
hitH: (() => {
if (e.type !== "checkbox" && e.type !== "radio") return r.h;
const lab = e.closest("label");
if (lab && lab.classList.contains("strategy-pick__item")) {
// 整张策略卡都是热区:用卡片高度(远大于 28px),而非 16px 方块
return +lab.getBoundingClientRect().height.toFixed(1);
}
return lab ? +lab.getBoundingClientRect().height.toFixed(1) : r.h;
})(),
// pill / chip 是刻意的胶囊形控件,不参与圆角统一
@@ -332,7 +340,8 @@ def check_geometry(g: dict) -> list[tuple[bool, str, str]]:
out.append((not mixed, "同排控件等高", detail or f"{len(rows)} 行全部等高"))
# 2 高度取值归一
uniq = sorted({round(c["hitH"], 1) for c in ctrls if not c["optionRow"] and not c["isTextarea"]})
uniq = sorted({round(c["hitH"], 1) for c in ctrls
if not c["optionRow"] and not c["isTextarea"] and not c.get("cardPick")})
out.append((len(uniq) <= 3, "控件高度取值归一(≤3 种)", f"实际 {len(uniq)} 种:{uniq}"))
# 3 点击目标
+283
View File
@@ -0,0 +1,283 @@
"""端到端验证:字段库单位「只能在给定范围内选」+ 界面单位 ⇄ 基准单位换算(2026-10)。
为什么值得单独一条契约脚本:单位换算写错**不会报错**,只会让策略静默按错的量级筛选
(把「总市值 ≥ 5 亿元」跑成「≥ 5 万元」)。所以这里从界面一路查到库里:
1. `/fields`:total_mv 的单位是下拉,且只有注册表给的 万元/亿元;用界面把单位改成亿元
→ 保存 → 回读 API 生效,且 base_unit 仍是 万元;显示模式注明「亿元(基准 万元,×10000)」;
375px 刷新后仍是亿元(持久化)且无横向溢出 / 几何不退化。
2. `/fields`:新增字段时单位也是受限下拉(总股本 = 万股/亿股,选项里带换算系数)。
3. `/strategies`:新建策略加条件「总市值 ≥ 5」,取值框后缀显示「亿元」、说明写明 ×10000;
保存后回读 API —— 库里必须存 **50000(基准单位万元)**,卡片显示「总市值 ≥ 5 亿元」。
4. 收尾复原现场:删掉临时策略,把 total_mv 的界面单位改回 万元。
需要有浏览器可用的 Web(:3000)与 API(:8000)。复用 verify_ui_alignment 的 CDP 管线
(系统 Chrome,无需 playwright)。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.request
sys.path.insert(0, "/Users/summer/project/qlib/scripts")
from verify_ui_alignment import CDP, check_geometry, find_chrome, free_port, probe # noqa: E402
WEB = "http://127.0.0.1:3000"
API = "http://127.0.0.1:8000"
TMP_NAME = "单位验证-临时(可删)"
FIELDS_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const rows = [...document.querySelectorAll("table.tbl tbody tr")];
const mv = rows.find((r) => (r.innerText || "").includes("total_mv"));
const unitCell = mv ? mv.querySelectorAll("td")[2] : null;
const sel = unitCell ? unitCell.querySelector("select") : null;
return JSON.stringify({
rowFound: !!mv,
unitCellText: t(unitCell),
isSelect: !!sel,
options: sel ? [...sel.options].map((o) => o.textContent.trim()) : [],
selected: sel && sel.selectedOptions[0] ? sel.selectedOptions[0].textContent.trim() : null,
hint: unitCell ? t(unitCell.querySelector(".cell-hint")) : null,
});
})()
"""
def click_by_text(cdp: CDP, text: str, nth: int = 0) -> str:
expr = (
"(() => { const bs=[...document.querySelectorAll('button,a')];"
f"const m=bs.filter(x=>x.innerText.replace(/\\s+/g,'').trim()==='{text}');"
f"const b=m[{nth}];"
"if(!b) return 'not-found'; b.click(); return 'clicked'; })()"
)
return cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})["result"]["value"]
def click_in_total_mv_row(cdp: CDP, label: str) -> str:
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
"const r=rows.find(x=>x.innerText.includes('total_mv'));if(!r)return 'no-row';"
f"const b=[...r.querySelectorAll('button')].find(x=>x.innerText.includes('{label}'));"
"if(!b)return 'no-btn';b.click();return 'clicked';})()"
)
return cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})["result"]["value"]
def eval_json(cdp: CDP, expr: str, wait: float = 1.2):
time.sleep(wait)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True})
val = r.get("result", {}).get("value")
return json.loads(val) if isinstance(val, str) else val
def set_input(cdp: CDP, selector: str, value: str) -> str:
expr = (
"(() => {"
f"const el=document.querySelector({selector!r});"
"if(!el) return 'not-found';"
"const proto=el.tagName==='SELECT'?HTMLSelectElement.prototype:"
"el.tagName==='TEXTAREA'?HTMLTextAreaElement.prototype:HTMLInputElement.prototype;"
"const setter=Object.getOwnPropertyDescriptor(proto,'value').set;"
f"setter.call(el,{value!r});"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"el.dispatchEvent(new Event('change',{bubbles:true}));"
"return el.value; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value", f"<err {r.get('exceptionDetails', {}).get('text')}>")
def api(method: str, path: str, body: dict | None = None) -> dict:
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{API}{path}", method=method, data=data,
headers={"Content-Type": "application/json"} if data else {},
)
with urllib.request.urlopen(req, timeout=20) as r:
return json.loads(r.read() or "{}")
def main() -> int:
chrome = os.environ.get("QLIB_CHROME") or find_chrome()
if not chrome:
print("no chrome", file=sys.stderr)
return 2
port = free_port()
profile = tempfile.mkdtemp(prefix="qlib-units-")
proc = subprocess.Popen(
[chrome, f"--remote-debugging-port={port}", "--headless=new", "--disable-gpu",
"--no-first-run", "--no-default-browser-check", "--hide-scrollbars",
"--disable-extensions", f"--user-data-dir={profile}",
"--window-size=1500,1200", "about:blank"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
failed = 0
created_id = None
def report(good: bool, name: str, extra: str = "") -> None:
nonlocal failed
print(f" {'✅' if good else '❌'} {name}" + (f" — {extra}" if extra else ""), flush=True)
failed += 0 if good else 1
try:
ws = None
for _ in range(80):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for tab in json.loads(r.read()):
if tab.get("type") == "page":
ws = tab["webSocketDebuggerUrl"]
break
if ws:
break
except Exception:
pass
time.sleep(0.3)
cdp = CDP(ws)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
# ---------- 1. /fields:单位是受限下拉,只能选注册表给的档 ----------
print("\n=== /fields @1500px:单位只能在给定范围内选 ===", flush=True)
probe(cdp, f"{WEB}/fields", 1500, 1200, 3.0)
before = eval_json(cdp, FIELDS_READ, wait=0.4)
print(f" 显示模式单位单元格:{before['unitCellText']}", flush=True)
report("万元" in (before["unitCellText"] or ""), "未改时显示当前单位 万元(= 基准,无需额外标注)")
report(click_in_total_mv_row(cdp, "编辑") == "clicked", "点 total_mv 行的「编辑」")
data = eval_json(cdp, FIELDS_READ)
print(f" 编辑模式单位单元格:{data['unitCellText']}", flush=True)
report(data["isSelect"], "单位是下拉(不是自由文本框)")
report(data["options"] == ["万元", "亿元"], "下拉只有注册表给的 万元/亿元", str(data["options"]))
report(data["selected"] == "万元", "当前界面单位 = 万元(基准)")
print(f" 换算提示:{data['hint']}", flush=True)
report((data["hint"] or "").startswith("1 万元 = 1 万元"), "显示 1 万元 = 1 万元(基准档)")
changed = set_input(cdp, 'select[aria-label$="的界面单位"]', "亿元")
print(f" 下拉改选 → {changed}", flush=True)
time.sleep(0.5)
print(f" 点「保存」→ {click_by_text(cdp, '保存')}", flush=True)
time.sleep(1.8)
rows = api("GET", "/api/condition-fields")
mv = next((r for r in rows if r["name"] == "total_mv"), {})
report(mv.get("unit") == "亿元", "界面上改单位后保存 → 库里 unit=亿元", f"unit={mv.get('unit')}")
report(mv.get("base_unit") == "万元", "基准单位仍是 万元(引擎口径不变)", f"base={mv.get('base_unit')}")
after = eval_json(cdp, FIELDS_READ, wait=0.6)
print(f" 保存后显示模式:{after['unitCellText']}", flush=True)
report("亿元" in (after["unitCellText"] or "") and "基准 万元" in (after["unitCellText"] or ""),
"显示模式注明「亿元(基准 万元,×10000)」", after["unitCellText"] or "")
# ---------- 1b. 刷新后仍生效(持久化),375px 也不溢出 ----------
print("\n=== /fields @375px:刷新后仍是亿元 + 无溢出 ===", flush=True)
g = probe(cdp, f"{WEB}/fields", 375, 1200, 2.8)
report(click_in_total_mv_row(cdp, "编辑") == "clicked", "点 total_mv 行的「编辑」(375px)")
small = eval_json(cdp, FIELDS_READ, wait=0.6)
report(small["selected"] == "亿元", "刷新后仍是 亿元(持久化)", str(small["selected"]))
report(small["options"] == ["万元", "亿元"], "375px 下拉仍是 万元/亿元", str(small["options"]))
over = [o for o in g["overflows"] if o["scrollW"] > o["clientW"] + 2]
print(f" 溢出元素:{over[:4]}", flush=True)
report(not over, "375px 无横向溢出", str(over[:2]))
geo = check_geometry(g)
bad = [(n, d) for ok, n, d in geo if not ok]
report(not bad, f"375px 几何检查 {len(geo)} 项全部通过", str(bad[:2]))
# ---------- 2. 新增字段的单位也是受限下拉 ----------
print("\n=== /fields @1500px:新增字段(总股本)的单位下拉 ===", flush=True)
probe(cdp, f"{WEB}/fields", 1500, 1200, 2.5)
picked = set_input(cdp, 'select[aria-label="可新增字段"]', "float_share")
print(f" 选 float_share → {picked}", flush=True)
time.sleep(0.8)
add = eval_json(
cdp,
"JSON.stringify({sel:(()=>{const s=[...document.querySelectorAll('select')]"
".find(x=>x.getAttribute('aria-label')==='新增字段的界面单位');"
"return s?{options:[...s.options].map(o=>o.textContent.trim()),"
"selected:s.selectedOptions[0].textContent.trim()}:null;})()})",
wait=0.4,
)
print(f" 单位下拉:{add['sel']}", flush=True)
opts = " ".join((add["sel"] or {}).get("options", []))
report(bool(add["sel"]) and "万股" in opts and "亿股" in opts and "10000" in opts,
"新增字段时单位只能是 万股/亿股(含换算系数)", opts)
# ---------- 3. /strategies:按亿元输入,库里存万元 ----------
print("\n=== /strategies @1500px:总市值 ≥ 5 亿元 ===", flush=True)
probe(cdp, f"{WEB}/strategies", 1500, 1200, 3.0)
print(f" 点「新建选股策略」→ {click_by_text(cdp, '新建选股策略')}", flush=True)
time.sleep(1.0)
set_input(cdp, ".params-meta__name input", TMP_NAME)
set_input(cdp, ".params-meta__desc textarea", "临时验证:单位换算(跑完即删)")
print(f" 点「添加因子」→ {click_by_text(cdp, '添加因子')}", flush=True)
time.sleep(0.6)
set_input(cdp, 'select[aria-label$="因子的名称"]', "dividend_yield")
time.sleep(0.4)
print(f" 点「添加条件」→ {click_by_text(cdp, '添加条件')}", flush=True)
time.sleep(0.8)
set_input(cdp, '.cond-row:last-child select[aria-label$="的字段"]', "total_mv")
time.sleep(0.6)
form = eval_json(
cdp,
"JSON.stringify({rows:document.querySelectorAll('.cond-row').length,"
"suffix:(()=>{const e=document.querySelector('.cond-row:last-child .unit-suffix');"
"return e?e.innerText.trim():null;})(),"
"hint:(()=>{const e=document.querySelector('.cond-row:last-child .cell-hint');"
"return e?e.innerText.replace(/\\s+/g,' ').trim():null;})()})",
wait=0.4,
)
print(f" 条件行:{form['rows']};单位后缀:{form['suffix']}", flush=True)
print(f" 字段说明:{form['hint']}", flush=True)
report(form["suffix"] == "亿元", "取值框显示界面单位后缀「亿元」", str(form["suffix"]))
hint = form["hint"] or ""
report("界面单位 亿元" in hint and "×10000" in hint and "万元" in hint,
"字段说明写明 ×10000 换算成基准单位 万元", hint[:60])
typed = set_input(cdp, '.cond-row:last-child input[aria-label$="的取值"]', "5")
print(f" 输入 5(亿元)→ {typed}", flush=True)
print(f" 点「保存到策略库」→ {click_by_text(cdp, '保存到策略库')}", flush=True)
time.sleep(2.5)
card = cdp.call(
"Runtime.evaluate",
{"expression": "(() => {const c=[...document.querySelectorAll('.chip')].find(x=>x.innerText.includes('条件'));return c?c.innerText.replace(/\\s+/g,' ').trim():null;})()",
"returnByValue": True},
)["result"].get("value")
print(f" 策略卡片条件文本:{card}", flush=True)
report(bool(card) and "总市值" in card and "亿元" in card and "50000" not in card,
"卡片显示「总市值 ≥ 5 亿元」(中文名+界面单位,不露基准数值)", card or "")
strategies = api("GET", "/api/strategies")
mine = [s for s in strategies if s.get("name") == TMP_NAME]
if not mine:
errs = eval_json(cdp, "JSON.stringify({banner:[...document.querySelectorAll('.banner')].map(b=>b.innerText.replace(/\\s+/g,' ').trim()),errs:[...document.querySelectorAll('.field-err')].map(e=>e.innerText.trim())})", wait=0.2)
report(False, "保存后的临时策略可在 API 查到", f"现有:{[s.get('name') for s in strategies]};页面:{errs}")
else:
created_id = mine[0]["id"]
conds = mine[0].get("conditions") or []
print(f" 回读 API:{json.dumps(conds, ensure_ascii=False)}", flush=True)
got = conds[0] if conds else {}
report(got.get("field") == "total_mv", "条件字段 = total_mv")
report(got.get("op") == "gte", "比较符 = ≥")
report(got.get("value") == 50000, "库里存的是 50000(基准单位万元),不是 5", str(got.get("value")))
return 1 if failed else 0
finally:
if created_id:
try:
api("DELETE", f"/api/strategies/{created_id}")
print(f"\n已清理临时策略 {created_id}", flush=True)
except Exception as exc: # noqa: BLE001
print(f"\n⚠ 清理临时策略失败:{exc}", flush=True)
try:
api("PUT", "/api/condition-fields/total_mv", {"unit": "万元"})
print("已把 total_mv 的界面单位改回 万元(复原现场)", flush=True)
except Exception as exc: # noqa: BLE001
print(f"⚠ 复原单位失败:{exc}", flush=True)
proc.terminate()
print(f"\n总计失败:{failed}", flush=True)
if __name__ == "__main__":
raise SystemExit(main())