Files
qlib/scripts/verify_unit_conversion.py
Simon 36fe018075 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/ 是临时验证证据,不入库(文件留在磁盘)。
2026-10-01 16:38:54 +08:00

283 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""端到端验证:字段库单位「只能在给定范围内选」+ 界面单位 ⇄ 基准单位换算(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())