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;
+139
View File
@@ -0,0 +1,139 @@
/**
* 技术指标计算(纯函数,前端本地计算)
* 输入 K 线数组(时间升序),输出与输入等长、前段为 null 的指标序列。
*/
export interface KBar {
time: string;
open: number;
close: number;
high: number;
low: number;
volume: number;
}
export type Nums = (number | null)[];
/** 简单移动平均 */
export function sma(closes: number[], period: number): Nums {
const out: Nums = new Array(closes.length).fill(null);
let sum = 0;
for (let i = 0; i < closes.length; i++) {
sum += closes[i];
if (i >= period) sum -= closes[i - period];
if (i >= period - 1) out[i] = sum / period;
}
return out;
}
/** EMA(标准 MACD 用的指数平滑) */
export function ema(values: number[], period: number): Nums {
const out: Nums = new Array(values.length).fill(null);
const k = 2 / (period + 1);
let prev: number | null = null;
for (let i = 0; i < values.length; i++) {
if (i === period - 1) {
// 首值取前 period 个的 SMA
let s = 0;
for (let j = 0; j < period; j++) s += values[j];
prev = s / period;
out[i] = prev;
} else if (prev !== null) {
prev = values[i] * k + prev * (1 - k);
out[i] = prev;
}
}
return out;
}
/** MACD(12,26,9):返回 DIF、DEA、MACD 柱(柱 = (DIF-DEA)*2,国内口径) */
export function macd(
closes: number[],
fast = 12,
slow = 26,
signal = 9,
): { dif: Nums; dea: Nums; hist: Nums } {
const ef = ema(closes, fast);
const es = ema(closes, slow);
const dif: Nums = closes.map((_, i) =>
ef[i] != null && es[i] != null ? (ef[i] as number) - (es[i] as number) : null,
);
// DEA = DIF 的 9 日 EMA(跳过 null)
const dea: Nums = new Array(closes.length).fill(null);
const difVals: number[] = [];
const difIdx: number[] = [];
for (let i = 0; i < dif.length; i++) {
if (dif[i] != null) {
difVals.push(dif[i] as number);
difIdx.push(i);
}
}
const deaVals = ema(difVals, signal);
for (let j = 0; j < difIdx.length; j++) {
if (deaVals[j] != null) dea[difIdx[j]] = deaVals[j];
}
const hist: Nums = closes.map((_, i) =>
dif[i] != null && dea[i] != null ? ((dif[i] as number) - (dea[i] as number)) * 2 : null,
);
return { dif, dea, hist };
}
/** RSI(Wilder 平滑,国内软件常用 SMA(X,N,1) 等价 Wilder) */
export function rsi(closes: number[], period = 14): Nums {
const out: Nums = new Array(closes.length).fill(null);
if (closes.length <= period) return out;
let avgGain = 0;
let avgLoss = 0;
// 首段:前 period 个变动的简单均值
for (let i = 1; i <= period; i++) {
const ch = closes[i] - closes[i - 1];
if (ch > 0) avgGain += ch;
else avgLoss -= ch;
}
avgGain /= period;
avgLoss /= period;
out[period] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
// Wilder 平滑
for (let i = period + 1; i < closes.length; i++) {
const ch = closes[i] - closes[i - 1];
const gain = ch > 0 ? ch : 0;
const loss = ch < 0 ? -ch : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
out[i] = avgLoss === 0 ? 100 : 100 - 100 / (1 + avgGain / avgLoss);
}
return out;
}
/** KDJ(9,3,3) */
export function kdj(
bars: KBar[],
n = 9,
m1 = 3,
m2 = 3,
): { k: Nums; d: Nums; j: Nums } {
const len = bars.length;
const k: Nums = new Array(len).fill(null);
const d: Nums = new Array(len).fill(null);
const j: Nums = new Array(len).fill(null);
let prevK = 50;
let prevD = 50;
for (let i = 0; i < len; i++) {
const from = Math.max(0, i - n + 1);
let hh = -Infinity;
let ll = Infinity;
for (let x = from; x <= i; x++) {
hh = Math.max(hh, bars[x].high);
ll = Math.min(ll, bars[x].low);
}
const rsv = hh === ll ? 50 : ((bars[i].close - ll) / (hh - ll)) * 100;
const kv = (2 * prevK + rsv) / m1; // 国内口径 K = 2/3 前值 + 1/3 RSV
const dv = (2 * prevD + kv) / m2;
prevK = kv;
prevD = dv;
k[i] = kv;
d[i] = dv;
j[i] = 3 * kv - 2 * dv;
}
return { k, d, j };
}
+23
View File
@@ -87,6 +87,9 @@ function StockDetail() {
const [dailyTableDays, setDailyTableDays] = useState<number>(7);
const [chartRange, setChartRange] = useState(90); // 默认近3月
const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); // 折线/蜡烛,默认蜡烛
const [indicators, setIndicators] = useState({ ma: true, macd: true, rsi: false, kdj: false });
const toggleIndicator = (key: keyof typeof indicators) =>
setIndicators((prev) => ({ ...prev, [key]: !prev[key] }));
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [inCollection, setInCollection] = useState(false);
@@ -542,6 +545,25 @@ function StockDetail() {
</button>
))}
</div>
{/* 指标开关:MA / MACD / RSI / KDJ */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ key: "ma", label: "MA" },
{ key: "macd", label: "MACD" },
{ key: "rsi", label: "RSI" },
{ key: "kdj", label: "KDJ" },
] as const).map((it) => (
<button
key={it.key}
onClick={() => toggleIndicator(it.key)}
className={`px-2.5 py-1 transition-colors ${
indicators[it.key] ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{it.label}
</button>
))}
</div>
{/* 时间范围:3月 / 6月 / 1年 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{[
@@ -588,6 +610,7 @@ function StockDetail() {
}))}
mode={chartMode}
hasAddedDate={inCollection}
indicators={indicators}
/>
</>
)}