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 逐类验证归档页)。
This commit is contained in:
Simon
2026-09-20 07:31:04 +08:00
parent 7e15b7251e
commit 23972e7063
112 changed files with 17908 additions and 3893 deletions
+610
View File
@@ -0,0 +1,610 @@
/**
* 归档详情页(`/experiments/{id}`)—— 「往复查看」的落点。
*
* 为什么单独一页,而不复用回测页:
* 回测页是**可编辑参数**的工作台,从它看归档会被误认为「当前参数就是归档参数」;
* 归档需要的是一个**只读、口径固定、可分享**的视图:URL 即快照地址,刷新/换设备都能看,
* 且必须明确回答两个问题 ——
* ① **选股条件**:这次回测到底按什么规则选股(因子/权重/条件/股票池/两级截断/周期/补位)
* ② **交易执行依据**:成交时点、价格口径、成本、涨跌停与停牌怎么处理、期末是否平仓
* 这两块来自归档里存的 **spec(复现依据)**,不来自当前页面状态,因此永不漂移;
* 同时用后端 `describe_strategy`(与引擎实执行规则同源)生成完整说明与计算公式。
*
* 结果区交给 `ArchiveResultView` 按归档 `kind` 分发(backtest/factor_test/selection 结构不同,
* 不能假定回测字段)——回测归档与刚跑完时看到的**完全同一套图表与表格**,
* 避免「归档里少一张图」的隐性不一致。
*
* **这里是 Server Component**:归档是只读内容,选股条件/执行依据/元数据必须在
* 服务端直出(否则不带 JS 的抓取与链接预览拿到的只是一个 Loading 骨架——
* 实测 SSR HTML 里搜不到「选股条件」)。图表与删除/导出等交互由客户端子组件承担。
*/
import { notFound } from "next/navigation";
import Link from "next/link";
import { ArchiveActions } from "@/components/ArchiveActions";
import { StrategyDocCard } from "@/components/StrategyDocCard";
import { ArchiveResultView } from "@/components/ArchiveResultView";
import { archivedFieldSummary } from "@/lib/archive";
import { PageHeader, Card, Pill, Empty, Banner } from "@/components/ui";
import { adjustLabel, experimentKindLabel, rebalanceLabel } from "@/lib/labels";
import { RichText } from "@/components/RichText";
import type { ExperimentDetail, FactorMeta, ResearchCondition, ResearchSpec, StrategyDoc } from "@/lib/types";
export const dynamic = "force-dynamic"; // 归档可能被删除/新增,禁止静态化缓存
/** 服务端访问后端:与 next.config.ts 的代理同一默认地址(服务端没有「同源」可用) */
const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, "");
async function serverGet<T>(path: string): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, { cache: "no-store" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
async function serverPost<T>(path: string, body: unknown): Promise<T | null> {
try {
const r = await fetch(`${BACKEND}/api${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
});
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
export default async function ExperimentArchivePage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id: rawId } = await params;
const id = decodeURIComponent(rawId ?? "");
const [detail, factors] = await Promise.all([
serverGet<ExperimentDetail>(`/experiments/${encodeURIComponent(id)}`),
serverGet<FactorMeta[]>("/factors"),
]);
// 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实)
if (!detail) notFound();
const spec = (detail.spec ?? null) as Partial<ResearchSpec> | null;
const result = detail.result;
// 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的)
const fieldSummary = archivedFieldSummary(detail.kind, result);
// 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误)
// 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径,
// 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。
const doc = spec && detail.kind === "backtest"
? await serverPost<StrategyDoc & { doc?: StrategyDoc }>("/strategies/describe", spec)
: null;
const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null;
return (
<>
<PageHeader
title={`归档 ${detail.id}`}
sub={`${experimentKindLabel(detail.kind)} · ${detail.summary_text ?? "无摘要"}`}
/>
<ArchiveActions id={detail.id} spec={detail.spec} />
<ArchivedSpecCards detail={detail} spec={spec} factors={factors ?? []} />
{detail.kind === "backtest" ? (
<StrategyDocCard
doc={docBody}
loading={false}
error={spec && !docBody ? "后端未能给出该 spec 的说明(describe 接口失败)" : ""}
title="完整说明与计算公式(后端按归档 spec 推导,与引擎执行规则同源)"
/>
) : null}
<Card icon="archive" title="归档元数据(复现依据)">
<div className="kv-grid">
<KV label="实验 ID" value={detail.id} mono />
<KV label="类型" value={experimentKindLabel(detail.kind)} />
<KV label="归档时间" value={fmtTime(detail.created_at)} mono />
<KV label="代码版本" value={detail.code_version ?? "—"} mono />
<KV
label="数据版本"
value={detail.data_version ?? "未记录(该归档早于数据指纹上线)"}
mono
warn={!detail.data_version}
/>
<KV label="来源作业" value={detail.job_id ?? "同步接口调用(无作业 id)"} mono />
<KV
label="归档体积"
value={detail.result_bytes ? `${(detail.result_bytes / 1024).toFixed(0)} KB` : "—"}
/>
<KV
label="区间"
value={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"}
mono
/>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="数据版本由后端在归档时写入(数据快照指纹):**同样的代码版本 + 同样的数据版本 + 同样的 spec,才应当复现出同样的结果**。若数据版本显示「未记录」,说明该归档产生于指纹上线之前 —— 此时不要把它当作可严格复现的依据。" />
</div>
<div className="row" style={{ marginTop: 10, gap: 8 }}>
{fieldSummary ? <span className="hint">{fieldSummary}</span> : null}
</div>
</Card>
{result ? (
<ArchiveResultView
kind={detail.kind}
result={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,
}}
/>
) : (
<Card>
<Empty
icon="archive"
title="该归档没有可展示的结果"
hint={
detail.kind === "factor_test"
? "因子测试归档没有净值曲线(结果结构不同);结果为空时只能导出 JSON 查看原始内容。"
: "结果为空(可能是历史版本归档)。可用上方「导出完整 JSON」查看原始内容。"
}
/>
</Card>
)}
</>
);
}
/* ------------------------------------------------------------------ */
function KV({
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 fmtTime(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())}`;
}
function directionText(name: string, factors: FactorMeta[]): string {
const f = factors.find((x) => x.name === name);
if (!f) return "—";
return f.direction === "lower_is_better" ? "越低越好" : "越高越好";
}
function opText(op?: string): string {
switch (op) {
case "gt":
return ">";
case "gte":
return "≥";
case "lt":
return "<";
case "lte":
return "≤";
case "eq":
return "=";
case "ne":
return "≠";
case "in":
return "∈";
case "not_in":
return "∉";
default:
return op ?? "?";
}
}
function condText(c: ResearchCondition): string {
const right = c.ref ? `(字段 ${c.ref})` : `${c.value ?? ""}`;
return `${c.field} ${opText(c.op)} ${right}`;
}
/**
* 因子测试归档的 spec 卡:只展示**真正生效**的字段。
*
* 诚实性要点(AGENT §7/§24):因子测试按横截面算 IC / RankIC / 分层收益,
* **不做两级截断、不下单、不计成本**。但归档 spec 沿用了与回测相同的请求结构
* (含 `selection` / `costs` / `initial_capital`)—— 把这些字段按回测口径展示,
* 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。
*/
function FactorTestSpecCards({
spec,
factors,
}: {
spec: Partial<ResearchSpec>;
factors: FactorMeta[];
}) {
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const period = spec.period ?? [];
return (
<>
<Card
icon="flask"
title="因子测试配置(本次实际生效的字段)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="被测因子(横截面 z-score 加权)">
{factorList.length === 0 ? (
<SpecRow k="因子" v="—" />
) : (
factorList.map((f) => (
<SpecRow
key={f.name}
k={f.name}
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
/>
))
)}
</SpecBlock>
<SpecBlock title="横截面范围(universe)">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
<SpecRow
k="剔除停牌"
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
/>
<SpecRow
k="上市天数下限"
v={uni.min_listing_days ? `${String(uni.min_listing_days)} 天` : "无限制"}
/>
</SpecBlock>
<SpecBlock title="测试区间">
<SpecRow
k="区间"
v={period.length === 2 ? `${period[0]} ~ ${period[1]}` : "—"}
mono
/>
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本次归档类型是**因子测试**:按横截面计算 IC / RankIC / 分层收益,**不做两级截断(n→x)、不下单、不计交易成本**。归档 spec 里沿用了与回测相同的请求结构(`selection` / `costs` / `initial_capital` 等),但这些字段本次**未生效** —— 不要把它们当作执行依据。" />
</div>
</Card>
</>
);
}
/**
* 选股归档的 spec 卡:一次性选股的规则(无调仓、无成交)。
*/
function SelectionSpecCards({
spec,
factors,
}: {
spec: Partial<ResearchSpec>;
factors: FactorMeta[];
}) {
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const conds = ((spec as Record<string, unknown>).conditions ?? []) as ResearchCondition[];
const method = String((spec as Record<string, unknown>).method ?? "score");
const topN = (spec as Record<string, unknown>).top_n;
const topPct = (spec as Record<string, unknown>).top_pct;
return (
<Card
icon="target"
title="选股条件(本次一次性选股实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="时点与方式">
<SpecRow
k="选股时点"
v={String((spec as Record<string, unknown>).as_of ?? "最近交易日")}
mono
/>
<SpecRow k="方式" v={method === "condition" ? "条件筛选" : "因子评分"} />
<SpecRow
k="复权口径"
v={spec.price_adjustment === "hfq" ? "后复权(hfq)" : "不复权(none)"}
/>
</SpecBlock>
<SpecBlock title={method === "condition" ? "筛选条件(AND)" : "打分因子(横截面 z-score 加权)"}>
{method === "condition"
? conds.length === 0
? <SpecRow k="条件" v="—" />
: conds.map((c) => <SpecRow key={condText(c)} k={c.field} v={condText(c)} />)
: factorList.length === 0
? <SpecRow k="因子" v="—" />
: factorList.map((f) => (
<SpecRow
key={f.name}
k={f.name}
v={`权重 ${f.weight} ${directionText(f.name, factors)}`}
/>
))}
</SpecBlock>
<SpecBlock title="股票池与选出数量">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow k="剔除 ST" v={uni.exclude_st ? "是(按当时名称判定)" : "否"} />
<SpecRow
k="剔除停牌"
v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"}
/>
<SpecRow
k="选出数量"
v={
topN
? `Top ${String(topN)}`
: topPct
? `前 ${String(topPct)}%`
: "—"
}
/>
</SpecBlock>
</div>
<div className="hint" style={{ marginTop: 10 }}>
<RichText text="本次归档类型是**一次性选股**:只在上述**单个交易日**求值一次,**没有调仓周期、没有成交与成本**(不会买卖任何东西)。归档 spec 里若出现 `costs` / `rebalance`,那只是同一请求结构的字段,本次未生效。" />
</div>
</Card>
);
}
/** 选股条件 + 交易执行依据:直接用归档 spec 渲染,回答「这次回测到底怎么选的、怎么成交的」 */
function ArchivedSpecCards({
detail,
spec,
factors,
}: {
detail: ExperimentDetail;
spec: Partial<ResearchSpec> | null;
factors: FactorMeta[];
}) {
if (!spec) {
return (
<Card icon="book" title="选股条件与交易执行依据">
<div className="hint">
该归档没有存储 spec(早于 spec 归档上线),无法回溯当时的规则;只能看下方结果。
</div>
</Card>
);
}
// 按归档类型分发:**只有回测**才有「选股条件 + 交易执行依据」这一对。
// 因子测试/选股与回测共用同一请求结构(spec 里也有 selection/costs/initial_capital),
// 但那些字段在因子测试/选股里**从未生效** —— 沿用回测口径展示等于谎报执行依据。
if (detail.kind === "factor_test") {
return <FactorTestSpecCards spec={spec} factors={factors} />;
}
if (detail.kind === "selection") {
return <SelectionSpecCards spec={spec} factors={factors} />;
}
if (detail.kind !== "backtest") {
return (
<Card icon="book" title={`归档 spec(类型:${detail.kind})`}>
<div className="hint">
<RichText text="该类型的 spec 展示口径尚未定义,这里**不猜测**它的含义;原始 spec 可用上方「查看原始 spec(JSON)」逐字查看。" />
</div>
</Card>
);
}
const uni = (spec.universe ?? {}) as Record<string, unknown>;
const sel = (spec.selection ?? {}) as Record<string, unknown>;
const costs = (spec.costs ?? {}) as Record<string, unknown>;
const conds = (spec.conditions ?? []) as ResearchCondition[];
const factorList = (spec.factors ?? []) as { name: string; weight: number }[];
const fillPolicy =
sel.defer_buy === true
? "顺延买入(等它到之后首个不涨停的交易日按收盘价买)"
: sel.allow_substitute === false
? "不补位(买不进就空着,实际持仓可能少于目标数)"
: "替补买入(从候选池之外按复合分往下找可买标的)";
return (
<>
<Card
icon="target"
title="选股条件(本次回测实际使用的规则)"
tools={<Pill tone="violet">来自归档 spec,非当前页面参数</Pill>}
>
<div className="spec-grid">
<SpecBlock title="股票池(universe)">
<SpecRow k="市场" v={String(uni.market ?? "CN_A")} />
<SpecRow
k="剔除 ST"
v={uni.exclude_st ? "是(按择股日**当时名称**判定)" : "否"}
/>
<SpecRow
k="上市天数下限"
v={uni.min_listing_days ? `${uni.min_listing_days} 个自然日` : "无限制"}
/>
<SpecRow k="指数成分" v={uni.index_code ? String(uni.index_code) : "不限定"} />
<SpecRow
k="白名单"
v={
Array.isArray(uni.symbols) && uni.symbols.length
? `${uni.symbols.length} 只`
: "全市场"
}
/>
<SpecRow k="剔除停牌" v={uni.exclude_suspended ? "是(近似:当日无行情)" : "否"} />
</SpecBlock>
<SpecBlock title="打分因子(横截面 z-score 加权)">
{factorList.length === 0 ? (
<div className="hint">无(纯条件选股)</div>
) : (
factorList.map((f) => (
<SpecRow
key={f.name}
k={`${f.name} 权重 ${f.weight}`}
v={directionText(f.name, factors)}
mono
/>
))
)}
<div className="hint" style={{ marginTop: 6 }}>
复合分 = Σ(权重 × 方向 × 当日截面 z-score),降序取候选池。
</div>
</SpecBlock>
<SpecBlock title="过滤条件(AND,逐择股日求值)">
{conds.length === 0 ? (
<div className="hint">无附加条件</div>
) : (
conds.map((c, i) => <SpecRow key={`${c.field}-${i}`} k={`条件 ${i + 1}`} v={condText(c)} mono />)
)}
</SpecBlock>
<SpecBlock title="两级截断与周期">
<SpecRow k="候选池 n" v={sel.top_n != null ? `${sel.top_n} 只` : "—"} />
<SpecRow
k="实际持仓 x"
v={
sel.hold_top_x != null && sel.hold_top_x !== sel.top_n
? `${sel.hold_top_x} 只`
: `= n${sel.top_n != null ? `(${sel.top_n} 只)` : ""}`
}
/>
<SpecRow
k="择股周期 m"
v={
spec.selection_interval_months
? `每 ${spec.selection_interval_months} 个月重新择股`
: "每次调仓都重新择股"
}
/>
<SpecRow
k="调仓周期 y"
v={
spec.rebalance_interval_months
? `每 ${spec.rebalance_interval_months} 个月调仓`
: `跟随调仓频率(${rebalanceLabel(spec.rebalance)})`
}
/>
<SpecRow k="买不进时" v={fillPolicy} />
<SpecRow k="资金分配" v="等权(可用现金 / 目标持仓数)" />
</SpecBlock>
</div>
</Card>
<Card icon="scale" title="交易执行依据(撮合价 / 成本 / 约束)">
<div className="spec-grid">
<SpecBlock title="成交口径">
<SpecRow k="价格口径" v={adjustLabel(spec.price_adjustment as string)} />
<SpecRow k="成交时点" v="调仓日**收盘**(调仓在当日收盘生效,自次日起计收益)" />
<SpecRow
k="买入价"
v={`收盘价 × (1 + 滑点 ${pct(costs.slippage_rate)})`}
/>
<SpecRow
k="卖出价"
v={`收盘价 × (1 − 滑点 ${pct(costs.slippage_rate)})`}
/>
<SpecRow k="回测区间" v={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : "—"} mono />
<SpecRow
k="初始资金"
v={
spec.initial_capital != null
? `${Number(spec.initial_capital).toLocaleString("zh-CN")} 元`
: "—"
}
/>
</SpecBlock>
<SpecBlock title="成本">
<SpecRow k="佣金" v={`${pct(costs.commission_rate)}(买入与卖出各计一次)`} />
<SpecRow k="印花税" v={`${pct(costs.stamp_tax_rate)}(仅卖出)`} />
<SpecRow k="滑点" v={pct(costs.slippage_rate)} />
<SpecRow
k="最低佣金"
v={
Number(costs.min_commission ?? 0) > 0
? `${costs.min_commission} 元/笔(max(按比例, 最低))`
: "未启用(0)"
}
/>
<SpecRow
k="对照基准"
v={String(costs.benchmark ?? "000300.SH") + "(仅展示,不参与交易)"}
/>
</SpecBlock>
<SpecBlock title="成交约束(近似建模)">
<SpecRow k="涨停" v="收盘价 / 上一有效收盘 ≥ 板块涨停系数 → 不可买入" />
<SpecRow k="跌停" v="→ 不可卖出(持仓保留到下一次调仓)" />
<SpecRow k="停牌" v="当日无行情 → 不可买不可卖" />
<SpecRow k="顺延买入" v={sel.defer_buy ? "启用(到下次调仓仍未成交则作废,资金留作现金)" : "未启用"} />
<SpecRow k="期末平仓" v="否 —— 最后持仓按最后交易日收盘估值,浮盈浮亏计入净值但不产生 Trade 记录" />
</SpecBlock>
<SpecBlock title="复现提示">
<div className="hint">
① 同样的 spec + 同样的代码版本 + 同样的数据版本才应复现同样结果;
② 复权口径为 {adjustLabel(spec.price_adjustment as string)},
{spec.price_adjustment === "none"
? "现金分红未计入收益、除权日价格下移会记为亏损 —— 股息类策略建议用 hfq 重跑对比。"
: "分红按复权因子隐含再投资处理。"}
③ 未建模项(涨跌停开盘路径、停牌明细、流动性冲击等)写在该归档结果的
<RichText text="`unimplemented`" /> 列表里,下方结果区已如实展示。
</div>
</SpecBlock>
</div>
{detail.kind !== "backtest" ? (
<div className="hint" style={{ marginTop: 10 }}>
该归档类型为「{experimentKindLabel(detail.kind)}」,其 spec 中的择股/调仓/成本字段不参与计算。
</div>
) : null}
</Card>
</>
);
}
function SpecBlock({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="spec-block">
<div className="spec-block__title">{title}</div>
{children}
</div>
);
}
function SpecRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) {
return (
<div className="spec-row">
<span className="spec-row__k">{k}</span>
<span className={`spec-row__v${mono ? " mono" : ""}`}>
<RichText text={v} />
</span>
</div>
);
}
function pct(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n)) return "未记录";
return `${(n * 100).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}%`;
}
+536 -77
View File
@@ -1,8 +1,24 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { apiGet, apiPost } from "@/lib/api";
import type { BacktestResult } from "@/lib/types";
/**
* 实验归档 + **对比**(微调闭环的核心)。
*
* 过去这一页只能「看详情 / 复跑」,微调时只能翻列表比数字。现在:
* - 勾选 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,
@@ -12,70 +28,98 @@ import {
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";
interface ExperimentMeta {
id: string;
kind: string;
factors: string[];
period?: [string, string] | null;
rebalance?: string;
top_n?: number | null;
summary_text?: string | null;
code_version?: string | null;
created_at?: string;
}
interface ExperimentDetail extends ExperimentMeta {
spec: { universe?: Record<string, unknown> };
result: BacktestResult | null;
}
const KIND_MAP: Record<string, { label: string; tone: "accent" | "violet" | "default" }> = {
backtest: { label: "回测", tone: "accent" },
factor_test: { label: "因子测试", tone: "violet" },
/**
* 类型徽标的**色调**(措辞统一走 `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) {
const k = KIND_MAP[kind] ?? { label: kind, tone: "default" as const };
return <Pill tone={k.tone}>{k.label}</Pill>;
return <Pill tone={KIND_TONE[kind] ?? "default"}>{experimentKindLabel(kind)}</Pill>;
}
function statusView(status: string) {
switch (status) {
case "success":
return (
<Pill tone="pos" icon="check">
完成
</Pill>
);
case "running":
return (
<Pill tone="accent" icon="spinner">
运行中
</Pill>
);
case "queued":
return (
<Pill tone="warn" icon="clock">
排队
</Pill>
);
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>
);
case "cancelled":
return <Pill tone="warn">已取消</Pill>;
return <Pill tone="neg" icon="alert">失败</Pill>;
default:
return <Pill>{status}</Pill>;
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);
@@ -84,22 +128,69 @@ export default function ExperimentsPage() {
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(() => {
apiGet<ExperimentMeta[]>("/experiments")
.then(setExps)
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);
})
.then((d) => setDetail(d))
.catch((e: Error) => setError(e.message))
.finally(() => setDetailLoading(false));
}
@@ -134,31 +225,135 @@ export default function ExperimentsPage() {
}, 1200);
}
const s = detail?.result?.summary;
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 与代码版本),保证可复现;支持一键复跑。"
actions={exps.length > 0 ? <Pill tone="accent" icon="archive">共 {exps.length} 个实验</Pill> : undefined}
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>
<div className="hint">任务状态实时轮询;完成后列表自动刷新,可直接展开查看最新结果。</div>
</Card>
) : null}
<Card title="实验列表" icon="archive" flush>
<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} />
@@ -167,25 +362,35 @@ export default function ExperimentsPage() {
<Empty
icon="archive"
title="暂无实验"
hint="在「回测」或「因子研究」中运行一次,即会自动归档于此。"
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 style={{ width: 150, textAlign: "right" }}>操作</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>
@@ -199,13 +404,43 @@ export default function ExperimentsPage() {
</td>
<td className="cell-mono">{e.period ? `${e.period[0]}~${e.period[1]}` : "—"}</td>
<td className="hint">{e.summary_text ?? "—"}</td>
<td>{e.code_version ? <span className="tag tag--mono">{e.code_version}</span> : <span className="hint">—</span>}</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>
<Btn size="sm" variant="primary" icon="refresh" onClick={() => rerun(e)} disabled={jobStatus === "running" || jobStatus === "queued" || jobStatus === "submitting"}>
<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>
@@ -224,10 +459,15 @@ export default function ExperimentsPage() {
title={`${detail.id} · ${detail.factors.join("、")}`}
sub={detail.period ? `${detail.period[0]} ~ ${detail.period[1]}` : undefined}
tools={
<Btn size="sm" onClick={() => setDetail(null)}>
<Icon name="x" size={13} />
关闭
</Btn>
<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 ? (
@@ -252,12 +492,36 @@ export default function ExperimentsPage() {
) : (
<div className="hint">该实验没有可展示的汇总结果(可能是因子测试或历史版本)。</div>
)}
{detail.spec ? (
<div className="hint" style={{ marginTop: 12 }}>
spec(可复现输入):<span className="mono">{JSON.stringify(detail.spec).slice(0, 500)}</span>
{JSON.stringify(detail.spec).length > 500 ? "…" : ""}
{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>
@@ -265,3 +529,198 @@ export default function ExperimentsPage() {
</>
);
}
/* ------------------------------------------------------------------ */
/** 净值曲线 → 累计收益率 %(以首个点为基准,消除初始资金差异后可直接叠加) */
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>
);
}