Files
qlib/frontend/web/components/BacktestResultView.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

656 lines
23 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";
/**
* 回测结果视图(**回测页**与**实验归档页**共用同一组件)。
*
* 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致——
* 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。
*
* 三处模式差异用 props 表达:
* - `name`:策略名(运行时的策略);归档页传归档里记录的策略名(若有)。
* - `archive`:传入后额外展示归档元数据(实验 id / 数据版本 / 代码版本 / 归档时间)
* 与**归档完整度**(`result.archive_meta.truncated` 为真时显式提示曲线被裁剪,不静默)。
*/
import { useMemo, useRef, useState } from "react";
import {
Card,
Pill,
Btn,
Empty,
BacktestMetrics,
MonthlyReturnsTable,
UnimplementedNote,
} from "@/components/ui";
import { LwChart, type LwMarker, type LwSeries } from "@/components/charts/LwChart";
import { CHART, fmtNum, fmtPct } from "@/components/charts/theme";
import Link from "next/link";
import { SymbolLink, useSymbolNames } from "@/lib/symbols";
import { adjustLabel } from "@/lib/labels";
import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types";
export interface ArchiveInfo {
id: string;
created_at?: string | null;
code_version?: string | null;
data_version?: string | null;
job_id?: string | null;
}
function pad(n: number): string {
return String(n).padStart(2, "0");
}
function fmtDateTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function BacktestResultView({
result,
name,
archive,
}: {
result: BacktestResult;
name?: string;
archive?: ArchiveInfo;
}) {
const s = result.summary;
const last = result.positions.at(-1);
const holdings = result.positions.filter((p) => p.date === last?.date);
const curves = result.symbol_curves ?? [];
const [active, setActive] = useState<string>(curves[0]?.symbol ?? "");
const [curveQuery, setCurveQuery] = useState("");
// 老实验(后端填充 name 之前归档的结果)里 curve.name 为空:
// 名称统一从 useSymbolNames 缓存取,保证「有代码必有名称」在任何历史结果上都成立
const { names: nameCache } = useSymbolNames();
const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? "";
const topRef = useRef<HTMLDivElement | null>(null);
// 组合净值上的买卖点:同一日的成交合并成一个标记,落在当日净值上
const equitySeries = useMemo<LwSeries[]>(
() => [
{
key: "equity",
label: "组合净值(元)",
type: "area",
color: CHART.pos,
data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
],
[result.equity_curve]
);
const equityMarkers = useMemo<LwMarker[]>(() => {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
for (const f of result.fills ?? []) {
const cur = byDate.get(f.date) ?? { BUY: false, SELL: false };
cur[f.signal] = true;
byDate.set(f.date, cur);
}
const equity = new Set(result.equity_curve.map((p) => p.date));
const out: LwMarker[] = [];
for (const [d, kinds] of byDate) {
if (!equity.has(d)) continue;
if (kinds.BUY) out.push({ time: d, kind: "BUY", text: "买" });
if (kinds.SELL) out.push({ time: d, kind: "SELL", text: "卖" });
}
return out;
}, [result.fills, result.equity_curve]);
const filteredCurves = useMemo(() => {
const q = curveQuery.trim().toLowerCase();
if (!q) return curves;
return curves.filter(
(c) => c.symbol.toLowerCase().includes(q) || nameOf(c).toLowerCase().includes(q)
);
// nameOf 只依赖 nameCache 与 c,二者都在依赖里
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [curves, curveQuery, nameCache]);
const activeCurve = curves.find((c) => c.symbol === active) ?? filteredCurves[0] ?? curves[0];
const buyDays = equityMarkers.filter((m) => m.kind === "BUY").length;
const sellDays = equityMarkers.filter((m) => m.kind === "SELL").length;
const SECTIONS = [
{ id: "sec-equity", label: "整体收益" },
{ id: "sec-symbols", label: "个股曲线" },
{ id: "sec-monthly", label: "月度/年度" },
{ id: "sec-holdings", label: "持仓" },
{ id: "sec-trades", label: "成交明细" },
];
return (
<>
<div ref={topRef} />
<div className="sticky-bar" style={{ gap: 10 }}>
<b style={{ fontSize: 13 }}>结果分区</b>
{SECTIONS.map((s) => (
<a key={s.id} href={`#${s.id}`} className="btn btn--sm">
<span>{s.label}</span>
</a>
))}
<span className="hint">共 {result.equity_curve.length} 个交易日 · {result.trades.length} 笔成交</span>
</div>
{archive ? <ArchiveMetaBar archive={archive} /> : null}
<ArchiveCompletenessNote result={result} archive={archive} />
<div className="between" style={{ margin: "6px 0 14px" }}>
<div className="row" style={{ flexWrap: "wrap" }}>
<Pill tone="pos" icon="check">
完成
</Pill>
{name ? (
<Pill tone="violet" icon="book">
{name}
</Pill>
) : null}
<Pill tone="violet" icon="target">
{poolLabel(result)}
</Pill>
<Pill>{adjustLabelFromSnapshot(result.config_snapshot)} 口径</Pill>
{stBasisNote(result) && (
<Pill tone="warn" icon="alert">
{stBasisNote(result)}
</Pill>
)}
<Pill>
{s.start} ~ {s.end}
</Pill>
</div>
<div className="row" style={{ fontSize: 22, fontWeight: 700 }}>
总收益 <SignedText value={s.total_return_pct} />
</div>
</div>
<BacktestMetrics s={s} />
<div className="chart-grid" id="sec-equity">
<Card
icon="chartLine"
title="整体收益趋势(含买卖点)"
tools={
<Pill tone="pos">
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
</Pill>
}
>
<LwChart
series={equitySeries}
markers={equityMarkers}
height={320}
valueFormat={(v) => fmtNum(v)}
ariaLabel="组合净值曲线与买卖点"
/>
<div className="hint" style={{ marginTop: 6 }}>
▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交;点位取当日组合净值。
悬停可看任意日期的净值;拖动/滚轮可缩放区间,双击图例可临时隐藏曲线。
</div>
</Card>
<Card
icon="chartLine"
title="回撤(%)"
tools={<Pill tone="neg">最大 {s.max_drawdown_pct.toFixed(2)}%</Pill>}
>
<LwChart
series={[
{
key: "dd",
label: "回撤(%)",
type: "area",
color: CHART.neg,
data: result.drawdown.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
height={320}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel="回撤曲线"
/>
</Card>
</div>
<Card
id="sec-symbols"
icon="target"
title="个股收益率趋势(持仓期累计收益,含买卖点)"
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input input--search"
placeholder="搜索代码或名称"
value={curveQuery}
onChange={(e) => setCurveQuery(e.target.value)}
aria-label="搜索个股"
/>
<Pill>{curves.length} 只有成交</Pill>
</div>
}
>
{curves.length === 0 ? (
<Empty title="无个股曲线" hint="回测区间内没有产生成交。" />
) : (
<>
<div className="row" style={{ gap: 8, marginBottom: 10, flexWrap: "wrap" }}>
<select
className="input input--picker"
aria-label="选择要查看的个股"
value={activeCurve?.symbol ?? ""}
onChange={(e) => setActive(e.target.value)}
>
{filteredCurves.map((c) => {
const nm = nameOf(c);
return (
<option key={c.symbol} value={c.symbol}>
{c.symbol} {nm || "—"}({fmtPct(c.final_return_pct)})
</option>
);
})}
</select>
<SymbolLink
symbol={activeCurve?.symbol ?? ""}
name={activeCurve ? nameOf(activeCurve) : undefined}
/>
<span className="hint">
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制,
分段间以直线连接,请以买卖点区分持仓区间。
</span>
</div>
{activeCurve ? <SymbolCurveChart curve={activeCurve} /> : null}
<div className="table-wrap" style={{ marginTop: 12 }}>
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>持仓期累计收益</th>
<th>买点</th>
<th>卖点</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredCurves.map((c) => (
<tr key={c.symbol} className={c.symbol === activeCurve?.symbol ? "row-active" : ""}>
<td className="sym-cell">
<SymbolLink symbol={c.symbol} name={c.name} />
</td>
<td className={c.final_return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{c.final_return_pct.toFixed(2)}%
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "BUY").length}
</td>
<td className="mono dim">
{(c.marks ?? []).filter((m) => m.signal === "SELL").length}
</td>
<td>
<Btn size="sm" onClick={() => setActive(c.symbol)}>
查看曲线
</Btn>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</Card>
<Card id="sec-monthly" icon="calendar" title="月度收益(%)">
<LwChart
series={[
{
key: "monthly",
label: "月度收益(%)",
type: "bar",
color: CHART.accent,
data: result.monthly_returns.map((m) => ({
time: `${m.year}-${String(m.month).padStart(2, "0")}-01`,
value: m.return_pct,
})),
},
]}
height={200}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
legend={false}
ariaLabel="月度收益柱状图"
/>
<details style={{ marginTop: 8 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看月度收益明细表
</summary>
<MonthlyReturnsTable rows={result.monthly_returns} />
</details>
</Card>
<Card
id="sec-holdings"
icon="target"
title={`最新持仓 · ${last?.date ?? "—"}`}
tools={<Pill>{holdings.length} 只</Pill>}
>
{holdings.length === 0 ? (
<Empty title="无持仓记录" hint="回测区间内没有产生持仓。" />
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>股票</th>
<th>权重</th>
</tr>
</thead>
<tbody>
{holdings.map((p) => (
<tr key={p.symbol}>
<td className="sym-cell">
<SymbolLink symbol={p.symbol} name={p.name} />
</td>
<td className="mono">{(p.weight * 100).toFixed(2)}%</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{result.yearly_returns.length ? (
<Card
icon="calendar"
title="年度收益(%)"
tools={
<Pill>
均值{" "}
{(
result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) /
result.yearly_returns.length
).toFixed(2)}
%
</Pill>
}
>
<div className="chips">
{result.yearly_returns.map((y) => (
<span className="chip" key={y.year}>
<b>{y.year}</b>
<span className={y.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{y.return_pct.toFixed(2)}%
</span>
</span>
))}
</div>
</Card>
) : null}
{result.trades.length ? (
<Card
id="sec-trades"
icon="scale"
title={`成交明细 · ${result.trades.length} 笔`}
tools={<Pill>{result.turnover_pct.toFixed(2)}% 累计换手</Pill>}
>
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th>买入日</th>
<th>卖出日</th>
<th>股票</th>
<th>买价</th>
<th>卖价</th>
<th>收益</th>
</tr>
</thead>
<tbody>
{result.trades.map((t) => (
<tr key={`${t.symbol}-${t.entry_date}-${t.exit_date}`}>
<td className="mono dim">{t.entry_date}</td>
<td className="mono dim">{t.exit_date}</td>
<td className="sym-cell">
<SymbolLink symbol={t.symbol} name={t.name} />
</td>
<td className="mono">{t.entry_price?.toFixed(2) ?? "-"}</td>
<td className="mono">{t.exit_price?.toFixed(2) ?? "-"}</td>
<td className={t.return_pct >= 0 ? "tone-pos" : "tone-neg"}>
{t.return_pct.toFixed(2)}%
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
) : null}
<NotFilledCard signals={result.signal_history ?? []} />
<UnimplementedNote items={result.unimplemented} />
</>
);
}
/** 未成交意图(涨停/停牌/顺延)透明化,避免「信号有了却没买」无法解释。 */
function NotFilledCard({ signals }: { signals: ActionRecord[] }) {
const rejects = signals.filter((a) => !a.filled && a.reject_reason);
if (rejects.length === 0) return null;
const byReason = new Map<string, number>();
for (const r of rejects) {
const key = (r.reject_reason ?? "").replace(/,?顺延.*$/, "(顺延)");
byReason.set(key, (byReason.get(key) ?? 0) + 1);
}
return (
<Card icon="info" title={`未成交意图 · ${rejects.length} 条`}>
<div className="chips">
{[...byReason.entries()].map(([reason, n]) => (
<span className="chip" key={reason}>
{reason} <b>{n}</b>
</span>
))}
</div>
<div className="hint" style={{ marginTop: 8 }}>
涨停/停牌导致的未成交按「信号已记录、成交未发生」处理(v3 §20.3):顺延买入会在之后首个
可成交交易日按收盘价成交;到下一次调仓仍未成交则作废。
</div>
</Card>
);
}
function SymbolCurveChart({ curve }: { curve: SymbolCurve }) {
const valueByDate = useMemo(() => new Map(curve.points.map((p) => [p.date, p.value])), [curve]);
const markers = useMemo<LwMarker[]>(
() =>
(curve.marks ?? [])
.filter((a) => valueByDate.has(a.date))
.map((a) => ({
time: a.date,
kind: a.signal,
text: a.signal === "BUY" ? "买" : "卖",
})),
[curve, valueByDate]
);
return (
<LwChart
series={[
{
key: "sym",
label: `${curve.symbol} 持仓期累计收益(%)`,
type: "area",
color: CHART.accent,
data: curve.points.map((p) => ({ time: p.date, value: p.value })),
lastValueVisible: true,
},
]}
markers={markers}
height={300}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${curve.symbol} 持仓期收益曲线与买卖点`}
/>
);
}
function poolLabel(result: BacktestResult): string {
const sel = result.config_snapshot?.selection as
| { top_n?: number; hold_top_x?: number | null }
| undefined;
if (!sel) return "候选池 —";
return `候选池 ${sel.top_n ?? "—"} → 持仓 ${sel.hold_top_x ?? sel.top_n ?? "—"}`;
}
/**
* ST(风险警示)剔除口径提示。
*
* 后端逐择股日按**当时名称**判定 exclude_st(依赖 `sync namechange`);未同步名称历史时
* 回退「最新名称快照」,会把「曾为高股息、后来才 ST/退市」的股息陷阱样本整段排除,
* 收益被高估(实测案例 +24.86% → +35.71%)。此处把口径显式暴露给使用者,避免误读。
*/
function stBasisNote(result: BacktestResult): string | null {
const basis = result.config_snapshot?.price_basis as
| { name_basis?: { point_in_time?: boolean } | null }
| undefined;
const nb = basis?.name_basis;
if (!nb) return null; // 未启用 exclude_st
return nb.point_in_time ? "ST 按时点名称判定" : "ST 用最新名称(可能高估)";
}
function adjustLabelFromSnapshot(snapshot: Record<string, unknown>): string {
const basis = snapshot?.price_basis as { adjust_mode?: string } | undefined;
const mode = basis?.adjust_mode ?? "none";
return mode === "hfq" ? "后复权" : mode === "qfq" ? "前复权" : "不复权";
}
function SignedText({ value }: { value: number }) {
const cls = value >= 0 ? "tone-pos" : "tone-neg";
return (
<span className={cls}>
{value > 0 ? "+" : ""}
{value.toFixed(2)}%
</span>
);
}
/**
* 归档元数据条:**归档能否被信任地复现,取决于这几项**,所以放在最显眼的位置。
* - 代码版本:跑出这个结果时的 git short rev
* - 数据版本:当时的数据快照指纹(由后端在归档时写入;老归档可能为空 → 如实显示「—」)
* - 实验 / 作业 id:可追到执行记录
*/
function ArchiveMetaBar({ archive }: { archive: ArchiveInfo }) {
return (
<div className="archive-bar">
<Pill tone="violet" icon="archive">
归档 {archive.id}
</Pill>
<span className="hint">
归档时间 <b className="mono">{fmtDateTime(archive.created_at)}</b> · 代码版本{" "}
<b className="mono">{archive.code_version ?? "—"}</b> · 数据版本{" "}
<b className="mono">{archive.data_version ?? "未记录"}</b>
{archive.job_id ? (
<>
{" "}
· 作业 <b className="mono">{archive.job_id}</b>
</>
) : null}
</span>
</div>
);
}
/**
* 归档完整度提示。
*
* `archive_meta.truncated` 为真表示:归档时结果 JSON 超过安全预算,个股曲线被裁剪
* (后端会在 `unimplemented` 里同时留下说明)。**必须显式告知**,否则用户会以为
* 「这只股票没被买过」,而实际只是没存下来。
*/
function ArchiveCompletenessNote({
result,
archive,
}: {
result: BacktestResult;
archive?: ArchiveInfo;
}) {
const meta = result.archive_meta as
| { curves_stored?: number; curves_total?: number; truncated?: boolean; budget_chars?: number }
| undefined;
const legacyNote = (result.unimplemented ?? []).find((u) => u.includes("个股收益曲线"));
// 情况 1:归档带完整度元数据(本轮之后产生的归档)
if (meta && meta.curves_total !== undefined) {
const total = meta.curves_total ?? 0;
const stored = meta.curves_stored ?? 0;
return (
<div className="archive-bar">
{meta.truncated ? (
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
) : (
<Pill tone="pos" icon="check">
归档完整
</Pill>
)}
<span className="hint">
个股曲线已存 <b className="mono">{stored}</b> / 期内持有 <b className="mono">{total}</b> 只
{meta.truncated
? "(超出结果体积预算被裁剪:请在下方用「成交明细 / 未成交意图」查缺失个股,或缩短区间后重跑以得到完整曲线)"
: "(全部持有过的股票都可在此查看)"}
</span>
</div>
);
}
// 情况 2:老归档没有元数据,但结果里留了截断说明 → **不能装作完整**
if (legacyNote) {
const held = /期内共持有\s*(\d+)\s*只/.exec(legacyNote)?.[1];
const kept = /最大的\s*(\d+)\s*只/.exec(legacyNote)?.[1];
return (
<div className="archive-bar">
<Pill tone="warn" icon="alert">
归档不完整
</Pill>
<span className="hint">
该归档产生于「完整存档」上线之前:个股曲线只有 <b className="mono">{kept ?? "60"}</b> 只(期内共持有{" "}
<b className="mono">{held ?? "更多"}</b> 只),换手但收益排名不靠前的个股在这里看不到曲线。
成交明细 / 未成交意图仍是完整的。要看全部个股曲线,请
{archive ? (
<>
{" "}
<Link href={`/backtest?from_experiment=${encodeURIComponent(archive.id)}`} className="btn btn--sm">
<span>以此参数重跑</span>
</Link>{" "}
生成一份完整归档。
</>
) : (
"以此参数重跑一次(新归档将保存全部持有过的个股曲线)。"
)}
</span>
</div>
);
}
// 情况 3:既无元数据也无截断说明 → 无从判断,不猜(只在老归档上出现)
if (archive) {
return (
<div className="archive-bar">
<Pill icon="info">
完整度未标注
</Pill>
<span className="hint">
该归档早于「归档完整度元数据」上线,无法确认个股曲线是否被裁剪过
(成交明细 / 未成交意图始终是完整的)。
</span>
</div>
);
}
return null;
}