diff --git a/.gitignore b/.gitignore
index 9651d1a..9fa8d88 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,6 +53,8 @@ experiments/*
# ================= archify 视觉验证副产物(重新生成即可) =================
docs/diagrams/*.visual-check.*
+# 界面自检截图:临时证据,不入库(要看图直接打开磁盘上的文件)
+docs/screenshots/
# ================= 运行期(dev.sh 管理) =================
.run/
diff --git a/docs/USAGE.md b/docs/USAGE.md
index 8af2c8d..ee85d77 100644
--- a/docs/USAGE.md
+++ b/docs/USAGE.md
@@ -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 幂等与「未来函数阻断」
diff --git a/scripts/verify_factor_params.py b/scripts/verify_factor_params.py
new file mode 100644
index 0000000..75ecab4
--- /dev/null
+++ b/scripts/verify_factor_params.py
@@ -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) || "";
+ // 预览键 = 模板名(参数=值,…)。**不要**扫整页文本:
+ // 页面说明里也有一个示例键,扫文本会拿示例当预览(假通过)。
+ 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 个条件的字段"]');
+ // 注意: