- 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/ 是临时验证证据,不入库(文件留在磁盘)。
517 lines
24 KiB
Python
517 lines
24 KiB
Python
"""端到端验证:因子参数化(暴露真实参数、可编辑、且真生效、真冻结)(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()) |