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
+283
View File
@@ -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())