192 lines
4.9 KiB
TypeScript
192 lines
4.9 KiB
TypeScript
import { subWeeks, subMonths, subYears } from 'date-fns';
|
|
import type { TimeRange, ChartDataPoint } from '@/lib/types';
|
|
|
|
function toTradingDayKey(value: string): string {
|
|
const parsed = Date.parse(value);
|
|
if (!Number.isFinite(parsed)) {
|
|
return value;
|
|
}
|
|
|
|
return new Date(parsed).toISOString().slice(0, 10);
|
|
}
|
|
|
|
/**
|
|
* Filter chart data by time range
|
|
*/
|
|
export function filterByTimeRange<T extends ChartDataPoint>(
|
|
data: T[],
|
|
range: TimeRange
|
|
): T[] {
|
|
if (data.length === 0) return data;
|
|
|
|
const now = new Date();
|
|
const cutoffDate = {
|
|
'1W': subWeeks(now, 1),
|
|
'1M': subMonths(now, 1),
|
|
'3M': subMonths(now, 3),
|
|
'1Y': subYears(now, 1),
|
|
'3Y': subYears(now, 3),
|
|
'5Y': subYears(now, 5),
|
|
'10Y': subYears(now, 10),
|
|
'20Y': subYears(now, 20)
|
|
}[range];
|
|
|
|
return data.filter(point => new Date(point.date) >= cutoffDate);
|
|
}
|
|
|
|
/**
|
|
* Check if data point has OHLCV fields
|
|
*/
|
|
export function isOHLCVData(data: ChartDataPoint): data is {
|
|
date: string;
|
|
open: number;
|
|
high: number;
|
|
low: number;
|
|
close: number;
|
|
volume: number;
|
|
} {
|
|
return 'open' in data && 'high' in data && 'low' in data && 'close' in data && 'volume' in data;
|
|
}
|
|
|
|
/**
|
|
* Check if data point has simple price field
|
|
*/
|
|
export function isPriceData(data: ChartDataPoint): data is { date: string; price: number } {
|
|
return 'price' in data;
|
|
}
|
|
|
|
/**
|
|
* Normalize data to ensure consistent structure
|
|
* Converts price data to OHLCV-like structure for candlestick charts
|
|
*/
|
|
export function normalizeChartData<T extends ChartDataPoint>(data: T[]): T[] {
|
|
if (!data || data.length === 0) return [];
|
|
|
|
// Sort by date ascending
|
|
return [...data].sort((a, b) =>
|
|
new Date(a.date).getTime() - new Date(b.date).getTime()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Sample data for performance with large datasets
|
|
* Keeps every Nth point when dataset is too large
|
|
*/
|
|
export function sampleData<T extends ChartDataPoint>(
|
|
data: T[],
|
|
maxPoints: number = 1000
|
|
): T[] {
|
|
if (data.length <= maxPoints) return data;
|
|
|
|
const samplingRate = Math.ceil(data.length / maxPoints);
|
|
const sampled: T[] = [];
|
|
|
|
for (let i = 0; i < data.length; i += samplingRate) {
|
|
sampled.push(data[i]);
|
|
}
|
|
|
|
// Always include the last point
|
|
if (sampled[sampled.length - 1] !== data[data.length - 1]) {
|
|
sampled.push(data[data.length - 1]);
|
|
}
|
|
|
|
return sampled;
|
|
}
|
|
|
|
/**
|
|
* Calculate min/max values for Y-axis domain
|
|
*/
|
|
export function calculateYAxisDomain<T extends ChartDataPoint>(
|
|
data: T[],
|
|
padding: number = 0.1
|
|
): [number, number] {
|
|
if (data.length === 0) return [0, 100];
|
|
|
|
let min = Infinity;
|
|
let max = -Infinity;
|
|
|
|
data.forEach(point => {
|
|
if (isOHLCVData(point)) {
|
|
min = Math.min(min, point.low);
|
|
max = Math.max(max, point.high);
|
|
} else if (isPriceData(point)) {
|
|
min = Math.min(min, point.price);
|
|
max = Math.max(max, point.price);
|
|
}
|
|
});
|
|
|
|
// Add padding
|
|
const range = max - min;
|
|
const paddedMin = min - (range * padding);
|
|
const paddedMax = max + (range * padding);
|
|
|
|
return [paddedMin, paddedMax];
|
|
}
|
|
|
|
/**
|
|
* Calculate volume max for volume indicator Y-axis
|
|
*/
|
|
export function calculateVolumeMax<T extends ChartDataPoint>(data: T[]): number {
|
|
if (data.length === 0 || !isOHLCVData(data[0])) return 0;
|
|
|
|
return Math.max(...data.map(d => (isOHLCVData(d) ? d.volume : 0)));
|
|
}
|
|
|
|
/**
|
|
* Merge multiple data series by date for combination charts
|
|
*/
|
|
export function mergeDataSeries<T extends ChartDataPoint>(
|
|
seriesArray: Array<{ id: string; data: T[] }>
|
|
): Array<{ date: string } & Record<string, string | number>> {
|
|
if (!seriesArray || seriesArray.length === 0) return [];
|
|
|
|
// Create map indexed by normalized trading day.
|
|
const dateMap = new Map<string, { date: string } & Record<string, string | number>>();
|
|
const seriesPointMaps = seriesArray.map((series) => {
|
|
const pointMap = new Map<string, number>();
|
|
|
|
series.data.forEach(point => {
|
|
const date = toTradingDayKey(point.date);
|
|
|
|
if (isOHLCVData(point)) {
|
|
pointMap.set(date, point.close);
|
|
} else if (isPriceData(point)) {
|
|
pointMap.set(date, point.price);
|
|
}
|
|
});
|
|
|
|
pointMap.forEach((_value, date) => {
|
|
const existing = dateMap.get(date) || { date } as { date: string } & Record<string, string | number>;
|
|
dateMap.set(date, existing);
|
|
});
|
|
|
|
return {
|
|
id: series.id,
|
|
pointMap
|
|
};
|
|
});
|
|
|
|
const mergedDates = Array.from(dateMap.keys()).sort((a, b) =>
|
|
new Date(a).getTime() - new Date(b).getTime()
|
|
);
|
|
|
|
// Fill forward each series to keep benchmark/comparison lines aligned on trading days.
|
|
seriesPointMaps.forEach(({ id, pointMap }) => {
|
|
let lastKnownValue: number | null = null;
|
|
|
|
mergedDates.forEach((date) => {
|
|
const currentValue = pointMap.get(date);
|
|
if (typeof currentValue === 'number') {
|
|
lastKnownValue = currentValue;
|
|
}
|
|
|
|
const existing = dateMap.get(date);
|
|
if (existing && lastKnownValue !== null) {
|
|
existing[id] = lastKnownValue;
|
|
}
|
|
});
|
|
});
|
|
|
|
return mergedDates.map((date) => dateMap.get(date)!);
|
|
}
|