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:
@@ -53,6 +53,8 @@ experiments/*
|
||||
|
||||
# ================= archify 视觉验证副产物(重新生成即可) =================
|
||||
docs/diagrams/*.visual-check.*
|
||||
# 界面自检截图:临时证据,不入库(要看图直接打开磁盘上的文件)
|
||||
docs/screenshots/
|
||||
|
||||
# ================= 运行期(dev.sh 管理) =================
|
||||
.run/
|
||||
|
||||
+111
-24
@@ -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 幂等与「未来函数阻断」
|
||||
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 点击目标
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user