From 36fe0180755f91800b5b569c6491dfcf13708ebd Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 1 Oct 2026 16:38:54 +0800 Subject: [PATCH] =?UTF-8?q?docs+chore:=20=E5=90=8C=E6=AD=A5=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=E8=AF=B4=E6=98=8E=E4=B8=8E=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E8=87=AA=E6=A3=80=EF=BC=88=E5=AD=97=E6=AE=B5=E5=BA=93/?= =?UTF-8?q?=E5=8D=95=E4=BD=8D=E6=8D=A2=E7=AE=97=E3=80=81=E5=9B=A0=E5=AD=90?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E5=8C=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/ 是临时验证证据,不入库(文件留在磁盘)。 --- .gitignore | 2 + docs/USAGE.md | 135 +++++-- scripts/verify_factor_params.py | 517 +++++++++++++++++++++++++++ scripts/verify_strategy_workspace.py | 429 ++++++++++++++++++++-- scripts/verify_ui_alignment.py | 13 +- scripts/verify_unit_conversion.py | 283 +++++++++++++++ 6 files changed, 1317 insertions(+), 62 deletions(-) create mode 100644 scripts/verify_factor_params.py create mode 100644 scripts/verify_unit_conversion.py 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 个条件的字段"]'); + // 注意: