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.
| 1 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | import { 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 | |
| 8 | export const HOST = 'api.massive.com' |
| 9 | export const FALLBACK = 'api.polygon.io' |
| 10 | export const HOSTS = [HOST, FALLBACK] |
| 11 | |
| 12 | /** The REST host, an argument of every source. */ |
| 13 | export 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 | |
| 18 | const HEADERS = { Authorization: 'Bearer ${secret:apikey}' } |
| 19 | const TIMEOUT_MS = 5000 |
| 20 | const BUDGET_MS = 5000 |
| 21 | |
| 22 | export type Params = Record<string, string | number | boolean | undefined | null> |
| 23 | export 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. */ |
| 26 | export class MassiveError extends Error { |
| 27 | status: number |
| 28 | constructor(message: string, status: number) { |
| 29 | super(message) |
| 30 | this.status = status |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | export 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 | |
| 41 | function 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 | |
| 48 | async 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. */ |
| 66 | export 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. */ |
| 85 | export function get(ctx: BackendContext, base: string, path: string, params: Params = {}): Promise<any> { |
| 86 | return fetchJson(ctx, `https://${base}${path}${query(params)}`) |
| 87 | } |
| 88 | |
| 89 | export 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. */ |
| 97 | export 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. */ |
| 118 | export 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. */ |
| 133 | export 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. */ |
| 140 | export 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 | |
| 154 | export function upper(s: string): string { |
| 155 | return s.trim().toUpperCase() |
| 156 | } |
| 157 | |
| 158 | /** A YYYY-MM-DD date `days` before today (UTC). */ |
| 159 | export function daysAgo(days: number): string { |
| 160 | return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10) |
| 161 | } |