From 0743014f1a3871e84c85cade427f2362adf0535f Mon Sep 17 00:00:00 2001 From: Sakurasan <26715255+Sakurasan@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:05:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=A6=E6=83=85=E9=A1=B5K=E7=BA=BF?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=8A=80=E6=9C=AF=E6=8C=87=E6=A0=87=20MA/MAC?= =?UTF-8?q?D/RSI/KDJ?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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),主图不被压缩 - 指标全部前端本地计算,无需后端 --- src/components/kline-chart.tsx | 206 +++++++++++++++++++++++++++++---- src/lib/indicators.ts | 139 ++++++++++++++++++++++ src/routes/stock.$code.tsx | 23 ++++ 3 files changed, 344 insertions(+), 24 deletions(-) create mode 100644 src/lib/indicators.ts diff --git a/src/components/kline-chart.tsx b/src/components/kline-chart.tsx index 77957cb..0d7ab16 100644 --- a/src/components/kline-chart.tsx +++ b/src/components/kline-chart.tsx @@ -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(null); const chartRef = useRef(null); const priceSeriesRef = useRef | null>(null); const volSeriesRef = useRef | null>(null); + // 指标 series(重建用) + const maSeriesRef = useRef[]>([]); + const macdSeriesRef = useRef[]>([]); + const rsiSeriesRef = useRef[]>([]); + const kdjSeriesRef = useRef[]>([]); - // 创建图表(仅一次) + // 容器高度随指标 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
; + return ( +
+ ); } export default KLineChart; diff --git a/src/lib/indicators.ts b/src/lib/indicators.ts new file mode 100644 index 0000000..3d4e579 --- /dev/null +++ b/src/lib/indicators.ts @@ -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 }; +} diff --git a/src/routes/stock.$code.tsx b/src/routes/stock.$code.tsx index d610d4d..310135c 100755 --- a/src/routes/stock.$code.tsx +++ b/src/routes/stock.$code.tsx @@ -87,6 +87,9 @@ function StockDetail() { const [dailyTableDays, setDailyTableDays] = useState(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(null); const [inCollection, setInCollection] = useState(false); @@ -542,6 +545,25 @@ function StockDetail() { ))}
+ {/* 指标开关:MA / MACD / RSI / KDJ */} +
+ {([ + { key: "ma", label: "MA" }, + { key: "macd", label: "MACD" }, + { key: "rsi", label: "RSI" }, + { key: "kdj", label: "KDJ" }, + ] as const).map((it) => ( + + ))} +
{/* 时间范围:3月 / 6月 / 1年 */}
{[ @@ -588,6 +610,7 @@ function StockDetail() { }))} mode={chartMode} hasAddedDate={inCollection} + indicators={indicators} /> )}