diff --git a/components/charts/utils/chart-data-transformers.test.ts b/components/charts/utils/chart-data-transformers.test.ts new file mode 100644 index 0000000..a76ee95 --- /dev/null +++ b/components/charts/utils/chart-data-transformers.test.ts @@ -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([ + { + 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([ + { + 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([ + { + 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 } + ]); + }); +}); diff --git a/components/charts/utils/chart-data-transformers.ts b/components/charts/utils/chart-data-transformers.ts index 3c060af..f306ccd 100644 --- a/components/charts/utils/chart-data-transformers.ts +++ b/components/charts/utils/chart-data-transformers.ts @@ -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(data: T[]): number */ export function mergeDataSeries( seriesArray: Array<{ id: string; data: T[] }> -): Array> { +): Array<{ date: string } & Record> { if (!seriesArray || seriesArray.length === 0) return []; - // Create map indexed by date - const dateMap = new Map>(); + // Create map indexed by normalized trading day. + const dateMap = new Map>(); + const seriesPointMaps = seriesArray.map((series) => { + const pointMap = new Map(); - seriesArray.forEach(series => { series.data.forEach(point => { - const date = point.date; - const existing = dateMap.get(date) || { date } as T & Record; + 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; + 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; } - dateMap.set(date, existing); + const existing = dateMap.get(date); + if (existing && lastKnownValue !== null) { + existing[id] = lastKnownValue; + } }); }); - // 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() - ); + return mergedDates.map((date) => dateMap.get(date)!); }