Files
qlib/frontend/web/components/charts/LwChart.tsx
T
Simon 23972e7063 feat: 股息率案例口径 + 策略库与图表统一 + 回测存档完整化
汇总三轮未提交的开发(每轮均在本机 MariaDB + 真实浏览器上验证):

1) 股息率案例(全市场股息率最高 n 只,默认 20,每 m 月择股)
   - 新增日频估值表 daily_basic + 迁移;股息率因子(dv_ratio / dividend_yield / TTM)
   - 名称历史表 stock_name_history:剔除 ST 按**择股日当时名称**判定,消除
     「曾高股息后 ST」的股息陷阱(实测 3.70pp 偏差)
   - 区间择股/调仓双周期(m 择股 / y 调仓)、指数成分与白名单、停牌近似剔除
   - 复权因子口径核对(4,164,742 行、缺失 0.0%)、收盘价成交与涨跌停拦单
   - 案例实测:2020-01-01~2026-09-04 总收益 +24.86%(年化 3.52%、回撤 -28.58%)

2) 策略库与前端统一
   - strategy 表 + CRUD/PUT 原地更新 + `describe_strategy` 按 spec 真实推导
     「一句话说明 + 计算公式 + 执行步骤 + 注意事项」(与引擎实执行规则同源)
   - 任何出现股票代码处都成对显示名称且可点击进个股页
   - 全站图表基座统一 TradingView Lightweight Charts(ECharts 依赖、
     锁文件、组件与文档标注一并清除),买卖点标记只落在真实交易日上

3) 回测存档完整化(可往复查看)
   - 同步端点(POST /api/backtests、/api/factor-tests)此前完全不落库 → 现在同样归档,
     归档 id 经响应头 X-Experiment-Id 返回(不破坏 response_model)
   - data_version 首次真实写入(数据快照指纹:最新交易日 + 各表规模)
   - 个股收益曲线默认**全量保存**(此前硬截断 60 只);超出体积预算才裁剪,
     并写 archive_meta(机器可读)+ unimplemented(人可读)如实标注
   - 列表 kind/q 过滤 + X-Total-Count(此前 limit=50 静默截断)、DELETE 归档
   - 只读归档页 /experiments/{id}(Server Component,SSR 直出**选股条件**与
     **交易执行依据**);结果视图按 kind 分发(backtest/factor_test/selection),
     非回测归档不套用回测口径
   - 新增 CLI:prune_experiments(保留策略,默认 dry-run)、
     restore_experiment_from_job(从 Job 副本按原 id 重建被删的历史归档,默认 dry-run)

门禁:pytest 388 passed、ruff All checks passed、tsc 0 错误、图表单测 7 passed、
next build 成功、契约脚本 verify_strategy_workspace 59/59(含按 kind 逐类验证归档页)。
2026-09-20 07:31:04 +08:00

382 lines
13 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;
/** 悬浮提示与价格轴的数值格式 */
valueFormat?: (v: number) => string;
/** 画一条 0 基准虚线(收益率曲线推荐开启) */
zeroLine?: boolean;
legend?: boolean;
ariaLabel?: string;
emptyHint?: string;
}
type AnySeries = ISeriesApi<"Line"> | ISeriesApi<"Area"> | ISeriesApi<"Histogram">;
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,
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 fmtRef = useRef(valueFormat);
fmtRef.current = valueFormat;
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>
);
}