Files
qlib/frontend/web/app/selection/page.tsx
T
Simon 9aaca12751 fix(web): 控件对齐与输入友好整改(控件高度令牌化 + 对齐自检脚本)
问题不是"不好看",而是**可测量的错位**:同一行里原生 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 小屏)。
2026-09-27 09:01:55 +08:00

504 lines
19 KiB
TypeScript
Raw 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.
"use client";
/** 股票筛选(选股系统 M6,v2 §14/§21.1):
* 评分模式(多因子加权 TopN)与条件模式(结构化条件 AND);
* 支持当前/历史 as_of;结果落库可查(selection_id),右侧展示最近选股历史。
*/
import Link from "next/link";
import { useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import { SymbolLink } from "@/lib/symbols";
import { waitJob } from "@/lib/jobs";
import type {
FactorMeta,
SelectionCondition,
SelectionMeta,
SelectionQuery,
SelectionResult,
SelectionRun,
} from "@/lib/types";
import {
PageHeader,
Card,
Pill,
Field,
Btn,
Banner,
Empty,
SkeletonLines,
} from "@/components/ui";
const FIELD_OPTIONS: { value: string; label: string; kind: "num" | "str" | "ref" }[] = [
{ value: "static.industry", label: "行业 industry", kind: "str" },
{ value: "static.market", label: "市场 market", kind: "str" },
{ value: "momentum_20", label: "动量 20 日", kind: "num" },
{ value: "momentum_60", label: "动量 60 日", kind: "num" },
{ value: "close", label: "收盘价 close", kind: "num" },
{ value: "close_ma60_ref", label: "收盘 > MA60", kind: "ref" },
{ value: "volume_ratio_5_60", label: "量比", kind: "num" },
{ value: "fundamental.roe", label: "ROE(%)", kind: "num" },
{ value: "fundamental.eps", label: "EPS", kind: "num" },
];
const OPS = ["gt", "gte", "lt", "lte", "eq", "ne", "in"];
/**
* 读取后端 payload 里的 name(若存在)。
*
* 为什么用宽松读取而非直接 `c.name`:`lib/types.ts` 的 SelectionCandidate 目前没有
* 声明 name 字段(该文件由其他改动维护),直接访问会类型报错;但后端可能已经返回更权威的
* 中文名。这里只做「有且非空则用」的读取,取不到就交给 SymbolLink 渲染占位「—」,
* 绝不编造名称(AGENT §24)。
*/
function payloadName(row: unknown): string | undefined {
if (row && typeof row === "object" && "name" in row) {
const n = (row as { name?: unknown }).name;
if (typeof n === "string" && n.trim()) return n;
}
return undefined;
}
function emptyCondition(): SelectionCondition {
return { field: "static.industry", op: "eq", value: "白酒" };
}
export default function SelectionPage() {
const [factors, setFactors] = useState<FactorMeta[]>([]);
const [mode, setMode] = useState<"score" | "condition">("score");
const [asOf, setAsOf] = useState(""); // 空 = 最近交易日
const [excludeSt, setExcludeSt] = useState(true);
const [topN, setTopN] = useState(20);
const [factorRows, setFactorRows] = useState([{ name: "momentum_60", weight: 1 }]);
const [conds, setConds] = useState<SelectionCondition[]>([emptyCondition()]);
const [result, setResult] = useState<SelectionResult | null>(null);
const [selectionId, setSelectionId] = useState("");
/**
* 已落库的 selection_id —— 只有「同步选股(SelectionRun.selection_id)」和「从历史列表打开(id)」两条路径才有。
*
* 为什么不能直接用 selectionId:异步路径(POST /selections/jobs)拿到的是 JOB id,
* 后端不会为它写 selection 记录(backend/app/api/selections.py 只在同步接口里 selection_repo.save),
* 拿它拼 /backtest?from_selection=<JOB id> 会让接收端 GET /api/selections/{id} 404 → 坏链接。
*/
const [savedSelectionId, setSavedSelectionId] = useState("");
const [running, setRunning] = useState(false);
const [asyncBusy, setAsyncBusy] = useState(false);
const [error, setError] = useState("");
const [history, setHistory] = useState<SelectionMeta[] | null>(null);
useEffect(() => {
let alive = true;
apiGet<FactorMeta[]>("/factors")
.then((list) => alive && list.length > 0 && setFactors(list))
.catch((e: Error) => alive && setError(e.message));
apiGet<SelectionMeta[]>("/selections?limit=8")
.then((rows) => alive && setHistory(rows))
.catch(() => alive && setHistory([]));
return () => {
alive = false;
};
}, []);
function buildQuery(): SelectionQuery {
if (mode === "score") {
return {
universe: { exclude_st: excludeSt, min_listing_days: 0 },
as_of: asOf || null,
method: "score",
factors: factorRows.filter((r) => r.name),
conditions: [],
top_n: topN,
};
}
return {
universe: { exclude_st: excludeSt, min_listing_days: 0 },
as_of: asOf || null,
method: "condition",
factors: [],
conditions: conds.map((c) => ({
...c,
...(c.field === "close_ma60_ref"
? { field: "close", op: c.op, ref: "ma60", value: null }
: {}),
})),
top_n: null,
};
}
async function run() {
setRunning(true);
setError("");
setResult(null);
// 新一轮结果尚未落库/尚未返回:先清掉旧的 selection_id,避免「结果 A + 直通到记录 B」的错配链接
setSavedSelectionId("");
try {
const run = await apiPost<SelectionRun>("/selections", buildQuery());
setSelectionId(run.selection_id);
setSavedSelectionId(run.selection_id);
setResult(run.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
} catch (e) {
setError((e as Error).message);
} finally {
setRunning(false);
}
}
/** 异步(全市场等长任务):提交 Job 后台执行并轮询(解决同步 60s+)。 */
async function runAsync() {
setAsyncBusy(true);
setError("");
setResult(null);
// 同上:异步任务不落库选股记录,直通回测必须隐藏按钮(不给坏链接)
setSavedSelectionId("");
try {
const { job_id } = await apiPost<{ job_id: string }>("/selections/jobs", buildQuery());
const out = await waitJob<SelectionResult>(job_id);
if (out.status === "success" && out.result) {
setSelectionId(job_id);
setResult(out.result);
apiGet<SelectionMeta[]>("/selections?limit=8").then(setHistory).catch(() => undefined);
} else {
setError(`任务${out.status}${out.error ? `:${out.error}` : ""}`);
}
} catch (e) {
setError((e as Error).message);
} finally {
setAsyncBusy(false);
}
}
async function openHistory(id: string) {
setError("");
try {
const detail = await apiGet<SelectionResult>(`/selections/${id}`);
setSelectionId(id);
setSavedSelectionId(id);
setResult(detail);
} catch (e) {
setError((e as Error).message);
}
}
return (
<>
<PageHeader
title="股票筛选"
sub="以因子评分 TopN 或结构化条件筛选股票,支持当前 / 历史时点;每次选股落库可复现、可解释(为什么选它)。"
actions={
selectionId ? (
<Pill tone="violet" icon="check">
{selectionId}
</Pill>
) : null
}
/>
<Card title="选股条件" icon="filter">
<div className="form-grid">
<Field label="方式">
<select
className="input"
value={mode}
onChange={(e) => setMode(e.target.value as "score" | "condition")}
>
<option value="score">因子评分(TopN)</option>
<option value="condition">条件筛选(AND)</option>
</select>
</Field>
<Field label="选股时点 as_of" hint="留空 = 最近可用交易日">
<input type="date" className="input" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
</Field>
<Field label="标的范围" hint="ST 按当时名称判定">
<label className="check">
<input type="checkbox" checked={excludeSt} onChange={(e) => setExcludeSt(e.target.checked)} />
剔除 ST
</label>
</Field>
</div>
{mode === "score" ? (
<>
<div className="form-section__title" style={{ margin: "12px 0 8px" }}>
评分因子与权重
</div>
{/* 与回测页同一套「表头 + 栅格行」:列宽由 CSS 决定,不再用 flex 拉伸
(flex:1 会把权重框拉到几百像素宽,与一位小数的内容完全不匹配) */}
<div className="factor-rows">
<div className="factor-rows__head" aria-hidden="true">
<span>因子</span>
<span>权重</span>
<span />
</div>
{factorRows.map((r, i) => (
<div className="factor-row" key={i}>
<select
className="input"
aria-label={`第 ${i + 1} 个因子的名称`}
value={r.name}
onChange={(e) => {
const next = [...factorRows];
next[i] = { ...next[i], name: e.target.value };
setFactorRows(next);
}}
>
{factors.map((f) => (
<option key={f.name} value={f.name}>{f.name}</option>
))}
</select>
<input
className="input mono"
type="number"
inputMode="decimal"
step="0.1"
min={0}
aria-label={`第 ${i + 1} 个因子的权重`}
value={r.weight}
onChange={(e) => {
const next = [...factorRows];
next[i] = { ...next[i], weight: Number(e.target.value) };
setFactorRows(next);
}}
/>
<Btn
icon="x"
disabled={factorRows.length <= 1}
aria-label={`删除第 ${i + 1} 个因子`}
title="删除该因子"
onClick={() => setFactorRows(factorRows.filter((_, j) => j !== i))}
/>
</div>
))}
</div>
<div className="row" style={{ gap: 10, marginTop: 10, alignItems: "flex-end" }}>
<Btn
icon="layers"
size="sm"
onClick={() => setFactorRows([...factorRows, { name: "momentum_60", weight: 1 }])}
>
添加因子
</Btn>
<Field label="Top N" hint="按综合分排序取前 N 只">
<input
className="input"
type="number"
inputMode="numeric"
min={1}
value={topN}
onChange={(e) => setTopN(Number(e.target.value))}
/>
</Field>
</div>
</>
) : (
<>
<div className="form-section__title" style={{ margin: "12px 0 8px" }}>
条件(全部满足才入选)
</div>
<div className="cond-rows">
<div className="cond-rows__head" aria-hidden="true">
<span>字段</span>
<span>比较</span>
<span>取值</span>
<span />
</div>
{conds.map((c, i) => {
const meta = FIELD_OPTIONS.find((f) => f.value === (c.field === "close" && c.ref ? "close_ma60_ref" : c.field));
const isRef = c.field === "close" && c.ref === "ma60";
return (
<div className="cond-row" key={i}>
<select
className="input"
aria-label={`第 ${i + 1} 个条件的字段`}
value={isRef ? "close_ma60_ref" : c.field}
onChange={(e) => {
const next = [...conds];
const v = e.target.value;
next[i] = v === "close_ma60_ref"
? { field: "close", op: "gt", ref: "ma60", value: null }
: { field: v, op: c.op, value: v.startsWith("static.") ? "" : 0 };
setConds(next);
}}
>
{FIELD_OPTIONS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
</select>
<select
className="input"
aria-label={`第 ${i + 1} 个条件的比较符`}
value={c.op}
onChange={(e) => {
const next = [...conds];
next[i] = { ...next[i], op: e.target.value as SelectionCondition["op"] };
setConds(next);
}}
>
{OPS.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
{!isRef ? (
<input
className="input mono"
aria-label={`第 ${i + 1} 个条件的取值`}
placeholder={c.op === "in" ? "逗号分隔多个值" : "值"}
value={String(c.value ?? "")}
onChange={(e) => {
const next = [...conds];
const raw = e.target.value;
const val = c.op === "in"
? raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
: (meta?.kind === "num" && !c.field.startsWith("static.")
? Number(raw)
: raw);
next[i] = { ...next[i], value: val as never };
setConds(next);
}}
/>
) : (
/* 占位保持第四列(操作列)不串位 */
<span />
)}
<Btn
icon="x"
disabled={conds.length <= 1}
aria-label={`删除第 ${i + 1} 个条件`}
title="删除该条件"
onClick={() => setConds(conds.filter((_, j) => j !== i))}
/>
</div>
);
})}
</div>
<div style={{ marginTop: 10 }}>
<Btn icon="layers" size="sm" onClick={() => setConds([...conds, emptyCondition()])}>
添加条件
</Btn>
</div>
</>
)}
<div style={{ marginTop: 14 }}>
<Btn variant="primary" icon="play" loading={running} disabled={running || asyncBusy} onClick={run}>
{running ? "筛选中…" : "执行选股"}
</Btn>
<Btn variant="ghost" icon="clock" loading={asyncBusy} disabled={running || asyncBusy} onClick={runAsync}>
{asyncBusy ? "后台执行中…" : "异步(全市场)"}
</Btn>
</div>
{error ? (
<div style={{ marginTop: 12 }}>
<Banner tone="error">{error}</Banner>
</div>
) : null}
</Card>
{!result && !running && history === null ? <SkeletonLines n={4} /> : null}
{result ? <ResultView result={result} selectionId={savedSelectionId} /> : null}
{history && history.length > 0 ? (
<Card title="最近选股记录" icon="archive">
<div className="chips">
{history.map((m) => (
<button
key={m.id}
className="chip"
style={{ cursor: "pointer" }}
onClick={() => openHistory(m.id)}
title={`${m.id} · ${m.method} · ${m.selected} 只`}
>
<b className="mono">{m.as_of}</b>
{m.method} · {m.selected} 只
</button>
))}
</div>
</Card>
) : null}
</>
);
}
function ResultView({ result, selectionId }: { result: SelectionResult; selectionId: string }) {
const s = result.statistics;
if (result.candidates.length === 0) {
return (
<Card>
<Empty
icon="filter"
title="无符合条件/可评分的股票"
hint={`范围 ${s.universe_size} 只,可评分 ${s.evaluated} 只。可放宽条件或选择更早的 as_of。`}
/>
</Card>
);
}
return (
<Card
icon="target"
title={`选股结果 · ${result.as_of_date} · ${result.method}`}
tools={
<>
<Pill>范围 {s.universe_size}</Pill>
<Pill>评分 {s.evaluated}</Pill>
<Pill tone="pos">选出 {s.selected}</Pill>
{/* 选股 → 回测直通:只带「已落库」的 selection_id;没有它(如异步任务未落库)就不渲染按钮,避免坏链接 */}
{selectionId ? (
<Link
href={`/backtest?from_selection=${encodeURIComponent(selectionId)}`}
className="btn btn--sm"
title="把这次的规则(条件/因子/TopN/口径)预填到回测页;回测会在每个择股日按同一规则重新选股,不是固定持有这批股票"
>
<span>按此条件回测</span>
</Link>
) : null}
</>
}
>
{selectionId ? (
<div className="hint" style={{ marginBottom: 10 }}>
按此条件回测:把这次的规则(条件 / 因子 / TopN / 口径)预填到回测页;回测会在每个择股日按同一规则重新选股,<b>不是固定持有这批股票</b>。
</div>
) : null}
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>#</th>
<th>代码 / 名称</th>
<th>得分</th>
<th>因子值</th>
<th>入选理由</th>
</tr>
</thead>
<tbody>
{result.candidates.map((c) => (
<tr key={c.symbol}>
<td className="mono dim">{c.rank}</td>
<td>
{/* 后端 payload 若已带 name 就用它(更权威),否则回落到全局名称表 */}
<SymbolLink symbol={c.symbol} name={payloadName(c)} />
</td>
<td className="mono">{c.score.toFixed(4)}</td>
<td style={{ fontSize: 12 }}>
{Object.entries(c.factor_values)
.map(([k, v]) => `${k}=${typeof v === "number" ? v.toFixed(4) : v}`)
.join(" ")}
</td>
<td>
<div className="chips">
{c.selection_reason.map((r) => (
<span className="chip" key={r}>{r.slice(0, 40)}</span>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{result.unimplemented.length ? (
<div style={{ marginTop: 10 }}>
<Banner tone="info">{result.unimplemented.join(";")}</Banner>
</div>
) : null}
</Card>
);
}