Files
Simon 2a88ca6076 fix(web): 去掉界面文案里的字面 **(hint 不走 Markdown)
反馈条与因子曲线的口径说明里写了 `**加粗**`,但这些位置是纯文本 hint,页面会把星号
原样显示出来(放大页截图逐字核对时发现)。改为中文引号「」,语义不变、不再露出 Markdown 语法。
2026-10-01 18:13:00 +08:00

152 lines
5.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 回测结果曲线 → 图表序列的**唯一构造处**。
*
* 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、
* 以及「新页面放大」的 `/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<string>
): LwMarker[] {
const byDate = new Map<string, { BUY: boolean; SELL: boolean }>();
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);