/** * 数据转换适配器 * * 将后端返回的各种格式数据转换为 KLineChart 10.0 所需的标准格式 */ import dayjs from 'dayjs'; import type { KLineDataPoint, RawDataPoint, ChartType } from '../types'; import { logger } from '@utils/logger'; /** * 将后端原始数据转换为 KLineChart 标准格式 * * @param rawData 后端原始数据数组 * @param chartType 图表类型(timeline/daily) * @param eventTime 事件时间(用于日期基准) * @returns KLineDataPoint[] 标准K线数据 */ export const convertToKLineData = ( rawData: RawDataPoint[], chartType: ChartType, eventTime?: string ): KLineDataPoint[] => { if (!rawData || !Array.isArray(rawData) || rawData.length === 0) { logger.warn('dataAdapter', '原始数据为空 (convertToKLineData)', { chartType }); return []; } try { return rawData.map((item, index) => { const timestamp = parseTimestamp(item, chartType, eventTime, index); return { timestamp, open: Number(item.open) || 0, high: Number(item.high) || 0, low: Number(item.low) || 0, close: Number(item.close) || 0, volume: Number(item.volume) || 0, turnover: item.turnover ? Number(item.turnover) : undefined, prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线) }; }); } catch (error) { logger.error('dataAdapter', 'convertToKLineData', error as Error, { chartType, dataLength: rawData.length, }); return []; } }; /** * 解析时间戳(兼容多种时间格式) * * @param item 原始数据项 * @param chartType 图表类型 * @param eventTime 事件时间 * @param index 数据索引(用于分时图时间推算) * @returns number 毫秒时间戳 */ const parseTimestamp = ( item: RawDataPoint, chartType: ChartType, eventTime?: string, index?: number ): number => { // 优先级1: 使用 timestamp 字段 if (item.timestamp) { const ts = typeof item.timestamp === 'number' ? item.timestamp : Number(item.timestamp); // 判断是秒级还是毫秒级时间戳 return ts > 10000000000 ? ts : ts * 1000; } // 优先级2: 使用 date 字段(日K线) if (item.date) { return dayjs(item.date).valueOf(); } // 优先级3: 使用 time 字段(分时图) if (item.time && eventTime) { return parseTimelineTimestamp(item.time, eventTime); } // 优先级4: 根据 chartType 和 index 推算(兜底逻辑) if (chartType === 'timeline' && eventTime && typeof index === 'number') { // 分时图:从事件时间推算(假设 09:30 开盘) const baseTime = dayjs(eventTime).startOf('day').add(9, 'hour').add(30, 'minute'); return baseTime.add(index, 'minute').valueOf(); } // 默认返回当前时间(避免图表崩溃) logger.warn('dataAdapter', '无法解析时间戳,使用当前时间 (parseTimestamp)', { item }); return Date.now(); }; /** * 解析分时图时间戳 * * 将 "HH:mm" 格式转换为完整时间戳 * * @param time 时间字符串(如 "09:30") * @param eventTime 事件时间(YYYY-MM-DD HH:mm:ss) * @returns number 毫秒时间戳 */ const parseTimelineTimestamp = (time: string, eventTime: string): number => { try { const [hours, minutes] = time.split(':').map(Number); const eventDate = dayjs(eventTime).startOf('day'); return eventDate.hour(hours).minute(minutes).second(0).valueOf(); } catch (error) { logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime }); return dayjs(eventTime).valueOf(); } }; /** * 数据验证和清洗 * * 移除无效数据(价格/成交量异常) * * @param data K线数据 * @returns KLineDataPoint[] 清洗后的数据 */ export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] => { return data.filter((item) => { // 移除价格为 0 或负数的数据 if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) { logger.warn('dataAdapter', '价格异常,已移除 (validateAndCleanData)', { item }); return false; } // 移除 high < low 的数据(数据错误) if (item.high < item.low) { logger.warn('dataAdapter', '最高价 < 最低价,已移除 (validateAndCleanData)', { item }); return false; } // 移除成交量为负数的数据 if (item.volume < 0) { logger.warn('dataAdapter', '成交量异常,已移除 (validateAndCleanData)', { item }); return false; } return true; }); }; /** * 数据排序(按时间升序) * * @param data K线数据 * @returns KLineDataPoint[] 排序后的数据 */ export const sortDataByTime = (data: KLineDataPoint[]): KLineDataPoint[] => { return [...data].sort((a, b) => a.timestamp - b.timestamp); }; /** * 数据去重(移除时间戳重复的数据,保留最后一条) * * @param data K线数据 * @returns KLineDataPoint[] 去重后的数据 */ export const deduplicateData = (data: KLineDataPoint[]): KLineDataPoint[] => { const map = new Map(); data.forEach((item) => { map.set(item.timestamp, item); // 相同时间戳会覆盖 }); return Array.from(map.values()); }; /** * 根据事件时间裁剪数据范围(前后2周) * * @param data K线数据 * @param eventTime 事件时间(ISO字符串) * @param chartType 图表类型 * @returns KLineDataPoint[] 裁剪后的数据 */ export const trimDataByEventTime = ( data: KLineDataPoint[], eventTime: string, chartType: ChartType ): KLineDataPoint[] => { if (!eventTime || !data || data.length === 0) { return data; } try { const eventTimestamp = dayjs(eventTime).valueOf(); // 根据图表类型设置不同的时间范围 let beforeDays: number; let afterDays: number; if (chartType === 'timeline') { // 分时图:只显示事件当天(前后0天) beforeDays = 0; afterDays = 0; } else { // 日K线:显示前后14天(2周) beforeDays = 14; afterDays = 14; } const startTime = dayjs(eventTime).subtract(beforeDays, 'day').startOf('day').valueOf(); const endTime = dayjs(eventTime).add(afterDays, 'day').endOf('day').valueOf(); const trimmedData = data.filter((item) => { return item.timestamp >= startTime && item.timestamp <= endTime; }); logger.debug('dataAdapter', '数据时间范围裁剪完成 (trimDataByEventTime)', { originalLength: data.length, trimmedLength: trimmedData.length, eventTime, chartType, dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`, }); return trimmedData; } catch (error) { logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime }); return data; // 出错时返回原始数据 } }; /** * 完整的数据处理流程 * * 转换 → 验证 → 去重 → 排序 → 时间裁剪(如果有 eventTime) * * @param rawData 后端原始数据 * @param chartType 图表类型 * @param eventTime 事件时间 * @returns KLineDataPoint[] 处理后的数据 */ export const processChartData = ( rawData: RawDataPoint[], chartType: ChartType, eventTime?: string ): KLineDataPoint[] => { // 1. 转换数据格式 let data = convertToKLineData(rawData, chartType, eventTime); // 2. 验证和清洗 data = validateAndCleanData(data); // 3. 去重 data = deduplicateData(data); // 4. 排序 data = sortDataByTime(data); // 5. 根据事件时间裁剪范围(如果提供了 eventTime) if (eventTime) { data = trimDataByEventTime(data, eventTime, chartType); } logger.debug('dataAdapter', '数据处理完成 (processChartData)', { rawLength: rawData.length, processedLength: data.length, chartType, hasEventTime: !!eventTime, }); return data; }; /** * 获取数据时间范围 * * @param data K线数据 * @returns { start: number, end: number } 时间范围(毫秒时间戳) */ export const getDataTimeRange = ( data: KLineDataPoint[] ): { start: number; end: number } | null => { if (!data || data.length === 0) { return null; } const timestamps = data.map((item) => item.timestamp); return { start: Math.min(...timestamps), end: Math.max(...timestamps), }; }; /** * 查找最接近指定时间的数据点 * * @param data K线数据 * @param targetTime 目标时间戳 * @returns KLineDataPoint | null 最接近的数据点 */ export const findClosestDataPoint = ( data: KLineDataPoint[], targetTime: number ): KLineDataPoint | null => { if (!data || data.length === 0) { return null; } let closest = data[0]; let minDiff = Math.abs(data[0].timestamp - targetTime); data.forEach((item) => { const diff = Math.abs(item.timestamp - targetTime); if (diff < minDiff) { minDiff = diff; closest = item; } }); return closest; };