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:
+182
-24
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user