SoftTraderFrontend/components/stock-chart/types.ts
2026-03-20 11:10:13 -04:00

261 lines
6.5 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)
}
function getUtcDateKey(timestamp: string) {
const d = new Date(timestamp)
if (Number.isNaN(d.getTime())) return ''
return d.toISOString().slice(0, 10)
}
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) => getUtcDateKey(r.timestamp)).filter(Boolean))]
if (!distinctDays.length) return sorted
const latest = distinctDays[distinctDays.length - 1]
return sorted.filter((r) => getUtcDateKey(r.timestamp) === latest)
}
function keepLatestNTradingDays<T extends { timestamp: string }>(rows: T[], n: number) {
const sorted = sortByTimestamp(rows)
const distinctDays = [...new Set(sorted.map((r) => getUtcDateKey(r.timestamp)).filter(Boolean))]
const keep = new Set(distinctDays.slice(-n))
return sorted.filter((r) => keep.has(getUtcDateKey(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',
}),
},
]