问题不是"不好看",而是**可测量的错位**:同一行里原生 date 输入 38.8px、数字输入
36.8px、按钮 34px;16px 的勾选框与 36.8px 的下拉同排;因子行的下拉与权重框没有
可见标签;列表里的勾选框点不中;参数非法时「运行回测」直接置灰且不说原因。
根因:控件高度靠「上下 padding + 行高」拼出来,而 input / select / button 的原生行高
各不相同(Chrome 的 date 还会多 2px),必然参差;加上各处内联像素宽度
(style={{width:220}}、flex:1)与自搓布局,列自然对不齐。
改动:
- 新增控件高度令牌 --ctl-h-sm/md/lg(28/34/38px)与 --ctl-px,.input/select/.btn/
.icon-btn/date 统一显式 height(不再拼 padding);原生 checkbox/radio 统一 16px,
点击热区交给外层 label(.check/.radio-row/.check--cell),表格整格可点
- 因子行、选股条件行改为「表头 + CSS 栅格」成列对齐,列宽由样式决定,
去掉内联像素宽度与 flex 拉伸,配 aria-label 供读屏分辨重复行
- 表单友好化:错误提示改为**失焦或提交后**才出现(清空重填的瞬间不再标红);
提交被拦下时一次展开全部行内错误 + 自动聚焦并滚动到第一个问题字段;
运行/保存按钮不再因参数非法而置灰(灰按钮不说原因 = 看起来不可点却无响应),
改为可点击并讲清原因;补齐 topN/costs 两处「产生了却没人显示」的行内错误落点
- 数值字段补 inputMode/step/min/max 与单位、取值范围提示;工具条检索/筛选用
.input--search/.input--filter/.input--picker 类,不再写内联宽度
- /experiments 筛选无结果的空态与「暂无实验」区分开(原文案会让人以为归档丢了)
- 同一页面可能挂两份表单:radio name 与 label/for 加表单实例前缀(useId),
否则两边单选互相取消、label 指错控件
- 新增 scripts/verify_ui_alignment.py:系统 Chrome + 原生 CDP(仅标准库,
独占随机端口与临时 profile),按 7 个页面 × 1500/375px 检查同排等高、
高度取值归一、点击目标、标签与无障碍名、字号圆角一致、尺寸匹配内容、
横向溢出、提示裁切;本次基线 108/32 → 现 140/140
验证:pytest 388 passed、ruff 全绿(顺带清掉 qlib_verify.py 一处死代码)、
tsc 0 错误、图表单测 7 passed、next build 成功、契约自检 59/59、
对齐自检 140/140(含 375px 小屏)。
107 lines
4.3 KiB
Python
107 lines
4.3 KiB
Python
"""Qlib(pyqlib git 源码安装)基线验证。
|
||
|
||
通过项(green,可重复):
|
||
1) qlib import 与版本
|
||
2) 合成数据 → calendars/day.txt、instruments/all.txt、features/<sym>/<field>.day.bin 落盘
|
||
3) qlib.init(provider_uri={"day": ...}) 后 calendars / instruments / feature 底层存储可直接读回
|
||
|
||
说明/受阻:
|
||
- 0.9.8.dev32 要求 feature 目录名与 instruments 第一列均为小写(如 600519.sh)
|
||
- instruments 文件为 3 列(instrument\\tstart\\tend),provider_uri 用 {"day": 路径} 字典形式
|
||
- D.features 高层通路需与官方 dump_bin 的 bin 对齐细节一致(当前手动 bin 读取会出现
|
||
形状不一致),完整「QLibDataset → Alpha158 → LightGBM → 回测」工作流属于
|
||
qlib_adapter.QlibEngine 的实现任务,本脚本仅验证「安装 + 数据落盘 + 底层读回」。
|
||
|
||
用法:cd backend && uv run python ../scripts/qlib_verify.py
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
import numpy as np
|
||
import pandas as pd
|
||
|
||
N_DAYS = 60
|
||
SYMBOLS = ["600519.sh", "600036.sh"]
|
||
FIELDS = ["open", "high", "low", "close", "volume", "factor", "vwap"]
|
||
|
||
|
||
def main() -> None:
|
||
tmp = Path(tempfile.mkdtemp(prefix="qlib_verify_"))
|
||
try:
|
||
days = pd.bdate_range("2023-01-03", periods=N_DAYS)
|
||
|
||
# 1) calendars / instruments / features
|
||
(tmp / "calendars").mkdir(parents=True)
|
||
(tmp / "calendars/day.txt").write_text(
|
||
"\n".join(d.strftime("%Y-%m-%d") for d in days), encoding="utf-8"
|
||
)
|
||
(tmp / "instruments").mkdir(parents=True)
|
||
(tmp / "instruments/all.txt").write_text(
|
||
"\n".join(f"{s}\t{days[0].strftime('%Y-%m-%d')}\t{days[-1].strftime('%Y-%m-%d')}" for s in SYMBOLS),
|
||
encoding="utf-8",
|
||
)
|
||
# 合成行情用解析式生成(无随机项),不需要随机数发生器
|
||
for k, sym in enumerate(SYMBOLS):
|
||
(tmp / "features" / sym).mkdir(parents=True)
|
||
drift = 0.002 + 0.001 * k
|
||
price = np.cumprod(1 + drift + 0.005 * np.sin(np.arange(N_DAYS) * 0.9 + k))
|
||
close = 100.0 * price
|
||
open_ = np.roll(close, 1)
|
||
fmap = {
|
||
"open": open_,
|
||
"high": np.maximum(open_, close) * 1.005,
|
||
"low": np.minimum(open_, close) * 0.995,
|
||
"close": close,
|
||
"volume": 1e6 + np.arange(N_DAYS) * 1e3,
|
||
"factor": np.ones(N_DAYS),
|
||
"vwap": close,
|
||
}
|
||
for f in FIELDS:
|
||
(tmp / "features" / sym / f"{f}.day.bin").write_bytes(
|
||
np.asarray(fmap[f], dtype=np.float32).tobytes()
|
||
)
|
||
|
||
# 2) qlib 初始化与底层读回
|
||
import qlib
|
||
from qlib.config import REG_CN
|
||
|
||
qlib.init(provider_uri={"day": str(tmp)}, region=REG_CN)
|
||
from qlib.data.storage.file_storage import FileFeatureStorage, FileInstrumentStorage
|
||
|
||
cal = FileCalendarStorage_probe(tmp)
|
||
assert len(cal) == N_DAYS, f"日历读回 {len(cal)} != {N_DAYS}"
|
||
|
||
inst = FileInstrumentStorage("all", "day")
|
||
parsed = inst.data
|
||
assert set(parsed.keys()) == set(SYMBOLS), f"instruments 读回异常: {list(parsed)}"
|
||
|
||
for sym in SYMBOLS:
|
||
fs = FileFeatureStorage(sym, "close", "day")
|
||
n = len(fs.data)
|
||
assert n > 0, f"{sym} close 读回为空"
|
||
print(f" [OK] {sym}/close.day.bin 读回 {n} 个值")
|
||
|
||
# 3) qlib 组件可用性(Alpha158 / LightGBM)
|
||
import lightgbm # noqa: F401
|
||
from qlib.contrib.data.handler import Alpha158 # noqa: F401
|
||
from qlib.contrib.model.gbdt import LGBModel # noqa: F401
|
||
|
||
print("==== qlib 基线验证通过(安装/落盘/底层读回/组件导入)====")
|
||
print("提示:D.features→Dataset→模型→回测的高层工作流需在实现 QlibEngine 时")
|
||
print(" 按官方 dump_bin 的对齐规则落盘后再打通(见 docs/QLIB_VERIFICATION.md)。")
|
||
finally:
|
||
shutil.rmtree(tmp, ignore_errors=True)
|
||
|
||
|
||
def FileCalendarStorage_probe(uri: Path):
|
||
"""直接按文件读回日历(绕开 storage 构造签名差异)。"""
|
||
return (uri / "calendars" / "day.txt").read_text(encoding="utf-8").strip().splitlines()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|