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)
|
* K 线图组件(基于 TradingView Lightweight Charts v5)
|
||||||
* 支持折线/蜡烛切换、成交量副图、自选日标记。
|
* 主图:蜡烛/折线 + MA(5/10/20/30);
|
||||||
|
* 副图:成交量(pane1)、MACD(pane2)、RSI(pane3)、KDJ(pane4),可开关。
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -14,8 +15,10 @@ import {
|
|||||||
createSeriesMarkers,
|
createSeriesMarkers,
|
||||||
type IChartApi,
|
type IChartApi,
|
||||||
type ISeriesApi,
|
type ISeriesApi,
|
||||||
|
type ISeriesApi as ISeriesAny,
|
||||||
type Time,
|
type Time,
|
||||||
} from "lightweight-charts";
|
} from "lightweight-charts";
|
||||||
|
import { sma, macd as calcMacd, rsi as calcRsi, kdj as calcKdj, type KBar } from "@/lib/indicators";
|
||||||
|
|
||||||
export interface KLineItem {
|
export interface KLineItem {
|
||||||
time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需)
|
time: string; // ISO 日期 yyyy-mm-dd(lightweight-charts 必需)
|
||||||
@@ -27,36 +30,66 @@ export interface KLineItem {
|
|||||||
isAddedDate?: boolean;
|
isAddedDate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IndicatorToggles {
|
||||||
|
ma: boolean;
|
||||||
|
macd: boolean;
|
||||||
|
rsi: boolean;
|
||||||
|
kdj: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: KLineItem[];
|
data: KLineItem[];
|
||||||
mode: "line" | "candle";
|
mode: "line" | "candle";
|
||||||
hasAddedDate?: boolean; // 是否在自选集合中(决定是否显示标记)
|
hasAddedDate?: boolean;
|
||||||
|
indicators: IndicatorToggles;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
// lightweight-charts 无法解析 CSS 变量或 oklch() 颜色,直接用具体 hex 色。
|
||||||
// 涨红跌绿 + 中性灰边框,亮色模式为主(暗色下亦可读)。
|
|
||||||
const UP = "#ef4444";
|
const UP = "#ef4444";
|
||||||
const DOWN = "#22c55e";
|
const DOWN = "#22c55e";
|
||||||
const PRIMARY = "#ef4444";
|
const PRIMARY = "#ef4444";
|
||||||
const TEXT = "#71717a"; // muted-foreground 近似灰
|
const TEXT = "#71717a";
|
||||||
const GRID = "#e4e4e7"; // border 近似灰
|
const GRID = "#e4e4e7";
|
||||||
const MARKER = "#f59e0b"; // 自选日标记
|
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 containerRef = useRef<HTMLDivElement>(null);
|
||||||
const chartRef = useRef<IChartApi | null>(null);
|
const chartRef = useRef<IChartApi | null>(null);
|
||||||
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
const priceSeriesRef = useRef<ISeriesApi<"Candlestick" | "Line"> | null>(null);
|
||||||
const volSeriesRef = useRef<ISeriesApi<"Histogram"> | 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(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const chart = createChart(containerRef.current, {
|
const el = containerRef.current;
|
||||||
autoSize: true,
|
const chart = createChart(el, {
|
||||||
|
width: el.clientWidth || 800,
|
||||||
|
height: chartHeight,
|
||||||
layout: {
|
layout: {
|
||||||
background: { type: ColorType.Solid, color: "transparent" },
|
background: { type: ColorType.Solid, color: "transparent" },
|
||||||
textColor: TEXT,
|
textColor: TEXT,
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
|
panes: { separatorColor: GRID, separatorHoverColor: "#94a3b8" },
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
vertLines: { color: GRID, style: LineStyle.Dashed, visible: true },
|
||||||
@@ -68,14 +101,11 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
|||||||
});
|
});
|
||||||
chartRef.current = chart;
|
chartRef.current = chart;
|
||||||
|
|
||||||
// 成交量副图(pane 1)
|
|
||||||
const vol = chart.addSeries(HistogramSeries, {
|
const vol = chart.addSeries(HistogramSeries, {
|
||||||
priceFormat: { type: "volume" },
|
priceFormat: { type: "volume" },
|
||||||
priceScaleId: "vol",
|
priceScaleId: "vol",
|
||||||
});
|
});
|
||||||
vol.priceScale().applyOptions({
|
vol.priceScale().applyOptions({ scaleMargins: { top: 0.8, bottom: 0 } });
|
||||||
scaleMargins: { top: 0.8, bottom: 0 },
|
|
||||||
});
|
|
||||||
volSeriesRef.current = vol;
|
volSeriesRef.current = vol;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -83,14 +113,140 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
|||||||
chartRef.current = null;
|
chartRef.current = null;
|
||||||
priceSeriesRef.current = null;
|
priceSeriesRef.current = null;
|
||||||
volSeriesRef.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(() => {
|
useEffect(() => {
|
||||||
const chart = chartRef.current;
|
const chart = chartRef.current;
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
// 移除旧的价格 series
|
|
||||||
if (priceSeriesRef.current) {
|
if (priceSeriesRef.current) {
|
||||||
chart.removeSeries(priceSeriesRef.current);
|
chart.removeSeries(priceSeriesRef.current);
|
||||||
priceSeriesRef.current = null;
|
priceSeriesRef.current = null;
|
||||||
@@ -110,16 +266,16 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
|||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 数据变更后重新填充
|
|
||||||
fillData();
|
fillData();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [mode]);
|
}, [mode]);
|
||||||
|
|
||||||
// 数据更新
|
// 数据或指标开关变化:重填数据 + 重建指标
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fillData();
|
fillData();
|
||||||
|
rebuildIndicators();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [data, hasAddedDate]);
|
}, [data, hasAddedDate, indicators]);
|
||||||
|
|
||||||
function fillData() {
|
function fillData() {
|
||||||
const chart = chartRef.current;
|
const chart = chartRef.current;
|
||||||
@@ -133,7 +289,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 蜡烛/折线数据
|
|
||||||
if (mode === "candle") {
|
if (mode === "candle") {
|
||||||
(ps as ISeriesApi<"Candlestick">).setData(
|
(ps as ISeriesApi<"Candlestick">).setData(
|
||||||
data.map((d) => ({
|
data.map((d) => ({
|
||||||
@@ -150,7 +305,6 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成交量柱(涨红跌绿,按当日开收)
|
|
||||||
vs.setData(
|
vs.setData(
|
||||||
data.map((d) => ({
|
data.map((d) => ({
|
||||||
time: d.time as Time,
|
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;
|
const markerItem = hasAddedDate ? data.find((d) => d.isAddedDate) : undefined;
|
||||||
if (markerItem) {
|
if (markerItem) {
|
||||||
createSeriesMarkers(ps, [
|
createSeriesMarkers(ps, [
|
||||||
@@ -175,11 +328,16 @@ export function KLineChart({ data, mode, hasAddedDate }: Props) {
|
|||||||
createSeriesMarkers(ps, []);
|
createSeriesMarkers(ps, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自适应可见范围
|
|
||||||
chart.timeScale().fitContent();
|
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;
|
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 [dailyTableDays, setDailyTableDays] = useState<number>(7);
|
||||||
const [chartRange, setChartRange] = useState(90); // 默认近3月
|
const [chartRange, setChartRange] = useState(90); // 默认近3月
|
||||||
const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); // 折线/蜡烛,默认蜡烛
|
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 [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [inCollection, setInCollection] = useState(false);
|
const [inCollection, setInCollection] = useState(false);
|
||||||
@@ -542,6 +545,25 @@ function StockDetail() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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年 */}
|
{/* 时间范围:3月 / 6月 / 1年 */}
|
||||||
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
|
||||||
{[
|
{[
|
||||||
@@ -588,6 +610,7 @@ function StockDetail() {
|
|||||||
}))}
|
}))}
|
||||||
mode={chartMode}
|
mode={chartMode}
|
||||||
hasAddedDate={inCollection}
|
hasAddedDate={inCollection}
|
||||||
|
indicators={indicators}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user