"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 = { backtest: "accent", factor_test: "violet", selection: "default", }; function kindView(kind: string) { return {experimentKindLabel(kind)}; } function statusView(status: string) { switch (status) { case "queued": return 排队中; case "running": return 执行中; case "success": return 完成; case "failed": return 失败; default: return {status || "—"}; } } /** config_snapshot 扁平化:嵌套对象转成 a.b.c 路径,便于逐项 diff */ function flatten(obj: unknown, prefix = "", out: Record = {}): Record { 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); 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 ( }> ); } function ExperimentsInner() { const search = useSearchParams(); const [exps, setExps] = useState([]); const [loading, setLoading] = useState(true); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(""); /** 图层是否打开(打开的是哪一条)—— detail 本身可能还在路上,所以不能只看 detail */ const [detailId, setDetailId] = useState(null); /** 打开图层的触发按钮:关闭后焦点要还给它(键盘用户不能"掉回页首") */ const [detailTrigger, setDetailTrigger] = useState(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([]); const [compare, setCompare] = useState(null); const [comparing, setComparing] = useState(false); const [onlyDiff, setOnlyDiff] = useState(true); // 批量删除的选择:与 picked(对比,上限 3 个)**刻意分开**—— // 复用同一个集合的话「全选本页」会被对比上限卡住,而且删除是不可逆动作, // 不该和「我想比一比」共用一次勾选(误删代价太大)。 const [toDelete, setToDelete] = useState([]); 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(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(`/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(`/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("/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(`/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 ( <> 0 ? ( 共 {exps.length} 个实验 ) : undefined } /> {error ? {error} : null} {/* 删除回执等操作结果:missing 非空时是 warn 色,避免"部分不存在"被当成完全成功 */} {notice ? {notice.text} : null} {picked.length > 0 || toDelete.length > 0 ? (
{toDelete.length > 0 ? ( <> 已选 {toDelete.length} 个(待删除) {deleting ? "删除中…" : "删除所选"} setToDelete([])}> 取消选择 范围:当前列表显示的本页 {rows.length} 条 {total !== null && total > rows.length ? `(筛选结果共 ${total} 条,本页之外的未列出、也不会被删)` : ""} ;删除不可恢复。 ) : null} {picked.length > 0 ? ( <> {toDelete.length > 0 ? ( /* 两组选择同时在时用一条分隔线隔开,否则会看成一整排互相矛盾的按钮 */ ) : null} 已选 {picked.length} 个(对比) {picked.join(" · ")} 对比选中的实验 { setPicked([]); setCompare(null); }}> 清空 {picked.length < 2 ? 再选 1 个即可对比 : null} ) : null}
) : null} {compare ? ( setCompare(null)} /> ) : null} {jobId ? (
任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。
) : null} exps.length ? "(后端单页上限内未列全,可用搜索或类型筛选缩小范围)" : "" }` : "第一列勾选可批量删除;「对比」列勾选 2~3 个可叠加曲线对比" } tools={
{ setQ(e.target.value); syncUrl(e.target.value, kind); }} aria-label="搜索实验" /> {q || kind ? ( { setQ(""); setKind(""); syncUrl("", ""); }}> 清除 ) : null}
} flush > {loading ? (
) : exps.length === 0 && (q || kind) ? ( /* 筛选无结果 ≠ 没有归档:文案必须区分,否则用户会以为归档全丢了 */ ) : exps.length === 0 ? ( ) : (
{/* 第一列:批量删除的选择(表头是全选本页)。与「对比」列分开, 因为两者用途/上限完全不同(删除可多选且不可逆,对比最多 3 个)。 */} {rows.map((e) => ( ))}
rows.length ? `全选本页(本页 ${rows.length} 个;筛选结果共 ${total} 个,本页之外的未列出,不会被删)` : `全选本页(${rows.length} 个)` } > 对比 ID 类型 {/* 排序交互沿用全站既有的表头按钮风格(见 TradeReasons 的日期列) */} 因子 区间 摘要 版本(代码 / 数据) 体积 操作
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */} {e.id} {kindView(e.kind)} {fmtLocalTime(e.created_at)} {e.factors.map((f) => ( {f} ))} {e.period ? `${e.period[0]}~${e.period[1]}` : "—"} {e.summary_text ?? "—"} {e.code_version ? ( {e.code_version} ) : ( — )} {e.data_version ? ( {shortDataVersion(e.data_version)} ) : ( 无数据版本 )} {e.result_bytes ? `${(e.result_bytes / 1024).toFixed(0)} KB` : "—"} {/* 完整归档是"可分享、可刷新"的只读视图,用新标签页打开, 列表页的筛选/选择状态原地不动(看完关掉标签页就回来)。 */} 打开归档 openDetail(e.id, ev.currentTarget)} > 详情 以此参数再跑 rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"} > 复跑
)}
{/* 详情图层:原地看归档,不再把用户从列表带走(列表的筛选/选择/滚动位置都留着)。 「选股条件 / 交易执行依据」那部分说明由后端 describe_strategy 依归档 spec 推导 (归档详情页是 Server Component,在服务端调用它)—— 图层里不重复这份推导, 也不自行拼造文案;要看它就点标题栏的「在新页面打开完整归档」。 */} {detailId ? ( {detailId} 归档详情 {detail ? ` · ${experimentKindLabel(detail.kind)}` : ""} } onClose={closeDetail} returnFocusTo={detailTrigger} tools={ 在新页面打开完整归档 } > {detailError ? ( /* 后端错误原文照贴:404(已被删)和 500(后端异常)该做的事完全不同, 只写「加载失败」等于把可排查的信息丢掉。 */ 详情加载失败:{detailError} ) : detailLoading ? ( <>
正在读取归档 {detailId}…
) : detail ? ( <>
结果区与归档页用的是同一个 ArchiveResultView(按 kind 分发), 所以回测的净值/成交、因子测试的 IC、选股的候选明细都在这里; 「选股条件 / 交易执行依据」这类需要后端依 spec 推导的说明只在完整归档页展示。
{detail.result ? ( ) : ( )} ) : (
详情为空 —— 这条归档可能刚被删除,请刷新列表核对。
)}
) : null} ); } /** * 图层里的元数据行:沿用归档详情页同一套 `kv` 样式。 * * 字段与取值口径跟归档页对齐(同一份归档在两个入口看到的 id/版本/作业 id/时间必须一致, * 否则用户会怀疑哪个是真的);文案上列表与图层统一叫「测试发起时间」,归档页沿用 * 既有的「归档时间」—— 同一个 `created_at`,不在这里改归档页的既有措辞。 */ function MetaKV({ label, value, mono, warn, }: { label: string; value: string; mono?: boolean; warn?: boolean; }) { return (
{label} {value}
); } /* ------------------------------------------------------------------ */ /** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */ 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( () => 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 ( r.id).join(" vs ")} tools={
关闭对比
} > `${v.toFixed(2)}%`} zeroLine ariaLabel="实验净值曲线叠加对比" />
曲线已归一化为**累计收益率 %**(各自以首个净值为 0%),因此初始资金不同也能直接比。 点击图例可临时隐藏某条曲线(只算收益差的实验建议隐藏基准再比)。
{rows.map((r) => ( ))} {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 ( {vals.map((v, i) => ( ))} ); })}
指标{r.id}相对 {rows[0].id} 的差值
{m.label} {m.fmt(v)} {i === 0 ? (基准) : null} {m.better === undefined ? m.fmt(delta) : `${delta >= 0 ? "+" : ""}${m.fmt(delta)}`} {good !== null && delta !== 0 ? (good ? " ✅" : " ⚠️") : ""}
差额列 = 最后一个实验 − 基准({rows[0].id})。✅ 表示该指标改善方向,⚠️ 表示变差 (收益/夏普/胜率越高越好,回撤/波动越低越好)。基准区间收益{" "} {base.total_return_pct.toFixed(2)}%。
参数差异(config_snapshot 逐项对比) {diffRows.length === 0 ? (
这几个实验的参数快照完全一致(说明结果是**同一配置**下的数据变动,或只是复跑)。 已排除 price_basis.* 运行元数据。
) : (
{rows.map((r) => ( ))} {diffRows.map((row) => ( {row.vals.map((v, i) => ( ))} ))}
参数{r.id}
{row.key} 1 && v !== row.vals[0] ? "cmp-diff-add num" : "num" } > {v}
)} {onlyDiff ? (
已隐藏所有实验取值相同的参数(取消上方勾选可看全量)。
) : null}
); }