Charts: Interactive price chart view with lines or candles and volume, backed by Yahoo, Massive, or Databento bar data.
| 1 | import { useData } from '@jaspers-ai/sdk' |
| 2 | import { useEffect, useMemo, useRef, useState } from 'react' |
| 3 | import { mergeBars, requestsFor, rowsFromText, toBars, type Bar, type HistoryArgs, type Interval } from './plan' |
| 4 | |
| 5 | /** What one symbol's calls have answered so far. */ |
| 6 | export interface FeedResult { |
| 7 | bars: Bar[] |
| 8 | error: string | null |
| 9 | loading: boolean |
| 10 | /** A short state for the chip: queued, rate-limited, retrying in 20s, failed, refresh failed. */ |
| 11 | note?: string |
| 12 | /** The message behind the note, for the chip's tooltip. */ |
| 13 | detail?: string |
| 14 | } |
| 15 | |
| 16 | const EMPTY: FeedResult = { bars: [], error: null, loading: true } |
| 17 | |
| 18 | /** Runs every call for one symbol at one interval and reports the merged bars under "interval:symbol"; draws nothing. */ |
| 19 | export function SymbolFeed(props: { |
| 20 | symbol: string |
| 21 | interval: Interval |
| 22 | tick: number |
| 23 | onResult: (symbol: string, result: FeedResult) => void |
| 24 | }) { |
| 25 | const { symbol, interval, tick, onResult } = props |
| 26 | // Dates are worked out again on each refresh, so a new day moves the window. |
| 27 | const requests = useMemo(() => requestsFor(symbol, interval, new Date()), [symbol, interval, tick]) |
| 28 | const [parts, setParts] = useState<FeedResult[]>([]) |
| 29 | useEffect(() => setParts([]), [symbol, interval]) |
| 30 | |
| 31 | useEffect(() => { |
| 32 | const done = requests.map((_, i) => parts[i] ?? EMPTY) |
| 33 | const bars = mergeBars(done.map((p) => p.bars)) |
| 34 | const loading = done.some((p) => p.loading) |
| 35 | // Older 1m chunks Yahoo refuses are dropped quietly while any call has bars. |
| 36 | const error = bars.length === 0 && !loading ? (done.find((p) => p.error)?.error ?? 'No data') : null |
| 37 | onResult(`yahoo:${interval}:${symbol}`, { bars, error, loading }) |
| 38 | }, [parts, requests, symbol, interval, onResult]) |
| 39 | |
| 40 | return ( |
| 41 | <> |
| 42 | {requests.map((args, i) => ( |
| 43 | <Chunk |
| 44 | key={`${interval}-${i}`} |
| 45 | args={args} |
| 46 | interval={interval} |
| 47 | tick={tick} |
| 48 | onDone={(result) => |
| 49 | setParts((prev) => { |
| 50 | const next = prev.slice() |
| 51 | next[i] = result |
| 52 | return next |
| 53 | }) |
| 54 | } |
| 55 | /> |
| 56 | ))} |
| 57 | </> |
| 58 | ) |
| 59 | } |
| 60 | |
| 61 | /** One history call. Runs again when its arguments change, and on each refresh tick. */ |
| 62 | function Chunk(props: { args: HistoryArgs; interval: Interval; tick: number; onDone: (r: FeedResult) => void }) { |
| 63 | const { args, interval, tick, onDone } = props |
| 64 | const { data, text, error, loading, refetch } = useData('charts/history', { ...args }) |
| 65 | const lastTick = useRef(tick) |
| 66 | const onDoneRef = useRef(onDone) |
| 67 | onDoneRef.current = onDone |
| 68 | |
| 69 | useEffect(() => { |
| 70 | if (lastTick.current === tick) return |
| 71 | lastTick.current = tick |
| 72 | refetch?.() |
| 73 | }, [tick, refetch]) |
| 74 | |
| 75 | useEffect(() => { |
| 76 | const rows = Array.isArray(data) && data.length > 0 ? (data as unknown[]) : rowsFromText(text) |
| 77 | const bars = toBars(rows, interval) |
| 78 | const pending = !error && (loading || (data === undefined && !text)) |
| 79 | const message = error |
| 80 | ? String(error).split('\n')[0] |
| 81 | : bars.length === 0 && !pending |
| 82 | ? (text ?? 'No data').split('\n')[0].slice(0, 160) |
| 83 | : null |
| 84 | onDoneRef.current({ bars, error: message, loading: pending && bars.length === 0 }) |
| 85 | }, [data, text, error, loading, interval]) |
| 86 | |
| 87 | return null |
| 88 | } |