Merge branch 't3code/fix-sp500-compare-gaps'

This commit is contained in:
2026-03-13 00:25:49 -04:00
2 changed files with 130 additions and 14 deletions

View 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 }
]);
});
});

View File

@@ -1,6 +1,15 @@
import { subWeeks, subMonths, subYears } from 'date-fns'; import { subWeeks, subMonths, subYears } from 'date-fns';
import type { TimeRange, ChartDataPoint } from '@/lib/types'; 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 * 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>( export function mergeDataSeries<T extends ChartDataPoint>(
seriesArray: Array<{ id: string; data: T[] }> seriesArray: Array<{ id: string; data: T[] }>
): Array<T & Record<string, number>> { ): Array<{ date: string } & Record<string, string | number>> {
if (!seriesArray || seriesArray.length === 0) return []; if (!seriesArray || seriesArray.length === 0) return [];
// Create map indexed by date // Create map indexed by normalized trading day.
const dateMap = new Map<string, T & Record<string, number>>(); 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 => { series.data.forEach(point => {
const date = point.date; const date = toTradingDayKey(point.date);
const existing = dateMap.get(date) || { date } as T & Record<string, number>;
// Add value for this series
if (isOHLCVData(point)) { if (isOHLCVData(point)) {
existing[series.id] = point.close; pointMap.set(date, point.close);
} else if (isPriceData(point)) { } 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); dateMap.set(date, existing);
}); });
return {
id: series.id,
pointMap
};
}); });
// Convert to array and sort by date const mergedDates = Array.from(dateMap.keys()).sort((a, b) =>
return Array.from(dateMap.values()).sort((a, b) => new Date(a).getTime() - new Date(b).getTime()
new Date(a.date).getTime() - new Date(b.date).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)!);
} }