"use client"; /** * 回测结果视图(**回测页**与**实验归档页**共用同一组件)。 * * 共用是刻意的:归档要能被「往复查看」,就必须和刚跑完时看到的形态完全一致—— * 两处各写一套迟早会漂移,导致「归档里少了一张图/多了一列」这种隐性不一致。 * * 三处模式差异用 props 表达: * - `name`:策略名(运行时的策略);归档页传归档里记录的策略名(若有)。 * - `archive`:传入后额外展示归档元数据(实验 id / 数据版本 / 代码版本 / 归档时间) * 与**归档完整度**(`result.archive_meta.truncated` 为真时显式提示曲线被裁剪,不静默)。 */ import { useMemo, useRef, useState } from "react"; import { Card, Pill, Btn, Empty, BacktestMetrics, MonthlyReturnsTable, UnimplementedNote, } from "@/components/ui"; import { LwChart, type LwMarker, type LwSeries } from "@/components/charts/LwChart"; import { CHART, fmtNum, fmtPct } from "@/components/charts/theme"; import Link from "next/link"; import { SymbolLink, useSymbolNames } from "@/lib/symbols"; import { adjustLabel } from "@/lib/labels"; import type { ActionRecord, BacktestResult, SymbolCurve } from "@/lib/types"; import { reasonLabel } from "@/lib/types"; import { ChartPopoutLink } from "@/components/ChartPopoutLink"; import { TradeReasonsCard, type FactorLabels } from "@/components/TradeReasons"; import { drawdownSeries, equitySeries, equityValueFormat, factorCurveNote, factorSeries, factorValueFormat, portfolioMarkers, symbolMarkers, symbolSeries, } from "@/lib/chartSeries"; export interface ArchiveInfo { id: string; created_at?: string | null; code_version?: string | null; data_version?: string | null; job_id?: string | null; } function pad(n: number): string { return String(n).padStart(2, "0"); } function fmtDateTime(v?: string | null): string { if (!v) return "—"; const d = new Date(v); if (Number.isNaN(d.getTime())) return v; return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } export function BacktestResultView({ result, name, archive, }: { result: BacktestResult; name?: string; archive?: ArchiveInfo; }) { const s = result.summary; const last = result.positions.at(-1); const holdings = result.positions.filter((p) => p.date === last?.date); const curves = result.symbol_curves ?? []; const [active, setActive] = useState(curves[0]?.symbol ?? ""); const [curveQuery, setCurveQuery] = useState(""); // 老实验(后端填充 name 之前归档的结果)里 curve.name 为空: // 名称统一从 useSymbolNames 缓存取,保证「有代码必有名称」在任何历史结果上都成立 const { names: nameCache } = useSymbolNames(); const nameOf = (c: SymbolCurve) => c.name ?? nameCache[c.symbol] ?? ""; const topRef = useRef(null); // 曲线序列统一走 lib/chartSeries(与「新页面放大」共用同一套口径与格式) const equityData = useMemo(() => equitySeries(result), [result]); const equityMarkers = useMemo( () => portfolioMarkers(result.fills, new Set(result.equity_curve.map((p) => p.date))), [result] ); // 因子键 → 展示名:买卖理由里的因子值要用中文名,不能只甩引擎键 const factorLabels = useMemo(() => { const out: FactorLabels = {}; for (const f of result.factor_curves ?? []) out[f.name] = f.label; return out; }, [result.factor_curves]); const filteredCurves = useMemo(() => { const q = curveQuery.trim().toLowerCase(); if (!q) return curves; return curves.filter( (c) => c.symbol.toLowerCase().includes(q) || nameOf(c).toLowerCase().includes(q) ); // nameOf 只依赖 nameCache 与 c,二者都在依赖里 // eslint-disable-next-line react-hooks/exhaustive-deps }, [curves, curveQuery, nameCache]); const activeCurve = curves.find((c) => c.symbol === active) ?? filteredCurves[0] ?? curves[0]; const buyDays = equityMarkers.filter((m) => m.kind === "BUY").length; const sellDays = equityMarkers.filter((m) => m.kind === "SELL").length; const SECTIONS = [ { id: "sec-equity", label: "整体收益" }, { id: "sec-factors", label: "因子曲线" }, { id: "sec-symbols", label: "个股曲线" }, { id: "sec-monthly", label: "月度/年度" }, { id: "sec-holdings", label: "持仓" }, { id: "sec-trades", label: "成交明细" }, { id: "sec-reasons", label: "买卖说明" }, ]; return ( <>
结果分区 {SECTIONS.map((s) => ( {s.label} ))} 共 {result.equity_curve.length} 个交易日 · {result.trades.length} 笔成交
{archive ? : null}
完成 {name ? ( {name} ) : null} {poolLabel(result)} {adjustLabelFromSnapshot(result.config_snapshot)} 口径 {stBasisNote(result) && ( {stBasisNote(result)} )} {s.start} ~ {s.end}
总收益
期末 {fmtNum(s.final_equity)} · 买入 {buyDays} 日 / 卖出 {sellDays} 日
} >
▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交;点位取当日组合净值。 悬停可看任意日期的净值;拖动/滚轮可缩放区间,双击图例可临时隐藏曲线。
最大 {s.max_drawdown_pct.toFixed(2)}%
} > `${v.toFixed(2)}%`} zeroLine ariaLabel="回撤曲线" /> setCurveQuery(e.target.value)} aria-label="搜索个股" /> {curves.length} 只有成交 } > {curves.length === 0 ? ( ) : ( <>
曲线口径:该股被持有期间按日复利累计(建仓当日为 0%);未持有期间不绘制, 分段间以直线连接,请以买卖点区分持仓区间。
{activeCurve ? : null}
{filteredCurves.map((c) => ( ))}
股票 持仓期累计收益 买点 卖点 操作
= 0 ? "tone-pos" : "tone-neg"}> {c.final_return_pct.toFixed(2)}% {(c.marks ?? []).filter((m) => m.signal === "BUY").length} {(c.marks ?? []).filter((m) => m.signal === "SELL").length} setActive(c.symbol)}> 查看曲线
)}
} > ({ time: `${m.year}-${String(m.month).padStart(2, "0")}-01`, value: m.return_pct, })), }, ]} height={200} valueFormat={(v) => `${v.toFixed(2)}%`} zeroLine legend={false} ariaLabel="月度收益柱状图" />
查看月度收益明细表
{holdings.length} 只} > {holdings.length === 0 ? ( ) : (
{holdings.map((p) => ( ))}
股票 权重
{(p.weight * 100).toFixed(2)}%
)}
{result.yearly_returns.length ? ( 均值{" "} {( result.yearly_returns.reduce((a, y) => a + y.return_pct, 0) / result.yearly_returns.length ).toFixed(2)} % } >
{result.yearly_returns.map((y) => ( {y.year} = 0 ? "tone-pos" : "tone-neg"}> {y.return_pct.toFixed(2)}% ))}
) : null} {result.trades.length ? ( {result.turnover_pct.toFixed(2)}% 累计换手} >
{result.trades.map((t) => ( ))}
买入日 卖出日 股票 买价 卖价 收益 为什么买 为什么卖
{t.entry_date} {t.exit_date} {t.entry_price?.toFixed(2) ?? "-"} {t.exit_price?.toFixed(2) ?? "-"} = 0 ? "tone-pos" : "tone-neg"}> {t.return_pct.toFixed(2)}% {reasonLabel(t.entry_reason?.code)} {reasonLabel(t.exit_reason?.code)}
) : null} ); } /** 未成交意图(涨停/停牌/顺延)透明化,避免「信号有了却没买」无法解释。 */ function NotFilledCard({ signals }: { signals: ActionRecord[] }) { const rejects = signals.filter((a) => !a.filled && a.reject_reason); if (rejects.length === 0) return null; const byReason = new Map(); for (const r of rejects) { const key = (r.reject_reason ?? "").replace(/,?顺延.*$/, "(顺延)"); byReason.set(key, (byReason.get(key) ?? 0) + 1); } return (
{[...byReason.entries()].map(([reason, n]) => ( {reason} {n} ))}
涨停/停牌导致的未成交按「信号已记录、成交未发生」处理(v3 §20.3):顺延买入会在之后首个 可成交交易日按收盘价成交;到下一次调仓仍未成交则作废。
); } function SymbolCurveChart({ curve }: { curve: SymbolCurve }) { return ( `${v.toFixed(2)}%`} zeroLine ariaLabel={`${curve.symbol} 持仓期收益曲线与买卖点`} /> ); } /** * 因子曲线:策略里每个因子一张图(**原始值**,不做 z-score)。 * * 用户要求:「本因子的买卖依据是股息率,那么要增加股息率曲线」。这里把**同一批成交日** * 标在因子曲线上,于是能一眼看出「买在什么水平、卖在什么水平」,而不是只看净值曲线 * 猜原因。口径(持仓加权平均、不按方向取反、空仓不落点)写在每张图下方。 */ function FactorCurvesCard({ result, fills, archiveId, }: { result: BacktestResult; fills?: ActionRecord[]; archiveId?: string | null; }) { const curves = result.factor_curves ?? []; if (!curves.length) return null; return ( 持仓加权平均原始值} >
每个因子一条曲线:值为当日「持仓股票按市值加权平均」的因子原始值,用来回答 「买入时这个因子处于什么水平、卖出时又变到哪」。图上 ▲/▼ 是「组合的成交日」(同一套买卖点), 因此能直接对照「因子在什么水平触发买卖」。曲线未做 z-score、也未按方向取反; 低为好的因子(方向标注为「越低越好」)曲线升高不等于更好。
{curves.map((c, i) => { const dates = new Set(c.points.map((p) => p.date)); return ( {c.direction === "lower_is_better" ? "越低越好" : "越高越好"}
} >
{factorCurveNote(c)}
); })} ); } function poolLabel(result: BacktestResult): string { const sel = result.config_snapshot?.selection as | { top_n?: number; hold_top_x?: number | null } | undefined; if (!sel) return "候选池 —"; return `候选池 ${sel.top_n ?? "—"} → 持仓 ${sel.hold_top_x ?? sel.top_n ?? "—"}`; } /** * ST(风险警示)剔除口径提示。 * * 后端逐择股日按**当时名称**判定 exclude_st(依赖 `sync namechange`);未同步名称历史时 * 回退「最新名称快照」,会把「曾为高股息、后来才 ST/退市」的股息陷阱样本整段排除, * 收益被高估(实测案例 +24.86% → +35.71%)。此处把口径显式暴露给使用者,避免误读。 */ function stBasisNote(result: BacktestResult): string | null { const basis = result.config_snapshot?.price_basis as | { name_basis?: { point_in_time?: boolean } | null } | undefined; const nb = basis?.name_basis; if (!nb) return null; // 未启用 exclude_st return nb.point_in_time ? "ST 按时点名称判定" : "ST 用最新名称(可能高估)"; } function adjustLabelFromSnapshot(snapshot: Record): string { const basis = snapshot?.price_basis as { adjust_mode?: string } | undefined; const mode = basis?.adjust_mode ?? "none"; return mode === "hfq" ? "后复权" : mode === "qfq" ? "前复权" : "不复权"; } function SignedText({ value }: { value: number }) { const cls = value >= 0 ? "tone-pos" : "tone-neg"; return ( {value > 0 ? "+" : ""} {value.toFixed(2)}% ); } /** * 归档元数据条:**归档能否被信任地复现,取决于这几项**,所以放在最显眼的位置。 * - 代码版本:跑出这个结果时的 git short rev * - 数据版本:当时的数据快照指纹(由后端在归档时写入;老归档可能为空 → 如实显示「—」) * - 实验 / 作业 id:可追到执行记录 */ function ArchiveMetaBar({ archive }: { archive: ArchiveInfo }) { return (
归档 {archive.id} 归档时间 {fmtDateTime(archive.created_at)} · 代码版本{" "} {archive.code_version ?? "—"} · 数据版本{" "} {archive.data_version ?? "未记录"} {archive.job_id ? ( <> {" "} · 作业 {archive.job_id} ) : null}
); } /** * 归档完整度提示。 * * `archive_meta.truncated` 为真表示:归档时结果 JSON 超过安全预算,个股曲线被裁剪 * (后端会在 `unimplemented` 里同时留下说明)。**必须显式告知**,否则用户会以为 * 「这只股票没被买过」,而实际只是没存下来。 */ function ArchiveCompletenessNote({ result, archive, }: { result: BacktestResult; archive?: ArchiveInfo; }) { const meta = result.archive_meta as | { curves_stored?: number; curves_total?: number; truncated?: boolean; budget_chars?: number } | undefined; const legacyNote = (result.unimplemented ?? []).find((u) => u.includes("个股收益曲线")); // 情况 1:归档带完整度元数据(本轮之后产生的归档) if (meta && meta.curves_total !== undefined) { const total = meta.curves_total ?? 0; const stored = meta.curves_stored ?? 0; return (
{meta.truncated ? ( 归档不完整 ) : ( 归档完整 )} 个股曲线已存 {stored} / 期内持有 {total} 只 {meta.truncated ? "(超出结果体积预算被裁剪:请在下方用「成交明细 / 未成交意图」查缺失个股,或缩短区间后重跑以得到完整曲线)" : "(全部持有过的股票都可在此查看)"}
); } // 情况 2:老归档没有元数据,但结果里留了截断说明 → **不能装作完整** if (legacyNote) { const held = /期内共持有\s*(\d+)\s*只/.exec(legacyNote)?.[1]; const kept = /最大的\s*(\d+)\s*只/.exec(legacyNote)?.[1]; return (
归档不完整 该归档产生于「完整存档」上线之前:个股曲线只有 {kept ?? "60"} 只(期内共持有{" "} {held ?? "更多"} 只),换手但收益排名不靠前的个股在这里看不到曲线。 成交明细 / 未成交意图仍是完整的。要看全部个股曲线,请 {archive ? ( <> {" "} 以此参数重跑 {" "} 生成一份完整归档。 ) : ( "以此参数重跑一次(新归档将保存全部持有过的个股曲线)。" )}
); } // 情况 3:既无元数据也无截断说明 → 无从判断,不猜(只在老归档上出现) if (archive) { return (
完整度未标注 该归档早于「归档完整度元数据」上线,无法确认个股曲线是否被裁剪过 (成交明细 / 未成交意图始终是完整的)。
); } return null; }