Jaspers Hub

biseroff / charts

pluginReviewed★ 0↓ 0

Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.

5.4 KB
1// What to ask Yahoo for at each interval, and how its rows become chart bars.
2
3export const INTERVALS = ['1m', '2m', '5m', '15m', '30m', '1h', '1d', '1wk'] as const
4export type Interval = (typeof INTERVALS)[number]
5
6export 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. */
9export 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. */
19export interface HistoryArgs {
20 symbol: string
21 interval: string
22 range?: string
23 start?: string
24 end?: string
25}
26
27export 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. */
32export 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". */
47function 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
55const 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. */
67function 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. */
74function 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. */
93export 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
108const num = (v: unknown) => (v === null || v === undefined || v === '' ? NaN : Number(v))
109
110/** The source's rows as bars, oldest first. */
111export 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. */
131export 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}