Jaspers Hub

biseroff / massive

pluginReviewed★ 0↓ 3

Massive Market Data (Polygon.io): Market data plugin wrapping the Polygon.io/Massive REST API: bars, snapshots, options, news, financials, indicators, and four chart/watchlist views.

5.3 KB
1import { useData, usePublish, type PanelRef } from '@jaspers-ai/sdk'
2import './styles.css'
3
4interface Bar {
5 time: string
6 open: number
7 high: number
8 low: number
9 close: number
10 volume: number | null
11 [field: string]: unknown
12}
13
14interface State {
15 ticker?: string
16 multiplier?: number
17 timespan?: string
18 from?: string
19 to?: string
20 style?: 'candles' | 'line'
21}
22
23const W = 800
24const PRICE_H = 250
25const VOL_TOP = 262
26const VOL_H = 60
27const H = VOL_TOP + VOL_H + 18
28const RIGHT = 56
29
30const day = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10)
31
32export function PriceView({ panel }: { panel: PanelRef }) {
33 const state = useData(`workspaces/${panel.workspaceId}/panels/${panel.id}/state`) as State | undefined
34 if (state === undefined) return <p className="mv-note">Loading…</p>
35 const args = {
36 ticker: (state.ticker ?? 'AAPL').trim().toUpperCase(),
37 multiplier: state.multiplier ?? 1,
38 timespan: state.timespan ?? 'day',
39 from: state.from || day(182),
40 to: state.to || day(0),
41 sort: 'asc',
42 limit: 5000,
43 }
44 return <Chart panel={panel} args={args} style={state.style ?? 'candles'} />
45}
46
47function Chart({ panel, args, style }: { panel: PanelRef; args: Record<string, string | number>; style: string }) {
48 const { data, error, loading, refetch } = useData('massive/aggregates', args)
49 const bars = ((data ?? []) as Bar[]).filter((b) => typeof b.close === 'number')
50 const lastBar = bars[bars.length - 1]
51 usePublish(panel, {
52 ticker: String(args.ticker),
53 bars: bars.length,
54 first: bars[0]?.time ?? null,
55 last: lastBar?.time ?? null,
56 close: lastBar?.close ?? null,
57 error: error ?? null,
58 })
59
60 const first = bars[0]
61 const change = first && lastBar ? lastBar.close - first.close : null
62 return (
63 <div className="mv-wrap">
64 <div className="mv-bar">
65 <span className="mv-title">
66 {args.ticker} · {args.multiplier} {args.timespan}{' '}
67 {lastBar ? (
68 <span className={change !== null && change < 0 ? 'mv-down' : 'mv-up'}>
69 {lastBar.close.toFixed(2)} ({change !== null && first ? ((change / first.close) * 100).toFixed(2) : '–'}%)
70 </span>
71 ) : null}
72 </span>
73 <button className="mv-button" onClick={() => refetch()} disabled={loading}>
74 {loading ? 'Loading…' : 'Refresh'}
75 </button>
76 </div>
77 {error ? <p className="mv-note mv-error">{error}</p> : null}
78 {!data && !error ? <p className="mv-note">Loading…</p> : null}
79 {data && !bars.length ? <p className="mv-note">No bars in that range.</p> : null}
80 {bars.length ? <Svg bars={bars} style={style} /> : null}
81 </div>
82 )
83}
84
85function Svg({ bars, style }: { bars: Bar[]; style: string }) {
86 const hi = Math.max(...bars.map((b) => b.high ?? b.close))
87 const lo = Math.min(...bars.map((b) => b.low ?? b.close))
88 const span = hi - lo || 1
89 const maxVol = Math.max(1, ...bars.map((b) => b.volume ?? 0))
90 const plotW = W - RIGHT
91 const step = plotW / bars.length
92 const x = (i: number) => i * step + step / 2
93 const y = (p: number) => 6 + ((hi - p) / span) * (PRICE_H - 12)
94 const bodyW = Math.max(1, step * 0.7)
95 const ticks = [0, 0.25, 0.5, 0.75, 1].map((f) => lo + f * span)
96 const labels = [0, Math.floor(bars.length / 2), bars.length - 1]
97 const line = bars.map((b, i) => `${i ? 'L' : 'M'}${x(i).toFixed(1)},${y(b.close).toFixed(1)}`).join(' ')
98
99 return (
100 <svg className="mv-chart" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
101 {ticks.map((t) => (
102 <g key={t}>
103 <line x1={0} x2={plotW} y1={y(t)} y2={y(t)} stroke="var(--jaspers-border, #e5e5e5)" strokeWidth={0.5} />
104 <text x={plotW + 4} y={y(t) + 3} fontSize={10} fill="var(--jaspers-muted-foreground, #737373)">
105 {t.toFixed(2)}
106 </text>
107 </g>
108 ))}
109 {style === 'line' ? (
110 <path d={line} fill="none" stroke="var(--jaspers-primary, #2563eb)" strokeWidth={1.5} />
111 ) : (
112 bars.map((b, i) => {
113 const up = b.close >= b.open
114 const color = up ? 'var(--jaspers-positive, #15803d)' : 'var(--jaspers-negative, #b91c1c)'
115 const top = y(Math.max(b.open, b.close))
116 const height = Math.max(1, Math.abs(y(b.open) - y(b.close)))
117 return (
118 <g key={b.time}>
119 <line x1={x(i)} x2={x(i)} y1={y(b.high)} y2={y(b.low)} stroke={color} strokeWidth={1} />
120 <rect x={x(i) - bodyW / 2} y={top} width={bodyW} height={height} fill={color} />
121 </g>
122 )
123 })
124 )}
125 {bars.map((b, i) => {
126 const h = ((b.volume ?? 0) / maxVol) * VOL_H
127 const up = b.close >= b.open
128 return (
129 <rect
130 key={`v${b.time}`}
131 x={x(i) - bodyW / 2}
132 y={VOL_TOP + VOL_H - h}
133 width={bodyW}
134 height={h}
135 fill={up ? 'var(--jaspers-positive, #15803d)' : 'var(--jaspers-negative, #b91c1c)'}
136 opacity={0.35}
137 />
138 )
139 })}
140 {labels.map((i) => (
141 <text
142 key={`t${i}`}
143 x={Math.min(Math.max(x(i), 30), plotW - 30)}
144 y={H - 4}
145 fontSize={10}
146 textAnchor="middle"
147 fill="var(--jaspers-muted-foreground, #737373)"
148 >
149 {bars[i]?.time.slice(0, bars[i]!.time.endsWith('T00:00:00.000Z') ? 10 : 16).replace('T', ' ')}
150 </text>
151 ))}
152 </svg>
153 )
154}