Files
qlib/frontend/web/components/charts/LwChart.tsx
T
Simon 48a97c2a12 feat(backtest): 买卖点理由(用数据说话)+ 因子曲线 + 曲线新页面放大
用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。

一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `quant/trade_reasons.py`:封闭词表 + 文案构造器,组合引擎与单策略引擎共用,
  避免两个引擎对同一件事写出两种说法。理由里带**引擎当时的真实数字**:
  综合分名次/候选数/综合分/各因子原始值/持有交易日/预算与最低佣金/涨停比值等。
- 买入:按名次建仓、顺延成交、涨停未买、停牌未买、现金不足、不足最低佣金;
  卖出:跌出 TopN(含第几名掉出)、被股票池过滤(与「跌出 TopN」分开写)、
  超 Tmax 强制了结、Tmin 保护暂留、停牌/跌停顺延。
- `ActionRecord.reason` 覆盖**成交与未成交**全部买卖点(原 `reject_reason` 保留不动,
  老归档仍可读);`Trade.entry_reason / exit_reason` 跟着成交记录走。
- 名次来自调仓日完整排名(新增 `_ranked_by_day`),拿不到名次时如实写「未给出名次」,
  绝不编造一个名次填进去。
- 未成交明细不再只写执行层原因:把「为什么选中它、当时各因子多少」一并给出。

二、因子曲线
- `FactorCurve`:每个策略因子一条曲线,值为**当日持仓按市值加权平均的原始值**
  (不做 z-score、不按方向取反,空仓日不落点、不插值、不用 0 填充),并带
  label/direction/unit 供界面说明口径;`FactorDef/FactorTemplate` 新增 `unit`
  (股息率 %、量比/接近新高 倍数、动量等 小数),11 个内置因子实例已逐一核对。
- 归档体积预算照旧按整包计量,无需改迁移。

三、界面
- 结果页新增「买卖说明」区块:全部买卖点 + 理由 + 数字标签,支持方向/成交状态/关键字
  筛选与日期排序;成交明细表加「为什么买 / 为什么卖」两列;新增「因子曲线」区块,
  每条曲线标出组合成交日,直接对照「买卖发生在什么水平」。
- 「新页面放大」:每条曲线(净值/回撤/因子/个股/月度)都能开 `/charts/{归档id}?s=...`
  整页看大图;放大页是 Server Component,数据从归档直出,URL 可分享且与归档一致。
  未归档的结果如实说明「未归档,无法放大」,不给坏链接。
- 数字格式与后端 `f"{v:.4f}"` 同规则(四舍六入五成双):修掉 0.03125 在理由原文里
  显示 0.0312、旁边标签显示 0.0313 的不一致(17 组边界值与 Python 逐一比对一致)。
- `/factors/compose` 结果区改用同一个 `BacktestResultView`,两处口径不会再漂移。

验证:
- 新增 `tests/test_trade_reasons.py` 8 条(买入数字、跌出 TopN 名次、不在候选池、
  Tmax、Tmin 暂留、涨停未成交、因子曲线加权值、空仓不落点);后端 510 条全过,ruff clean。
- 真实数据端到端:`/api/combos/run` 6 个月高股息组合(EXP-8EA2819B)13 个买卖点
  100% 带理由与数字,因子曲线 dividend_yield 117 点、单位 %;
  `scripts/verify_backtest_page_contract.py`(4 年、301 个买卖点、140 笔成交)扩展断言
  理由词表/名次/因子值/曲线单调性后通过。
- 浏览器实测:归档详情页与放大页 `/charts/...?s=factor:dividend_yield` 等 5 种曲线
  全部 200 渲染,截图确认表格与曲线数值正确。
2026-10-01 17:57:00 +08:00

414 lines
14 KiB
TypeScript
Raw 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.
"use client";
/**
* 通用折线/面积/柱状图 —— TradingView Lightweight Charts 实现(唯一图表基座)。
*
* 为什么自建而不直接用 LW 原语:LW 没有内置 tooltip 与图例,且硬性要求
* ① 标记时间必须存在于序列数据中,② 标记必须按时间升序。
* 本组件把这两条约束在内部处理掉(过滤 + 排序),避免每个页面各写一遍而踩坑
* —— 回测买卖点必须精确落在净值曲线日期上(v3 §20.3)。
*
* 设计要点:
* - 图表实例只在「结构签名」(序列个数/类型/高度)变化时重建;数据变化走 setData
* + 数值校验和作为数据签名,避免每次渲染重建导致闪烁、丢失缩放位置。
* - 多序列共用价格轴(实验对比均为收益率 %,可直接叠加比较)。
* - 图例可点击隐藏/显示单条序列;tooltip 显示十字光标处全部序列数值。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ColorType,
CrosshairMode,
LineStyle,
createChart,
type HistogramData,
type IChartApi,
type ISeriesApi,
type LineData,
type MouseEventParams,
type SeriesMarker,
type Time,
} from "lightweight-charts";
import { CHART, MARKER_COLORS } from "./theme";
import { isBuyMarker, prepareMarkers } from "./markers";
export interface LwPoint {
time: string;
value: number;
}
export interface LwSeries {
key: string;
label: string;
data: LwPoint[];
color?: string;
/** line=折线;area=带渐变面积;bar=柱状(月度收益等) */
type?: "line" | "area" | "bar";
lineWidth?: 1 | 2 | 3 | 4;
dashed?: boolean;
/** 是否在价格轴显示最新值标签(多序列对比时只留一条即可) */
lastValueVisible?: boolean;
}
export interface LwMarker {
time: string;
kind: keyof typeof MARKER_COLORS;
label?: string;
/** 覆盖默认文本(默认买 B / 卖 S) */
text?: string;
}
export interface LwChartProps {
series: LwSeries[];
markers?: LwMarker[];
height?: number;
/** 悬浮提示与价格轴的数值格式(客户端组件用;Server Component 请用 formatKey) */
valueFormat?: (v: number) => string;
/**
* 数值格式的**可序列化标识**。
*
* 为什么需要它:Server Component 不能把函数传给 Client Component(`valueFormat`
* 会直接 500:「Functions cannot be passed directly to Client Components」)。
* 因此服务端渲染的图表(归档详情、曲线放大页)用这个标识指定格式,由本组件在
* 客户端解析成函数 —— 两端看到的数字格式仍然只有一份定义。
*/
formatKey?: LwFormatKey;
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
zeroLine?: boolean;
legend?: boolean;
ariaLabel?: string;
emptyHint?: string;
}
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
/** 数值格式的可序列化标识(Server Component ↔ Client Component 的桥) */
export type LwFormatKey =
| "num"
| "num0"
| "pct2"
| "pct3"
| "times3"
| "auto4"
/** 原值为小数、按百分数显示(0.15 → 15.00%) */
| "frac-pct";
export const FORMATTERS: Record<LwFormatKey, (v: number) => string> = {
num: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }),
num0: (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 0 }),
pct2: (v) => `${v.toFixed(2)}%`,
pct3: (v) => `${v.toFixed(3)}%`,
times3: (v) => `${v.toFixed(3)}×`,
auto4: (v) => v.toFixed(4),
"frac-pct": (v) => `${(v * 100).toFixed(2)}%`,
};
function toTime(t: string): Time {
return t as Time;
}
/** 结构签名:序列个数/类型/高度 —— 变化才重建图表实例 */
function structureKey(series: LwSeries[], height: number): string {
return `${height}|${series.map((s) => `${s.key}:${s.type ?? "line"}`).join(",")}`;
}
/**
* 数据签名:长度 + 首末时间 + **数值校验和**。
* 必须含数值校验和:只改成本/滑点后曲线数值变了但日期与长度不变,
* 若签名只看长度就会漏更新(图表停留在上一次结果)。
*/
function dataKey(series: LwSeries[], markers: LwMarker[]): string {
const parts = series.map((s) => {
let sum = 0;
for (const p of s.data) sum += p.value;
return `${s.key}:${s.data.length}:${s.data[0]?.time ?? ""}:${s.data.at(-1)?.time ?? ""}:${sum.toFixed(4)}`;
});
const mk = markers.map((m) => `${m.time}${m.kind}`).join(",");
return `${parts.join("|")}#${mk}`;
}
export function LwChart({
series,
markers = [],
height = 320,
valueFormat,
formatKey,
zeroLine = false,
legend = true,
ariaLabel,
emptyHint = "暂无数据",
}: LwChartProps) {
const wrapRef = useRef<HTMLDivElement | null>(null);
const boxRef = useRef<HTMLDivElement | null>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<Map<string, AnySeries>>(new Map());
const zeroLineDrawn = useRef(false);
/** 图例隐藏集合:tooltip 订阅里读取,用 ref 避免闭包过期 */
const hiddenRef = useRef<Set<string>>(new Set());
const resolvedFormat = valueFormat ?? (formatKey ? FORMATTERS[formatKey] : undefined);
const fmtRef = useRef(resolvedFormat);
fmtRef.current = resolvedFormat;
const [hidden, setHidden] = useState<Set<string>>(new Set());
const [tip, setTip] = useState<{
x: number;
y: number;
time: string;
rows: { label: string; color: string; text: string }[];
} | null>(null);
hiddenRef.current = hidden;
const struct = structureKey(series, height);
const dkey = dataKey(series, markers);
const fmt = useCallback(
(v: number) => (fmtRef.current ? fmtRef.current(v) : v.toFixed(2)),
[]
);
/* ---------- 创建/销毁图表实例 ---------- */
useEffect(() => {
const el = boxRef.current;
if (!el) return;
const chart = createChart(el, {
autoSize: true,
height,
layout: {
background: { type: ColorType.Solid, color: CHART.background },
textColor: CHART.text,
fontSize: 11,
},
grid: {
vertLines: { color: CHART.grid },
horzLines: { color: CHART.grid },
},
rightPriceScale: {
borderColor: CHART.border,
scaleMargins: { top: 0.12, bottom: 0.12 },
},
timeScale: { borderColor: CHART.border, timeVisible: false, secondsVisible: false },
crosshair: {
mode: CrosshairMode.Normal,
vertLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true },
horzLine: { color: CHART.crosshair, width: 1, style: LineStyle.Dashed, labelVisible: true },
},
});
chartRef.current = chart;
zeroLineDrawn.current = false;
const map = new Map<string, AnySeries>();
series.forEach((s, i) => {
const color = s.color ?? CHART.series[i % CHART.series.length];
let api: AnySeries;
if (s.type === "bar") {
api = chart.addHistogramSeries({
color,
priceLineVisible: false,
lastValueVisible: false,
});
} else if (s.type === "area") {
api = chart.addAreaSeries({
lineColor: color,
topColor: `${color}55`,
bottomColor: `${color}05`,
lineWidth: s.lineWidth ?? 2,
priceLineVisible: false,
lastValueVisible: s.lastValueVisible ?? false,
});
} else {
api = chart.addLineSeries({
color,
lineWidth: s.lineWidth ?? 2,
lineStyle: s.dashed ? LineStyle.Dashed : LineStyle.Solid,
priceLineVisible: false,
lastValueVisible: s.lastValueVisible ?? series.length === 1,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 4,
});
}
map.set(s.key, api);
});
seriesRef.current = map;
// tooltip:LW 无内置 tooltip,订阅十字光标自行渲染
const onMove = (param: MouseEventParams) => {
if (!param.time || !param.point) {
setTip(null);
return;
}
const rows: { label: string; color: string; text: string }[] = [];
series.forEach((s, i) => {
const api = map.get(s.key);
if (!api || hiddenRef.current.has(s.key)) return;
const v = param.seriesData.get(api as unknown as ISeriesApi<"Line">) as
| LineData
| HistogramData
| undefined;
if (!v || typeof v.value !== "number") return;
rows.push({
label: s.label,
color: s.color ?? CHART.series[i % CHART.series.length],
text: fmt(v.value),
});
});
if (!rows.length) {
setTip(null);
return;
}
setTip({ x: param.point.x, y: param.point.y, time: String(param.time), rows });
};
chart.subscribeCrosshairMove(onMove);
const onLeave = () => setTip(null);
el.addEventListener("mouseleave", onLeave);
return () => {
el.removeEventListener("mouseleave", onLeave);
chart.unsubscribeCrosshairMove(onMove);
chart.remove();
chartRef.current = null;
seriesRef.current = new Map();
};
// 仅结构变化才重建;数据/标记由下方 effect 推入
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [struct, fmt]);
/* ---------- 推入数据与标记 ---------- */
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
series.forEach((s) => {
const api = seriesRef.current.get(s.key);
if (!api) return;
if (s.type === "bar") {
api.setData(
s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as HistogramData[]
);
} else {
api.setData(s.data.map((p) => ({ time: toTime(p.time), value: p.value })) as LineData[]);
}
});
// 0 基准线:每个图表实例只画一次(重复调用会叠加多条价格线)
if (zeroLine && !zeroLineDrawn.current) {
const first = seriesRef.current.get(series[0]?.key ?? "");
first?.createPriceLine({
price: 0,
color: CHART.faint,
lineWidth: 1,
lineStyle: LineStyle.Dotted,
axisLabelVisible: false,
title: "",
});
zeroLineDrawn.current = true;
}
// 标记只能挂在某个序列上;统一挂第一条非柱状序列(净值/收益曲线)
const primaryKey = series.find((s) => s.type !== "bar")?.key ?? series[0]?.key;
const primary = primaryKey ? seriesRef.current.get(primaryKey) : undefined;
if (primary && "setMarkers" in primary) {
const validTimes = new Set(
series.find((s) => s.key === primaryKey)?.data.map((p) => p.time) ?? []
);
// LW 硬约束:标记时间必须存在且升序 → 先过滤再排序,否则买卖点会丢或抛错。
// 传空数组同样重要:结果切换后必须清掉上一次的标记。
const ms: SeriesMarker<Time>[] = prepareMarkers(markers, [...validTimes]).map((m) => ({
time: toTime(m.time),
position: isBuyMarker(m.kind) ? "belowBar" : "aboveBar",
color: MARKER_COLORS[m.kind],
shape: isBuyMarker(m.kind) ? "arrowUp" : "arrowDown",
size: 1,
text: m.text,
}));
(primary as ISeriesApi<"Line">).setMarkers(ms);
// 把「实际画上去的标记数」暴露成 DOM 契约:便于端到端断言,
// 也避免「页面传了 30 个买点、图上其实一个没显示」这类静默失败无从发现
if (wrapRef.current) wrapRef.current.dataset.markerCount = String(ms.length);
}
chart.timeScale().fitContent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dkey, struct, zeroLine]);
/* ---------- 图例显隐 ---------- */
useEffect(() => {
series.forEach((s) => {
const api = seriesRef.current.get(s.key);
if (api) api.applyOptions({ visible: !hidden.has(s.key) });
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hidden, struct, dkey]);
const legendItems = useMemo(
() =>
series.map((s, i) => ({
key: s.key,
label: s.label,
color: s.color ?? CHART.series[i % CHART.series.length],
last: s.data.at(-1)?.value,
})),
[series]
);
const hasData = series.some((s) => s.data.length > 0);
return (
<div className="lw-wrap" ref={wrapRef}>
{legend && legendItems.length > 0 && (
<div className="lw-legend" role="group" aria-label="图例(点击可隐藏/显示)">
{legendItems.map((it) => (
<button
key={it.key}
type="button"
className={hidden.has(it.key) ? "lw-legend-item is-off" : "lw-legend-item"}
onClick={() =>
setHidden((prev) => {
const next = new Set(prev);
if (next.has(it.key)) next.delete(it.key);
else next.add(it.key);
return next;
})
}
aria-pressed={!hidden.has(it.key)}
title={`点击${hidden.has(it.key) ? "显示" : "隐藏"}「${it.label}」`}
>
<i style={{ background: it.color }} />
<span>{it.label}</span>
{typeof it.last === "number" && <b className="mono">{fmt(it.last)}</b>}
</button>
))}
</div>
)}
<div className="lw-canvas" style={{ height }}>
<div
ref={boxRef}
style={{ height, width: "100%" }}
role="img"
aria-label={ariaLabel ?? series.map((s) => s.label).join(" / ")}
/>
{tip && (
<div
className="lw-tip"
style={{
left: Math.max(8, Math.min(tip.x + 14, (boxRef.current?.clientWidth ?? 300) - 150)),
top: Math.max(8, tip.y - 12),
}}
>
<div className="lw-tip-time mono">{tip.time}</div>
{tip.rows.map((r) => (
<div key={r.label} className="lw-tip-row">
<i style={{ background: r.color }} />
<span className="lw-tip-label">{r.label}</span>
<b className="mono">{r.text}</b>
</div>
))}
</div>
)}
</div>
{!hasData && <div className="hint">{emptyHint}</div>}
</div>
);
}