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:
@@ -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())
|
||||
Reference in New Issue
Block a user