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.

7.5 KB
1import { useData } from '@jaspers-ai/sdk'
2import { useEffect, useRef, useState } from 'react'
3import type { FeedResult } from './Feeds'
4import { mergeBars, type Bar, type Interval } from './plan'
5import { databentoArgs, databentoDaily, databentoRetryEnd, isRateLimit, massiveArgs, massiveBars, weeklyBars } from './sources'
6
7/** Bars kept across source and interval switches: "massive:<interval>:<symbol>" and "databento:<symbol>" (daily). */
8export type BarCache = Map<string, Bar[]>
9
10interface Job {
11 id: number
12 symbol: string
13 kind: 'full' | 'recent'
14 attempt: number
15 args: Record<string, unknown>
16 /** Databento: the earlier end a refused call is retried at, and how many times it was moved. */
17 end?: string
18 endTries: number
19}
20
21// Massive's limit is per minute: the first wait is half a window, then whole windows.
22const BACKOFF_S = [30, 65, 65, 90, 120, 120]
23
24/**
25 * Loads Massive or Databento bars one symbol at a time, through the installed plugins' sources: a full
26 * history on first sight of a symbol, a short recent window merged in on each refresh tick, and a
27 * rate-limited call waited out and tried again. Draws nothing; reports each symbol under
28 * "<source>:<interval>:<symbol>".
29 */
30export function QueuedFeeds(props: {
31 source: 'massive' | 'databento'
32 symbols: string[]
33 interval: Interval
34 tick: number
35 cache: BarCache
36 onResult: (key: string, result: FeedResult) => void
37}) {
38 const { source, symbols, interval, tick, cache, onResult } = props
39 const [queue, setQueue] = useState<Job[]>([])
40 const [waiting, setWaiting] = useState(false)
41 const known = useRef(new Set<string>())
42 const ids = useRef(1)
43 const timer = useRef<ReturnType<typeof setTimeout> | null>(null)
44 const firstTick = useRef(tick)
45 useEffect(() => () => void (timer.current && clearTimeout(timer.current)), [])
46
47 const cacheKey = (s: string) => (source === 'databento' ? `databento:${s}` : `massive:${interval}:${s}`)
48 const shown = (s: string) => {
49 const bars = cache.get(cacheKey(s))
50 return bars && source === 'databento' && interval === '1wk' ? weeklyBars(bars) : bars
51 }
52 const report = (s: string, r: Partial<FeedResult>) =>
53 onResult(`${source}:${interval}:${s}`, { bars: shown(s) ?? [], error: null, loading: false, ...r })
54 const job = (symbol: string, kind: 'full' | 'recent', attempt = 0, end?: string, endTries = 0): Job => {
55 const now = new Date()
56 const id = ids.current++
57 const args =
58 source === 'massive' ? massiveArgs(symbol, interval, kind, now) : databentoArgs(symbol, kind, now, id + attempt, end)
59 return { id, symbol, kind, attempt, args, end, endTries }
60 }
61 const local = (s: string) => (source === 'databento' && s.startsWith('^') ? 'no index data on Databento' : null)
62
63 // New symbols: cached bars at once, else a full load queued. Removed symbols leave the queue.
64 useEffect(() => {
65 const add: Job[] = []
66 for (const s of symbols) {
67 if (known.current.has(s)) continue
68 known.current.add(s)
69 const refused = local(s)
70 if (refused) report(s, { error: refused, note: 'failed' })
71 else if (cache.has(cacheKey(s))) report(s, {})
72 else {
73 report(s, { loading: true, note: 'queued' })
74 add.push(job(s, 'full'))
75 }
76 }
77 for (const s of [...known.current]) if (!symbols.includes(s)) known.current.delete(s)
78 setQueue((q) => [...q.filter((j) => symbols.includes(j.symbol)), ...add])
79 }, [symbols.join(',')])
80
81 // Each refresh: a recent window for every cached symbol, the full load again for one that failed.
82 useEffect(() => {
83 if (tick === firstTick.current) return
84 setQueue((q) => {
85 const add: Job[] = []
86 for (const s of symbols) {
87 if (local(s) || q.some((j) => j.symbol === s)) continue
88 add.push(job(s, cache.has(cacheKey(s)) ? 'recent' : 'full'))
89 }
90 return [...q, ...add]
91 })
92 }, [tick])
93
94 const done = (j: Job, result: { rows?: unknown[]; error?: string }) => {
95 const key = cacheKey(j.symbol)
96 const had = cache.has(key)
97 if (result.error !== undefined) {
98 const message = result.error.split('\n')[0].slice(0, 200)
99 // Databento has not published up to the end asked: try once more at the end it names, quietly.
100 const earlier = source === 'databento' && j.endTries < 1 ? databentoRetryEnd(result.error, String(j.args.end)) : null
101 if (earlier) {
102 const next = job(j.symbol, j.kind, j.attempt, earlier, j.endTries + 1)
103 setQueue((q) => q.map((x) => (x.id === j.id ? next : x)))
104 return
105 }
106 if (isRateLimit(message) && j.attempt < BACKOFF_S.length) {
107 const wait = BACKOFF_S[j.attempt]
108 report(j.symbol, { loading: !had, note: `rate-limited, retrying in ${wait}s`, error: null, detail: message })
109 setWaiting(true)
110 timer.current = setTimeout(() => {
111 setQueue((q) => (q[0]?.id === j.id ? [job(j.symbol, j.kind, j.attempt + 1, j.end, j.endTries), ...q.slice(1)] : q))
112 setWaiting(false)
113 }, wait * 1000)
114 return
115 }
116 // An index the Massive plan does not cover says so, rather than the raw 403.
117 const shownError =
118 source === 'massive' && String(j.args.ticker).startsWith('I:') && /\b403\b|not_authorized|not entitled|does not include/i.test(message)
119 ? 'no index data on your plan'
120 : message
121 // A refresh that fails keeps the bars already drawn.
122 report(j.symbol, had ? { note: 'refresh failed', detail: message } : { error: shownError, note: 'failed', detail: message })
123 } else {
124 const rows = result.rows ?? []
125 const bars = source === 'massive' ? massiveBars(rows, interval, j.kind) : databentoDaily(rows)
126 if (j.kind === 'full') {
127 if (bars.length > 0) cache.set(key, bars)
128 } else if (bars.length > 0) cache.set(key, mergeBars([cache.get(key) ?? [], bars]))
129 if (cache.has(key)) report(j.symbol, {})
130 else report(j.symbol, { error: 'No data', note: 'failed' })
131 // Databento's end is exclusive: the full load ends yesterday, so ask at once for the last published day.
132 if (source === 'databento' && j.kind === 'full' && bars.length > 0) {
133 const next = job(j.symbol, 'recent')
134 setQueue((q) => [...q.filter((x) => x.id !== j.id), next])
135 return
136 }
137 }
138 setQueue((q) => q.filter((x) => x.id !== j.id))
139 }
140
141 const head = waiting ? undefined : queue[0]
142 // The symbol now loading shows as loading, not queued; a refresh keeps its chip as it is.
143 useEffect(() => {
144 if (head && head.kind === 'full') report(head.symbol, { loading: true, note: head.attempt > 0 ? 'retrying' : undefined })
145 }, [head?.id])
146 if (!head) return null
147 return (
148 <Request
149 key={head.id}
150 sourceId={source === 'massive' ? 'massive/aggregates' : 'databento/underlying-ohlcv'}
151 args={head.args}
152 onDone={(r) => done(head, r)}
153 />
154 )
155}
156
157/** One call to another plugin's source; reports once when it answers. */
158function Request(props: { sourceId: string; args: Record<string, unknown>; onDone: (r: { rows?: unknown[]; error?: string }) => void }) {
159 const { data, text, error, loading } = useData(props.sourceId, props.args)
160 const fired = useRef(false)
161 const onDone = useRef(props.onDone)
162 onDone.current = props.onDone
163 useEffect(() => {
164 if (fired.current) return
165 if (error) {
166 fired.current = true
167 onDone.current({ error: String(error) })
168 } else if (!loading && Array.isArray(data)) {
169 fired.current = true
170 onDone.current({ rows: data as unknown[] })
171 } else if (!loading && data === undefined && text) {
172 fired.current = true
173 onDone.current({ error: text })
174 }
175 }, [data, text, error, loading])
176 return null
177}