// What to ask Yahoo for at each interval, and how its rows become chart bars. export const INTERVALS = ['1m', '2m', '5m', '15m', '30m', '1h', '1d', '1wk'] as const export type Interval = (typeof INTERVALS)[number] export const DEFAULT_SYMBOLS = ['FLWS', 'BRCB', 'GO', 'CWH', 'JBSS', 'WEN'] /** 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. */ export interface Bar { time: number | string open: number high: number low: number close: number volume: number } /** The arguments of one history call. */ export interface HistoryArgs { symbol: string interval: string range?: string start?: string end?: string } export function isIntraday(interval: Interval): boolean { return interval !== '1d' && interval !== '1wk' } /** The calls covering the longest history Yahoo allows at this interval, newest last. */ export function requestsFor(symbol: string, interval: Interval, now: Date): HistoryArgs[] { const day = (offset: number) => new Date(now.getTime() + offset * 86_400_000).toISOString().slice(0, 10) // range 'max' answers monthly bars whatever the interval; a start date far back keeps the interval. if (interval === '1d' || interval === '1wk') return [{ symbol, interval, start: '1970-01-01' }] if (interval === '1h') return [{ symbol, interval, start: day(-728) }] if (interval !== '1m') return [{ symbol, interval, start: day(-58) }] // 1m: the tool chains Yahoo's 7-day windows itself from a start about 28 days back. The 7-day call // beside it still answers when Yahoo refuses the longer one; the two are merged. return [ { symbol, interval, start: day(-28) }, { symbol, interval, start: day(-7) }, ] } /** Reads a field by any of its spellings: Close, close, adj_close, "Adj Close". */ function field(row: Record, ...names: string[]): unknown { for (const [key, value] of Object.entries(row)) { const k = key.toLowerCase().replace(/[\s_-]/g, '') if (names.includes(k)) return value } return undefined } const NY = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }) /** An instant as New York wall-clock seconds read as UTC, which lightweight-charts shows as is. */ function nyWallSeconds(ms: number): number { const p: Record = {} for (const part of NY.formatToParts(new Date(ms))) if (part.type !== 'literal') p[part.type] = Number(part.value) return Date.UTC(p.year, p.month - 1, p.day, p.hour % 24, p.minute, p.second) / 1000 } /** A row's date as the chart's time, or null when it cannot be read. */ function toTime(raw: unknown, intraday: boolean): number | string | null { if (typeof raw === 'number') { const ms = raw > 1e11 ? raw : raw * 1000 return intraday ? nyWallSeconds(ms) : new Date(ms).toISOString().slice(0, 10) } if (typeof raw !== 'string') return null const s = raw.trim() const m = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?(?:\.\d+)?)?\s*(Z|[+-]\d{2}:?\d{2})?$/.exec(s) if (!m) return null if (!intraday) return `${m[1]}-${m[2]}-${m[3]}` if (m[7]) { const ms = Date.parse(s.replace(' ', 'T')) return Number.isFinite(ms) ? nyWallSeconds(ms) : null } // No offset: Yahoo's own New York wall clock. return Date.UTC(+m[1], +m[2] - 1, +m[3], +(m[4] ?? 0), +(m[5] ?? 0), +(m[6] ?? 0)) / 1000 } /** The rows written as CSV in the tool's text, for when it answers no rows. */ export function rowsFromText(text: string | undefined | null): Record[] { if (!text) return [] const lines = text.split('\n') const head = lines.findIndex((l) => /^date,/i.test(l.trim())) if (head < 0) return [] const keys = lines[head].trim().split(',') const rows: Record[] = [] for (const line of lines.slice(head + 1)) { if (!/^\d{4}-\d{2}-\d{2}/.test(line.trim())) break const cells = line.trim().split(',') rows.push(Object.fromEntries(keys.map((k, i) => [k, cells[i] ?? '']))) } return rows } const num = (v: unknown) => (v === null || v === undefined || v === '' ? NaN : Number(v)) /** The source's rows as bars, oldest first. */ export function toBars(rows: unknown[], interval: Interval): Bar[] { const intraday = isIntraday(interval) const bars: Bar[] = [] for (const item of rows) { if (!item || typeof item !== 'object') continue const row = item as Record const time = toTime(field(row, 'date', 'datetime', 'time', 'timestamp', 'index'), intraday) const close = num(field(row, 'close')) if (time === null || !Number.isFinite(close)) continue const pick = (v: number) => (Number.isFinite(v) ? v : close) const open = pick(num(field(row, 'open'))) const high = Math.max(pick(num(field(row, 'high'))), open, close) const low = Math.min(pick(num(field(row, 'low'))), open, close) const volume = num(field(row, 'volume')) bars.push({ time, open, high, low, close, volume: Number.isFinite(volume) ? volume : 0 }) } return mergeBars([bars]) } /** Several calls' bars as one series, oldest first, each time once. */ export function mergeBars(lists: Bar[][]): Bar[] { const byTime = new Map() for (const list of lists) for (const bar of list) byTime.set(String(bar.time), bar) return [...byTime.values()].sort((a, b) => typeof a.time === 'number' && typeof b.time === 'number' ? a.time - b.time : String(a.time).localeCompare(String(b.time)), ) }