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.

6.2 KB
1import type { BackendContext } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3
4// Massive (formerly Polygon.io) REST client. The key goes as a Bearer header on every request,
5// next_url pages included, filled in by the app from the "apikey" secret. Every request gives up
6// after 5 s, and a paginated read stops paging once 5 s have gone by, answering what it has.
7
8export const HOST = 'api.massive.com'
9export const FALLBACK = 'api.polygon.io'
10export const HOSTS = [HOST, FALLBACK]
11
12/** The REST host, an argument of every source. */
13export const baseField = z
14 .enum(['api.massive.com', 'api.polygon.io'])
15 .default('api.massive.com')
16 .describe('The REST host: api.massive.com (default) or api.polygon.io, which serves the same API.')
17
18const HEADERS = { Authorization: 'Bearer ${secret:apikey}' }
19const TIMEOUT_MS = 5000
20const BUDGET_MS = 5000
21
22export type Params = Record<string, string | number | boolean | undefined | null>
23export type Row = Record<string, unknown>
24
25/** An error from Massive, with the HTTP status (408 for a timeout) so callers can tell plan limits from rate limits. */
26export class MassiveError extends Error {
27 status: number
28 constructor(message: string, status: number) {
29 super(message)
30 this.status = status
31 }
32}
33
34export function query(params: Params): string {
35 const q = new URLSearchParams()
36 for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') q.set(k, String(v))
37 const s = q.toString()
38 return s ? `?${s}` : ''
39}
40
41function planError(message: string): MassiveError {
42 return new MassiveError(
43 `Your Massive plan does not include this endpoint (403 NOT_AUTHORIZED): ${message}. Upgrading at massive.com/pricing adds it.`,
44 403,
45 )
46}
47
48async function describeError(response: Response): Promise<MassiveError> {
49 const text = await response.text().catch(() => '')
50 let message = text.split('\n')[0]?.slice(0, 300) ?? ''
51 try {
52 const body = JSON.parse(text) as { message?: string; error?: string }
53 message = body.message ?? body.error ?? message
54 } catch {
55 /* not JSON */
56 }
57 const s = response.status
58 if (s === 401) return new MassiveError(`Massive refused the API key (401): ${message}. Check the key in Settings > Plugins.`, s)
59 if (s === 403) return planError(message)
60 if (s === 429) return new MassiveError(`Massive rate limit reached (429): ${message}. Wait a minute, or use a plan with more calls.`, s)
61 if (s === 404) return new MassiveError(`Massive found nothing there (404): ${message}`, s)
62 return new MassiveError(`Massive answered ${s}: ${message}`, s)
63}
64
65/** One GET of a whole URL, parsed. Gives up after 5 s, or sooner when the deadline is nearer. */
66export async function fetchJson(ctx: BackendContext, url: string, deadline = Date.now() + TIMEOUT_MS): Promise<any> {
67 const wait = Math.max(500, Math.min(TIMEOUT_MS, deadline - Date.now()))
68 const controller = new AbortController()
69 const timer = setTimeout(() => controller.abort(), wait)
70 try {
71 const response = await ctx.fetch(url, { headers: HEADERS, signal: controller.signal })
72 if (!response.ok) throw await describeError(response)
73 const body = await response.json()
74 if (body && body.status === 'NOT_AUTHORIZED') throw planError(String(body.message ?? 'not entitled'))
75 return body
76 } catch (error) {
77 if (controller.signal.aborted) throw new MassiveError(`Massive did not answer within ${wait / 1000} s.`, 408)
78 throw error
79 } finally {
80 clearTimeout(timer)
81 }
82}
83
84/** One GET of a path like /v2/aggs/ticker/AAPL/prev on the chosen host. */
85export function get(ctx: BackendContext, base: string, path: string, params: Params = {}): Promise<any> {
86 return fetchJson(ctx, `https://${base}${path}${query(params)}`)
87}
88
89export interface Listed {
90 rows: any[]
91 /** True when the API had more pages than were read. */
92 more: boolean
93 pages: number
94}
95
96/** A list endpoint: collects `results` across next_url pages until `max` rows, `maxPages` pages, or 5 s. */
97export async function list(ctx: BackendContext, base: string, path: string, params: Params, max: number, maxPages = 10): Promise<Listed> {
98 const deadline = Date.now() + BUDGET_MS
99 const rows: any[] = []
100 let body = await get(ctx, base, path, params)
101 let pages = 1
102 for (;;) {
103 const r = body?.results
104 if (Array.isArray(r)) rows.push(...r)
105 else if (r && typeof r === 'object') rows.push(r)
106 const next = typeof body?.next_url === 'string' ? body.next_url : null
107 if (!next || rows.length >= max || pages >= maxPages || Date.now() > deadline - 500) {
108 return { rows: rows.slice(0, max), more: Boolean(next) || rows.length > max, pages }
109 }
110 const nextUrl = new URL(next)
111 if (!HOSTS.includes(nextUrl.host)) nextUrl.host = base
112 body = await fetchJson(ctx, nextUrl.toString(), deadline)
113 pages++
114 }
115}
116
117/** Nested objects become a_b fields; arrays of plain values are joined; arrays of objects are JSON. */
118export function flatten(obj: unknown, prefix = '', out: Row = {}): Row {
119 if (obj === null || typeof obj !== 'object') {
120 if (prefix) out[prefix] = obj
121 return out
122 }
123 for (const [k, v] of Object.entries(obj as Row)) {
124 const key = prefix ? `${prefix}_${k}` : k
125 if (Array.isArray(v)) out[key] = v.every((x) => x === null || typeof x !== 'object') ? v.join(', ') : JSON.stringify(v)
126 else if (v && typeof v === 'object') flatten(v, key, out)
127 else out[key] = v
128 }
129 return out
130}
131
132/** Seconds, milliseconds, microseconds, or nanoseconds since the epoch, as an ISO string. */
133export function iso(t: unknown): string | null {
134 if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) return null
135 const ms = t > 1e17 ? t / 1e6 : t > 1e14 ? t / 1e3 : t > 1e11 ? t : t * 1000
136 return new Date(ms).toISOString()
137}
138
139/** One aggregate bar as a row. */
140export function bar(b: any, ticker?: string): Row {
141 return {
142 ticker: ticker ?? b.T ?? null,
143 time: iso(b.t),
144 open: b.o ?? null,
145 high: b.h ?? null,
146 low: b.l ?? null,
147 close: b.c ?? null,
148 volume: b.v ?? null,
149 vwap: b.vw ?? null,
150 transactions: b.n ?? null,
151 }
152}
153
154export function upper(s: string): string {
155 return s.trim().toUpperCase()
156}
157
158/** A YYYY-MM-DD date `days` before today (UTC). */
159export function daysAgo(days: number): string {
160 return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10)
161}