/** * 归档详情页(`/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 { BacktestCombo, BacktestResult, 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(path: string): Promise { 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(path: string, body: unknown): Promise { 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(`/experiments/${encodeURIComponent(id)}`), serverGet("/factors"), ]); // 归档不存在 → 交给 Next 的 404(比渲染一个空壳页更诚实) if (!detail) notFound(); const rawSpec = (detail.spec ?? null) as Record | null; // 组合回测的归档:spec_json 是 BacktestCombo(含 strategy_ids/hold_count),不是 ResearchSpec。 // 用同一套「选股条件/交易执行依据」卡去解读它会满屏「未记录」—— 那是谎报。 // 这里识别出来交给专门的 ComboArchiveCards;成本/复权从 result.config_snapshot 取快照。 const isComboArchive = detail.kind === "backtest" && !!rawSpec && (Array.isArray(rawSpec.strategy_ids) || "hold_count" in rawSpec); const result = detail.result; const spec = isComboArchive ? null : (rawSpec as Partial | null); const comboSpec = isComboArchive ? (rawSpec as unknown as BacktestCombo) : null; const comboCosts = isComboArchive ? ((result as BacktestResult | null)?.config_snapshot as Record | undefined) : undefined; // 关键字段计数按 kind 给不同口径(用回测字段去数因子/选股结果是错的) const fieldSummary = archivedFieldSummary(detail.kind, result); // 与引擎执行规则同源的说明(后端按归档 spec 推导;推导失败时如实显示错误) // 只有回测归档才需要「完整说明与计算公式」:describe 推导的是回测策略口径, // 因子测试/选股用同一 spec 结构但规则并未执行,拉回来展示等于误导(且白花一次请求)。 const doc = spec && detail.kind === "backtest" && !isComboArchive ? await serverPost("/strategies/describe", spec) : null; const docBody = doc ? ((doc as { doc?: StrategyDoc }).doc ?? doc) : null; return ( <> {detail.kind === "backtest" && !isComboArchive ? ( ) : null}
{fieldSummary ? {fieldSummary} : null}
{result ? ( ) : ( )} ); } /* ------------------------------------------------------------------ */ function KV({ label, value, mono, warn, }: { label: string; value: string; mono?: boolean; warn?: boolean; }) { return (
{label} {value}
); } 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`)—— 把这些字段按回测口径展示, * 会让人以为这次跑过调仓与撮合。所以这里只列生效项,并显式说明哪些字段未生效。 */ /** * 组合回测归档的 spec 卡:如实展示「引用了哪些选股策略 + 回测参数 + 成本/复权快照」。 * * 关键点(诚实性,AGENT §7/§24):组合归档的 spec_json 是 BacktestCombo,不含 * factors/selection/costs;成本与复权在运行时从公共配置快照进了 result.config_snapshot, * 这里从 `costs`(即 config_snapshot)读取并明确标注「来自运行时的公共配置快照」。 */ function ComboArchiveCards({ combo, costs, }: { combo: BacktestCombo; costs?: Record; }) { const costObj = (costs?.costs ?? {}) as Record; const adj = String(costs?.price_adjustment ?? "未记录"); const freqLabel = combo.rebalance_freq === "daily" ? "每日" : combo.rebalance_freq === "weekly" ? "每周" : "每月"; return ( 来自归档 spec + 运行时配置快照} >
{(combo.strategy_ids ?? []).length === 0 ? ( ) : ( (combo.strategy_ids ?? []).map((id) => ( )) )}
); } function FactorTestSpecCards({ spec, factors, }: { spec: Partial; factors: FactorMeta[]; }) { const uni = (spec.universe ?? {}) as Record; const factorList = (spec.factors ?? []) as { name: string; weight: number }[]; const period = spec.period ?? []; return ( <> 来自归档 spec,非当前页面参数} >
{factorList.length === 0 ? ( ) : ( factorList.map((f) => ( )) )}
); } /** * 选股归档的 spec 卡:一次性选股的规则(无调仓、无成交)。 */ function SelectionSpecCards({ spec, factors, }: { spec: Partial; factors: FactorMeta[]; }) { const uni = (spec.universe ?? {}) as Record; const factorList = (spec.factors ?? []) as { name: string; weight: number }[]; const conds = ((spec as Record).conditions ?? []) as ResearchCondition[]; const method = String((spec as Record).method ?? "score"); const topN = (spec as Record).top_n; const topPct = (spec as Record).top_pct; return ( 来自归档 spec,非当前页面参数} >
).as_of ?? "最近交易日")} mono /> {method === "condition" ? conds.length === 0 ? : conds.map((c) => ) : factorList.length === 0 ? : factorList.map((f) => ( ))}
); } /** 选股条件 + 交易执行依据:直接用归档 spec 渲染,回答「这次回测到底怎么选的、怎么成交的」 */ function ArchivedSpecCards({ detail, spec, factors, combo, comboCosts, }: { detail: ExperimentDetail; spec: Partial | null; factors: FactorMeta[]; combo?: BacktestCombo | null; comboCosts?: Record; }) { // 组合回测归档:spec 是 BacktestCombo,用专属卡片如实展示(引用的策略 + 回测参数 + // 从 config_snapshot 取的成本/复权快照)。绝不能套单策略回测的「选股条件/交易执行依据」。 if (combo) { return ; } if (!spec) { return (
该归档没有存储 spec(早于 spec 归档上线),无法回溯当时的规则;只能看下方结果。
); } // 按归档类型分发:**只有回测**才有「选股条件 + 交易执行依据」这一对。 // 因子测试/选股与回测共用同一请求结构(spec 里也有 selection/costs/initial_capital), // 但那些字段在因子测试/选股里**从未生效** —— 沿用回测口径展示等于谎报执行依据。 if (detail.kind === "factor_test") { return ; } if (detail.kind === "selection") { return ; } if (detail.kind !== "backtest") { return (
); } const uni = (spec.universe ?? {}) as Record; const sel = (spec.selection ?? {}) as Record; const costs = (spec.costs ?? {}) as Record; 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 ( <> 来自归档 spec,非当前页面参数} >
{factorList.length === 0 ? (
无(纯条件选股)
) : ( factorList.map((f) => ( )) )}
复合分 = Σ(权重 × 方向 × 当日截面 z-score),降序取候选池。
{conds.length === 0 ? (
无附加条件
) : ( conds.map((c, i) => ) )}
0 ? `${costs.min_commission} 元/笔(max(按比例, 最低))` : "未启用(0)" } />
① 同样的 spec + 同样的代码版本 + 同样的数据版本才应复现同样结果; ② 复权口径为 {adjustLabel(spec.price_adjustment as string)}, {spec.price_adjustment === "none" ? "现金分红未计入收益、除权日价格下移会记为亏损 —— 股息类策略建议用 hfq 重跑对比。" : "分红按复权因子隐含再投资处理。"} ③ 未建模项(涨跌停开盘路径、停牌明细、流动性冲击等)写在该归档结果的 列表里,下方结果区已如实展示。
{detail.kind !== "backtest" ? (
该归档类型为「{experimentKindLabel(detail.kind)}」,其 spec 中的择股/调仓/成本字段不参与计算。
) : null}
); } function SpecBlock({ title, children }: { title: string; children: React.ReactNode }) { return (
{title}
{children}
); } function SpecRow({ k, v, mono }: { k: string; v: string; mono?: boolean }) { return (
{k}
); } function pct(v: unknown): string { const n = Number(v); if (!Number.isFinite(n)) return "未记录"; return `${(n * 100).toFixed(3).replace(/0+$/, "").replace(/\.$/, "")}%`; }