/** * 回测结果曲线 → 图表序列的**唯一构造处**。 * * 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、 * 以及「新页面放大」的 `/charts/{id}`。三处各写一份格式化逻辑,迟早出现 * 「同一张图两个页面数值口径不一样」。这里把每类曲线的取数、颜色、数值格式、 * 买卖点标注统一成函数,页面只管摆放。 */ import type { LwFormatKey, LwSeries, LwMarker } from "@/components/charts/LwChart"; import { CHART, fmtNum } from "@/components/charts/theme"; import type { ActionRecord, BacktestResult, FactorCurve, SymbolCurve } from "@/lib/types"; /** 因子值的量纲说明与人读格式("%" / "倍数" / "小数",None = 无量纲) */ export function factorValueFormat(curve: FactorCurve): (v: number) => string { if (curve.unit === "%") return (v) => `${v.toFixed(3)}%`; if (curve.unit === "倍数") return (v) => `${v.toFixed(3)}×`; // 小数:原值 0.15 = 15% —— 图上按百分数显示更好读,但标签里会注明「原值为小数」 if (curve.unit === "小数") return (v) => `${(v * 100).toFixed(2)}%`; return (v) => v.toFixed(4); } /** * 因子曲线的格式标识(给 Server Component 用)。 * * 与 `factorValueFormat` 必须一致:两处定义同一件事会漂移,因此这里直接按 unit 分支, * 且在单测/自检里比对两者的输出。 */ export function factorFormatKey(curve: FactorCurve): LwFormatKey { if (curve.unit === "%") return "pct3"; if (curve.unit === "倍数") return "times3"; if (curve.unit === "小数") return "frac-pct"; return "auto4"; } /** 因子曲线口径的完整说明(图上必须写,避免把「持仓加权平均」读成别的口径) */ export function factorCurveNote(curve: FactorCurve): string { const unitNote = curve.unit === "小数" ? "原值为小数(0.15 即 15%),图上按百分数显示" : curve.unit ? `单位:${curve.unit}` : "无量纲"; const dir = curve.direction === "lower_is_better" ? "越低越好" : "越高越好"; return ( `口径:每个交易日**当日持仓按市值加权平均**的因子原始值(不做 z-score、不按方向取反),` + `空仓日不落点。${unitNote};方向 ${dir}。` ); } /** 因子曲线序列 */ export function factorSeries(curve: FactorCurve, index = 0): LwSeries { const colors = [CHART.accent, CHART.violet, CHART.pos, CHART.neg, CHART.amber]; return { key: `factor-${curve.name}`, label: `${curve.label}(持仓加权)`, type: "line", lineWidth: 2, color: colors[index % colors.length], data: curve.points.map((p) => ({ time: p.date, value: p.value })), lastValueVisible: true, }; } /** 同一日多次成交合并成一个标记(图上不叠字) */ export function portfolioMarkers( fills: ActionRecord[] | undefined, validDates?: Set ): LwMarker[] { const byDate = new Map(); for (const f of fills ?? []) { if (validDates && !validDates.has(f.date)) continue; const cur = byDate.get(f.date) ?? { BUY: false, SELL: false }; cur[f.signal] = true; byDate.set(f.date, cur); } const out: LwMarker[] = []; for (const [time, kinds] of byDate) { if (kinds.BUY) out.push({ time, kind: "BUY", text: "买" }); if (kinds.SELL) out.push({ time, kind: "SELL", text: "卖" }); } return out.sort((a, b) => (a.time < b.time ? -1 : 1)); } export function equitySeries(result: BacktestResult): LwSeries[] { return [ { key: "equity", label: "组合净值(元)", type: "area", color: CHART.pos, data: result.equity_curve.map((p) => ({ time: p.date, value: p.value })), lastValueVisible: true, }, ]; } export function drawdownSeries(result: BacktestResult): LwSeries[] { return [ { key: "dd", label: "回撤(%)", type: "area", color: CHART.neg, data: result.drawdown.map((p) => ({ time: p.date, value: p.value })), lastValueVisible: true, }, ]; } export function symbolSeries(curve: SymbolCurve): LwSeries[] { return [ { key: "sym", label: `${curve.symbol} 持仓期累计收益(%)`, type: "area", color: CHART.accent, data: curve.points.map((p) => ({ time: p.date, value: p.value })), lastValueVisible: true, }, ]; } export function symbolMarkers(curve: SymbolCurve): LwMarker[] { const dates = new Set(curve.points.map((p) => p.date)); return (curve.marks ?? []) .filter((a) => dates.has(a.date)) .map((a) => ({ time: a.date, kind: a.signal, text: a.signal === "BUY" ? "买" : "卖", })); } /** 月度收益(柱状) */ export function monthlySeries(result: BacktestResult): LwSeries[] { return [ { key: "monthly", label: "月度收益(%)", type: "bar", color: CHART.accent, data: result.monthly_returns.map((m) => ({ time: `${m.year}-${String(m.month).padStart(2, "0")}-01`, value: m.return_pct, })), lastValueVisible: true, }, ]; } export const equityValueFormat = (v: number) => fmtNum(v);