/** * 曲线放大页(`/charts/{归档id}?s={曲线}`)—— 「所有曲线都能弹出新页面看大的」。 * * 设计取舍: * - **Server Component**:数据从归档直出(URL 即快照地址,刷新/分享都还原同一张图); * 曲线切换用 URL 参数(`?s=`),每个切换按钮就是一个 ``,不需要客户端状态。 * - **一页一曲线、尽可能大**:放大页只干一件事 —— 把一条曲线画大。因此高度直接给足 * (`CHART_HEIGHT`),并给出该曲线的口径说明与买卖点标注。 * - **数据只来自归档**:不重新跑回测、不从内存里取,避免「放大页与归档不一致」。 * - 归档不存在 → 404;归档类型没有该曲线 → 如实说明并给回归档详情页的入口。 */ import { notFound } from "next/navigation"; import Link from "next/link"; import { LwChart } from "@/components/charts/LwChart"; import { CHART, fmtNum } from "@/components/charts/theme"; import { Card, Pill, Banner } from "@/components/ui"; import type { LwFormatKey } from "@/components/charts/LwChart"; import { drawdownSeries, equitySeries, factorCurveNote, factorFormatKey, factorSeries, monthlySeries, portfolioMarkers, symbolMarkers, symbolSeries, } from "@/lib/chartSeries"; import { experimentKindLabel } from "@/lib/labels"; import type { BacktestResult, ExperimentDetail } from "@/lib/types"; export const dynamic = "force-dynamic"; const BACKEND = (process.env.BACKEND_API_URL ?? "http://127.0.0.1:8000").replace(/\/$/, ""); /** 放大页的主要目的就是「看大图」,给足高度(窄屏靠 CSS 缩到视口内) */ const CHART_HEIGHT = 640; 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; } } interface Option { key: string; label: string; hint: string; } /** 该归档里所有可放大的曲线(顺序即推荐阅读顺序) */ function optionsFor(result: BacktestResult): Option[] { const out: Option[] = [ { key: "equity", label: "组合净值", hint: "含买卖点(成交日)" }, { key: "drawdown", label: "回撤", hint: "距历史最高的回撤(%)" }, ]; for (const f of result.factor_curves ?? []) { out.push({ key: `factor:${f.name}`, label: `因子 · ${f.label}`, hint: "持仓加权平均原始值" }); } for (const c of result.symbol_curves ?? []) { out.push({ key: `sym:${c.symbol}`, label: `个股 · ${c.symbol}`, hint: "持仓期累计收益(%)" }); } if ((result.monthly_returns ?? []).length) { out.push({ key: "monthly", label: "月度收益", hint: "每月收益(%)" }); } return out; } export default async function ChartPage({ params, searchParams, }: { params: Promise<{ id: string }>; searchParams: Promise<{ s?: string }>; }) { const { id: rawId } = await params; const id = decodeURIComponent(rawId ?? ""); const sp = await searchParams; const detail = await serverGet(`/experiments/${encodeURIComponent(id)}`); if (!detail) notFound(); const result = detail.result as BacktestResult | null; const isBacktest = Boolean(result && Array.isArray(result.equity_curve)); if (!isBacktest) { return ( 归档 {id} 的类型是「{experimentKindLabel(detail.kind)}」,它的结果结构里没有净值 / 因子曲线(这些曲线只在回测归档里)。这不是错误,只是曲线放大页不适用于该类型。
打开归档详情
); } const res = result as BacktestResult; const options = optionsFor(res); const want = sp?.s ?? options[0]?.key ?? "equity"; const current = options.find((o) => o.key === want) ?? options[0]; const factorKey = current?.key.startsWith("factor:") ? current.key.slice("factor:".length) : null; const symKey = current?.key.startsWith("sym:") ? current.key.slice("sym:".length) : null; let series = equitySeries(res); let markers = portfolioMarkers( res.fills, new Set(res.equity_curve.map((p) => p.date)) ); let formatKey: LwFormatKey = "num"; let note = "组合净值(元):每日收盘后按持仓市值结算;▲ 绿 = 当日有买入成交,▼ 红 = 当日有卖出成交。"; let zeroLine = false; if (factorKey) { const curve = (res.factor_curves ?? []).find((f) => f.name === factorKey); if (curve) { const idx = (res.factor_curves ?? []).findIndex((f) => f.name === factorKey); series = [factorSeries(curve, idx)]; formatKey = factorFormatKey(curve); note = factorCurveNote(curve); // 因子曲线上的买卖点:把组合的成交日标在因子曲线上,直接看「买卖发生在什么水平」 markers = portfolioMarkers(res.fills, new Set(curve.points.map((p) => p.date))); } } else if (symKey) { const curve = (res.symbol_curves ?? []).find((c) => c.symbol === symKey); if (curve) { series = symbolSeries(curve); markers = symbolMarkers(curve); formatKey = "pct2"; note = `${curve.symbol} 持仓期间的累计收益率(%,以建仓日收盘为 0% 基准,按日复利),` + "只在该股持仓的交易日落点;买卖点为实际成交。"; zeroLine = true; } } else if (current?.key === "drawdown") { series = drawdownSeries(res); markers = []; formatKey = "pct2"; note = "回撤(%):净值相对历史最高点的跌幅,越负越深。"; zeroLine = true; } else if (current?.key === "monthly") { series = monthlySeries(res); markers = []; formatKey = "pct2"; note = "月度收益(%):每个月末相对上月末的净值变化。"; zeroLine = true; } const seriesLabel = series[0]?.label ?? current?.label ?? "曲线"; return ( <>
曲线放大 {experimentKindLabel(detail.kind)} {id} {detail.created_at ? ( 归档于 {String(detail.created_at).slice(0, 19).replace("T", " ")} ) : null}
打开归档详情
{options.map((o) => ( {o.label} ))}
{series[0]?.data.length ?? 0} 个点 {markers.length ? ` · ${markers.length} 个买卖标注` : ""} } >
{note} 拖动 / 滚轮可缩放,双击图例可临时隐藏曲线;地址栏 URL 可直接分享或收藏 (换设备打开还原同一张图,因为数据来自归档快照)。
{res.summary ? (
区间 {res.summary.start} ~{" "} {res.summary.end} 总收益{" "} = 0 ? "tone-pos" : "tone-neg"}> {res.summary.total_return_pct.toFixed(2)}% 年化 {res.summary.annual_return_pct.toFixed(2)}% Sharpe {res.summary.sharpe.toFixed(3)} 最大回撤 {res.summary.max_drawdown_pct.toFixed(2)}% 期末权益 {fmtNum(res.summary.final_equity)} 共 {res.summary.total_trades} 笔成交
) : null} ); }