Files
qlib/frontend/web/app/experiments/page.tsx
T
Simon a395db892d 修复实验页右侧内容溢出:宽表固定两端 + 提示;对比表首列固定;月度表收紧内边距
- 实验列表 11 列在 1366px 及更窄窗口放不下,「操作」列被推到卡片外;macOS 覆盖式
  滚动条不滚动不显示,看起来就是「右侧内容溢出/被切掉」。现在左右两端固定
  (选择列、ID、操作列),列宽按实测单行内容宽度定,只有真溢出时才提示可横向滚动。
- 对比视图(指标对比 / 参数差异)列数随实验个数增长,首列改为固定,同样按需提示。
- 详情图层里的月度收益表 13 列超出 38px:收紧内边距并固定「年份」列。
- 可排序表头命中区 19.2px → 34px(与同排复选框等高),修掉 UI 规范门禁的 7 项失败。
- 顺手把对比区两处会原样显示的字面 ** 改成「」。

门禁:ruff 通过;pytest 524 passed;tsc 0 error;npm run build OK;test:charts 7 passed;
verify_ui_alignment.py 160 项通过 / 0 失败。
2026-10-01 19:33:33 +08:00

1131 lines
47 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(全站统一图表基座)。
*
* 后续补上的三件事(都是「列表页该有的基本操作」):
* - **批量删除**:每行第一列的复选框(与「对比」那列刻意分开)攒出待删集合,
* 顶部工具条一次提交;后端按 id 逐个回报 `deleted` / `missing`,界面**照实**显示。
* - **测试发起时间列**:用 `created_at` 转本地时区渲染,并可点击表头排序。
* - **详情改成图层**:列表原地看结果,不再把用户从列表页带走;「打开归档」仍在
* 新标签页打开完整视图(需要 URL 可分享/可刷新时用它)。
*/
import { Suspense, useCallback, useEffect, useMemo, useRef, 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,
SkeletonLines,
Loading,
} from "@/components/ui";
import { Icon } from "@/components/icons";
import { LwChart, type LwSeries } from "@/components/charts/LwChart";
import { seriesColor } from "@/components/charts/theme";
import { Modal } from "@/components/Modal";
import { ArchiveResultView } from "@/components/ArchiveResultView";
/**
* 类型徽标的**色调**(措辞统一走 `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;
}
/**
* 测试发起时间:后端给的是 ISO 字符串(本机时区的 naive datetime,如 `2026-10-01T17:45:05`)。
*
* 必须过一遍 `new Date(...)` 再取本地字段,**不能直接截字符串**:截字符串等于把后端
* 字符串里的时间当本地时间照搬,一旦后端改成带时区的 UTC(`...Z`)就会差 8 小时,
* 而且错得没有任何提示。由 Date 负责时区换算,格式异常时原样回显(不假装是时间)。
*/
function fmtLocalTime(v?: string | null): string {
if (!v) return "—";
const d = new Date(v);
if (Number.isNaN(d.getTime())) return v;
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(
d.getMinutes()
)}:${p(d.getSeconds())}`;
}
/** 后端批量删除单次最多接受的 id 数(`BULK_DELETE_MAX_IDS`);超了接口直接 422,一个都不删 */
const BULK_DELETE_MAX_IDS = 200;
/** 按上限切片:超过 200 个选中必须分批提交,否则整批被 422 拒掉 */
function chunkIds(ids: string[], size: number): string[][] {
const out: string[][] = [];
for (let i = 0; i < ids.length; i += size) out.push(ids.slice(i, i + size));
return out;
}
/** 批量删除的响应(后端语义:存在的进 deleted,库里没有的如实进 missing,绝不静默吞掉) */
interface BulkDeleteResult {
deleted: string[];
missing: string[];
count: number;
}
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>
);
}
/**
* 宽表此刻是否需要横向滚动(按实测 `scrollWidth > clientWidth`,容器尺寸变化时重算)。
*
* 为什么要提示:卡片内的横向滚动条在 macOS 上是覆盖式的(不滚就不显示),
* 用户看到的就是「右侧内容被切掉了」——这正是「experiments 页面右侧内容溢出了」的来源。
* 为什么要在运行时判断而不是写死一句提示:列宽固定,窗口够宽时根本不需要滚动,
* 那种情况下还挂着提示就是废话(还让人以为页面坏了)。
*/
function useTableScrollHint(deps: React.DependencyList) {
const ref = useRef<HTMLDivElement | null>(null);
const [scrolls, setScrolls] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => setScrolls(el.scrollWidth > el.clientWidth + 1);
check();
const ro = new ResizeObserver(check);
ro.observe(el);
return () => ro.disconnect();
// deps 由调用方给(行数 / 筛选变化时要重新量)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return { ref, scrolls };
}
function ExperimentsInner() {
const search = useSearchParams();
const [exps, setExps] = useState<ExperimentMeta[]>([]);
const [loading, setLoading] = useState(true);
// 列表容器:窄窗口下宽表要横向滚动,提示只在真需要时出现
const { ref: tableWrapRef, scrolls: tableScrolls } = useTableScrollHint([exps.length, loading]);
const [detail, setDetail] = useState<ExperimentDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState("");
/** 图层是否打开(打开的是哪一条)—— detail 本身可能还在路上,所以不能只看 detail */
const [detailId, setDetailId] = useState<string | null>(null);
/** 打开图层的触发按钮:关闭后焦点要还给它(键盘用户不能"掉回页首") */
const [detailTrigger, setDetailTrigger] = useState<HTMLElement | null>(null);
const [jobId, setJobId] = useState("");
const [jobStatus, setJobStatus] = useState("");
const [error, setError] = useState("");
/**
* 操作结果提示(删除回执等):与 error 分开 —— 红色横幅只留给真正的失败。
* tone 显式存下来,而不是在渲染时用文案里有没有"不存在"去猜(文案一改就会猜错)。
*/
const [notice, setNotice] = useState<{ text: string; tone: "info" | "warn" } | null>(null);
// 对比状态
const [picked, setPicked] = useState<string[]>([]);
const [compare, setCompare] = useState<BacktestDetail[] | null>(null);
const [comparing, setComparing] = useState(false);
const [onlyDiff, setOnlyDiff] = useState(true);
// 批量删除的选择:与 picked(对比,上限 3 个)**刻意分开**——
// 复用同一个集合的话「全选本页」会被对比上限卡住,而且删除是不可逆动作,
// 不该和「我想比一比」共用一次勾选(误删代价太大)。
const [toDelete, setToDelete] = useState<string[]>([]);
const [deleting, setDeleting] = useState(false);
// 列表排序:默认按发起时间倒序(与后端 list_filtered 的 created_at desc 一致),
// 点表头在升降之间切换。
const [timeSort, setTimeSort] = useState<"desc" | "asc">("desc");
// 列表过滤:后端支持 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
}, []);
/**
* 详情加载序号:用户可能连点两行的「详情」,先发的请求后到会把 A 的内容
* 盖到 B 的图层上(旧代码原地渲染时同样有这个竞态)。每次打开自增,回调里
* 只认最后一次,迟到的一律丢弃。
*/
const detailSeq = useRef(0);
/**
* 打开详情图层:失败**原文**显示在后端错误里(不吞成"加载失败"),
* 因为这里通常能直接看出是 404(已删)/ 500 还是网络问题。
*/
function openDetail(id: string, trigger: HTMLElement | null) {
const seq = ++detailSeq.current;
setDetailTrigger(trigger);
setDetailId(id);
setDetail(null);
setDetailError("");
setDetailLoading(true);
apiGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`)
.then((d) => {
if (seq === detailSeq.current) setDetail(d);
})
.catch((e: Error) => {
if (seq === detailSeq.current) setDetailError(e.message);
})
.finally(() => {
if (seq === detailSeq.current) setDetailLoading(false);
});
}
function closeDetail() {
// 自增序号:关闭之后迟到的响应不能再把图层"顶"回来
detailSeq.current += 1;
setDetailId(null);
setDetail(null);
setDetailError("");
setDetailLoading(false);
}
/** 单行「删除选择」勾选:不设上限——批量删除本来就要跨多行选 */
function toggleDelete(id: string) {
setNotice(null);
setToDelete((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
}
/** 批量删除:一次最多 200 个 id,超了前端分批,最后把各批的 deleted/missing 合并上报 */
async function removeSelected() {
if (!toDelete.length || deleting) return;
const ids = [...toDelete];
const n = ids.length;
const ok = window.confirm(
`将永久删除 ${n} 个归档,不可恢复。\n\n` +
`· 结果只存归档这一份:删除后这些回测/因子测试/选股的净值曲线、成交与 spec 都无法再查看,也无法导出;\n` +
`· 关联的作业记录会保留,但结果读不回(接口会如实标注"结果已随归档删除");\n` +
`· 想留底请先用每行「打开归档」进详情页导出完整 JSON。\n\n确认删除这 ${n} 个归档?`
);
if (!ok) return;
setDeleting(true);
setError("");
setNotice(null);
const deleted: string[] = [];
const missing: string[] = [];
try {
for (const batch of chunkIds(ids, BULK_DELETE_MAX_IDS)) {
const r = await apiPost<BulkDeleteResult>("/experiments/bulk-delete", { ids: batch });
deleted.push(...r.deleted);
missing.push(...r.missing);
}
} catch (e) {
// 分批提交时前面几批**已经真删了**:必须把"已删几个"说出来,
// 否则用户会以为一个都没删(或以为全删了)——两种都是谎报。
setError(`批量删除未全部完成:已删除 ${deleted.length} 个,之后失败 —— ${(e as Error).message}`);
} finally {
setDeleting(false);
// 只清掉「确实删掉了」与「本来就不存在」的选中项;因网络/接口失败没删成的仍留在
// 选中里,用户可直接重试,不用重新勾一遍。
const gone = new Set([...deleted, ...missing]);
setToDelete((prev) => prev.filter((id) => !gone.has(id)));
// 正开着的详情/对比里若含已删归档,必须一起清掉,否则会继续展示一份已不存在的快照。
// 用 detailId 而不是 detail 判定:详情可能还在加载(detail 还是 null),
// 这时请求回来仍会把一份已删的快照渲染进图层。
if (detailId && gone.has(detailId)) closeDetail();
setCompare((prev) => {
if (!prev) return prev;
const left = prev.filter((r) => !gone.has(r.id));
if (left.length === prev.length) return prev;
// 剩不到 2 条就没有"对比"可言了:关掉对比区,而不是画一条什么都比不出来的曲线
return left.length >= 2 ? left : null;
});
setPicked((prev) => prev.filter((id) => !gone.has(id)));
if (deleted.length || missing.length) {
// 照实报告:missing 非空必须显示("不存在的 id 也算删成功"会让用户以为库里干净了)
setNotice({
text:
`已删除 ${deleted.length} 个归档` +
(missing.length
? `,${missing.length} 个不存在(可能已被别处删掉,未计入删除数)`
: "") +
"。",
// missing 非空用警示色:不能让"有 id 没找到"被当成完全成功一眼扫过去
tone: missing.length ? "warn" : "info",
});
}
load();
}
}
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();
// 复跑会新增归档;图层里那份可能是刚被替换的旧快照,关掉避免看串
closeDetail();
}
})
.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);
}
}
/**
* 展示用的行序:按「测试发起时间」升/降序排列(表头可切换)。
*
* 两个细节:
* - `created_at` 缺失的归档一律排在**末尾**,不参与升降 —— 让「—」混在时间中间会
* 让人以为那是一条很新/很旧的归档,那是假信息;
* - 同秒(MySQL datetime(0) 只有秒精度)用 id 兜底,方向与时间一致,
* 否则等长时间的行序会随机抖动(也和后端 created_at desc, id desc 的口径一致)。
*/
const rows = useMemo(() => {
const ts = (e: ExperimentMeta) => (e.created_at ? new Date(e.created_at).getTime() : NaN);
const byId = (a: ExperimentMeta, b: ExperimentMeta) =>
timeSort === "desc" ? (a.id < b.id ? 1 : a.id > b.id ? -1 : 0) : a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
return [...exps].sort((a, b) => {
const ta = ts(a);
const tb = ts(b);
const aBad = Number.isNaN(ta);
const bBad = Number.isNaN(tb);
if (aBad && bBad) return byId(a, b);
if (aBad) return 1;
if (bBad) return -1;
if (ta !== tb) return timeSort === "desc" ? tb - ta : ta - tb;
return byId(a, b);
});
}, [exps, timeSort]);
const pageIds = rows.map((e) => e.id);
const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => toDelete.includes(id));
/**
* 表头「全选本页」:只作用于**当前显示**的这些行。
* 列表可能有后端单页上限(total > rows.length),"全选筛选结果"会把看不见的行也删掉,
* 那是拿用户没看过的数据冒险,所以这里不做,并在工具条里说明范围。
*/
function toggleAllOnPage() {
setNotice(null);
setToDelete((prev) => {
if (allOnPageSelected) return prev.filter((id) => !pageIds.includes(id));
return Array.from(new Set([...prev, ...pageIds]));
});
}
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}
{/* 删除回执等操作结果:missing 非空时是 warn 色,避免"部分不存在"被当成完全成功 */}
{notice ? <Banner tone={notice.tone}>{notice.text}</Banner> : null}
{picked.length > 0 || toDelete.length > 0 ? (
<div className="sticky-bar">
{toDelete.length > 0 ? (
<>
<Pill tone="neg" icon="trash">
已选 {toDelete.length} 个(待删除)
</Pill>
<Btn
variant="danger"
icon="trash"
loading={deleting}
disabled={deleting}
onClick={removeSelected}
>
{deleting ? "删除中…" : "删除所选"}
</Btn>
<Btn icon="x" disabled={deleting} onClick={() => setToDelete([])}>
取消选择
</Btn>
<span className="hint">
范围:当前列表显示的本页 {rows.length} 条
{total !== null && total > rows.length
? `(筛选结果共 ${total} 条,本页之外的未列出、也不会被删)`
: ""}
;删除不可恢复。
</span>
</>
) : null}
{picked.length > 0 ? (
<>
{toDelete.length > 0 ? (
/* 两组选择同时在时用一条分隔线隔开,否则会看成一整排互相矛盾的按钮 */
<span aria-hidden style={{ width: 1, alignSelf: "stretch", background: "var(--line)" }} />
) : null}
<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}
</>
) : 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={
<span>
{total !== null
? `显示 ${exps.length} 条 / 共 ${total} 条${
total > exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : ""
}`
: "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比"}
{tableScrolls ? (
// 只在真的放不下时才说:卡片内的横向滚动条容易看不见(触控板尤其),
// 而右端「操作」列已固定,用户不必先滚到底才知道能点哪里。
<>
{" "}
<span className="hint">列较多,可横向滚动;选择列与「操作」列固定不动。</span>
</>
) : null}
</span>
}
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-wrap--wide" ref={tableWrapRef}>
<table className="tbl tbl--wide">
<thead>
<tr>
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开,
因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */}
<th
title={
total !== null && total > rows.length
? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)`
: `全选本页(${rows.length} 个)`
}
>
<label className="check check--cell">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
aria-label={`全选本页 ${rows.length} 个归档用于删除`}
/>
</label>
</th>
<th>
<span className="hint" title="勾选这一列可挑 2~3 个实验做对比">
对比
</span>
</th>
<th>ID</th>
<th>类型</th>
<th>
{/* 排序交互沿用全站既有的表头按钮风格(见 TradeReasons 的日期列) */}
<button
type="button"
className="th-sort"
onClick={() => setTimeSort((d) => (d === "desc" ? "asc" : "desc"))}
title="按测试发起时间排序,点击切换升序/降序(空值始终排在最后)"
aria-label={`按测试发起时间排序:当前${timeSort === "desc" ? "降序" : "升序"},点击切换`}
>
发起时间 {timeSort === "desc" ? "↓" : "↑"}
</button>
</th>
<th>因子</th>
<th>区间</th>
<th>摘要</th>
<th>版本(代码 / 数据)</th>
<th>体积</th>
{/* 列宽由 .tbl--wide 统一给(含固定右端):行内 width 会盖掉 CSS,
曾经就是它让「操作」列保持 260px、整表多出 88px 放不下 */}
<th>操作</th>
</tr>
</thead>
<tbody>
{rows.map((e) => (
<tr key={e.id} className={detailId === e.id ? "row-active" : undefined}>
<td>
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */}
<label className="check check--cell" title={`选择 ${e.id} 用于批量删除`}>
<input
type="checkbox"
checked={toDelete.includes(e.id)}
onChange={() => toggleDelete(e.id)}
aria-label={`选择 ${e.id} 用于批量删除`}
/>
</label>
</td>
<td>
<label className="check check--cell" title={`选择 ${e.id} 用于对比(最多 3 个)`}>
<input
type="checkbox"
checked={picked.includes(e.id)}
onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
</label>
</td>
<td className="cell-mono cell-strong" title={e.id}>
{e.id}
</td>
<td>{kindView(e.kind)}</td>
<td className="cell-mono" title={e.created_at ?? "该归档没有记录发起时间"}>
{fmtLocalTime(e.created_at)}
</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"
title={e.period ? `区间 ${e.period[0]} ~ ${e.period[1]}` : "该归档没有记录区间"}
>
{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 }}>
{/* 完整归档是"可分享、可刷新"的只读视图,用新标签页打开,
列表页的筛选/选择状态原地不动(看完关掉标签页就回来)。 */}
<a
href={`/experiments/${encodeURIComponent(e.id)}`}
className="btn btn--sm"
target="_blank"
rel="noreferrer"
>
<span>打开归档</span>
</a>
<Btn
size="sm"
icon="search"
// 把触发按钮传进图层:关闭后焦点要回到它(键盘用户不"掉回页首")
onClick={(ev) => openDetail(e.id, ev.currentTarget)}
>
详情
</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>
{/* 详情图层:原地看归档,不再把用户从列表带走(列表的筛选/选择/滚动位置都留着)。
「选股条件 / 交易执行依据」那部分说明由后端 describe_strategy 依归档 spec 推导
(归档详情页是 Server Component,在服务端调用它)—— 图层里不重复这份推导,
也不自行拼造文案;要看它就点标题栏的「在新页面打开完整归档」。 */}
{detailId ? (
<Modal
title={
<>
{detailId} 归档详情
{detail ? ` · ${experimentKindLabel(detail.kind)}` : ""}
</>
}
onClose={closeDetail}
returnFocusTo={detailTrigger}
tools={
<a
className="btn btn--sm"
href={`/experiments/${encodeURIComponent(detailId)}`}
target="_blank"
rel="noreferrer"
>
<span>在新页面打开完整归档</span>
</a>
}
>
{detailError ? (
/* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同,
只写「加载失败」等于把可排查的信息丢掉。 */
<Banner tone="error">详情加载失败:{detailError}</Banner>
) : detailLoading ? (
<>
<div className="hint" style={{ marginBottom: 8 }}>
正在读取归档 {detailId}…
</div>
<SkeletonLines n={5} />
</>
) : detail ? (
<>
<div className="kv-grid">
<MetaKV label="实验 ID" value={detail.id} mono />
<MetaKV label="类型" value={experimentKindLabel(detail.kind)} />
<MetaKV label="测试发起时间" value={fmtLocalTime(detail.created_at)} mono />
<MetaKV label="代码版本" value={detail.code_version ?? "—"} mono />
<MetaKV
label="数据版本"
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
mono
warn={!detail.data_version}
/>
<MetaKV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
<MetaKV
label="区间"
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
mono
/>
<MetaKV
label="归档体积"
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
/>
</div>
<div className="hint" style={{ margin: "10px 0 14px" }}>
结果区与归档页用的是同一个 <code>ArchiveResultView</code>(按 kind 分发),
所以回测的净值/成交、因子测试的 IC、选股的候选明细都在这里;
「选股条件 / 交易执行依据」这类需要后端依 spec 推导的说明只在完整归档页展示。
</div>
{detail.result ? (
<ArchiveResultView
kind={detail.kind}
result={detail.result}
archive={{
id: detail.id,
created_at: detail.created_at,
code_version: detail.code_version,
data_version: detail.data_version,
job_id: detail.job_id,
}}
/>
) : (
<Empty
icon="archive"
title="该归档没有可展示的结果"
hint="结果为空(可能是历史版本归档)。可用标题栏的「在新页面打开完整归档」,在那边「导出完整 JSON」查看原始内容。"
/>
)}
</>
) : (
<div className="hint">详情为空 —— 这条归档可能刚被删除,请刷新列表核对。</div>
)}
</Modal>
) : null}
</>
);
}
/**
* 图层里的元数据行:沿用归档详情页同一套 `kv` 样式。
*
* 字段与取值口径跟归档页对齐(同一份归档在两个入口看到的 id/版本/作业 id/时间必须一致,
* 否则用户会怀疑哪个是真的);文案上列表与图层统一叫「测试发起时间」,归档页沿用
* 既有的「归档时间」—— 同一个 `created_at`,不在这里改归档页的既有措辞。
*/
function MetaKV({
label,
value,
mono,
warn,
}: {
label: string;
value: string;
mono?: boolean;
warn?: boolean;
}) {
return (
<div className="kv">
<span className="kv__k">{label}</span>
<span className={`kv__v${mono ? " mono" : ""}${warn ? " kv__v--warn" : ""}`}>{value}</span>
</div>
);
}
/* ------------------------------------------------------------------ */
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
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 metricTable = useTableScrollHint([rows.length]);
const paramTable = useTableScrollHint([rows.length, onlyDiff]);
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 }} ref={metricTable.ref}>
<table className="tbl tbl--pin-first">
<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)}%。
{metricTable.scrolls ? " 列较多,可横向滚动;首列「指标」固定不动。" : ""}
</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 }} ref={paramTable.ref}>
<table className="tbl tbl--pin-first">
<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 || paramTable.scrolls ? (
<div className="hint" style={{ marginTop: 6 }}>
{onlyDiff ? "已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。" : null}
{paramTable.scrolls ? " 参数表列较多,可横向滚动;首列「参数」固定不动。" : null}
</div>
) : null}
</div>
</Card>
);
}