267 lines
6.8 KiB
TypeScript
267 lines
6.8 KiB
TypeScript
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<T extends { timestamp: string }>(rows: T[]) {
|
|
return [...rows].sort(
|
|
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
)
|
|
}
|
|
|
|
function keepLatestTradingDay<T extends { timestamp: string }>(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<T extends { timestamp: string }>(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<string, unknown>
|
|
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',
|
|
}),
|
|
},
|
|
]
|