Files
vf_react/src/components/StockChart/utils/dataAdapter.ts
2025-11-25 15:30:43 +08:00

321 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 数据转换适配器
*
* 将后端返回的各种格式数据转换为 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;
};