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

- docs/USAGE.md:
  · 因子层一行改为「代码注册表投影 + 参数化实例,参数写在名字里以冻结口径」;
  · 新增 `GET/POST/PATCH /api/factors`、`GET /api/factors/templates` 与
    `/api/condition-fields`、`/fields` 的说明;
  · 新增「参数化因子(2026-10)」块:受控范围、键必须写全参数(缺项就靠可改的
    默认值兜底 = 追溯改义,所以拒绝)、口径文案按代码收敛、停用 ≠ 删除、
    参数化因子也能当过滤条件;
  · 自检清单一并更新(pytest 500 条;verify_strategy_workspace 145 项 skip-job;
    verify_ui_alignment 8 页 160 项;新增 verify_unit_conversion、verify_factor_params)。
- scripts/:新增 verify_unit_conversion.py(单位只能在给定范围里选 + 界面单位⇄
  基准单位换算)、verify_factor_params.py(参数暴露/界面新建/越界拒绝/停用语义,
  跑完自动清掉临时因子);verify_strategy_workspace.py 加 [5.7b] 因子参数化一节,
  临时因子的清理挪进 finally(断言中途失败也不给真人库留垃圾)。
- .gitignore:docs/screenshots/ 是临时验证证据,不入库(文件留在磁盘)。
This commit is contained in:
Simon
2026-10-01 16:38:54 +08:00
parent f13b34c59e
commit 36fe018075
6 changed files with 1317 additions and 62 deletions
+517
View File
@@ -0,0 +1,517 @@
"""端到端验证:因子参数化(暴露真实参数、可编辑、且真生效、真冻结)(2026-10)。
为什么单独一条契约脚本:因子参数写错**不会报错**,只会让策略按另一个口径选股
(「窗口 20」悄悄算成「窗口 90」= 另一个因子的结果)。所以这里从界面一路查到引擎:
1. `/factors`:目录暴露 template/params/param_specs(中文名含参数、允许范围 2~500、
方向二选一);展开行给出精确引擎键与来源;页面不再是「不可修改」的旧说法。
2. `/factors`:用界面新建参数化因子(窗口 90、越低越好)—— 预览键必须与后端规范键
逐字符一致;创建成功后 API 里出现该行,中文名/回看/方向全部由引擎投影。
3. `/factors`:越界参数(窗口 999)在界面上被拒,且**没有**留下任何垃圾行。
4. `/factors`:停用/启用真的写库,且停用后仍能被引擎解析(历史不变义)。
5. `/strategies`:条件字段下拉里出现该参数化因子(因子目录并入字段库),
因子下拉显示中文名(含参数)而不是又长又生的引擎键。
6. 375px:页面无横向溢出、几何检查全过。
7. 收尾:删掉验证期间创建的临时因子(目录没有删除接口,直接按主键清),回到 11 条内置。
需要浏览器可用的 Web(:3000)与 API(:8000)。复用 verify_ui_alignment 的 CDP 管线
(系统 Chrome,无需 playwright)。
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
sys.path.insert(0, "/Users/summer/project/qlib/scripts")
from verify_ui_alignment import CDP, check_geometry, find_chrome, free_port, probe # noqa: E402
WEB = "http://127.0.0.1:3000"
API = "http://127.0.0.1:8000"
TMP_KEY = "momentum(window=90,direction=lower_is_better)"
BAD_KEY = "momentum(window=999,direction=lower_is_better)"
FACTORS_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const rows = [...document.querySelectorAll("table.tbl tbody tr")];
const find = (name) => rows.find((r) => (r.innerText || "").includes(name));
const mv = find("momentum_60");
const mvCells = mv ? [...mv.querySelectorAll("td")].map((td) => t(td)) : [];
const probe = find("__PROBE__");
const probeCells = probe ? [...probe.querySelectorAll("td")].map((td) => t(td)) : [];
const probeBtn = probe
? [...probe.querySelectorAll("button")].map((b) => t(b))
: [];
return JSON.stringify({
rowCount: rows.length,
headers: [...document.querySelectorAll("table.tbl thead th")].map((th) => t(th)),
momentumCells: mvCells,
probeCells,
probeButtons: probeBtn,
// 展开行的文本:主行的下一个兄弟 tr(class=expand-cell)就是展开内容
probeExpandedText: probe ? t(probe.nextElementSibling) : null,
bodyText: t(document.body),
});
})()
"""
PREVIEW_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const w = document.querySelector("#factor-new-window");
const d = document.querySelector("#factor-new-direction");
const all = t(document.body) || "";
// 预览键 = <b class="mono">模板名(参数=值,…)</b>。**不要**扫整页文本:
// 页面说明里也有一个示例键,扫文本会拿示例当预览(假通过)。
const previewKeys = [
...new Set(
[...document.querySelectorAll("b.mono")]
.map((el) => t(el))
.filter((x) => x && /^[a-z_][a-z0-9_]*\(/.test(x)),
),
];
return JSON.stringify({
windowValue: w ? w.value : null,
windowMin: w ? w.min : null,
windowMax: w ? w.max : null,
directionOptions: d ? [...d.options].map((o) => o.textContent.trim()) : [],
directionValue: d ? d.value : null,
templateOptions: [...(document.querySelectorAll("#factor-new-template option") || [])]
.map((o) => o.textContent.trim()),
previewKeys,
bodyText: all,
});
})()
"""
STRATEGY_FORM_READ = r"""
(() => {
const t = (el) => (el ? el.innerText.replace(/\s+/g, " ").trim() : null);
const factorSel = document.querySelector('select[aria-label="第 1 个因子的名称"]');
const condSel = document.querySelector('select[aria-label="第 1 个条件的字段"]');
// 注意:<option> 没有 innerText(它不被渲染),只能用 textContent
const opts = (sel) =>
sel
? [...sel.querySelectorAll("option")].map((o) =>
(o.textContent || "").replace(/\s+/g, " ").trim(),
)
: [];
// optgroup 的 label 是字符串(不是元素):别喂给只处理元素的 t()
const groups = (sel) =>
sel
? [...sel.querySelectorAll("optgroup")].map((g) =>
(g.getAttribute("label") || "").trim(),
)
: [];
return JSON.stringify({
hasFactorSelect: !!factorSel,
hasCondSelect: !!condSel,
factorOptions: opts(factorSel),
condOptions: opts(condSel),
condGroups: groups(condSel),
});
})()
"""
def api(method: str, path: str, body: dict | None = None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(
f"{API}{path}",
method=method,
data=data,
headers={"Content-Type": "application/json"} if data else {},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or "{}")
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read() or "{}")
def eval_json(cdp: CDP, expr: str, wait: float = 1.0):
time.sleep(wait)
r = cdp.call(
"Runtime.evaluate", {"expression": expr, "returnByValue": True, "awaitPromise": True}
)
val = r.get("result", {}).get("value")
if isinstance(val, str):
try:
return json.loads(val)
except json.JSONDecodeError:
return val
return val
def click_by_text(cdp: CDP, text: str, nth: int = 0, exact: bool = True) -> str:
expr = (
"(() => { const bs=[...document.querySelectorAll('button,a')];"
f"const m=bs.filter(x=>x.innerText.replace(/\\s+/g,'').trim()"
f"{'===' if exact else '.includes('}'{text}'{')' if not exact else ''});"
f"const b=m[{nth}];"
"if(!b) return 'not-found'; b.click(); return 'clicked'; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def click_row(cdp: CDP, row_needle: str) -> str:
"""点整行展开定义(页面的展开热区是 <tr> 本身,不是按钮)。"""
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
f"const r=rows.find(x=>x.innerText.includes({row_needle!r}) && "
"!x.classList.contains('expand-cell'));"
"if(!r)return 'no-row';r.click();return 'clicked';})()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def click_in_row(cdp: CDP, row_needle: str, btn_text: str) -> str:
expr = (
"(() => {const rows=[...document.querySelectorAll('table.tbl tbody tr')];"
f"const r=rows.find(x=>x.innerText.includes({row_needle!r}));if(!r)return 'no-row';"
f"const b=[...r.querySelectorAll('button')].find(x=>x.innerText.includes({btn_text!r}));"
"if(!b)return 'no-btn';b.click();return 'clicked';})()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value")
def set_input(cdp: CDP, selector: str, value: str) -> str:
expr = (
"(() => {"
f"const el=document.querySelector({selector!r});"
"if(!el) return 'not-found';"
"const proto=el.tagName==='SELECT'?HTMLSelectElement.prototype:HTMLInputElement.prototype;"
"const setter=Object.getOwnPropertyDescriptor(proto,'value').set;"
f"setter.call(el,{value!r});"
"el.dispatchEvent(new Event('input',{bubbles:true}));"
"el.dispatchEvent(new Event('change',{bubbles:true}));"
"return el.value; })()"
)
r = cdp.call("Runtime.evaluate", {"expression": expr, "returnByValue": True})
return r.get("result", {}).get("value", f"<err {r.get('exceptionDetails', {}).get('text')}>")
BACKEND = "/Users/summer/project/qlib/backend"
PY = os.environ.get("QLIB_PY") or f"{BACKEND}/.venv/bin/python"
def cleanup_factor(key: str) -> bool:
"""删掉临时参数化因子:目录没有删除接口(设计如此),所以按主键直接清库。
走子进程 + 后端 venv 解释器:本脚本刻意只用标准库(CDP 管线不依赖第三方),
但删库要用 SQLAlchemy/ORM 配置,不能要求调用方先把 venv 激起来。
"""
code = (
"import sys; sys.path.insert(0, %r)\n"
"from sqlalchemy import delete\n"
"from app.infrastructure.persistence.sqlalchemy.models.factor import FactorDefinitionModel\n"
"from app.infrastructure.persistence.sqlalchemy.session import SessionLocal\n"
"with SessionLocal() as s:\n"
" n = s.execute(delete(FactorDefinitionModel).where(FactorDefinitionModel.name == %r)).rowcount\n"
" s.commit()\n"
"print(n)\n"
) % (BACKEND, key)
out = subprocess.run([PY, "-c", code], capture_output=True, text=True, timeout=120)
if out.returncode != 0:
raise RuntimeError(out.stderr.strip()[-200:])
return out.stdout.strip() == "1"
def main() -> int:
chrome = os.environ.get("QLIB_CHROME") or find_chrome()
if not chrome:
print("no chrome", file=sys.stderr)
return 2
port = free_port()
profile = tempfile.mkdtemp(prefix="qlib-factorparams-")
proc = subprocess.Popen(
[
chrome,
f"--remote-debugging-port={port}",
"--headless=new",
"--disable-gpu",
"--no-first-run",
"--no-default-browser-check",
"--hide-scrollbars",
"--disable-extensions",
f"--user-data-dir={profile}",
"--window-size=1500,1200",
"about:blank",
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
failed = 0
created = False
original_enabled: bool | None = None
def report(good: bool, name: str, extra: str = "") -> None:
nonlocal failed
print(f" {'✅' if good else '❌'} {name}" + (f" — {extra}" if extra else ""), flush=True)
failed += 0 if good else 1
try:
ws = None
for _ in range(80):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/json/list", timeout=2) as r:
for tab in json.loads(r.read()):
if tab.get("type") == "page":
ws = tab["webSocketDebuggerUrl"]
break
if ws:
break
except Exception:
pass
time.sleep(0.3)
cdp = CDP(ws)
cdp.call("Page.enable")
cdp.call("Runtime.enable")
before = {f["name"]: f for f in api("GET", "/api/factors")[1]}
original_enabled = before.get(TMP_KEY, {}).get("enabled")
# ---------- 1. 目录暴露真实参数 ----------
print("\n=== /factors @1500px:真实参数与允许范围 ===", flush=True)
probe(cdp, f"{WEB}/factors", 1500, 1200, 3.2)
data = eval_json(cdp, FACTORS_READ.replace("__PROBE__", "momentum_60"), wait=0.5)
print(f" 表头:{data['headers']}", flush=True)
print(f" momentum_60 行:{data['momentumCells']}", flush=True)
report("参数" in " ".join(data["headers"]), "表格有「参数」列", str(data["headers"]))
joined = " ".join(data["momentumCells"])
report("窗口 60" in joined and "越高越好" in joined, "行内显示真实参数(窗口 60 · 越高越好)", joined)
report("动量" in joined, "因子列显示中文名(含参数)", joined)
body = data["bodyText"] or ""
report("不可修改" not in body, "旧的「不可修改」说法已下线(不再说谎)")
report(
("新身份" in body or "不变义" in body) and "参数" in body,
"页面说明「改参数 = 新身份,旧因子/既有策略不变义」",
)
# 展开行:精确键 + 参数范围 + 来源
exp = click_row(cdp, "momentum_60")
print(f" 展开 momentum_60 → {exp}", flush=True)
detail = eval_json(cdp, FACTORS_READ.replace("__PROBE__", "momentum_60"), wait=0.8)
dtext = " ".join(detail["momentumCells"]) + " " + (detail["probeExpandedText"] or "")
report("momentum_60" in dtext, "展开行给出精确引擎键", dtext[-90:])
report("2 ~ 500" in dtext or "2~500" in dtext, "展开行给出参数允许范围(2 ~ 500)")
report("越高越好" in dtext and ("内置" in dtext or "注册表" in dtext), "展开行标出方向与来源")
# ---------- 2. 界面新建参数化因子(预览键必须与后端一致) ----------
print("\n=== /factors:用界面新建参数化因子 ===", flush=True)
picked = set_input(cdp, "#factor-new-template", "momentum")
print(f" 选模板 momentum → {picked}", flush=True)
prev = eval_json(cdp, PREVIEW_READ, wait=1.0)
report("动量" in " ".join(prev["templateOptions"]), "模板下拉显示中文名与已有实例",
str(prev["templateOptions"][:2]))
report(prev["windowValue"] == "20", "窗口默认值来自模板(20)", str(prev["windowValue"]))
report(
prev["windowMin"] == "2" and prev["windowMax"] == "500",
"窗口输入框带 min/max(2/500,受控)",
f"min={prev['windowMin']} max={prev['windowMax']}",
)
report(
prev["directionOptions"] == ["越高越好", "越低越好"],
"方向下拉只有两个中文选项(不是自由文本)",
str(prev["directionOptions"]),
)
# 两次赋值之间要留一拍:受控 <select> 的原生赋值会被 React 的提交还原,
# 连着改两个控件时第二个改动会丢(这是无头驱动的时序,不是页面 bug)
set_input(cdp, "#factor-new-window", "90")
time.sleep(0.6)
set_input(cdp, "#factor-new-direction", "lower_is_better")
time.sleep(0.6)
prev2 = eval_json(cdp, PREVIEW_READ, wait=0.9)
keys = prev2["previewKeys"] or []
ptext = prev2["bodyText"] or ""
print(f" 预览键:{keys}", flush=True)
report(TMP_KEY in keys, "预览的规范键与后端逐字符一致", str(keys))
report("动量(窗口 90,越低越好)" in ptext, "预览中文名含参数与方向", ptext[:120])
print(f" 点「新建」→ {click_by_text(cdp, '新建参数化因子')}", flush=True)
made = eval_json(cdp, PREVIEW_READ, wait=2.4)
code, rows = api("GET", "/api/factors")
cat = {f["name"]: f for f in rows}
created = TMP_KEY in cat
report(created, "创建成功:API 目录里出现该参数化因子", str(sorted(cat)[-3:]))
if created:
row = cat[TMP_KEY]
report(
row.get("label") == "动量(窗口 90,越低越好)"
and row.get("lookback") == 90
and row.get("direction") == "lower_is_better"
and row.get("source") == "custom",
"新因子口径由引擎投影(中文名/回看/方向/来源)",
json.dumps({k: row.get(k) for k in ("label", "lookback", "direction", "source")},
ensure_ascii=False),
)
report(
"不变义" in (made["bodyText"] or "") or "新身份" in (made["bodyText"] or ""),
"成功提示里写明「旧因子不变义」",
)
# ---------- 3. 越界参数在界面被拒,且不留垃圾行 ----------
print("\n=== /factors:越界参数被拒(不静默截断) ===", flush=True)
probe(cdp, f"{WEB}/factors", 1500, 1200, 2.5)
set_input(cdp, "#factor-new-template", "momentum")
time.sleep(0.8)
set_input(cdp, "#factor-new-window", "999")
time.sleep(0.6)
click_by_text(cdp, "新建参数化因子")
bad = eval_json(cdp, PREVIEW_READ, wait=2.2)
btext = bad["bodyText"] or ""
report("2 ~ 500" in btext, "界面显示后端拒绝原因(含允许范围 2 ~ 500)", btext[:0])
_, rows2 = api("GET", "/api/factors")
report(BAD_KEY not in {f["name"] for f in rows2}, "越界请求没有在目录里留下垃圾行")
# 在目录里能看到新建的因子行(中文名含参数)
data3 = eval_json(cdp, FACTORS_READ.replace("__PROBE__", TMP_KEY), wait=1.0)
report(
bool(data3["probeCells"]) and "窗口 90" in " ".join(data3["probeCells"]),
"目录表格里出现新因子并显示参数",
str(data3["probeCells"])[:120],
)
# ---------- 4. 停用 / 启用(只影响能否被选中) ----------
print("\n=== /factors:停用/启用真的写库 ===", flush=True)
acts = data3["probeButtons"]
print(f" 该行按钮:{acts}", flush=True)
clicked = click_in_row(cdp, TMP_KEY, "停用")
time.sleep(1.6)
_, rows3 = api("GET", "/api/factors")
off = {f["name"]: f.get("enabled") for f in rows3}.get(TMP_KEY)
report(clicked == "clicked" and off is False, "界面停用 → 库里 enabled=false", f"{clicked}/{off}")
click_in_row(cdp, TMP_KEY, "启用")
time.sleep(1.6)
_, rows4 = api("GET", "/api/factors")
on = {f["name"]: f.get("enabled") for f in rows4}.get(TMP_KEY)
report(on is True, "界面启用 → 库里 enabled=true", str(on))
# 停用不影响引擎解析(历史不变义)
code, _ = api("GET", f"/api/factors?include_disabled=true")
report(code == 200, "目录接口在停用状态下仍正常(停用不是删除)")
# ---------- 5. 策略表单里能用(因子 + 条件两条路径) ----------
print("\n=== /strategies:参数化因子能当因子用,也能当条件用 ===", flush=True)
probe(cdp, f"{WEB}/strategies", 1500, 1200, 3.0)
print(f" 点「新建选股策略」→ {click_by_text(cdp, '新建选股策略')}", flush=True)
time.sleep(1.2)
click_by_text(cdp, "添加因子")
click_by_text(cdp, "添加条件")
time.sleep(1.2)
form = eval_json(cdp, STRATEGY_FORM_READ, wait=1.0)
fopts = form["factorOptions"]
print(f" 因子下拉选项:{fopts[:3]}", flush=True)
report(
any("动量(窗口 90,越低越好)" in o for o in fopts),
"因子下拉显示中文名(含参数)而不是裸键",
str([o for o in fopts if "90" in o][:1]),
)
report(
any(TMP_KEY not in o and "动量(窗口 20" in o for o in fopts),
"内置实例也显示中文名(含各自参数)",
str([o for o in fopts if "momentum_20" in o][:1]),
)
copts = form["condOptions"]
report(
any(TMP_KEY in o for o in copts),
"条件字段下拉并入参数化因子(因子目录 → 字段库)",
str([o for o in copts if "momentum" in o][:2]),
)
report("因子" in (form["condGroups"] or []), "条件字段下拉有「因子」分组",
str(form["condGroups"]))
# ---------- 5b. 停用后不该出现在任何「选因子」的地方 ----------
print("\n=== 停用 = 不出现在选择列表(各页一致,不是假开关) ===", flush=True)
api("PATCH", "/api/factors", {"name": TMP_KEY, "enabled": False})
time.sleep(0.6)
pages = [
("/selection", "select[aria-label=\"第 1 个因子的名称\"]", "添加因子"),
("/signals", "select[aria-label=\"评分因子\"]", None),
]
for path, selector, add_btn in pages:
probe(cdp, f"{WEB}{path}", 1500, 1200, 2.6)
if add_btn:
print(f" {path} 点「{add_btn}」→ {click_by_text(cdp, add_btn)}", flush=True)
time.sleep(0.8)
# /signals 的因子下拉是 <Field label> 包出来的(没有 aria-label),
# 所以按「选项里有 momentum_」来定位那一个 select —— 资产名比选择器稳。
finder = (
"(() => {const sels=[...document.querySelectorAll('select')];"
"const sel=sels.find(s=>[...s.options].some(o=>/momentum/.test(o.textContent||'')));"
"return JSON.stringify(sel?[...sel.options].map(o=>(o.textContent||'').trim()):[]);})()"
if add_btn is None
else "JSON.stringify([...document.querySelectorAll(%r + ' option')]"
".map((o) => (o.textContent || '').trim()))" % selector
)
opts = eval_json(cdp, finder, wait=0.8)
opts = opts or []
print(f" {path} 因子下拉 {len(opts)} 项", flush=True)
report(
not any(TMP_KEY in o for o in opts),
f"{path} 停用的因子不出现在候选里",
str([o for o in opts if "momentum" in o][:2]),
)
api("PATCH", "/api/factors", {"name": TMP_KEY, "enabled": True})
time.sleep(0.5)
probe(cdp, f"{WEB}/signals", 1500, 1200, 2.4)
# 这里连 value 一起读:显示文字是「中文名 · 短键」(direction 在提示里说),
# 但提交给引擎的 value 必须还是**完整键** —— 两者都要验,缺一个就是骗人
pairs = eval_json(
cdp,
"(() => {const sels=[...document.querySelectorAll('select')];"
"const sel=sels.find(s=>[...s.options].some(o=>/momentum/.test(o.textContent||'')));"
"return JSON.stringify(sel?[...sel.options].map(o=>[o.value,(o.textContent||'').trim()]):[]);})()",
wait=0.8,
) or []
hit = [t for v, t in pairs if v == TMP_KEY]
report(
bool(hit) and "动量(窗口 90,越低越好)" in hit[0] and "momentum(window=90)" in hit[0],
"启用后该页按「中文名 · 短键」显示,而 value 仍是完整引擎键",
str(hit[:1] or pairs[:1]),
)
# ---------- 6. 375px:无溢出 + 几何 ----------
print("\n=== /factors @375px:无横向溢出 ===", flush=True)
g = probe(cdp, f"{WEB}/factors", 375, 1200, 3.0)
small = eval_json(cdp, FACTORS_READ.replace("__PROBE__", TMP_KEY), wait=0.8)
over = [o for o in g["overflows"] if o["scrollW"] > o["clientW"] + 2]
print(f" 溢出元素:{over[:4]}", flush=True)
report(not over, "375px 无横向溢出", str(over[:2]))
report(bool(small["probeCells"]), "375px 表格仍渲染出因子行", str(len(small["probeCells"])))
geo = check_geometry(g)
bad_geo = [(n, d) for ok, n, d in geo if not ok]
report(not bad_geo, f"375px 几何检查 {len(geo)} 项全部通过", str(bad_geo[:2]))
finally:
# ---------- 7. 收尾:删掉临时因子,回到只有内置实例 ----------
print("\n=== 收尾 ===", flush=True)
try:
if created:
cleanup_factor(TMP_KEY)
print(f"[cleanup] 已删除临时参数化因子 {TMP_KEY}", flush=True)
_, rows = api("GET", "/api/factors")
names = [f["name"] for f in rows]
extra = [n for n in names if "(" in n]
report(not extra, "目录回到只有内置实例(无临时参数化行)", str(extra))
report(len(names) == 11, "目录行数回到 11(内置实例)", str(len(names)))
except Exception as e: # noqa: BLE001 - 清理失败要显式说出来
print(f" ⚠ 清理失败(请手工检查 factor_definition):{e}", flush=True)
failed += 1
proc.terminate()
print(f"\n{'✅ 全部通过' if failed == 0 else f'❌ {failed} 项失败'}", flush=True)
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())