import type { BackendContext } from '@jaspers-ai/sdk' import { z } from 'zod' // Massive (formerly Polygon.io) REST client. The key goes as a Bearer header on every request, // next_url pages included, filled in by the app from the "apikey" secret. Every request gives up // after 5 s, and a paginated read stops paging once 5 s have gone by, answering what it has. export const HOST = 'api.massive.com' export const FALLBACK = 'api.polygon.io' export const HOSTS = [HOST, FALLBACK] /** The REST host, an argument of every source. */ export const baseField = z .enum(['api.massive.com', 'api.polygon.io']) .default('api.massive.com') .describe('The REST host: api.massive.com (default) or api.polygon.io, which serves the same API.') const HEADERS = { Authorization: 'Bearer ${secret:apikey}' } const TIMEOUT_MS = 5000 const BUDGET_MS = 5000 export type Params = Record export type Row = Record /** An error from Massive, with the HTTP status (408 for a timeout) so callers can tell plan limits from rate limits. */ export class MassiveError extends Error { status: number constructor(message: string, status: number) { super(message) this.status = status } } export function query(params: Params): string { const q = new URLSearchParams() for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') q.set(k, String(v)) const s = q.toString() return s ? `?${s}` : '' } function planError(message: string): MassiveError { return new MassiveError( `Your Massive plan does not include this endpoint (403 NOT_AUTHORIZED): ${message}. Upgrading at massive.com/pricing adds it.`, 403, ) } async function describeError(response: Response): Promise { const text = await response.text().catch(() => '') let message = text.split('\n')[0]?.slice(0, 300) ?? '' try { const body = JSON.parse(text) as { message?: string; error?: string } message = body.message ?? body.error ?? message } catch { /* not JSON */ } const s = response.status if (s === 401) return new MassiveError(`Massive refused the API key (401): ${message}. Check the key in Settings > Plugins.`, s) if (s === 403) return planError(message) if (s === 429) return new MassiveError(`Massive rate limit reached (429): ${message}. Wait a minute, or use a plan with more calls.`, s) if (s === 404) return new MassiveError(`Massive found nothing there (404): ${message}`, s) return new MassiveError(`Massive answered ${s}: ${message}`, s) } /** One GET of a whole URL, parsed. Gives up after 5 s, or sooner when the deadline is nearer. */ export async function fetchJson(ctx: BackendContext, url: string, deadline = Date.now() + TIMEOUT_MS): Promise { const wait = Math.max(500, Math.min(TIMEOUT_MS, deadline - Date.now())) const controller = new AbortController() const timer = setTimeout(() => controller.abort(), wait) try { const response = await ctx.fetch(url, { headers: HEADERS, signal: controller.signal }) if (!response.ok) throw await describeError(response) const body = await response.json() if (body && body.status === 'NOT_AUTHORIZED') throw planError(String(body.message ?? 'not entitled')) return body } catch (error) { if (controller.signal.aborted) throw new MassiveError(`Massive did not answer within ${wait / 1000} s.`, 408) throw error } finally { clearTimeout(timer) } } /** One GET of a path like /v2/aggs/ticker/AAPL/prev on the chosen host. */ export function get(ctx: BackendContext, base: string, path: string, params: Params = {}): Promise { return fetchJson(ctx, `https://${base}${path}${query(params)}`) } export interface Listed { rows: any[] /** True when the API had more pages than were read. */ more: boolean pages: number } /** A list endpoint: collects `results` across next_url pages until `max` rows, `maxPages` pages, or 5 s. */ export async function list(ctx: BackendContext, base: string, path: string, params: Params, max: number, maxPages = 10): Promise { const deadline = Date.now() + BUDGET_MS const rows: any[] = [] let body = await get(ctx, base, path, params) let pages = 1 for (;;) { const r = body?.results if (Array.isArray(r)) rows.push(...r) else if (r && typeof r === 'object') rows.push(r) const next = typeof body?.next_url === 'string' ? body.next_url : null if (!next || rows.length >= max || pages >= maxPages || Date.now() > deadline - 500) { return { rows: rows.slice(0, max), more: Boolean(next) || rows.length > max, pages } } const nextUrl = new URL(next) if (!HOSTS.includes(nextUrl.host)) nextUrl.host = base body = await fetchJson(ctx, nextUrl.toString(), deadline) pages++ } } /** Nested objects become a_b fields; arrays of plain values are joined; arrays of objects are JSON. */ export function flatten(obj: unknown, prefix = '', out: Row = {}): Row { if (obj === null || typeof obj !== 'object') { if (prefix) out[prefix] = obj return out } for (const [k, v] of Object.entries(obj as Row)) { const key = prefix ? `${prefix}_${k}` : k if (Array.isArray(v)) out[key] = v.every((x) => x === null || typeof x !== 'object') ? v.join(', ') : JSON.stringify(v) else if (v && typeof v === 'object') flatten(v, key, out) else out[key] = v } return out } /** Seconds, milliseconds, microseconds, or nanoseconds since the epoch, as an ISO string. */ export function iso(t: unknown): string | null { if (typeof t !== 'number' || !Number.isFinite(t) || t <= 0) return null const ms = t > 1e17 ? t / 1e6 : t > 1e14 ? t / 1e3 : t > 1e11 ? t : t * 1000 return new Date(ms).toISOString() } /** One aggregate bar as a row. */ export function bar(b: any, ticker?: string): Row { return { ticker: ticker ?? b.T ?? null, time: iso(b.t), open: b.o ?? null, high: b.h ?? null, low: b.l ?? null, close: b.c ?? null, volume: b.v ?? null, vwap: b.vw ?? null, transactions: b.n ?? null, } } export function upper(s: string): string { return s.trim().toUpperCase() } /** A YYYY-MM-DD date `days` before today (UTC). */ export function daysAgo(days: number): string { return new Date(Date.now() - days * 86_400_000).toISOString().slice(0, 10) }