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