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.

8.1 KB
1// The three bar sources the chart can read, what each offers, and how the Massive and Databento
2// plugins' rows become chart bars. Yahoo's own plan stays in plan.ts.
3import { INTERVALS, isIntraday, mergeBars, toBars, type Bar, type Interval } from './plan'
4
5export const SOURCES = ['yahoo', 'massive', 'databento'] as const
6export type Source = (typeof SOURCES)[number]
7
8export const SOURCE_NAMES: Record<Source, string> = {
9 yahoo: 'Yahoo Finance',
10 massive: 'Massive (delayed)',
11 databento: 'Databento DBEQ.BASIC',
12}
13
14export const SOURCE_INTERVALS: Record<Source, readonly Interval[]> = {
15 yahoo: INTERVALS,
16 massive: INTERVALS,
17 databento: ['1d', '1wk'],
18}
19
20export const DATABENTO_INTRADAY_TIP = "Databento intraday bars aren't available in the installed databento plugin"
21
22/** The interval the chart draws: the one asked for when the source offers it, else 1d. */
23export function effectiveInterval(source: Source, interval: Interval): Interval {
24 return SOURCE_INTERVALS[source].includes(interval) ? interval : '1d'
25}
26
27const day = (now: Date, offset: number) => new Date(now.getTime() + offset * 86_400_000).toISOString().slice(0, 10)
28
29// ---- Massive ------------------------------------------------------------------------------------
30
31const INDEX: Record<string, string> = {
32 '^GSPC': 'I:SPX',
33 '^SPX': 'I:SPX',
34 '^NDX': 'I:NDX',
35 '^DJI': 'I:DJI',
36 '^RUT': 'I:RUT',
37 '^VIX': 'I:VIX',
38}
39
40/** A Yahoo-style symbol as Massive's ticker: ^GSPC is I:SPX, any other ^XYZ is tried as I:XYZ. */
41export function massiveTicker(symbol: string): string {
42 const s = symbol.toUpperCase()
43 return INDEX[s] ?? (s.startsWith('^') ? `I:${s.slice(1)}` : s)
44}
45
46// How far back one call goes. Massive's minute and hour aggregates cover regular AND extended hours
47// (pre-market and after-hours, about 04:00-20:00 ET), so a busy day is up to 960 one-minute bars; each
48// window keeps a liquid stock's bar count under the 50,000 cap of one call (1m ~40k, 2m ~40k, 5m ~24k,
49// 15m ~24k, 30m ~16k, 1h ~8k, 1d ~5k, 1wk ~1k). recent is the window a 5-minute refresh asks for.
50const MASSIVE: Record<Interval, { multiplier: number; timespan: string; days: number; recent: number }> = {
51 '1m': { multiplier: 1, timespan: 'minute', days: 60, recent: 4 },
52 '2m': { multiplier: 2, timespan: 'minute', days: 120, recent: 4 },
53 '5m': { multiplier: 5, timespan: 'minute', days: 180, recent: 4 },
54 '15m': { multiplier: 15, timespan: 'minute', days: 548, recent: 4 },
55 '30m': { multiplier: 30, timespan: 'minute', days: 730, recent: 4 },
56 '1h': { multiplier: 1, timespan: 'hour', days: 730, recent: 4 },
57 '1d': { multiplier: 1, timespan: 'day', days: 7305, recent: 10 },
58 '1wk': { multiplier: 1, timespan: 'week', days: 7305, recent: 21 },
59}
60
61/** One massive/aggregates call. `to` is now in milliseconds, so every call is a fresh run. */
62export function massiveArgs(symbol: string, interval: Interval, kind: 'full' | 'recent', now: Date) {
63 const plan = MASSIVE[interval]
64 return {
65 ticker: massiveTicker(symbol),
66 multiplier: plan.multiplier,
67 timespan: plan.timespan,
68 from: day(now, -(kind === 'full' ? plan.days : plan.recent)),
69 to: String(now.getTime()),
70 adjusted: true,
71 sort: 'asc',
72 limit: 50000,
73 }
74}
75
76/** Massive's rows (time as ISO UTC) as bars: New York wall clock intraday, dates for 1d and 1wk. */
77export function massiveBars(rows: unknown[], interval: Interval, kind: 'full' | 'recent'): Bar[] {
78 const bars = toBars(rows, interval)
79 // A weekly window that starts mid-week answers a partial first week: leave the cached one.
80 return kind === 'recent' && interval === '1wk' ? bars.slice(1) : bars
81}
82
83// ---- Databento ----------------------------------------------------------------------------------
84
85const NY_DATE = new Intl.DateTimeFormat('en-CA', {
86 timeZone: 'America/New_York',
87 year: 'numeric',
88 month: '2-digit',
89 day: '2-digit',
90})
91
92/** A YYYY-MM-DD date moved by whole days. */
93export function shiftDate(date: string, days: number): string {
94 return new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10)
95}
96
97/** The New York calendar date of an instant, moved by whole days. */
98export function nyDate(now: Date, offset = 0): string {
99 const p: Record<string, string> = {}
100 for (const part of NY_DATE.formatToParts(now)) p[part.type] = part.value
101 return shiftDate(`${p.year}-${p.month}-${p.day}`, offset)
102}
103
104const DATABENTO_START = '2023-03-28'
105
106/**
107 * DBEQ.BASIC begins 2023-03-28: an earlier start is refused (422 data_start_before_available_start), so
108 * the full load starts there. Databento refuses the whole call (422 data_end_after_available_end) when
109 * end is past what it has published, so the full load ends yesterday (New York) and a refresh tries
110 * today, each retried once at the end the error names (see databentoRetryEnd). A weekend or holiday
111 * end is fine: Databento answers the trading days before it. nonce varies the limit a little so a
112 * retry or a refresh is a fresh run.
113 */
114export function databentoArgs(symbol: string, kind: 'full' | 'recent', now: Date, nonce: number, endOverride?: string) {
115 const end = endOverride ?? nyDate(now, kind === 'full' ? -1 : 0)
116 const start = kind === 'full' ? DATABENTO_START : shiftDate(end, -7)
117 return {
118 ticker: symbol.toUpperCase(),
119 start: start < end ? start : shiftDate(end, -1),
120 end,
121 limit: 50000 - (nonce % 1000),
122 }
123}
124
125/**
126 * When Databento refused a call because its end is past the published data, the end to try instead:
127 * the date the error names ("data available up to '2026-10-22 00:00:00+00:00'") when it is earlier than
128 * the end tried, else one day before it. Null for any other error.
129 */
130export function databentoRetryEnd(message: string, triedEnd: string): string | null {
131 if (!/data_end_after_available_end|after the available range|available up to|data.?not.?available|not yet available/i.test(message))
132 return null
133 const named = /available up to '?(\d{4}-\d{2}-\d{2})/i.exec(message)?.[1]
134 const end = named && named < triedEnd ? named : shiftDate(triedEnd, -1)
135 return end > DATABENTO_START ? end : null
136}
137
138interface VenueRow {
139 date?: unknown
140 open?: unknown
141 high?: unknown
142 low?: unknown
143 close?: unknown
144 volume?: unknown
145}
146
147/** One bar per day from one row per day per venue: high max, low min, volume summed, open and close from the busiest venue. */
148export function databentoDaily(rows: unknown[]): Bar[] {
149 const days = new Map<string, { bar: Bar; top: number }>()
150 for (const item of rows) {
151 if (!item || typeof item !== 'object') continue
152 const r = item as VenueRow
153 const date = typeof r.date === 'string' ? r.date.slice(0, 10) : ''
154 const [open, high, low, close, volume] = [r.open, r.high, r.low, r.close, r.volume].map(Number)
155 if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || !Number.isFinite(close)) continue
156 const v = Number.isFinite(volume) ? volume : 0
157 const o = Number.isFinite(open) ? open : close
158 const h = Number.isFinite(high) ? high : close
159 const l = Number.isFinite(low) ? low : close
160 const seen = days.get(date)
161 if (!seen) {
162 days.set(date, { bar: { time: date, open: o, high: h, low: l, close, volume: v }, top: v })
163 continue
164 }
165 const b = seen.bar
166 b.high = Math.max(b.high, h)
167 b.low = Math.min(b.low, l)
168 b.volume += v
169 if (v > seen.top) {
170 seen.top = v
171 b.open = o
172 b.close = close
173 }
174 }
175 return mergeBars([[...days.values()].map((d) => d.bar)])
176}
177
178/** Daily bars as weeks starting Monday, each dated its Monday. */
179export function weeklyBars(daily: Bar[]): Bar[] {
180 const weeks = new Map<string, Bar>()
181 for (const d of daily) {
182 const t = new Date(`${String(d.time)}T00:00:00Z`)
183 const monday = new Date(t.getTime() - ((t.getUTCDay() + 6) % 7) * 86_400_000).toISOString().slice(0, 10)
184 const w = weeks.get(monday)
185 if (!w) weeks.set(monday, { ...d, time: monday })
186 else {
187 w.high = Math.max(w.high, d.high)
188 w.low = Math.min(w.low, d.low)
189 w.close = d.close
190 w.volume += d.volume
191 }
192 }
193 return mergeBars([[...weeks.values()]])
194}
195
196/** Whether a source's error is a rate limit worth waiting out. */
197export function isRateLimit(message: string): boolean {
198 return /\b429\b|rate.?limit|too many requests|maximum requests|exceeded the maximum/i.test(message)
199}
200
201export { isIntraday }