反馈条与因子曲线的口径说明里写了 `**加粗**`,但这些位置是纯文本 hint,页面会把星号 原样显示出来(放大页截图逐字核对时发现)。改为中文引号「」,语义不变、不再露出 Markdown 语法。
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
/**
|
||
* 回测结果曲线 → 图表序列的**唯一构造处**。
|
||
*
|
||
* 为什么单独抽出来:同一条曲线会在三个地方出现 —— 回测结果页、归档详情页、
|
||
* 以及「新页面放大」的 `/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); |