Fix compare chart date alignment
This commit is contained in:
82
components/charts/utils/chart-data-transformers.test.ts
Normal file
82
components/charts/utils/chart-data-transformers.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { mergeDataSeries } from './chart-data-transformers';
|
||||
|
||||
type PricePoint = {
|
||||
date: string;
|
||||
price: number;
|
||||
};
|
||||
|
||||
describe('mergeDataSeries', () => {
|
||||
it('normalizes intraday timestamps onto the same trading day', () => {
|
||||
const merged = mergeDataSeries<PricePoint>([
|
||||
{
|
||||
id: 'stock',
|
||||
data: [
|
||||
{ date: '2026-03-10T21:00:00.000Z', price: 100 },
|
||||
{ date: '2026-03-11T21:00:00.000Z', price: 101 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 5000 },
|
||||
{ date: '2026-03-11', price: 5050 }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ date: '2026-03-10', stock: 100, sp500: 5000 },
|
||||
{ date: '2026-03-11', stock: 101, sp500: 5050 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('fills a comparison series from the nearest prior trading day when dates are missing', () => {
|
||||
const merged = mergeDataSeries<PricePoint>([
|
||||
{
|
||||
id: 'stock',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 100 },
|
||||
{ date: '2026-03-11', price: 103 },
|
||||
{ date: '2026-03-12', price: 104 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 5000 },
|
||||
{ date: '2026-03-12', price: 5060 }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ date: '2026-03-10', stock: 100, sp500: 5000 },
|
||||
{ date: '2026-03-11', stock: 103, sp500: 5000 },
|
||||
{ date: '2026-03-12', stock: 104, sp500: 5060 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not backfill dates before a series first appears', () => {
|
||||
const merged = mergeDataSeries<PricePoint>([
|
||||
{
|
||||
id: 'stock',
|
||||
data: [
|
||||
{ date: '2026-03-10', price: 100 },
|
||||
{ date: '2026-03-11', price: 103 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'sp500',
|
||||
data: [
|
||||
{ date: '2026-03-11', price: 5000 }
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
expect(merged).toEqual([
|
||||
{ date: '2026-03-10', stock: 100 },
|
||||
{ date: '2026-03-11', stock: 103, sp500: 5000 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,15 @@
|
||||
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
|
||||
*/
|
||||
@@ -128,30 +137,55 @@ export function calculateVolumeMax<T extends ChartDataPoint>(data: T[]): number
|
||||
*/
|
||||
export function mergeDataSeries<T extends ChartDataPoint>(
|
||||
seriesArray: Array<{ id: string; data: T[] }>
|
||||
): Array<T & Record<string, number>> {
|
||||
): Array<{ date: string } & Record<string, string | number>> {
|
||||
if (!seriesArray || seriesArray.length === 0) return [];
|
||||
|
||||
// Create map indexed by date
|
||||
const dateMap = new Map<string, T & Record<string, number>>();
|
||||
// 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>();
|
||||
|
||||
seriesArray.forEach(series => {
|
||||
series.data.forEach(point => {
|
||||
const date = point.date;
|
||||
const existing = dateMap.get(date) || { date } as T & Record<string, number>;
|
||||
const date = toTradingDayKey(point.date);
|
||||
|
||||
// Add value for this series
|
||||
if (isOHLCVData(point)) {
|
||||
existing[series.id] = point.close;
|
||||
pointMap.set(date, point.close);
|
||||
} else if (isPriceData(point)) {
|
||||
existing[series.id] = point.price;
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
return Array.from(dateMap.values()).sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
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)!);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user