From 6cb2742cf67f2edb3471d41752e9030088628812 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Wed, 17 Dec 2025 23:20:33 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0Company=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E7=9A=84UI=E4=B8=BAFUI=E9=A3=8E=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../panels/TradeDataPanel/KLineModule.tsx | 216 +++- .../MarketDataView/utils/chartOptions.ts | 1048 +++++++++++++---- 2 files changed, 977 insertions(+), 287 deletions(-) diff --git a/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx index 2e27deeb..67107e23 100644 --- a/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx +++ b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx @@ -1,5 +1,5 @@ // src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx -// K线模块 - 日K线/分钟K线切换展示(黑金主题) +// K线模块 - 日K线/分时图切换展示(黑金主题 + 专业技术指标) import React, { useState } from 'react'; import { @@ -14,13 +14,24 @@ import { Spinner, Icon, Select, + Menu, + MenuButton, + MenuList, + MenuItem, + MenuDivider, + Tooltip, } from '@chakra-ui/react'; -import { RepeatIcon, InfoIcon } from '@chakra-ui/icons'; -import { BarChart2, Clock, TrendingUp, Calendar } from 'lucide-react'; +import { RepeatIcon, InfoIcon, ChevronDownIcon } from '@chakra-ui/icons'; +import { BarChart2, Clock, TrendingUp, Calendar, LineChart, Activity } from 'lucide-react'; import ReactECharts from 'echarts-for-react'; import { darkGoldTheme, PERIOD_OPTIONS } from '../../../constants'; -import { getKLineDarkGoldOption, getMinuteKLineDarkGoldOption } from '../../../utils/chartOptions'; +import { + getKLineDarkGoldOption, + getMinuteKLineDarkGoldOption, + type IndicatorType, + type MainIndicatorType, +} from '../../../utils/chartOptions'; import type { KLineModuleProps } from '../../../types'; // 空状态组件(内联) @@ -41,6 +52,21 @@ export type { KLineModuleProps } from '../../../types'; type ChartMode = 'daily' | 'minute'; +// 副图指标选项 +const SUB_INDICATOR_OPTIONS: { value: IndicatorType; label: string; description: string }[] = [ + { value: 'MACD', label: 'MACD', description: '平滑异同移动平均线' }, + { value: 'KDJ', label: 'KDJ', description: '随机指标' }, + { value: 'RSI', label: 'RSI', description: '相对强弱指标' }, + { value: 'VOL', label: '仅成交量', description: '不显示副图指标' }, +]; + +// 主图指标选项 +const MAIN_INDICATOR_OPTIONS: { value: MainIndicatorType; label: string; description: string }[] = [ + { value: 'MA', label: 'MA均线', description: 'MA5/MA10/MA20' }, + { value: 'BOLL', label: '布林带', description: '布林通道指标' }, + { value: 'NONE', label: '无', description: '不显示主图指标' }, +]; + const KLineModule: React.FC = ({ theme, tradeData, @@ -53,9 +79,11 @@ const KLineModule: React.FC = ({ onPeriodChange, }) => { const [mode, setMode] = useState('daily'); + const [subIndicator, setSubIndicator] = useState('MACD'); + const [mainIndicator, setMainIndicator] = useState('MA'); const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0; - // 切换到分钟模式时自动加载数据 + // 切换到分时模式时自动加载数据 const handleModeChange = (newMode: ChartMode) => { setMode(newMode); if (newMode === 'minute' && !hasMinuteData && !minuteLoading) { @@ -91,14 +119,18 @@ const KLineModule: React.FC = ({ > {/* 卡片头部 */} - + - + {mode === 'daily' ? ( + + ) : ( + + )} = ({ bgGradient={`linear(to-r, ${darkGoldTheme.gold}, ${darkGoldTheme.orange})`} bgClip="text" > - {mode === 'daily' ? '日K线图' : '分钟K线图'} + {mode === 'daily' ? '日K线图' : '分时走势'} {mode === 'minute' && minuteData?.trade_date && ( = ({ )} - - {/* 日K模式下显示时间范围选择器 */} - {mode === 'daily' && onPeriodChange && ( - - - - + + {/* 日K模式下显示时间范围选择器和指标选择 */} + {mode === 'daily' && ( + <> + {/* 时间范围选择器 */} + {onPeriodChange && ( + + + + + )} + + {/* 主图指标选择 */} + + + } + {...inactiveButtonStyle} + minW="90px" + > + {MAIN_INDICATOR_OPTIONS.find(o => o.value === mainIndicator)?.label || 'MA均线'} + + + + {MAIN_INDICATOR_OPTIONS.map((option) => ( + setMainIndicator(option.value)} + bg={mainIndicator === option.value ? 'rgba(212, 175, 55, 0.2)' : 'transparent'} + color={mainIndicator === option.value ? darkGoldTheme.gold : darkGoldTheme.textPrimary} + _hover={{ bg: 'rgba(212, 175, 55, 0.1)' }} + > + + + {option.label} + + + {option.description} + + + + ))} + + + + {/* 副图指标选择 */} + + + } + leftIcon={} + {...inactiveButtonStyle} + minW="100px" + > + {SUB_INDICATOR_OPTIONS.find(o => o.value === subIndicator)?.label || 'MACD'} + + + + {SUB_INDICATOR_OPTIONS.map((option) => ( + setSubIndicator(option.value)} + bg={subIndicator === option.value ? 'rgba(212, 175, 55, 0.2)' : 'transparent'} + color={subIndicator === option.value ? darkGoldTheme.gold : darkGoldTheme.textPrimary} + _hover={{ bg: 'rgba(212, 175, 55, 0.1)' }} + > + + + {option.label} + + + {option.description} + + + + ))} + + + )} - {/* 分钟模式下的刷新按钮 */} + {/* 分时模式下的刷新按钮 */} {mode === 'minute' && ( @@ -192,23 +310,24 @@ const KLineModule: React.FC = ({ {/* 卡片内容 */} {mode === 'daily' ? ( - // 日K线图 + // 日K线图(带技术指标) tradeData.length > 0 ? ( - + ) : ( ) ) : ( - // 分钟K线图 + // 分时走势图 minuteLoading ? ( -
+
= ({ size="lg" /> - 加载分钟频数据中... + 加载分时数据中...
) : hasMinuteData ? ( - + ) : ( - + ) )} diff --git a/src/views/Company/components/MarketDataView/utils/chartOptions.ts b/src/views/Company/components/MarketDataView/utils/chartOptions.ts index 47a4ea46..e84d7a17 100644 --- a/src/views/Company/components/MarketDataView/utils/chartOptions.ts +++ b/src/views/Company/components/MarketDataView/utils/chartOptions.ts @@ -33,6 +33,223 @@ export const calculateMA = (data: number[], period: number): (number | null)[] = return result; }; +/** + * 计算EMA (指数移动平均线) + */ +export const calculateEMA = (data: number[], period: number): (number | null)[] => { + const result: (number | null)[] = []; + const multiplier = 2 / (period + 1); + + for (let i = 0; i < data.length; i++) { + if (i < period - 1) { + result.push(null); + continue; + } + if (i === period - 1) { + // 第一个EMA使用SMA + let sum = 0; + for (let j = 0; j < period; j++) { + sum += data[i - j]; + } + result.push(sum / period); + } else { + const prevEMA = result[i - 1] as number; + result.push((data[i] - prevEMA) * multiplier + prevEMA); + } + } + return result; +}; + +/** + * 计算MACD指标 + * @param closePrices 收盘价数组 + * @param fastPeriod 快线周期 (默认12) + * @param slowPeriod 慢线周期 (默认26) + * @param signalPeriod 信号线周期 (默认9) + */ +export const calculateMACD = ( + closePrices: number[], + fastPeriod = 12, + slowPeriod = 26, + signalPeriod = 9 +): { dif: (number | null)[]; dea: (number | null)[]; macd: (number | null)[] } => { + const emaFast = calculateEMA(closePrices, fastPeriod); + const emaSlow = calculateEMA(closePrices, slowPeriod); + + // DIF = 快线EMA - 慢线EMA + const dif: (number | null)[] = []; + for (let i = 0; i < closePrices.length; i++) { + if (emaFast[i] === null || emaSlow[i] === null) { + dif.push(null); + } else { + dif.push(emaFast[i]! - emaSlow[i]!); + } + } + + // DEA = DIF的EMA + const difValues = dif.filter(v => v !== null) as number[]; + const deaRaw = calculateEMA(difValues, signalPeriod); + const dea: (number | null)[] = []; + let deaIndex = 0; + for (let i = 0; i < dif.length; i++) { + if (dif[i] === null) { + dea.push(null); + } else { + dea.push(deaRaw[deaIndex] ?? null); + deaIndex++; + } + } + + // MACD柱 = (DIF - DEA) * 2 + const macd: (number | null)[] = []; + for (let i = 0; i < dif.length; i++) { + if (dif[i] === null || dea[i] === null) { + macd.push(null); + } else { + macd.push((dif[i]! - dea[i]!) * 2); + } + } + + return { dif, dea, macd }; +}; + +/** + * 计算KDJ指标 + * @param highPrices 最高价数组 + * @param lowPrices 最低价数组 + * @param closePrices 收盘价数组 + * @param period K周期 (默认9) + * @param kPeriod K平滑周期 (默认3) + * @param dPeriod D平滑周期 (默认3) + */ +export const calculateKDJ = ( + highPrices: number[], + lowPrices: number[], + closePrices: number[], + period = 9, + kPeriod = 3, + dPeriod = 3 +): { k: (number | null)[]; d: (number | null)[]; j: (number | null)[] } => { + const k: (number | null)[] = []; + const d: (number | null)[] = []; + const j: (number | null)[] = []; + + let prevK = 50; + let prevD = 50; + + for (let i = 0; i < closePrices.length; i++) { + if (i < period - 1) { + k.push(null); + d.push(null); + j.push(null); + continue; + } + + // 计算周期内最高和最低 + let highestHigh = -Infinity; + let lowestLow = Infinity; + for (let j = 0; j < period; j++) { + highestHigh = Math.max(highestHigh, highPrices[i - j]); + lowestLow = Math.min(lowestLow, lowPrices[i - j]); + } + + // RSV = (收盘价 - 最低价) / (最高价 - 最低价) * 100 + const rsv = highestHigh === lowestLow ? 50 : ((closePrices[i] - lowestLow) / (highestHigh - lowestLow)) * 100; + + // K = 2/3 * 前一日K + 1/3 * RSV + const curK = (2 / kPeriod) * prevK + (1 / kPeriod) * rsv; + // D = 2/3 * 前一日D + 1/3 * K + const curD = (2 / dPeriod) * prevD + (1 / dPeriod) * curK; + // J = 3K - 2D + const curJ = 3 * curK - 2 * curD; + + k.push(curK); + d.push(curD); + j.push(curJ); + + prevK = curK; + prevD = curD; + } + + return { k, d, j }; +}; + +/** + * 计算RSI指标 + * @param closePrices 收盘价数组 + * @param period RSI周期 (默认14) + */ +export const calculateRSI = (closePrices: number[], period = 14): (number | null)[] => { + const result: (number | null)[] = []; + + for (let i = 0; i < closePrices.length; i++) { + if (i < period) { + result.push(null); + continue; + } + + let gains = 0; + let losses = 0; + + for (let j = 0; j < period; j++) { + const change = closePrices[i - j] - closePrices[i - j - 1]; + if (change > 0) { + gains += change; + } else { + losses -= change; + } + } + + const avgGain = gains / period; + const avgLoss = losses / period; + + if (avgLoss === 0) { + result.push(100); + } else { + const rs = avgGain / avgLoss; + result.push(100 - (100 / (1 + rs))); + } + } + + return result; +}; + +/** + * 计算布林带 + * @param closePrices 收盘价数组 + * @param period 周期 (默认20) + * @param multiplier 标准差倍数 (默认2) + */ +export const calculateBOLL = ( + closePrices: number[], + period = 20, + multiplier = 2 +): { upper: (number | null)[]; middle: (number | null)[]; lower: (number | null)[] } => { + const middle = calculateMA(closePrices, period); + const upper: (number | null)[] = []; + const lower: (number | null)[] = []; + + for (let i = 0; i < closePrices.length; i++) { + if (middle[i] === null) { + upper.push(null); + lower.push(null); + continue; + } + + // 计算标准差 + let sum = 0; + for (let j = 0; j < period; j++) { + sum += Math.pow(closePrices[i - j] - middle[i]!, 2); + } + const std = Math.sqrt(sum / period); + + upper.push(middle[i]! + multiplier * std); + lower.push(middle[i]! - multiplier * std); + } + + return { upper, middle, lower }; +}; + /** * 生成日K线图配置 */ @@ -429,12 +646,24 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null }; }; +// 技术指标类型 +export type IndicatorType = 'VOL' | 'MACD' | 'KDJ' | 'RSI' | 'BOLL' | 'NONE'; + +// 主图指标类型 +export type MainIndicatorType = 'MA' | 'BOLL' | 'NONE'; + /** - * 生成日K线图配置 - 黑金主题 + * 生成日K线图配置 - 黑金主题(专业版 - 支持多技术指标) + * @param tradeData K线数据 + * @param analysisMap 涨幅分析数据 + * @param subIndicator 副图指标 (默认MACD) + * @param mainIndicator 主图指标 (默认MA) */ export const getKLineDarkGoldOption = ( tradeData: TradeDayData[], - analysisMap: Record + analysisMap: Record, + subIndicator: IndicatorType = 'MACD', + mainIndicator: MainIndicatorType = 'MA' ): EChartsOption => { if (!tradeData || tradeData.length === 0) return {}; @@ -444,6 +673,8 @@ export const getKLineDarkGoldOption = ( const orange = '#FF9500'; const red = '#FF4444'; const green = '#00C851'; + const cyan = '#00D4FF'; + const purple = '#A855F7'; const textColor = 'rgba(255, 255, 255, 0.85)'; const textMuted = 'rgba(255, 255, 255, 0.5)'; const borderColor = 'rgba(212, 175, 55, 0.2)'; @@ -452,10 +683,24 @@ export const getKLineDarkGoldOption = ( const kData = tradeData.map((item) => [item.open, item.close, item.low, item.high]); const volumes = tradeData.map((item) => item.volume); const closePrices = tradeData.map((item) => item.close); + const highPrices = tradeData.map((item) => item.high); + const lowPrices = tradeData.map((item) => item.low); + + // 计算主图指标 const ma5 = calculateMA(closePrices, 5); const ma10 = calculateMA(closePrices, 10); const ma20 = calculateMA(closePrices, 20); + // 计算布林带(如果选择) + const boll = mainIndicator === 'BOLL' ? calculateBOLL(closePrices) : null; + + // 计算副图指标 + const macdData = calculateMACD(closePrices); + const kdjData = calculateKDJ(highPrices, lowPrices, closePrices); + const rsi6 = calculateRSI(closePrices, 6); + const rsi12 = calculateRSI(closePrices, 12); + const rsi24 = calculateRSI(closePrices, 24); + // 创建涨幅分析标记点 const scatterData: [number, number][] = []; Object.keys(analysisMap).forEach((dateIndex) => { @@ -466,196 +711,430 @@ export const getKLineDarkGoldOption = ( } }); - return { - backgroundColor: 'transparent', - animation: true, - legend: { - data: ['K线', 'MA5', 'MA10', 'MA20'], - top: 5, - left: 'center', - textStyle: { - color: textColor, - }, + // 根据是否显示副图指标调整布局 + const hasSubIndicator = subIndicator !== 'VOL' && subIndicator !== 'NONE'; + + // 布局配置(优化比例) + // 主图: 55%, 成交量: 12%, 副图指标: 18%(如有) + const grids: EChartsOption['grid'] = [ + { + left: '3%', + right: '3%', + top: '8%', + height: hasSubIndicator ? '50%' : '68%', + containLabel: true, }, - tooltip: { - trigger: 'axis', - axisPointer: { - type: 'cross', + { + left: '3%', + right: '3%', + top: hasSubIndicator ? '62%' : '80%', + height: '12%', + containLabel: true, + }, + ]; + + // 如果有副图指标,添加第三个grid + if (hasSubIndicator) { + grids.push({ + left: '3%', + right: '3%', + top: '78%', + height: '18%', + containLabel: true, + }); + } + + // X轴配置(使用 boundaryGap: true 确保柱状图对齐) + const xAxes: EChartsOption['xAxis'] = [ + { + type: 'category', + data: dates, + boundaryGap: true, // K线图使用 true,确保蜡烛居中 + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted, fontSize: 10 }, + splitLine: { show: false }, + }, + { + type: 'category', + gridIndex: 1, + data: dates, + boundaryGap: true, // 成交量使用 true,与K线对齐 + axisLine: { onZero: false, lineStyle: { color: borderColor } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }, + ]; + + // Y轴配置 + const yAxes: EChartsOption['yAxis'] = [ + { + scale: true, + splitLine: { + show: true, lineStyle: { - color: gold, - width: 1, - opacity: 0.8, + color: borderColor, + type: 'dashed', }, }, - backgroundColor: 'rgba(26, 26, 46, 0.95)', - borderColor: gold, - borderWidth: 1, - textStyle: { - color: textColor, + axisLine: { lineStyle: { color: borderColor } }, + axisLabel: { color: textMuted, fontSize: 10 }, + }, + { + scale: true, + gridIndex: 1, + splitNumber: 2, + axisLabel: { + show: true, + color: textMuted, + fontSize: 9, + formatter: (value: number) => { + if (value >= 100000000) return (value / 100000000).toFixed(1) + '亿'; + if (value >= 10000) return (value / 10000).toFixed(0) + '万'; + return value.toString(); + } + }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { show: false }, + }, + ]; + + // 如果有副图指标,添加第三个X轴和Y轴 + if (hasSubIndicator) { + xAxes.push({ + type: 'category', + gridIndex: 2, + data: dates, + boundaryGap: true, + axisLine: { onZero: false, lineStyle: { color: borderColor } }, + axisTick: { show: false }, + splitLine: { show: false }, + axisLabel: { show: false }, + }); + yAxes.push({ + scale: true, + gridIndex: 2, + splitNumber: 2, + axisLabel: { show: true, color: textMuted, fontSize: 9 }, + axisLine: { show: false }, + axisTick: { show: false }, + splitLine: { + show: true, + lineStyle: { color: borderColor, type: 'dashed' } + }, + }); + } + + // 图例数据 + const legendData = ['K线']; + if (mainIndicator === 'MA') { + legendData.push('MA5', 'MA10', 'MA20'); + } else if (mainIndicator === 'BOLL') { + legendData.push('BOLL上轨', 'BOLL中轨', 'BOLL下轨'); + } + + // 构建系列数据 + const series: EChartsOption['series'] = [ + { + name: 'K线', + type: 'candlestick', + data: kData, + itemStyle: { + color: red, + color0: green, + borderColor: red, + borderColor0: green, }, }, - xAxis: [ - { - type: 'category', - data: dates, - boundaryGap: false, - axisLine: { lineStyle: { color: borderColor } }, - axisLabel: { color: textMuted }, - splitLine: { show: false }, - }, - { - type: 'category', - gridIndex: 1, - data: dates, - boundaryGap: false, - axisLine: { onZero: false, lineStyle: { color: borderColor } }, - axisTick: { show: false }, - splitLine: { show: false }, - axisLabel: { show: false }, - }, - ], - yAxis: [ - { - scale: true, - splitLine: { - show: true, - lineStyle: { - color: borderColor, - type: 'dashed', - }, - }, - axisLine: { lineStyle: { color: borderColor } }, - axisLabel: { color: textMuted }, - }, - { - scale: true, - gridIndex: 1, - splitNumber: 2, - axisLabel: { show: false }, - axisLine: { show: false }, - axisTick: { show: false }, - splitLine: { show: false }, - }, - ], - grid: [ - { - left: '3%', - right: '3%', - top: '8%', - height: '55%', - containLabel: true, - }, - { - left: '3%', - right: '3%', - top: '68%', - height: '28%', - containLabel: true, - }, - ], - series: [ - { - name: 'K线', - type: 'candlestick', - data: kData, - itemStyle: { - color: red, - color0: green, - borderColor: red, - borderColor0: green, - }, - }, + ]; + + // 添加主图指标 + if (mainIndicator === 'MA') { + series.push( { name: 'MA5', type: 'line', data: ma5, smooth: true, - lineStyle: { - color: gold, - width: 1, - }, - itemStyle: { - color: gold, - }, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, }, { name: 'MA10', type: 'line', data: ma10, smooth: true, - lineStyle: { - color: goldLight, - width: 1, - }, - itemStyle: { - color: goldLight, - }, + symbol: 'none', + lineStyle: { color: goldLight, width: 1 }, }, { name: 'MA20', type: 'line', data: ma20, smooth: true, - lineStyle: { - color: orange, - width: 1, - }, - itemStyle: { - color: orange, - }, + symbol: 'none', + lineStyle: { color: orange, width: 1 }, + } + ); + } else if (mainIndicator === 'BOLL' && boll) { + series.push( + { + name: 'BOLL上轨', + type: 'line', + data: boll.upper, + smooth: true, + symbol: 'none', + lineStyle: { color: cyan, width: 1 }, }, { - name: '涨幅分析', - type: 'scatter', - data: scatterData, - symbolSize: [80, 36], - symbol: 'roundRect', - itemStyle: { - color: 'rgba(26, 26, 46, 0.9)', - borderColor: gold, - borderWidth: 1, - shadowBlur: 8, - shadowColor: 'rgba(212, 175, 55, 0.4)', - }, - label: { - show: true, - formatter: '涨幅分析\n(点击查看)', - fontSize: 10, - lineHeight: 12, - position: 'inside', - color: gold, - fontWeight: 'bold', - }, - emphasis: { - scale: false, - itemStyle: { - borderColor: goldLight, - borderWidth: 2, - }, - }, - z: 100, + name: 'BOLL中轨', + type: 'line', + data: boll.middle, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, }, { - name: '成交量', - type: 'bar', - xAxisIndex: 1, - yAxisIndex: 1, - data: volumes, + name: 'BOLL下轨', + type: 'line', + data: boll.lower, + smooth: true, + symbol: 'none', + lineStyle: { color: purple, width: 1 }, + } + ); + } + + // 添加涨幅分析标记点 + if (scatterData.length > 0) { + series.push({ + name: '涨幅分析', + type: 'scatter', + data: scatterData, + symbolSize: [80, 36], + symbol: 'roundRect', + itemStyle: { + color: 'rgba(26, 26, 46, 0.9)', + borderColor: gold, + borderWidth: 1, + shadowBlur: 8, + shadowColor: 'rgba(212, 175, 55, 0.4)', + }, + label: { + show: true, + formatter: '涨幅分析\n(点击查看)', + fontSize: 10, + lineHeight: 12, + position: 'inside', + color: gold, + fontWeight: 'bold', + }, + emphasis: { + scale: false, itemStyle: { - color: (params: { dataIndex: number }) => { - const item = tradeData[params.dataIndex]; - return item.change_percent >= 0 - ? 'rgba(255, 68, 68, 0.6)' - : 'rgba(0, 200, 81, 0.6)'; - }, + borderColor: goldLight, + borderWidth: 2, }, }, + z: 100, + }); + } + + // 添加成交量 + series.push({ + name: '成交量', + type: 'bar', + xAxisIndex: 1, + yAxisIndex: 1, + data: volumes, + barWidth: '60%', + itemStyle: { + color: (params: { dataIndex: number }) => { + const item = tradeData[params.dataIndex]; + return item.change_percent >= 0 + ? 'rgba(255, 68, 68, 0.7)' + : 'rgba(0, 200, 81, 0.7)'; + }, + }, + }); + + // 添加副图指标 + if (subIndicator === 'MACD') { + // MACD柱状图 + series.push({ + name: 'MACD', + type: 'bar', + xAxisIndex: 2, + yAxisIndex: 2, + data: macdData.macd, + barWidth: '60%', + itemStyle: { + color: (params: { dataIndex: number }) => { + const value = macdData.macd[params.dataIndex]; + return value !== null && value >= 0 ? red : green; + }, + }, + }); + // DIF线 + series.push({ + name: 'DIF', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: macdData.dif, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, + }); + // DEA线 + series.push({ + name: 'DEA', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: macdData.dea, + smooth: true, + symbol: 'none', + lineStyle: { color: cyan, width: 1 }, + }); + legendData.push('MACD', 'DIF', 'DEA'); + } else if (subIndicator === 'KDJ') { + series.push( + { + name: 'K', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: kdjData.k, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, + }, + { + name: 'D', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: kdjData.d, + smooth: true, + symbol: 'none', + lineStyle: { color: cyan, width: 1 }, + }, + { + name: 'J', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: kdjData.j, + smooth: true, + symbol: 'none', + lineStyle: { color: purple, width: 1 }, + } + ); + legendData.push('K', 'D', 'J'); + } else if (subIndicator === 'RSI') { + series.push( + { + name: 'RSI6', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: rsi6, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, + }, + { + name: 'RSI12', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: rsi12, + smooth: true, + symbol: 'none', + lineStyle: { color: cyan, width: 1 }, + }, + { + name: 'RSI24', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: rsi24, + smooth: true, + symbol: 'none', + lineStyle: { color: purple, width: 1 }, + } + ); + legendData.push('RSI6', 'RSI12', 'RSI24'); + } + + return { + backgroundColor: 'transparent', + animation: true, + legend: { + data: legendData, + top: 5, + left: 'center', + textStyle: { color: textColor, fontSize: 11 }, + itemWidth: 15, + itemHeight: 10, + itemGap: 8, + }, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'cross', + lineStyle: { color: gold, width: 1, opacity: 0.8 }, + }, + backgroundColor: 'rgba(26, 26, 46, 0.95)', + borderColor: gold, + borderWidth: 1, + textStyle: { color: textColor, fontSize: 11 }, + }, + axisPointer: { + link: [{ xAxisIndex: 'all' }], + }, + dataZoom: [ + { + type: 'inside', + xAxisIndex: hasSubIndicator ? [0, 1, 2] : [0, 1], + start: 50, + end: 100, + }, + { + show: true, + type: 'slider', + xAxisIndex: hasSubIndicator ? [0, 1, 2] : [0, 1], + bottom: 5, + height: 20, + start: 50, + end: 100, + borderColor: borderColor, + backgroundColor: 'rgba(26, 26, 46, 0.5)', + dataBackground: { + lineStyle: { color: gold, opacity: 0.5 }, + areaStyle: { color: gold, opacity: 0.1 }, + }, + selectedDataBackground: { + lineStyle: { color: gold }, + areaStyle: { color: gold, opacity: 0.2 }, + }, + fillerColor: 'rgba(212, 175, 55, 0.2)', + handleStyle: { color: gold, borderColor: goldLight }, + textStyle: { color: textMuted, fontSize: 10 }, + }, ], + xAxis: xAxes, + yAxis: yAxes, + grid: grids, + series: series, }; }; /** - * 生成分钟K线图配置 - 黑金主题 + * 生成分时图配置 - 黑金主题(折线图+面积图,专业版) + * 将原来的分钟K线改为专业的分时走势图 */ export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): EChartsOption => { if (!minuteData || !minuteData.data || minuteData.data.length === 0) return {}; @@ -666,78 +1145,120 @@ export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): ECh const orange = '#FF9500'; const red = '#FF4444'; const green = '#00C851'; + const cyan = '#00D4FF'; const textColor = 'rgba(255, 255, 255, 0.85)'; const textMuted = 'rgba(255, 255, 255, 0.5)'; const borderColor = 'rgba(212, 175, 55, 0.2)'; const times = minuteData.data.map((item) => item.time); - const kData = minuteData.data.map((item) => [item.open, item.close, item.low, item.high]); - const volumes = minuteData.data.map((item) => item.volume); const closePrices = minuteData.data.map((item) => item.close); - const avgPrice = calculateMA(closePrices, 5); + const volumes = minuteData.data.map((item) => item.volume); - const openPrice = minuteData.data.length > 0 ? minuteData.data[0].open : 0; + // 计算均价线(使用累计成交额/累计成交量,这里简化用MA5代替) + // 如果数据中有 avg_price 字段则优先使用 + const avgPrices = minuteData.data.map((item, index) => { + // 优先使用API返回的均价 + if ((item as any).avg_price) { + return (item as any).avg_price; + } + // 否则计算简单移动平均作为近似均价 + if (index < 5) return null; + let sum = 0; + for (let j = 0; j <= index && j < 10; j++) { + sum += minuteData.data[index - j].close; + } + return sum / Math.min(index + 1, 10); + }); + + // 获取昨收价(用于计算涨跌幅和基准线) + const prevClose = (minuteData as any).prev_close || minuteData.data[0].open; + const openPrice = minuteData.data[0].open; + + // 计算最大最小值,用于Y轴范围 + const allPrices = closePrices.filter(p => p > 0); + const maxPrice = Math.max(...allPrices); + const minPrice = Math.min(...allPrices); + const priceRange = maxPrice - minPrice; + const yAxisPadding = priceRange * 0.1 || prevClose * 0.02; + + // 判断整体涨跌,决定分时线颜色 + const lastPrice = closePrices[closePrices.length - 1]; + const isUp = lastPrice >= prevClose; + const lineColor = isUp ? red : green; + const areaColorStart = isUp ? 'rgba(255, 68, 68, 0.3)' : 'rgba(0, 200, 81, 0.3)'; + const areaColorEnd = isUp ? 'rgba(255, 68, 68, 0.05)' : 'rgba(0, 200, 81, 0.05)'; return { backgroundColor: 'transparent', tooltip: { trigger: 'axis', - axisPointer: { type: 'cross' }, + axisPointer: { + type: 'cross', + lineStyle: { color: gold, width: 1, opacity: 0.8 }, + crossStyle: { color: gold }, + }, backgroundColor: 'rgba(26, 26, 46, 0.95)', borderColor: gold, borderWidth: 1, - textStyle: { - color: textColor, - fontSize: 12, - }, + textStyle: { color: textColor, fontSize: 11 }, formatter: (params: unknown) => { - const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number; value: number }[]; - let result = `${paramsArr[0].name}
`; - paramsArr.forEach((param) => { - if (param.seriesName === '分钟K线') { - const [open, close, , high] = param.data as number[]; - const low = (param.data as number[])[2]; - const changePercent = - openPrice > 0 ? (((close - openPrice) / openPrice) * 100).toFixed(2) : '0.00'; - result += `${param.marker} ${param.seriesName}
`; - result += `开盘: ${open.toFixed(2)}
`; - result += `收盘: ${close.toFixed(2)}
`; - result += `最高: ${high.toFixed(2)}
`; - result += `最低: ${low.toFixed(2)}
`; - result += `涨跌: ${changePercent}%
`; - } else if (param.seriesName === '均价线') { - result += `${param.marker} ${param.seriesName}: ${(param.value as number).toFixed(2)}
`; - } else if (param.seriesName === '成交量') { - result += `${param.marker} ${param.seriesName}: ${formatNumber(param.value as number, 0)}
`; - } - }); + const paramsArr = params as { name: string; marker: string; seriesName: string; data: number; value: number }[]; + if (!paramsArr || paramsArr.length === 0) return ''; + + const timeStr = paramsArr[0].name; + const dataIndex = times.indexOf(timeStr); + const item = minuteData.data[dataIndex]; + if (!item) return ''; + + const price = item.close; + const change = price - prevClose; + const changePercent = prevClose > 0 ? (change / prevClose * 100) : 0; + const changeColor = change >= 0 ? red : green; + const changeSign = change >= 0 ? '+' : ''; + + let result = `
${timeStr}
`; + result += `
`; + result += `现价:${price.toFixed(2)}
`; + result += `
`; + result += `涨跌:${changeSign}${change.toFixed(2)} (${changeSign}${changePercent.toFixed(2)}%)
`; + + // 查找均价 + const avgPrice = avgPrices[dataIndex]; + if (avgPrice !== null) { + result += `
`; + result += `均价:${avgPrice.toFixed(2)}
`; + } + + // 成交量 + const volume = item.volume; + const volumeStr = volume >= 10000 ? (volume / 10000).toFixed(1) + '万' : volume.toString(); + result += `
`; + result += `成交:${volumeStr}手
`; + return result; }, }, legend: { - data: ['分钟K线', '均价线', '成交量'], + data: ['分时价格', '均价线'], top: 5, left: 'center', - textStyle: { - color: textColor, - fontSize: 12, - }, - itemWidth: 25, - itemHeight: 14, + textStyle: { color: textColor, fontSize: 11 }, + itemWidth: 20, + itemHeight: 10, }, grid: [ { left: '3%', right: '3%', top: '10%', - height: '65%', + height: '68%', containLabel: true, }, { left: '3%', right: '3%', - top: '78%', - height: '15%', + top: '82%', + height: '12%', containLabel: true, }, ], @@ -750,9 +1271,20 @@ export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): ECh axisLabel: { color: textMuted, fontSize: 10, - interval: 'auto', + interval: (index: number) => { + // 显示关键时间点:09:30, 10:30, 11:30, 13:00, 14:00, 15:00 + const time = times[index]; + return ['09:30', '10:00', '10:30', '11:00', '11:30', '13:00', '13:30', '14:00', '14:30', '15:00'].includes(time); + }, + }, + splitLine: { + show: true, + interval: (index: number) => { + const time = times[index]; + return time === '11:30' || time === '13:00'; + }, + lineStyle: { color: borderColor, type: 'dashed' }, }, - splitLine: { show: false }, }, { type: 'category', @@ -760,85 +1292,118 @@ export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): ECh data: times, boundaryGap: false, axisLine: { lineStyle: { color: borderColor } }, - axisLabel: { - color: textMuted, - fontSize: 10, - }, + axisLabel: { show: false }, splitLine: { show: false }, }, ], yAxis: [ { scale: true, + min: minPrice - yAxisPadding, + max: maxPrice + yAxisPadding, axisLine: { lineStyle: { color: borderColor } }, - axisLabel: { color: textMuted, fontSize: 10 }, - splitLine: { - lineStyle: { - color: borderColor, - type: 'dashed', - }, + axisLabel: { + color: textMuted, + fontSize: 10, + formatter: (value: number) => value.toFixed(2), }, + splitLine: { + lineStyle: { color: borderColor, type: 'dashed' }, + }, + // 添加昨收价基准线的参考 + splitNumber: 4, }, { gridIndex: 1, scale: true, axisLine: { lineStyle: { color: borderColor } }, - axisLabel: { color: textMuted, fontSize: 10 }, + axisLabel: { + color: textMuted, + fontSize: 9, + formatter: (value: number) => { + if (value >= 10000) return (value / 10000).toFixed(0) + '万'; + return value.toString(); + } + }, splitLine: { show: false }, - }, - ], - dataZoom: [ - { - type: 'inside', - xAxisIndex: [0, 1], - start: 70, - end: 100, - minValueSpan: 20, + splitNumber: 2, }, ], series: [ + // 分时价格线(带面积) { - name: '分钟K线', - type: 'candlestick', - data: kData, - itemStyle: { - color: red, - color0: green, - borderColor: red, - borderColor0: green, - borderWidth: 1, - }, - barWidth: '60%', - }, - { - name: '均价线', + name: '分时价格', type: 'line', - data: avgPrice, + data: closePrices, smooth: true, symbol: 'none', lineStyle: { - color: gold, - width: 2, - opacity: 0.8, + color: lineColor, + width: 1.5, + }, + areaStyle: { + color: { + type: 'linear', + x: 0, + y: 0, + x2: 0, + y2: 1, + colorStops: [ + { offset: 0, color: areaColorStart }, + { offset: 1, color: areaColorEnd }, + ], + }, }, }, + // 均价线 + { + name: '均价线', + type: 'line', + data: avgPrices, + smooth: true, + symbol: 'none', + lineStyle: { + color: orange, + width: 1.5, + type: 'dashed', + }, + }, + // 昨收价基准线 + { + name: '昨收价', + type: 'line', + data: times.map(() => prevClose), + symbol: 'none', + lineStyle: { + color: textMuted, + width: 1, + type: 'dotted', + }, + silent: true, + }, + // 成交量 { name: '成交量', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volumes, - barWidth: '50%', + barWidth: '80%', itemStyle: { color: (params: { dataIndex: number }) => { - const item = minuteData.data[params.dataIndex]; - return item.close >= item.open + const idx = params.dataIndex; + if (idx === 0) { + return closePrices[idx] >= prevClose ? 'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)'; + } + return closePrices[idx] >= closePrices[idx - 1] ? 'rgba(255, 68, 68, 0.6)' : 'rgba(0, 200, 81, 0.6)'; }, }, }, ], + // 分时图不需要缩放 + dataZoom: [], }; }; @@ -1366,6 +1931,11 @@ export const getPledgeDarkGoldOption = (pledgeData: PledgeData[]): EChartsOption export default { calculateMA, + calculateEMA, + calculateMACD, + calculateKDJ, + calculateRSI, + calculateBOLL, getKLineOption, getKLineDarkGoldOption, getMinuteKLineOption,