321 lines
8.9 KiB
TypeScript
321 lines
8.9 KiB
TypeScript
/**
|
||
* 数据转换适配器
|
||
*
|
||
* 将后端返回的各种格式数据转换为 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<number, KLineDataPoint>();
|
||
|
||
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;
|
||
};
|