feat: 详情页K线增加技术指标 MA/MACD/RSI/KDJ

- 新增 indicators.ts 指标计算库:SMA/EMA/MACD(12,26,9)/RSI(14)/KDJ(9,3,3)
- KLineChart 支持指标开关:MA(5/10/20/30) 主图叠加、MACD/RSI/KDJ 独立副图 pane
- 图表高度随指标 pane 数量动态增长(主图 300 + 每个副图 95),主图不被压缩
- 指标全部前端本地计算,无需后端
This commit is contained in:
Sakurasan
2026-08-28 12:05:37 +08:00
parent 25c179dc06
commit 0743014f1a
3 changed files with 344 additions and 24 deletions
+182 -24
View File
@@ -1,6 +1,7 @@
/**
* K 线图组件(基于 TradingView Lightweight Charts v5)
* 支持折线/蜡烛切换、成交量副图、自选日标记。
* 主图:蜡烛/折线 + MA(5/10/20/30);
* 副图:成交量(pane1)、MACD(pane2)、RSI(pane3)、KDJ(pane4),可开关。
*/
import { useEffect, useRef } from "react";
import {
@@ -14,8 +15,10 @@ import {
createSeriesMarkers,
type IChartApi,
type ISeriesApi,
type ISeriesApi as ISeriesAny,
type Time,
} from "lightweight-charts";
import { sma, macd as calcMacd, rsi as calcRsi, kdj as calcKdj, type KBar } from "@/lib/indicators";
export interface KLineItem {
time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需)
@@ -27,36 +30,66 @@ export interface KLineItem {
isAddedDate?: boolean;
}
export interface IndicatorToggles {
ma: boolean;
macd: boolean;
rsi: boolean;
kdj: boolean;
}
interface Props {
data: KLineItem[];
mode: "line" | "candle";
hasAddedDate?: boolean; // 是否在自选集合中(决定是否显示标记)
hasAddedDate?: boolean;
indicators: IndicatorToggles;
}
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
// 涨红跌绿 + 中性灰边框,亮色模式为主(暗色下亦可读)。
const UP = "#ef4444";
const DOWN = "#22c55e";
const PRIMARY = "#ef4444";
const TEXT = "#71717a"; // muted-foreground 近似灰
const GRID = "#e4e4e7"; // border 近似灰
const MARKER = "#f59e0b"; // 自选日标记
const TEXT = "#71717a";
const GRID = "#e4e4e7";
const MARKER = "#f59e0b";
export function KLineChart({ data, mode, hasAddedDate }: Props) {
// MA / 指标线配色
const MA_COLORS = ["#f59e0b", "#3b82f6", "#a855f7", "#10b981"];
const MA_PERIODS = [5, 10, 20, 30];
const MACD_DIF = "#3b82f6";
const MACD_DEA = "#f59e0b";
const RSI_COLOR = "#a855f7";
const KDJ_K = "#3b82f6";
const KDJ_D = "#f59e0b";
const KDJ_J = "#a855f7";
export function KLineChart({ data, mode, hasAddedDate, indicators }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | null>(null);
// 指标 series(重建用)
const maSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
const macdSeriesRef = useRef<ISeriesAny<"Line" | "Histogram">[]>([]);
const rsiSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
const kdjSeriesRef = useRef<ISeriesAny<"Line">[]>([]);
// 创建图表(仅一次)
// 容器高度随指标 pane 数量增长,避免主图被压缩
const extraPanes = [indicators.macd, indicators.rsi, indicators.kdj].filter(Boolean).length;
// 固定图表高度:主图+成交量 300,每个指标副图 +95
const chartHeight = 300 + extraPanes * 95;
// 创建图表(仅一次):pane0 主图 + pane1 成交量
useEffect(() => {
if (!containerRef.current) return;
const chart = createChart(containerRef.current, {
autoSize: true,
const el = containerRef.current;
const chart = createChart(el, {
width: el.clientWidth || 800,
height: chartHeight,
layout: {
background: { type: ColorType.Solid, color: "transparent" },
textColor: TEXT,
fontSize: 10,
panes: { separatorColor: GRID, separatorHoverColor: "#94a3b8" },
},
grid: {
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
@@ -68,14 +101,11 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
});
chartRef.current = chart;
// 成交量副图(pane 1)
const vol = chart.addSeries(HistogramSeries, {
priceFormat: { type: "volume" },
priceScaleId: "vol",
});
vol.priceScale().applyOptions({
scaleMargins: { top: 0.8, bottom: 0 },
});
vol.priceScale().applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
volSeriesRef.current = vol;
return () => {
@@ -83,14 +113,140 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
chartRef.current = null;
priceSeriesRef.current = null;
volSeriesRef.current = null;
maSeriesRef.current = [];
macdSeriesRef.current = [];
rsiSeriesRef.current = [];
kdjSeriesRef.current = [];
};
}, []);
// 模式切换:重建价格 series
// 移除某组 series
function removeGroup(list: ISeriesAny<"Line" | "Histogram">[], chart: IChartApi) {
for (const s of list) {
try {
chart.removeSeries(s);
} catch {
// 已被移除
}
}
list.length = 0;
}
// 填充/重建全部指标 series
function rebuildIndicators() {
const chart = chartRef.current;
if (!chart) return;
// 指标开关改变容器高度后,同步图表尺寸
if (containerRef.current) {
chart.applyOptions({
width: containerRef.current.clientWidth || 800,
height: chartHeight,
});
}
const bars: KBar[] = data.map((d) => ({
time: d.time,
open: d.open,
close: d.close,
high: d.high,
low: d.low,
volume: d.volume,
}));
const closes = bars.map((b) => b.close);
// --- MA(主图 pane0 叠加)---
removeGroup(maSeriesRef.current, chart);
if (indicators.ma && bars.length > 0) {
MA_PERIODS.forEach((p, i) => {
const vals = sma(closes, p);
const s = chart.addSeries(
LineSeries,
{ color: MA_COLORS[i % MA_COLORS.length], lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
0,
);
s.setData(
bars
.map((b, idx) => ({ time: b.time as Time, value: vals[idx] }))
.filter((x) => x.value != null) as { time: Time; value: number }[],
);
maSeriesRef.current.push(s);
});
}
// --- MACD(pane2)---
removeGroup(macdSeriesRef.current, chart);
if (indicators.macd && bars.length > 0) {
const { dif, dea, hist } = calcMacd(closes);
const line = (color: string) =>
chart.addSeries(
LineSeries,
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
2,
);
const difS = line(MACD_DIF);
const deaS = line(MACD_DEA);
difS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dif[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
deaS.setData(bars.map((b, i) => ({ time: b.time as Time, value: dea[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
const histS = chart.addSeries(
HistogramSeries,
{
priceLineVisible: false,
lastValueVisible: false,
},
2,
);
histS.setData(
bars
.map((b, i) => ({
time: b.time as Time,
value: hist[i],
color: (hist[i] ?? 0) >= 0 ? UP : DOWN,
}))
.filter((x) => x.value != null) as { time: Time; value: number; color: string }[],
);
macdSeriesRef.current.push(difS, deaS, histS);
}
// --- RSI(pane3)---
removeGroup(rsiSeriesRef.current, chart);
if (indicators.rsi && bars.length > 0) {
const vals = calcRsi(closes, 14);
const s = chart.addSeries(
LineSeries,
{ color: RSI_COLOR, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
3,
);
s.setData(
bars
.map((b, i) => ({ time: b.time as Time, value: vals[i] }))
.filter((x) => x.value != null) as { time: Time; value: number }[],
);
rsiSeriesRef.current.push(s);
}
// --- KDJ(pane4)---
removeGroup(kdjSeriesRef.current, chart);
if (indicators.kdj && bars.length > 0) {
const { k, d, j } = calcKdj(bars);
const mk = (color: string) =>
chart.addSeries(
LineSeries,
{ color, lineWidth: 1, priceLineVisible: false, lastValueVisible: false },
4,
);
const kS = mk(KDJ_K);
const dS = mk(KDJ_D);
const jS = mk(KDJ_J);
kS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: k[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
dS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: d[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
jS.setData(bars.map((_, i) => ({ time: bars[i].time as Time, value: j[i] })).filter((x) => x.value != null) as { time: Time; value: number }[]);
kdjSeriesRef.current.push(kS, dS, jS);
}
}
// 模式切换:重建价格 series + 指标
useEffect(() => {
const chart = chartRef.current;
if (!chart) return;
// 移除旧的价格 series
if (priceSeriesRef.current) {
chart.removeSeries(priceSeriesRef.current);
priceSeriesRef.current = null;
@@ -110,16 +266,16 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
lineWidth: 2,
});
}
// 数据变更后重新填充
fillData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
// 数据更新
// 数据或指标开关变化:重填数据 + 重建指标
useEffect(() => {
fillData();
rebuildIndicators();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, hasAddedDate]);
}, [data, hasAddedDate, indicators]);
function fillData() {
const chart = chartRef.current;
@@ -133,7 +289,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
return;
}
// 蜡烛/折线数据
if (mode === "candle") {
(ps as ISeriesApi<"Candlestick">).setData(
data.map((d) => ({
@@ -150,7 +305,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
);
}
// 成交量柱(涨红跌绿,按当日开收)
vs.setData(
data.map((d) => ({
time: d.time as Time,
@@ -159,7 +313,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
})),
);
// 自选日标记
const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined;
if (markerItem) {
createSeriesMarkers(ps, [
@@ -175,11 +328,16 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
createSeriesMarkers(ps, []);
}
// 自适应可见范围
chart.timeScale().fitContent();
}
return <div ref={containerRef} className="h-[320px] sm:h-[380px] md:h-[440px] w-full" />;
return (
<div
ref={containerRef}
style={{ height: `${chartHeight}px` }}
className="w-full transition-[height] duration-200"
/>
);
}
export default KLineChart;