- 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/ 是临时验证证据,不入库(文件留在磁盘)。
283 lines
15 KiB
Python
283 lines
15 KiB
Python
"""端到端验证:字段库单位「只能在给定范围内选」+ 界面单位 ⇄ 基准单位换算(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()) |