用户要求:「所有买卖点详细说明买卖理由,用数据说话」「回测图上增加因子相关曲线
(买卖依据是股息率,就加股息率曲线)」「所有曲线能弹出新页面放大」。
一、买卖理由(后端产出结构化数据,前端只展示)
- 新增 `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 渲染,截图确认表格与曲线数值正确。
414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
} |