Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.
| 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. |
| 3 | import { INTERVALS, isIntraday, mergeBars, toBars, type Bar, type Interval } from './plan' |
| 4 | |
| 5 | export const SOURCES = ['yahoo', 'massive', 'databento'] as const |
| 6 | export type Source = (typeof SOURCES)[number] |
| 7 | |
| 8 | export const SOURCE_NAMES: Record<Source, string> = { |
| 9 | yahoo: 'Yahoo Finance', |
| 10 | massive: 'Massive (delayed)', |
| 11 | databento: 'Databento DBEQ.BASIC', |
| 12 | } |
| 13 | |
| 14 | export const SOURCE_INTERVALS: Record<Source, readonly Interval[]> = { |
| 15 | yahoo: INTERVALS, |
| 16 | massive: INTERVALS, |
| 17 | databento: ['1d', '1wk'], |
| 18 | } |
| 19 | |
| 20 | export 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. */ |
| 23 | export function effectiveInterval(source: Source, interval: Interval): Interval { |
| 24 | return SOURCE_INTERVALS[source].includes(interval) ? interval : '1d' |
| 25 | } |
| 26 | |
| 27 | const day = (now: Date, offset: number) => new Date(now.getTime() + offset * 86_400_000).toISOString().slice(0, 10) |
| 28 | |
| 29 | // ---- Massive ------------------------------------------------------------------------------------ |
| 30 | |
| 31 | const 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. */ |
| 41 | export 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. |
| 50 | const 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. */ |
| 62 | export 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. */ |
| 77 | export 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 | |
| 85 | const 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. */ |
| 93 | export 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. */ |
| 98 | export 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 | |
| 104 | const 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 | */ |
| 114 | export 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 | */ |
| 130 | export 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 | |
| 138 | interface 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. */ |
| 148 | export 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. */ |
| 179 | export 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. */ |
| 197 | export function isRateLimit(message: string): boolean { |
| 198 | return /\b429\b|rate.?limit|too many requests|maximum requests|exceeded the maximum/i.test(message) |
| 199 | } |
| 200 | |
| 201 | export { isIntraday } |