export type TimeRange = '1D' | '5D' | '1M' | '3M' | '6M' | 'YTD' | '1Y' | '3Y' | '5Y' export interface RawHistoricalLinePoint { timestamp: string price: number | null volume: number | null } export interface ChartPoint { index: number timestamp: string // ISO 8601 UTC price: number | null volume: number | null percentChange: number | null } export interface TimeRangeConfig { label: TimeRange duration: string barSize: string tickInterval: (data: ChartPoint[]) => number[] formatLabel: (timestamp: string) => string } function formatInLocalTime( timestamp: string, opts: Intl.DateTimeFormatOptions ): string { const date = new Date(timestamp) if (Number.isNaN(date.getTime())) return '' return new Intl.DateTimeFormat(undefined, opts).format(date) } /** * Get the trading day key for a timestamp based on US Eastern time * (NYSE operates in Eastern time, so trading days should be determined by ET, not UTC) */ function getTradingDayKey(timestamp: string) { const d = new Date(timestamp) if (Number.isNaN(d.getTime())) return '' // Format in US Eastern time to get the correct trading day return d.toLocaleDateString('en-CA', { timeZone: 'America/New_York' }) // 'en-CA' gives YYYY-MM-DD format } function sortByTimestamp(rows: T[]) { return [...rows].sort( (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ) } function keepLatestTradingDay(rows: T[]) { const sorted = sortByTimestamp(rows) const distinctDays = [...new Set(sorted.map((r) => getTradingDayKey(r.timestamp)).filter(Boolean))] if (!distinctDays.length) return sorted const latest = distinctDays[distinctDays.length - 1] return sorted.filter((r) => getTradingDayKey(r.timestamp) === latest) } function keepLatestNTradingDays(rows: T[], n: number) { const sorted = sortByTimestamp(rows) const distinctDays = [...new Set(sorted.map((r) => getTradingDayKey(r.timestamp)).filter(Boolean))] const keep = new Set(distinctDays.slice(-n)) return sorted.filter((r) => keep.has(getTradingDayKey(r.timestamp))) } function normalizeRawRows(input: unknown[]): RawHistoricalLinePoint[] { return input .map((row): RawHistoricalLinePoint | null => { if (!row || typeof row !== 'object') return null const r = row as Record const timestamp = typeof r.timestamp === 'string' ? r.timestamp : null const price = typeof r.price === 'number' ? r.price : typeof r.price === 'string' ? Number(r.price) : null const volume = typeof r.volume === 'number' ? r.volume : typeof r.volume === 'string' ? Number(r.volume) : null if (!timestamp) return null const parsed = new Date(timestamp) if (Number.isNaN(parsed.getTime())) return null return { timestamp: parsed.toISOString(), price: price !== null && Number.isFinite(price) ? price : null, volume: volume !== null && Number.isFinite(volume) ? volume : null, } }) .filter((row): row is RawHistoricalLinePoint => row !== null) } export function buildChartData(input: unknown[], range: TimeRange): ChartPoint[] { let rows = normalizeRawRows(input) rows = sortByTimestamp(rows) if (range === '1D') { rows = keepLatestTradingDay(rows) } else if (range === '5D') { rows = keepLatestNTradingDays(rows, 5) } const firstValid = rows.find((r) => r.price !== null) const openPrice = firstValid?.price ?? null return rows.map((row, index) => { const percentChange = row.price !== null && openPrice !== null && openPrice !== 0 ? ((row.price - openPrice) / openPrice) * 100 : null return { index, timestamp: row.timestamp, price: row.price, volume: row.volume, percentChange, } }) } function tickEveryN(data: ChartPoint[], count: number) { if (data.length <= 1) return [0] const step = Math.max(1, Math.floor((data.length - 1) / count)) const indices: number[] = [] for (let i = 0; i < data.length; i += step) { indices.push(i) } if (indices[indices.length - 1] !== data.length - 1) { indices.push(data.length - 1) } return [...new Set(indices)] } function tickByDistinctLocalDays(data: ChartPoint[]) { const out: number[] = [] let lastDay = '' for (let i = 0; i < data.length; i++) { const label = formatInLocalTime(data[i].timestamp, { year: 'numeric', month: '2-digit', day: '2-digit', }) if (label !== lastDay) { out.push(i) lastDay = label } } return out } export const TIME_RANGE_CONFIGS: TimeRangeConfig[] = [ { label: '1D', duration: '1 D', barSize: 'FiveMins', tickInterval: (data) => tickEveryN(data, 4), formatLabel: (timestamp) => formatInLocalTime(timestamp, { hour: '2-digit', minute: '2-digit', hour12: false, }), }, { label: '5D', duration: '5 D', barSize: 'FiveMins', tickInterval: (data) => tickByDistinctLocalDays(data), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', day: 'numeric', }), }, { label: '1M', duration: '1 M', barSize: 'OneHour', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', day: 'numeric', }), }, { label: '3M', duration: '3 M', barSize: 'OneDay', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', day: 'numeric', }), }, { label: '6M', duration: '6 M', barSize: 'OneDay', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', day: 'numeric', }), }, { label: 'YTD', duration: '1 Y', barSize: 'OneDay', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', }), }, { label: '1Y', duration: '1 Y', barSize: 'OneDay', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', }), }, { label: '3Y', duration: '3 Y', barSize: 'OneWeek', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { month: 'short', year: '2-digit', }), }, { label: '5Y', duration: '5 Y', barSize: 'OneMonth', tickInterval: (data) => tickEveryN(data, 5), formatLabel: (timestamp) => formatInLocalTime(timestamp, { year: '2-digit', }), }, ]