Files
qlib/frontend/web/app/experiments/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

736 lines
28 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";
/**
* 实验归档 + **对比**(微调闭环的核心)。
*
* 过去这一页只能「看详情 / 复跑」,微调时只能翻列表比数字。现在:
* - 勾选 2~3 个实验 → 净值曲线归一化为「累计收益率 %」叠加(消除初始资金差异),
* 因此可以直接看出「改了哪个参数、曲线变好还是变坏」;
* - 指标对比表:每个指标一行、每个实验一列,最后一列是与第一个实验的**差值**;
* - 参数 diff 表:把 config_snapshot 扁平化后逐项对比,默认只显示「有差异」的项 ——
* 微调时一眼看出「这次到底改了什么」。
*
* 曲线用 TradingView Lightweight Charts(全站统一图表基座)。
*/
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { apiGet, apiGetWithHeaders, apiPost } from "@/lib/api";
import { experimentKindLabel } from "@/lib/labels";
import type { BacktestResult, ExperimentDetail, ExperimentMeta } from "@/lib/types";
import { isBacktestDetail, type BacktestDetail } from "@/lib/archive";
import {
PageHeader,
Card,
Pill,
Btn,
Banner,
Empty,
Metric,
SkeletonLines,
Loading,
} from "@/components/ui";
import { Icon } from "@/components/icons";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { CHART, seriesColor } from "@/components/charts/theme";
/**
* 类型徽标的**色调**(措辞统一走 `lib/labels.ts::experimentKindLabel`)。
*
* 之前这里自己维护了一份 `KIND_MAP` 文案,只覆盖 backtest/factor_test,
* 于是 `selection` 在表格里显示成英文原值 `selection`,而同一页的筛选下拉写「选股」、
* 归档页写「选股」—— 同一口径三种叫法。现在文案只有一份,这里只管颜色。
*/
const KIND_TONE: Record<string, "accent" | "violet" | "default"> = {
backtest: "accent",
factor_test: "violet",
selection: "default",
};
function kindView(kind: string) {
return <Pill tone={KIND_TONE[kind] ?? "default"}>{experimentKindLabel(kind)}</Pill>;
}
function statusView(status: string) {
switch (status) {
case "queued":
return <Pill tone="accent">排队中</Pill>;
case "running":
return <Pill tone="accent" icon="spinner">执行中</Pill>;
case "success":
return <Pill tone="pos" icon="check">完成</Pill>;
case "failed":
return <Pill tone="neg" icon="alert">失败</Pill>;
default:
return <Pill>{status || "—"}</Pill>;
}
}
/** config_snapshot 扁平化:嵌套对象转成 a.b.c 路径,便于逐项 diff */
function flatten(obj: unknown, prefix = "", out: Record<string, string> = {}): Record<string, string> {
if (obj === null || obj === undefined) {
out[prefix || "—"] = "—";
return out;
}
if (Array.isArray(obj)) {
out[prefix] = obj.length ? JSON.stringify(obj) : "[]";
return out;
}
if (typeof obj === "object") {
const entries = Object.entries(obj as Record<string, unknown>);
if (!entries.length) {
out[prefix || "—"] = "{}";
return out;
}
for (const [k, v] of entries) flatten(v, prefix ? `${prefix}.${k}` : k, out);
return out;
}
out[prefix || "—"] = String(obj);
return out;
}
/** 数据指纹在列表里只显示关键前缀(完整值放 title,悬停可见) */
function shortDataVersion(v: string): string {
return v.length > 28 ? `${v.slice(0, 28)}…` : v;
}
const METRICS: { key: keyof BacktestResult["summary"]; label: string; fmt: (v: number) => string; better?: "high" | "low" }[] = [
{ key: "total_return_pct", label: "区间收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
{ key: "annual_return_pct", label: "年化收益", fmt: (v) => `${v >= 0 ? "+" : ""}${v.toFixed(2)}%`, better: "high" },
{ key: "sharpe", label: "Sharpe", fmt: (v) => v.toFixed(3), better: "high" },
{ key: "max_drawdown_pct", label: "最大回撤", fmt: (v) => `${v.toFixed(2)}%`, better: "low" },
{ key: "volatility_pct", label: "年化波动", fmt: (v) => `${v.toFixed(2)}%`, better: "low" },
{ key: "win_rate_pct", label: "胜率", fmt: (v) => `${v.toFixed(2)}%`, better: "high" },
{ key: "total_trades", label: "成交笔数", fmt: (v) => String(v) },
{ key: "avg_turnover_pct", label: "平均换手", fmt: (v) => `${v.toFixed(2)}%` },
{ key: "final_equity", label: "期末权益", fmt: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }) },
];
/**
* Next 15 要求 `useSearchParams` 处于 Suspense 边界内。
* `?exp=EXP-x,EXP-y` 用于从其它页面直接带实验进对比(策略库一键回测后「去对比」)。
*/
export default function ExperimentsPage() {
return (
<Suspense fallback={<Loading label="加载实验页…" />}>
<ExperimentsInner />
</Suspense>
);
}
function ExperimentsInner() {
const search = useSearchParams();
const [exps, setExps] = useState<ExperimentMeta[]>([]);
const [loading, setLoading] = useState(true);
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [jobId, setJobId] = useState("");
const [jobStatus, setJobStatus] = useState("");
const [error, setError] = useState("");
// 对比状态
const [picked, setPicked] = useState<string[]>([]);
const [compare, setCompare] = useState<BacktestDetail[] | null>(null);
const [comparing, setComparing] = useState(false);
const [onlyDiff, setOnlyDiff] = useState(true);
// 列表过滤:后端支持 kind/q(并把过滤后总数放在 X-Total-Count 响应头)。
// 初始值取自 URL(`?q=`/`?kind=`),且每次改动**回写 URL**:筛选条件可分享、刷新不丢,
// 也让「当前看到的是哪一批」有据可查(而不是只存在于组件状态里)。
const [q, setQ] = useState(() => search.get("q") ?? "");
const [kind, setKind] = useState(() => search.get("kind") ?? "");
const [total, setTotal] = useState<number | null>(null);
const syncUrl = useCallback(
(nextQ: string, nextKind: string) => {
const params = new URLSearchParams();
if (nextKind) params.set("kind", nextKind);
if (nextQ.trim()) params.set("q", nextQ.trim());
// 保留已勾选的对比实验,避免筛一下就丢掉选择
const exp = search.get("exp");
if (exp) params.set("exp", exp);
const qs = params.toString();
window.history.replaceState(null, "", qs ? `/experiments?${qs}` : "/experiments");
},
[search]
);
const load = useCallback(() => {
const params = new URLSearchParams();
if (kind) params.set("kind", kind);
if (q.trim()) params.set("q", q.trim());
const qs = params.toString();
apiGetWithHeaders<ExperimentMeta[]>(`/experiments${qs ? `?${qs}` : ""}`)
.then(({ data, headers }) => {
setExps(data);
const t = headers.get("X-Total-Count");
setTotal(t ? Number(t) : data.length);
})
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
}, [q, kind]);
useEffect(load, [load]);
// 从 URL 预勾选要对比的实验(最多 3 个,与手动勾选同一上限)
useEffect(() => {
const raw = search.get("exp");
if (!raw) return;
const ids = raw
.split(",")
.map((x) => x.trim())
.filter(Boolean)
.slice(0, 3);
if (ids.length) setPicked(ids);
// 仅在首次进入时读一次,避免用户手动改选后被 URL 覆盖
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function open(id: string) {
setDetailLoading(true);
setDetail(null);
apiGet<ExperimentDetail>(`/experiments/${id}`)
.then((d) => setDetail(d))
.catch((e: Error) => setError(e.message))
.finally(() => setDetailLoading(false));
}
function rerun(exp: ExperimentMeta) {
setError("");
setJobId("");
setJobStatus("submitting");
apiPost<{ job_id: string; status: string }>(`/experiments/${exp.id}/rerun`, {})
.then((r) => {
setJobId(r.job_id);
setJobStatus(r.status);
poll(r.job_id);
})
.catch((e: Error) => setError(e.message));
}
function poll(id: string) {
let tries = 0;
const timer = window.setInterval(() => {
tries += 1;
apiGet<{ status: string }>(`/jobs/${id}`)
.then((j) => {
setJobStatus(j.status);
if (j.status === "success" || j.status === "failed" || tries > 60) {
window.clearInterval(timer);
load();
setDetail(null);
}
})
.catch(() => window.clearInterval(timer));
}, 1200);
}
function togglePick(id: string) {
setError("");
setPicked((prev) => {
if (prev.includes(id)) return prev.filter((x) => x !== id);
if (prev.length >= 3) {
setError("最多同时对比 3 个实验(再多曲线会互相遮挡,也不利于读图)");
return prev;
}
return [...prev, id];
});
}
async function runCompare() {
setComparing(true);
setError("");
try {
const rows = await Promise.all(
picked.map((id) => apiGet<ExperimentDetail>(`/experiments/${id}`))
);
const usable = rows.filter(isBacktestDetail);
if (usable.length < 2) {
setError("所选实验里可对比的净值曲线不足 2 条(因子测试没有净值曲线)。");
setCompare(null);
} else {
setCompare(usable);
}
} catch (e) {
setError((e as Error).message);
} finally {
setComparing(false);
}
}
const s = detail && isBacktestDetail(detail) ? detail.result.summary : undefined;
return (
<>
<PageHeader
title="实验对比"
sub="每次因子测试与回测自动归档(含 Research Spec 与代码版本),保证可复现;勾选 2~3 个即可叠加净值曲线、对比指标与参数差异。"
actions={
exps.length > 0 ? (
<Pill tone="accent" icon="archive">
共 {exps.length} 个实验
</Pill>
) : undefined
}
/>
{error ? <Banner tone="error">{error}</Banner> : null}
{picked.length > 0 ? (
<div className="sticky-bar">
<Pill tone="violet" icon="layers">
已选 {picked.length} 个
</Pill>
<span className="hint">{picked.join(" · ")}</span>
<Btn variant="primary" icon="chartLine" loading={comparing} disabled={comparing || picked.length < 2} onClick={runCompare}>
对比选中的实验
</Btn>
<Btn icon="x" onClick={() => { setPicked([]); setCompare(null); }}>
清空
</Btn>
{picked.length < 2 ? <span className="hint">再选 1 个即可对比</span> : null}
</div>
) : null}
{compare ? (
<CompareView rows={compare} onlyDiff={onlyDiff} setOnlyDiff={setOnlyDiff} onClose={() => setCompare(null)} />
) : null}
{jobId ? (
<Card
icon="refresh"
title={`复跑任务 ${jobId}`}
tools={statusView(jobStatus === "submitting" ? "queued" : jobStatus)}
>
<div className="hint">任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。</div>
</Card>
) : null}
<Card
id="list"
icon="archive"
title="实验列表"
sub={
total !== null
? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}`
: "勾选左侧方框可选 2~3 个做对比"
}
tools={
<div className="row" style={{ gap: 8 }}>
<input
className="input input--search"
placeholder="搜索 ID / 因子 / 摘要"
value={q}
onChange={(e) => {
setQ(e.target.value);
syncUrl(e.target.value, kind);
}}
aria-label="搜索实验"
/>
<select
className="input input--filter"
value={kind}
onChange={(e) => {
setKind(e.target.value);
syncUrl(q, e.target.value);
}}
aria-label="按类型筛选"
>
<option value="">全部类型</option>
<option value="backtest">回测</option>
<option value="factor_test">因子测试</option>
<option value="selection">选股</option>
</select>
{q || kind ? (
<Btn size="sm" icon="x" onClick={() => { setQ(""); setKind(""); syncUrl("", ""); }}>
清除
</Btn>
) : null}
</div>
}
flush
>
{loading ? (
<div style={{ padding: 18 }}>
<SkeletonLines n={6} />
</div>
) : exps.length === 0 && (q || kind) ? (
/* 筛选无结果 ≠ 没有归档:文案必须区分,否则用户会以为归档全丢了 */
<Empty
icon="search"
title="没有匹配的归档"
hint={`筛选条件:${[q ? `关键词「${q}」` : "", kind ? `类型「${experimentKindLabel(kind)}」` : ""]
.filter(Boolean)
.join(" · ")};共 ${total} 条归档。清除筛选即可看到全部。`}
/>
) : exps.length === 0 ? (
<Empty
icon="archive"
title="暂无实验"
hint="在「选股回测」或「因子研究」中运行一次,即会自动归档于此。"
/>
) : (
<div className="table-wrap">
<table className="tbl">
<thead>
<tr>
<th style={{ width: 36 }} aria-label="选择对比" />
<th>ID</th>
<th>类型</th>
<th>因子</th>
<th>区间</th>
<th>摘要</th>
<th>版本(代码 / 数据)</th>
<th>体积</th>
<th style={{ width: 260, textAlign: "right" }}>操作</th>
</tr>
</thead>
<tbody>
{exps.map((e) => (
<tr key={e.id} className={detail?.id === e.id ? "row-active" : undefined}>
<td>
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
<label className="check check--cell" title={`选择 ${e.id} 用于对比`}>
<input
type="checkbox"
checked={picked.includes(e.id)}
onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
</label>
</td>
<td className="cell-mono cell-strong">{e.id}</td>
<td>{kindView(e.kind)}</td>
<td>
<span className="row" style={{ gap: 4 }}>
{e.factors.map((f) => (
<span key={f} className="tag tag--mono">
{f}
</span>
))}
</span>
</td>
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
<td className="hint">{e.summary_text ?? "—"}</td>
<td>
<span className="row" style={{ gap: 4 }}>
{e.code_version ? (
<span className="tag tag--mono" title="代码版本">{e.code_version}</span>
) : (
<span className="hint">—</span>
)}
{e.data_version ? (
<span className="tag tag--mono" title={`数据快照:${e.data_version}`}>
{shortDataVersion(e.data_version)}
</span>
) : (
<span className="hint" title="该归档早于数据指纹上线,不可严格复现">无数据版本</span>
)}
</span>
</td>
<td className="mono dim">
{e.result_bytes ? `${(e.result_bytes / 1024).toFixed(0)} KB` : "—"}
</td>
<td style={{ textAlign: "right" }}>
<span className="row" style={{ justifyContent: "flex-end", gap: 6 }}>
<Link href={`/experiments/${encodeURIComponent(e.id)}`} className="btn btn--sm">
<span>打开归档</span>
</Link>
<Btn size="sm" icon="search" onClick={() => open(e.id)}>
详情
</Btn>
<Link href={`/backtest?from_experiment=${encodeURIComponent(e.id)}`} className="btn btn--sm">
<span>以此参数再跑</span>
</Link>
<Btn
size="sm"
variant="primary"
icon="refresh"
onClick={() => rerun(e)}
disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}
>
复跑
</Btn>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{detail ? (
<Card
icon="book"
title={`${detail.id} · ${detail.factors.join("、")}`}
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
tools={
<div className="row" style={{ gap: 8 }}>
<Link href={`/experiments/${encodeURIComponent(detail.id)}`} className="btn btn--sm">
<span>打开归档(完整视图)</span>
</Link>
<Btn size="sm" onClick={() => setDetail(null)}>
<Icon name="x" size={13} />
关闭
</Btn>
</div>
}
>
{detailLoading ? (
<SkeletonLines n={3} />
) : (
<>
{s ? (
<div className="metric-grid" style={{ marginBottom: 0 }}>
<Metric
label="区间收益"
tone={s.total_return_pct >= 0 ? "pos" : "neg"}
value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`}
/>
<Metric
label="年化收益"
tone={s.annual_return_pct >= 0 ? "pos" : "neg"}
value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`}
/>
<Metric label="Sharpe" value={s.sharpe.toFixed(2)} />
<Metric label="最大回撤" tone="neg" value={`${s.max_drawdown_pct.toFixed(2)}%`} />
</div>
) : (
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
)}
{detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
<div style={{ marginTop: 12 }}>
<LwChart
series={[
{
key: "eq",
label: "累计收益率(%)",
type: "area",
color: CHART.accent,
data: normalize(detail.result),
lastValueVisible: true,
},
]}
height={260}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel={`${detail.id} 累计收益率曲线`}
/>
</div>
) : null}
{detail.spec ? (
<details style={{ marginTop: 12 }}>
<summary className="hint" style={{ cursor: "pointer" }}>
查看 spec(可复现输入)
</summary>
<pre className="mono" style={{ whiteSpace: "pre-wrap", fontSize: 12 }}>
{JSON.stringify(detail.spec, null, 2)}
</pre>
</details>
) : null}
</>
)}
</Card>
) : null}
</>
);
}
/* ------------------------------------------------------------------ */
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
function normalize(r: BacktestResult): { time: string; value: number }[] {
const base = r.equity_curve[0]?.value || 1;
return r.equity_curve.map((p) => ({ time: p.date, value: (p.value / base - 1) * 100 }));
}
/** 参数签名:一眼看出这一版的关键旋钮(微调时最重要) */
function sig(r: BacktestResult): string {
const c = r.config_snapshot as {
selection?: { top_n?: number; hold_top_x?: number | null };
selection_interval_months?: number | null;
rebalance_interval_months?: number | null;
price_adjustment?: string;
factors?: { name: string; weight: number }[];
costs?: { commission_rate?: number; min_commission?: number };
universe?: { exclude_st?: boolean; min_listing_days?: number };
};
const parts = [
(c.factors ?? []).map((f) => f.name).join("+") || "—",
`n=${c.selection?.top_n ?? "—"}/x=${c.selection?.hold_top_x ?? "—"}`,
`m=${c.selection_interval_months ?? "—"}/y=${c.rebalance_interval_months ?? "—"}`,
c.price_adjustment ?? "—",
c.universe?.exclude_st ? "剔ST" : "含ST",
`佣金${(((c.costs?.commission_rate ?? 0) * 100) as number).toFixed(3)}%`,
];
return parts.join(" · ");
}
function CompareView({
rows,
onlyDiff,
setOnlyDiff,
onClose,
}: {
rows: BacktestDetail[];
onlyDiff: boolean;
setOnlyDiff: (v: boolean) => void;
onClose: () => void;
}) {
const series = useMemo<LwSeries[]>(
() =>
rows.map((r, i) => ({
key: r.id,
label: `${r.id}(${sig(r.result)})`,
color: seriesColor(i),
data: normalize(r.result),
type: "line" as const,
lineWidth: 2 as const,
lastValueVisible: i === 0,
})),
[rows]
);
// 参数 diff:扁平化后并集,只显示有差异的项(默认)
const diffRows = useMemo(() => {
const flat = rows.map((r) => flatten(r.result?.config_snapshot ?? {}));
const keys = Array.from(new Set(flat.flatMap((f) => Object.keys(f)))).sort();
return keys
// price_basis.* 是回测运行元数据(数据快照/复权缺口核对),不是用户可调的旋钮:
// 混进「参数差异」表只会制造噪声(复权口径本身在顶层 price_adjustment 里已展示)
.filter((k) => !k.startsWith("price_basis."))
.map((k) => ({ key: k, vals: flat.map((f) => f[k] ?? "—") }))
.filter((row) => (onlyDiff ? new Set(row.vals).size > 1 : true));
}, [rows, onlyDiff]);
const base = rows[0].result.summary;
return (
<Card
icon="chartLine"
title={`实验对比 · ${rows.length} 个`}
sub={rows.map((r) => r.id).join(" vs ")}
tools={
<div className="row" style={{ gap: 8 }}>
<label className="cmp-badge" style={{ cursor: "pointer" }}>
<input type="checkbox" checked={onlyDiff} onChange={(e) => setOnlyDiff(e.target.checked)} />
参数只显示有差异项
</label>
<Btn size="sm" onClick={onClose}>
<Icon name="x" size={13} />
关闭对比
</Btn>
</div>
}
>
<LwChart
series={series}
height={360}
valueFormat={(v) => `${v.toFixed(2)}%`}
zeroLine
ariaLabel="实验净值曲线叠加对比"
/>
<div className="hint" style={{ marginTop: 6 }}>
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。
点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
</div>
<div className="table-wrap" style={{ marginTop: 14 }}>
<table className="tbl">
<thead>
<tr>
<th>指标</th>
{rows.map((r) => (
<th key={r.id}>{r.id}</th>
))}
<th>相对 {rows[0].id} 的差值</th>
</tr>
</thead>
<tbody>
{METRICS.map((m) => {
const vals = rows.map((r) => Number(r.result!.summary[m.key] ?? 0));
const delta = vals[vals.length - 1] - vals[0];
const good =
m.better === undefined
? null
: m.better === "high"
? delta > 0
: delta < 0;
return (
<tr key={String(m.key)}>
<td className="cell-strong">{m.label}</td>
{vals.map((v, i) => (
<td key={rows[i].id} className="num">
{m.fmt(v)}
{i === 0 ? <span className="hint"> (基准)</span> : null}
</td>
))}
<td className={delta === 0 ? "cmp-diff-same" : good ? "cmp-diff-add" : "cmp-diff-del"}>
{m.better === undefined
? m.fmt(delta)
: `${delta >= 0 ? "+" : ""}${m.fmt(delta)}`}
{good !== null && delta !== 0 ? (good ? " ✅" : " ⚠️") : ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="hint" style={{ marginTop: 6 }}>
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差
(收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "}
{base.total_return_pct.toFixed(2)}%。
</div>
<div style={{ marginTop: 16 }}>
<b style={{ fontSize: 13 }}>参数差异(config_snapshot 逐项对比)</b>
{diffRows.length === 0 ? (
<div className="hint" style={{ marginTop: 6 }}>
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。
已排除 price_basis.* 运行元数据。
</div>
) : (
<div className="table-wrap" style={{ marginTop: 6 }}>
<table className="tbl">
<thead>
<tr>
<th>参数</th>
{rows.map((r) => (
<th key={r.id}>{r.id}</th>
))}
</tr>
</thead>
<tbody>
{diffRows.map((row) => (
<tr key={row.key}>
<td className="cell-mono">{row.key}</td>
{row.vals.map((v, i) => (
<td
key={rows[i].id}
className={
new Set(row.vals).size > 1 && v !== row.vals[0] ? "cmp-diff-add num" : "num"
}
>
{v}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
{onlyDiff ? (
<div className="hint" style={{ marginTop: 6 }}>
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
</div>
) : null}
</div>
</Card>
);
}