Files
qlib/frontend/web/components/StockChart/CandleChart.tsx
T
Simon 03fb463216 feat(web): K线图库对比 —— TradingView Lightweight Charts 实现 + 双库切换
- 按确认方案「两套实现并存对比」:
  - npm 依赖 lightweight-charts@4.2.3(v3 §21 推荐库)
  - components/StockChart/chartTypes.ts:共享 props 接口
  - CandleChart.tsx(ECharts 版,重构复用共享类型)+ CandleChartLW.tsx
    (Lightweight 版:蜡烛+成交量副刻度+MA+成交实心箭头/未成交信号圆点 markers)
  - StockChart.tsx wrapper;个股页顶部「ECharts / Lightweight」切换(默认 Lightweight)
- /stocks/600519.SH 冒烟 200;tsc --noEmit 通过
2026-09-09 07:21:07 +08:00

201 lines
6.2 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";
/**
* K 线图(v3 §20 Chart):蜡烛图 + 成交量 + MA 指标 + 事件标记。
*
* 标记语义(v3 §20.3 Signal↔Fill 严格区分):
* - fills(实际成交):实心标记 —— fill_buy 红色 ▲ / fill_sell 绿色 ▼
* - signals(未成交意图):空心标记 —— signal_buy 红圈 ▲ / signal_sell 绿圈
* 组件只做展示,绝不自行计算(数据来自后端 Chart API)。
*
* 基于 ECharts candlestick(ECharts 实现);另一套实现见 CandleChartLW.tsx(TradingView
* Lightweight Charts),两套共用同一 props 接口(chartTypes.ts)供对比。
*/
import { useEffect, useRef } from "react";
import * as echarts from "echarts/core";
import { BarChart, CandlestickChart, LineChart, ScatterChart } from "echarts/charts";
import { GridComponent, TooltipComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import type { ChartMarker } from "@/lib/types";
import type { CandleChartProps } from "./chartTypes";
export type { CandleDatum } from "./chartTypes";
echarts.use([
CandlestickChart,
LineChart,
BarChart,
ScatterChart,
GridComponent,
TooltipComponent,
CanvasRenderer,
]);
const C = {
faint: "#8b98b2",
line: "rgba(150,165,195,0.13)",
tooltipBg: "#141d30",
tooltipBorder: "rgba(150,165,195,0.28)",
up: "#e5484d",
down: "#2fb36b",
ma20: "#3fb6ff",
ma60: "#d4a72c",
};
export function CandleChart({
data,
volume = [],
indicators = {},
fills = [],
signals = [],
height = 480,
name = "",
}: CandleChartProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current || data.length === 0) return;
const chart = echarts.init(ref.current);
const dates = data.map((d) => d.time);
const vol = volume ?? [];
// echarts option 结构较宽,series 用宽松类型(组件层实现细节)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const series: any[] = [
{
name: "K线",
type: "candlestick",
data: data.map((d) => [d.open, d.close, d.low, d.high]),
itemStyle: {
color: C.up, // 阳线(收盘>开盘)红涨
color0: C.down,
borderColor: C.up,
borderColor0: C.down,
},
},
];
for (const [name, pts] of Object.entries(indicators)) {
const vals = dates.map((t) => {
const p = pts.find((x) => x.time === t);
return p?.value ?? null;
});
series.push({
name,
type: "line",
data: vals,
smooth: false,
showSymbol: false,
lineStyle: { width: 1.2 },
itemStyle: { color: C[name as keyof typeof C] ?? "#b98bff" },
connectNulls: true,
});
}
// 成交标记(实心)与未成交信号(空心):以散点叠加在价格坐标
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mk = (m: ChartMarker, filled: boolean): any => {
const buy = m.kind.endsWith("buy");
return {
value: [m.time, m.price ?? 0],
symbol: filled ? (buy ? "triangle" : "triangle") : buy ? "triangle" : "triangle",
symbolRotate: buy ? 0 : 180,
symbolSize: 13,
itemStyle: {
color: filled ? (buy ? C.up : C.down) : "transparent",
borderColor: buy ? C.up : C.down,
borderWidth: 1.6,
},
tooltip: { formatter: m.text.join("<br/>") },
};
};
series.push(
{ name: "成交", type: "scatter", data: fills.map((m) => mk(m, true)), z: 6 },
{ name: "信号(未成交)", type: "scatter", data: signals.map((m) => mk(m, false)), z: 5 }
);
const hasVol = vol.length > 0;
const mainGridBottom = hasVol ? 58 : 14;
series.push({
name: "成交量",
type: "bar",
xAxisIndex: 1,
yAxisIndex: 1,
data: dates.map((t) => {
const p = vol.find((x) => x.time === t);
return p?.value ?? 0;
}),
itemStyle: { color: "rgba(150,165,195,0.35)" },
});
chart.setOption({
backgroundColor: "transparent",
tooltip: {
trigger: "axis",
axisPointer: { type: "cross" },
backgroundColor: C.tooltipBg,
borderColor: C.tooltipBorder,
textStyle: { color: "#e9eef7", fontSize: 12 },
},
axisPointer: { link: [{ xAxisIndex: "all" }] },
grid: [
{ left: 8, right: 18, top: 16, height: hasVol ? "62%" : "82%", containLabel: true },
...(hasVol
? [{ left: 8, right: 18, top: hasVol ? "76%" : undefined, height: "14%", containLabel: true }]
: []),
],
xAxis: [
{
type: "category",
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: { color: C.faint, fontSize: 10 },
splitLine: { show: false },
},
...(hasVol
? [{
type: "category",
gridIndex: 1,
data: dates,
boundaryGap: true,
axisLine: { lineStyle: { color: C.line } },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: false },
}]
: []),
],
yAxis: [
{
type: "value",
scale: true,
splitNumber: 4,
axisLabel: { color: C.faint, fontSize: 10 },
splitLine: { lineStyle: { color: C.line } },
},
...(hasVol
? [{
type: "value",
gridIndex: 1,
scale: true,
axisLabel: { color: C.faint, fontSize: 9 },
splitLine: { show: false },
}]
: []),
],
series,
legend: {
show: false,
},
});
const onResize = () => chart.resize();
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
chart.dispose();
};
}, [data, volume, indicators, fills, signals, height]);
if (data.length === 0) return <div className="hint">暂无 K 线数据</div>;
return <div ref={ref} style={{ height, width: "100%" }} aria-label={`${name} K 线图`} />;
}