Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.
| 1 | // What to ask Yahoo for at each interval, and how its rows become chart bars. |
| 2 | |
| 3 | export const INTERVALS = ['1m', '2m', '5m', '15m', '30m', '1h', '1d', '1wk'] as const |
| 4 | export type Interval = (typeof INTERVALS)[number] |
| 5 | |
| 6 | export const DEFAULT_SYMBOLS = ['FLWS', 'BRCB', 'GO', 'CWH', 'JBSS', 'WEN'] |
| 7 | |
| 8 | /** One bar. time is a 'YYYY-MM-DD' date for daily and weekly bars, else seconds of New York wall-clock time read as UTC. */ |
| 9 | export interface Bar { |
| 10 | time: number | string |
| 11 | open: number |
| 12 | high: number |
| 13 | low: number |
| 14 | close: number |
| 15 | volume: number |
| 16 | } |
| 17 | |
| 18 | /** The arguments of one history call. */ |
| 19 | export interface HistoryArgs { |
| 20 | symbol: string |
| 21 | interval: string |
| 22 | range?: string |
| 23 | start?: string |
| 24 | end?: string |
| 25 | } |
| 26 | |
| 27 | export function isIntraday(interval: Interval): boolean { |
| 28 | return interval !== '1d' && interval !== '1wk' |
| 29 | } |
| 30 | |
| 31 | /** The calls covering the longest history Yahoo allows at this interval, newest last. */ |
| 32 | export function requestsFor(symbol: string, interval: Interval, now: Date): HistoryArgs[] { |
| 33 | const day = (offset: number) => new Date(now.getTime() + offset * 86_400_000).toISOString().slice(0, 10) |
| 34 | // range 'max' answers monthly bars whatever the interval; a start date far back keeps the interval. |
| 35 | if (interval === '1d' || interval === '1wk') return [{ symbol, interval, start: '1970-01-01' }] |
| 36 | if (interval === '1h') return [{ symbol, interval, start: day(-728) }] |
| 37 | if (interval !== '1m') return [{ symbol, interval, start: day(-58) }] |
| 38 | // 1m: the tool chains Yahoo's 7-day windows itself from a start about 28 days back. The 7-day call |
| 39 | // beside it still answers when Yahoo refuses the longer one; the two are merged. |
| 40 | return [ |
| 41 | { symbol, interval, start: day(-28) }, |
| 42 | { symbol, interval, start: day(-7) }, |
| 43 | ] |
| 44 | } |
| 45 | |
| 46 | /** Reads a field by any of its spellings: Close, close, adj_close, "Adj Close". */ |
| 47 | function field(row: Record<string, unknown>, ...names: string[]): unknown { |
| 48 | for (const [key, value] of Object.entries(row)) { |
| 49 | const k = key.toLowerCase().replace(/[\s_-]/g, '') |
| 50 | if (names.includes(k)) return value |
| 51 | } |
| 52 | return undefined |
| 53 | } |
| 54 | |
| 55 | const NY = new Intl.DateTimeFormat('en-US', { |
| 56 | timeZone: 'America/New_York', |
| 57 | hourCycle: 'h23', |
| 58 | year: 'numeric', |
| 59 | month: '2-digit', |
| 60 | day: '2-digit', |
| 61 | hour: '2-digit', |
| 62 | minute: '2-digit', |
| 63 | second: '2-digit', |
| 64 | }) |
| 65 | |
| 66 | /** An instant as New York wall-clock seconds read as UTC, which lightweight-charts shows as is. */ |
| 67 | function nyWallSeconds(ms: number): number { |
| 68 | const p: Record<string, number> = {} |
| 69 | for (const part of NY.formatToParts(new Date(ms))) if (part.type !== 'literal') p[part.type] = Number(part.value) |
| 70 | return Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second) / 1000 |
| 71 | } |
| 72 | |
| 73 | /** A row's date as the chart's time, or null when it cannot be read. */ |
| 74 | function toTime(raw: unknown, intraday: boolean): number | string | null { |
| 75 | if (typeof raw === 'number') { |
| 76 | const ms = raw > 1e11 ? raw : raw * 1000 |
| 77 | return intraday ? nyWallSeconds(ms) : new Date(ms).toISOString().slice(0, 10) |
| 78 | } |
| 79 | if (typeof raw !== 'string') return null |
| 80 | const s = raw.trim() |
| 81 | const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/.exec(s) |
| 82 | if (!m) return null |
| 83 | if (!intraday) return `${m[1]}-${m[2]}-${m[3]}` |
| 84 | if (m[7]) { |
| 85 | const ms = Date.parse(s.replace(' ', 'T')) |
| 86 | return Number.isFinite(ms) ? nyWallSeconds(ms) : null |
| 87 | } |
| 88 | // No offset: Yahoo's own New York wall clock. |
| 89 | return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0)) / 1000 |
| 90 | } |
| 91 | |
| 92 | /** The rows written as CSV in the tool's text, for when it answers no rows. */ |
| 93 | export function rowsFromText(text: string | undefined | null): Record<string, string>[] { |
| 94 | if (!text) return [] |
| 95 | const lines = text.split('\n') |
| 96 | const head = lines.findIndex((l) => /^date,/i.test(l.trim())) |
| 97 | if (head < 0) return [] |
| 98 | const keys = lines[head].trim().split(',') |
| 99 | const rows: Record<string, string>[] = [] |
| 100 | for (const line of lines.slice(head + 1)) { |
| 101 | if (!/^\d{4}-\d{2}-\d{2}/.test(line.trim())) break |
| 102 | const cells = line.trim().split(',') |
| 103 | rows.push(Object.fromEntries(keys.map((k, i) => [k, cells[i] ?? '']))) |
| 104 | } |
| 105 | return rows |
| 106 | } |
| 107 | |
| 108 | const num = (v: unknown) => (v === null || v === undefined || v === '' ? NaN : Number(v)) |
| 109 | |
| 110 | /** The source's rows as bars, oldest first. */ |
| 111 | export function toBars(rows: unknown[], interval: Interval): Bar[] { |
| 112 | const intraday = isIntraday(interval) |
| 113 | const bars: Bar[] = [] |
| 114 | for (const item of rows) { |
| 115 | if (!item || typeof item !== 'object') continue |
| 116 | const row = item as Record<string, unknown> |
| 117 | const time = toTime(field(row, 'date', 'datetime', 'time', 'timestamp', 'index'), intraday) |
| 118 | const close = num(field(row, 'close')) |
| 119 | if (time === null || !Number.isFinite(close)) continue |
| 120 | const pick = (v: number) => (Number.isFinite(v) ? v : close) |
| 121 | const open = pick(num(field(row, 'open'))) |
| 122 | const high = Math.max(pick(num(field(row, 'high'))), open, close) |
| 123 | const low = Math.min(pick(num(field(row, 'low'))), open, close) |
| 124 | const volume = num(field(row, 'volume')) |
| 125 | bars.push({ time, open, high, low, close, volume: Number.isFinite(volume) ? volume : 0 }) |
| 126 | } |
| 127 | return mergeBars([bars]) |
| 128 | } |
| 129 | |
| 130 | /** Several calls' bars as one series, oldest first, each time once. */ |
| 131 | export function mergeBars(lists: Bar[][]): Bar[] { |
| 132 | const byTime = new Map<string, Bar>() |
| 133 | for (const list of lists) for (const bar of list) byTime.set(String(bar.time), bar) |
| 134 | return [...byTime.values()].sort((a, b) => |
| 135 | typeof a.time === 'number' && typeof b.time === 'number' |
| 136 | ? a.time - b.time |
| 137 | : String(a.time).localeCompare(String(b.time)), |
| 138 | ) |
| 139 | } |