Files
qlib/frontend/web/app/experiments/page.tsx
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

726 lines
27 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(全站统一图表基座)。
*/
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"
style={{ width: 200 }}
placeholder="搜索 ID / 因子 / 摘要"
value={q}
onChange={(e) => {
setQ(e.target.value);
syncUrl(e.target.value, kind);
}}
aria-label="搜索实验"
/>
<select
className="input"
style={{ width: 130 }}
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 ? (
<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>
<input
type="checkbox"
checked={picked.includes(e.id)}
onChange={() => togglePick(e.id)}
aria-label={`选择 ${e.id} 用于对比`}
/>
</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>
);
}