import type { BackendContext } from '@jaspers-ai/sdk' // Alpaca's transport: hosts, the key headers, error messages, and next_page_token paging. export const DATA_HOST = 'data.alpaca.markets' export const PAPER_HOST = 'paper-api.alpaca.markets' export const LIVE_HOST = 'api.alpaca.markets' export const TRADING_HOSTS = [PAPER_HOST, LIVE_HOST] export type Query = Record export interface Request { host: string path: string query?: Query method?: 'GET' | 'POST' | 'DELETE' body?: unknown /** Use the live keys. Only trading calls on api.alpaca.markets set this. */ live?: boolean } /** One call to Alpaca: answers the parsed JSON, or null for an empty body (204). Throws Alpaca's message. */ export async function alpaca(ctx: BackendContext, req: Request): Promise { // Single quotes: the app fills the secret references in before the request goes out. const headers: Record = req.live ? { 'APCA-API-KEY-ID': '${secret:live_key_id}', 'APCA-API-SECRET-KEY': '${secret:live_secret_key}' } : { 'APCA-API-KEY-ID': '${secret:key_id}', 'APCA-API-SECRET-KEY': '${secret:secret_key}' } headers.Accept = 'application/json' const init: RequestInit = { method: req.method ?? 'GET', headers } if (req.body !== undefined) { headers['Content-Type'] = 'application/json' init.body = JSON.stringify(req.body) } const query = new URLSearchParams() for (const [key, value] of Object.entries(req.query ?? {})) if (value !== undefined && value !== null && value !== '') query.set(key, String(value)) const qs = query.toString() const url = `https://${req.host}${req.path}${qs ? `?${qs}` : ''}` const response = await ctx.fetch(url, init) const text = await response.text() if (!response.ok) throw alpacaError(response.status, text) if (!text.trim()) return null return JSON.parse(text) } function alpacaError(status: number, body: string): Error { let message = body.split('\n')[0]?.slice(0, 300) ?? '' try { const parsed = JSON.parse(body) as { message?: string; code?: number } if (parsed.message) message = parsed.message } catch { // not JSON: keep the first line } const hint: Record = { 401: 'unauthorized: check the key ID and secret key (paper and live accounts have separate keys)', 403: 'forbidden: the keys or plan may not include this (sip needs a paid plan for recent data, opra needs an options subscription), or the account cannot do this', 404: 'not found', 422: 'rejected as invalid', 429: 'rate limit reached: wait a minute and try again', } return new Error(`Alpaca answered ${status}${hint[status] ? ` (${hint[status]})` : ''}: ${message || '(empty body)'}`) } /** * Follows next_page_token on a market-data endpoint whose answer keeps a symbol-keyed map under `key` * (bars, quotes, trades, snapshots), until `limit` items or `maxPages` pages. Arrays per symbol are * concatenated, objects merged. */ export async function paged( ctx: BackendContext, path: string, query: Query, key: string, limit: number, pageMax: number, maxPages = 10, ): Promise<{ data: Record; truncated: boolean }> { const data: Record = {} let count = 0 let token: string | undefined for (let page = 0; page < maxPages; page++) { const answer = (await alpaca(ctx, { host: DATA_HOST, path, query: { ...query, limit: Math.min(pageMax, limit - count), page_token: token }, })) as Record | null const map = (answer?.[key] ?? {}) as Record for (const [symbol, value] of Object.entries(map)) { if (Array.isArray(value)) { const have = (data[symbol] as unknown[] | undefined) ?? [] data[symbol] = have.concat(value) count += value.length } else { data[symbol] = value count += 1 } } token = (answer?.next_page_token as string | null | undefined) ?? undefined if (!token || count >= limit) return { data, truncated: Boolean(token) } } return { data, truncated: Boolean(token) } }