- 实验列表 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 失败。
1131 lines
47 KiB
TypeScript
1131 lines
47 KiB
TypeScript
"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>
|
||
);
|
||
} |