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 67107e23..6cb4075b 100644 --- a/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx +++ b/src/views/Company/components/MarketDataView/components/panels/TradeDataPanel/KLineModule.tsx @@ -21,8 +21,8 @@ import { MenuDivider, Tooltip, } from '@chakra-ui/react'; -import { RepeatIcon, InfoIcon, ChevronDownIcon } from '@chakra-ui/icons'; -import { BarChart2, Clock, TrendingUp, Calendar, LineChart, Activity } from 'lucide-react'; +import { RepeatIcon, InfoIcon, ChevronDownIcon, ViewIcon, ViewOffIcon } from '@chakra-ui/icons'; +import { BarChart2, Clock, TrendingUp, Calendar, LineChart, Activity, Pencil } from 'lucide-react'; import ReactECharts from 'echarts-for-react'; import { darkGoldTheme, PERIOD_OPTIONS } from '../../../constants'; @@ -31,6 +31,7 @@ import { getMinuteKLineDarkGoldOption, type IndicatorType, type MainIndicatorType, + type DrawingType, } from '../../../utils/chartOptions'; import type { KLineModuleProps } from '../../../types'; @@ -57,6 +58,9 @@ const SUB_INDICATOR_OPTIONS: { value: IndicatorType; label: string; description: { value: 'MACD', label: 'MACD', description: '平滑异同移动平均线' }, { value: 'KDJ', label: 'KDJ', description: '随机指标' }, { value: 'RSI', label: 'RSI', description: '相对强弱指标' }, + { value: 'WR', label: 'WR', description: '威廉指标(超买超卖)' }, + { value: 'CCI', label: 'CCI', description: '商品通道指标' }, + { value: 'BIAS', label: 'BIAS', description: '乖离率' }, { value: 'VOL', label: '仅成交量', description: '不显示副图指标' }, ]; @@ -67,6 +71,14 @@ const MAIN_INDICATOR_OPTIONS: { value: MainIndicatorType; label: string; descrip { value: 'NONE', label: '无', description: '不显示主图指标' }, ]; +// 绘图工具选项 +const DRAWING_OPTIONS: { value: DrawingType; label: string; description: string }[] = [ + { value: 'NONE', label: '无', description: '不显示绘图工具' }, + { value: 'SUPPORT_RESISTANCE', label: '支撑/阻力', description: '自动识别支撑位和阻力位' }, + { value: 'TREND_LINE', label: '趋势线', description: '基于线性回归的趋势线' }, + { value: 'ALL', label: '全部显示', description: '显示所有参考线' }, +]; + const KLineModule: React.FC = ({ theme, tradeData, @@ -81,6 +93,8 @@ const KLineModule: React.FC = ({ const [mode, setMode] = useState('daily'); const [subIndicator, setSubIndicator] = useState('MACD'); const [mainIndicator, setMainIndicator] = useState('MA'); + const [showAnalysis, setShowAnalysis] = useState(true); + const [drawingType, setDrawingType] = useState('NONE'); const hasMinuteData = minuteData && minuteData.data && minuteData.data.length > 0; // 切换到分时模式时自动加载数据 @@ -188,6 +202,20 @@ const KLineModule: React.FC = ({ )} + {/* 隐藏/显示涨幅分析 */} + + + + {/* 主图指标选择 */} @@ -268,6 +296,47 @@ const KLineModule: React.FC = ({ ))} + + {/* 绘图工具选择 */} + + + } + leftIcon={} + {...(drawingType !== 'NONE' ? activeButtonStyle : inactiveButtonStyle)} + minW="90px" + > + {DRAWING_OPTIONS.find(o => o.value === drawingType)?.label || '绘图'} + + + + {DRAWING_OPTIONS.map((option) => ( + setDrawingType(option.value)} + bg={drawingType === option.value ? 'rgba(212, 175, 55, 0.2)' : 'transparent'} + color={drawingType === option.value ? darkGoldTheme.gold : darkGoldTheme.textPrimary} + _hover={{ bg: 'rgba(212, 175, 55, 0.1)' }} + > + + + {option.label} + + + {option.description} + + + + ))} + + )} @@ -314,7 +383,7 @@ const KLineModule: React.FC = ({ tradeData.length > 0 ? ( { + const result: (number | null)[] = []; + + for (let i = 0; i < closePrices.length; i++) { + if (i < period - 1) { + result.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]); + } + + if (highestHigh === lowestLow) { + result.push(0); + } else { + // WR = (最高价 - 收盘价) / (最高价 - 最低价) * -100 + result.push(((highestHigh - closePrices[i]) / (highestHigh - lowestLow)) * -100); + } + } + + return result; +}; + +/** + * 计算CCI (商品通道指标) + * @param highPrices 最高价数组 + * @param lowPrices 最低价数组 + * @param closePrices 收盘价数组 + * @param period 周期 (默认14) + */ +export const calculateCCI = ( + highPrices: number[], + lowPrices: number[], + closePrices: number[], + period = 14 +): (number | null)[] => { + const result: (number | null)[] = []; + + // 计算典型价格 (TP = (High + Low + Close) / 3) + const tp = closePrices.map((close, i) => (highPrices[i] + lowPrices[i] + close) / 3); + + for (let i = 0; i < closePrices.length; i++) { + if (i < period - 1) { + result.push(null); + continue; + } + + // 计算TP的移动平均 + let tpSum = 0; + for (let j = 0; j < period; j++) { + tpSum += tp[i - j]; + } + const tpMA = tpSum / period; + + // 计算平均绝对偏差 + let madSum = 0; + for (let j = 0; j < period; j++) { + madSum += Math.abs(tp[i - j] - tpMA); + } + const mad = madSum / period; + + // CCI = (TP - MA) / (0.015 * MAD) + if (mad === 0) { + result.push(0); + } else { + result.push((tp[i] - tpMA) / (0.015 * mad)); + } + } + + return result; +}; + +/** + * 计算BIAS (乖离率) + * @param closePrices 收盘价数组 + * @param period 周期 (默认6) + */ +export const calculateBIAS = (closePrices: number[], period = 6): (number | null)[] => { + const ma = calculateMA(closePrices, period); + const result: (number | null)[] = []; + + for (let i = 0; i < closePrices.length; i++) { + if (ma[i] === null || ma[i] === 0) { + result.push(null); + } else { + // BIAS = (收盘价 - MA) / MA * 100 + result.push(((closePrices[i] - ma[i]!) / ma[i]!) * 100); + } + } + + return result; +}; + +/** + * 格式化数字(最多2位小数,去除无意义的尾数) + */ +export const formatPrice = (value: number | null | undefined): string => { + if (value === null || value === undefined || isNaN(value)) return '--'; + // 使用 toFixed(2) 然后去除尾部的0 + const fixed = value.toFixed(2); + return parseFloat(fixed).toString(); +}; + +/** + * 格式化百分比(最多2位小数) + */ +export const formatPercent = (value: number | null | undefined): string => { + if (value === null || value === undefined || isNaN(value)) return '--'; + return parseFloat(value.toFixed(2)).toString(); +}; + /** * 生成日K线图配置 */ @@ -647,23 +774,96 @@ export const getMinuteKLineOption = (theme: Theme, minuteData: MinuteData | null }; // 技术指标类型 -export type IndicatorType = 'VOL' | 'MACD' | 'KDJ' | 'RSI' | 'BOLL' | 'NONE'; +export type IndicatorType = 'VOL' | 'MACD' | 'KDJ' | 'RSI' | 'WR' | 'CCI' | 'BIAS' | 'NONE'; // 主图指标类型 export type MainIndicatorType = 'MA' | 'BOLL' | 'NONE'; +// 绘图工具类型 +export type DrawingType = 'NONE' | 'SUPPORT_RESISTANCE' | 'TREND_LINE' | 'ALL'; + +/** + * 计算支撑位和阻力位 + * 基于近期高低点自动识别 + * @param highPrices 最高价数组 + * @param lowPrices 最低价数组 + * @param period 回看周期(默认20日) + */ +export const calculateSupportResistance = ( + highPrices: number[], + lowPrices: number[], + period = 20 +): { support: number | null; resistance: number | null } => { + if (highPrices.length < period) { + return { support: null, resistance: null }; + } + + // 取最近 period 天的数据 + const recentHighs = highPrices.slice(-period); + const recentLows = lowPrices.slice(-period); + + // 阻力位:近期最高价 + const resistance = Math.max(...recentHighs); + // 支撑位:近期最低价 + const support = Math.min(...recentLows); + + return { support, resistance }; +}; + +/** + * 计算趋势线数据点 + * 使用线性回归拟合趋势 + * @param closePrices 收盘价数组 + * @param period 回看周期(默认60日) + */ +export const calculateTrendLine = ( + closePrices: number[], + period = 60 +): { startPrice: number; endPrice: number; slope: number } | null => { + if (closePrices.length < 5) return null; + + // 取最近 period 天的数据(或全部数据如果不足) + const len = Math.min(period, closePrices.length); + const prices = closePrices.slice(-len); + + // 使用简单线性回归计算趋势线 + // y = mx + b + let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0; + const n = prices.length; + + for (let i = 0; i < n; i++) { + sumX += i; + sumY += prices[i]; + sumXY += i * prices[i]; + sumXX += i * i; + } + + const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); + const intercept = (sumY - slope * sumX) / n; + + // 起点和终点价格 + const startPrice = intercept; + const endPrice = intercept + slope * (n - 1); + + return { startPrice, endPrice, slope }; +}; + /** * 生成日K线图配置 - 黑金主题(专业版 - 支持多技术指标) * @param tradeData K线数据 * @param analysisMap 涨幅分析数据 * @param subIndicator 副图指标 (默认MACD) * @param mainIndicator 主图指标 (默认MA) + * @param showAnalysis 是否显示涨幅分析标记 (默认true) + * @param drawingType 绘图工具类型 (默认NONE) */ export const getKLineDarkGoldOption = ( tradeData: TradeDayData[], analysisMap: Record, subIndicator: IndicatorType = 'MACD', - mainIndicator: MainIndicatorType = 'MA' + mainIndicator: MainIndicatorType = 'MA', + showAnalysis: boolean = true, + drawingType: DrawingType = 'NONE' ): EChartsOption => { if (!tradeData || tradeData.length === 0) return {}; @@ -700,16 +900,24 @@ export const getKLineDarkGoldOption = ( const rsi6 = calculateRSI(closePrices, 6); const rsi12 = calculateRSI(closePrices, 12); const rsi24 = calculateRSI(closePrices, 24); + const wr14 = calculateWR(highPrices, lowPrices, closePrices, 14); + const wr6 = calculateWR(highPrices, lowPrices, closePrices, 6); + const cci14 = calculateCCI(highPrices, lowPrices, closePrices, 14); + const bias6 = calculateBIAS(closePrices, 6); + const bias12 = calculateBIAS(closePrices, 12); + const bias24 = calculateBIAS(closePrices, 24); - // 创建涨幅分析标记点 + // 创建涨幅分析标记点(仅当 showAnalysis 为 true 时) const scatterData: [number, number][] = []; - Object.keys(analysisMap).forEach((dateIndex) => { - const idx = parseInt(dateIndex); - if (tradeData[idx]) { - const value = tradeData[idx].high * 1.02; - scatterData.push([idx, value]); - } - }); + if (showAnalysis) { + Object.keys(analysisMap).forEach((dateIndex) => { + const idx = parseInt(dateIndex); + if (tradeData[idx]) { + const value = tradeData[idx].high * 1.02; + scatterData.push([idx, value]); + } + }); + } // 根据是否显示副图指标调整布局 const hasSubIndicator = subIndicator !== 'VOL' && subIndicator !== 'NONE'; @@ -834,6 +1042,45 @@ export const getKLineDarkGoldOption = ( legendData.push('BOLL上轨', 'BOLL中轨', 'BOLL下轨'); } + // 计算绘图工具数据 + const supportResistance = (drawingType === 'SUPPORT_RESISTANCE' || drawingType === 'ALL') + ? calculateSupportResistance(highPrices, lowPrices) + : null; + const trendLine = (drawingType === 'TREND_LINE' || drawingType === 'ALL') + ? calculateTrendLine(closePrices) + : null; + + // 构建 markLine 数据 + const markLineData: any[] = []; + if (supportResistance) { + if (supportResistance.resistance !== null) { + markLineData.push({ + name: '阻力位', + yAxis: supportResistance.resistance, + lineStyle: { color: red, type: 'dashed', width: 1.5 }, + label: { + formatter: `阻力 ${formatPrice(supportResistance.resistance)}`, + position: 'end', + color: red, + fontSize: 10, + }, + }); + } + if (supportResistance.support !== null) { + markLineData.push({ + name: '支撑位', + yAxis: supportResistance.support, + lineStyle: { color: green, type: 'dashed', width: 1.5 }, + label: { + formatter: `支撑 ${formatPrice(supportResistance.support)}`, + position: 'end', + color: green, + fontSize: 10, + }, + }); + } + } + // 构建系列数据 const series: EChartsOption['series'] = [ { @@ -846,9 +1093,47 @@ export const getKLineDarkGoldOption = ( borderColor: red, borderColor0: green, }, + markLine: markLineData.length > 0 ? { + symbol: ['none', 'none'], + data: markLineData, + silent: true, + } : undefined, }, ]; + // 添加趋势线(使用单独的 line series) + if (trendLine && (drawingType === 'TREND_LINE' || drawingType === 'ALL')) { + const trendLineLen = Math.min(60, closePrices.length); + const trendLineStartIdx = closePrices.length - trendLineLen; + const trendLineData: (number | null)[] = []; + + // 前面填充 null + for (let i = 0; i < trendLineStartIdx; i++) { + trendLineData.push(null); + } + + // 计算趋势线上每个点的值 + const { startPrice, slope } = trendLine; + for (let i = 0; i < trendLineLen; i++) { + trendLineData.push(startPrice + slope * i); + } + + series.push({ + name: '趋势线', + type: 'line', + data: trendLineData, + symbol: 'none', + lineStyle: { + color: '#FF6B6B', + width: 2, + type: 'dashed', + opacity: 0.8, + }, + z: 50, + }); + legendData.push('趋势线'); + } + // 添加主图指标 if (mainIndicator === 'MA') { series.push( @@ -1067,6 +1352,110 @@ export const getKLineDarkGoldOption = ( } ); legendData.push('RSI6', 'RSI12', 'RSI24'); + } else if (subIndicator === 'WR') { + series.push( + { + name: 'WR14', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: wr14, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, + }, + { + name: 'WR6', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: wr6, + smooth: true, + symbol: 'none', + lineStyle: { color: cyan, width: 1 }, + } + ); + legendData.push('WR14', 'WR6'); + } else if (subIndicator === 'CCI') { + series.push({ + name: 'CCI', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: cci14, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1.5 }, + }); + // 添加超买超卖参考线 + series.push( + { + name: '+100', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: dates.map(() => 100), + symbol: 'none', + lineStyle: { color: red, width: 1, type: 'dashed', opacity: 0.5 }, + silent: true, + }, + { + name: '-100', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: dates.map(() => -100), + symbol: 'none', + lineStyle: { color: green, width: 1, type: 'dashed', opacity: 0.5 }, + silent: true, + } + ); + legendData.push('CCI'); + } else if (subIndicator === 'BIAS') { + series.push( + { + name: 'BIAS6', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: bias6, + smooth: true, + symbol: 'none', + lineStyle: { color: gold, width: 1 }, + }, + { + name: 'BIAS12', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: bias12, + smooth: true, + symbol: 'none', + lineStyle: { color: cyan, width: 1 }, + }, + { + name: 'BIAS24', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: bias24, + smooth: true, + symbol: 'none', + lineStyle: { color: purple, width: 1 }, + } + ); + // 添加零轴参考线 + series.push({ + name: '零轴', + type: 'line', + xAxisIndex: 2, + yAxisIndex: 2, + data: dates.map(() => 0), + symbol: 'none', + lineStyle: { color: textMuted, width: 1, type: 'dashed', opacity: 0.5 }, + silent: true, + }); + legendData.push('BIAS6', 'BIAS12', 'BIAS24'); } return { @@ -1091,6 +1480,32 @@ export const getKLineDarkGoldOption = ( borderColor: gold, borderWidth: 1, textStyle: { color: textColor, fontSize: 11 }, + formatter: (params: unknown) => { + const paramsArr = params as { name: string; marker: string; seriesName: string; data: number[] | number | null; value: number | null }[]; + if (!paramsArr || paramsArr.length === 0) return ''; + + const dateStr = paramsArr[0].name; + let result = `
${dateStr}
`; + + paramsArr.forEach((param) => { + const { seriesName, marker, data, value } = param; + if (seriesName === 'K线' && Array.isArray(data)) { + const [open, close, low, high] = data; + result += `${marker} 开:${formatPrice(open)} 收:${formatPrice(close)} 低:${formatPrice(low)} 高:${formatPrice(high)}
`; + } else if (seriesName === '成交量' && typeof value === 'number') { + const volStr = value >= 100000000 ? (value / 100000000).toFixed(2) + '亿' : + value >= 10000 ? (value / 10000).toFixed(0) + '万' : value.toString(); + result += `${marker} 成交量:${volStr}
`; + } else if (seriesName === '涨幅分析') { + // 跳过涨幅分析标记 + } else if (typeof value === 'number' || (value !== null && !isNaN(Number(value)))) { + // MA、BOLL、MACD、KDJ、RSI 等指标 + result += `${marker} ${seriesName}:${formatPrice(Number(value))}
`; + } + }); + + return result; + }, }, axisPointer: { link: [{ xAxisIndex: 'all' }], @@ -1218,20 +1633,20 @@ export const getMinuteKLineDarkGoldOption = (minuteData: MinuteData | null): ECh let result = `
${timeStr}
`; result += `
`; - result += `现价:${price.toFixed(2)}
`; + result += `现价:${formatPrice(price)}`; result += `
`; - result += `涨跌:${changeSign}${change.toFixed(2)} (${changeSign}${changePercent.toFixed(2)}%)
`; + result += `涨跌:${changeSign}${formatPrice(change)} (${changeSign}${formatPercent(changePercent)}%)`; // 查找均价 const avgPrice = avgPrices[dataIndex]; if (avgPrice !== null) { result += `
`; - result += `均价:${avgPrice.toFixed(2)}
`; + result += `均价:${formatPrice(avgPrice)}`; } // 成交量 const volume = item.volume; - const volumeStr = volume >= 10000 ? (volume / 10000).toFixed(1) + '万' : volume.toString(); + const volumeStr = volume >= 10000 ? formatPrice(volume / 10000) + '万' : volume.toString(); result += `
`; result += `成交:${volumeStr}手
`;