"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 = { 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; } 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 [jobId, setJobId] = useState(""); const [jobStatus, setJobStatus] = useState(""); const [error, setError] = useState(""); // 对比状态 const [picked, setPicked] = useState([]); const [compare, setCompare] = useState(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(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 }, []); function open(id: string) { setDetailLoading(true); setDetail(null); apiGet(`/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(`/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 ( <> 0 ? ( 共 {exps.length} 个实验 ) : undefined } /> {error ? {error} : null} {picked.length > 0 ? (
已选 {picked.length} 个 {picked.join(" · ")} 对比选中的实验 { setPicked([]); setCompare(null); }}> 清空 {picked.length < 2 ? 再选 1 个即可对比 : 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 ? ( ) : (
{exps.map((e) => ( ))}
ID 类型 因子 区间 摘要 版本(代码 / 数据) 体积 操作
{/* 整个单元都是热区:16px 的方块本身达不到点击目标下限 */} {e.id} {kindView(e.kind)} {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` : "—"} 打开归档 open(e.id)}> 详情 以此参数再跑 rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"} > 复跑
)}
{detail ? ( 打开归档(完整视图) setDetail(null)}> 关闭 } > {detailLoading ? ( ) : ( <> {s ? (
= 0 ? "pos" : "neg"} value={`${s.total_return_pct >= 0 ? "+" : ""}${s.total_return_pct.toFixed(2)}%`} /> = 0 ? "pos" : "neg"} value={`${s.annual_return_pct >= 0 ? "+" : ""}${s.annual_return_pct.toFixed(2)}%`} />
) : (
该实验没有可展示的汇总结果(可能是因子测试或历史版本)。
)} {detail && isBacktestDetail(detail) && detail.result.equity_curve.length ? (
`${v.toFixed(2)}%`} zeroLine ariaLabel={`${detail.id} 累计收益率曲线`} />
) : null} {detail.spec ? (
查看 spec(可复现输入)
                    {JSON.stringify(detail.spec, null, 2)}
                  
) : null} )}
) : 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( () => 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}
); }