Alpaca: Alpaca market data, account, positions, and order management, with watchlist, account, and order-blotter views.
| 1 | import type { BackendContext } from '@jaspers-ai/sdk' |
| 2 | |
| 3 | // Alpaca's transport: hosts, the key headers, error messages, and next_page_token paging. |
| 4 | |
| 5 | export const DATA_HOST = 'data.alpaca.markets' |
| 6 | export const PAPER_HOST = 'paper-api.alpaca.markets' |
| 7 | export const LIVE_HOST = 'api.alpaca.markets' |
| 8 | export const TRADING_HOSTS = [PAPER_HOST, LIVE_HOST] |
| 9 | |
| 10 | export type Query = Record<string, string | number | boolean | undefined | null> |
| 11 | |
| 12 | export interface Request { |
| 13 | host: string |
| 14 | path: string |
| 15 | query?: Query |
| 16 | method?: 'GET' | 'POST' | 'DELETE' |
| 17 | body?: unknown |
| 18 | /** Use the live keys. Only trading calls on api.alpaca.markets set this. */ |
| 19 | live?: boolean |
| 20 | } |
| 21 | |
| 22 | /** One call to Alpaca: answers the parsed JSON, or null for an empty body (204). Throws Alpaca's message. */ |
| 23 | export async function alpaca(ctx: BackendContext, req: Request): Promise<unknown> { |
| 24 | // Single quotes: the app fills the secret references in before the request goes out. |
| 25 | const headers: Record<string, string> = req.live |
| 26 | ? { 'APCA-API-KEY-ID': '${secret:live_key_id}', 'APCA-API-SECRET-KEY': '${secret:live_secret_key}' } |
| 27 | : { 'APCA-API-KEY-ID': '${secret:key_id}', 'APCA-API-SECRET-KEY': '${secret:secret_key}' } |
| 28 | headers.Accept = 'application/json' |
| 29 | const init: RequestInit = { method: req.method ?? 'GET', headers } |
| 30 | if (req.body !== undefined) { |
| 31 | headers['Content-Type'] = 'application/json' |
| 32 | init.body = JSON.stringify(req.body) |
| 33 | } |
| 34 | const query = new URLSearchParams() |
| 35 | for (const [key, value] of Object.entries(req.query ?? {})) |
| 36 | if (value !== undefined && value !== null && value !== '') query.set(key, String(value)) |
| 37 | const qs = query.toString() |
| 38 | const url = `https://${req.host}${req.path}${qs ? `?${qs}` : ''}` |
| 39 | const response = await ctx.fetch(url, init) |
| 40 | const text = await response.text() |
| 41 | if (!response.ok) throw alpacaError(response.status, text) |
| 42 | if (!text.trim()) return null |
| 43 | return JSON.parse(text) |
| 44 | } |
| 45 | |
| 46 | function alpacaError(status: number, body: string): Error { |
| 47 | let message = body.split('\n')[0]?.slice(0, 300) ?? '' |
| 48 | try { |
| 49 | const parsed = JSON.parse(body) as { message?: string; code?: number } |
| 50 | if (parsed.message) message = parsed.message |
| 51 | } catch { |
| 52 | // not JSON: keep the first line |
| 53 | } |
| 54 | const hint: Record<number, string> = { |
| 55 | 401: 'unauthorized: check the key ID and secret key (paper and live accounts have separate keys)', |
| 56 | 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', |
| 57 | 404: 'not found', |
| 58 | 422: 'rejected as invalid', |
| 59 | 429: 'rate limit reached: wait a minute and try again', |
| 60 | } |
| 61 | return new Error(`Alpaca answered ${status}${hint[status] ? ` (${hint[status]})` : ''}: ${message || '(empty body)'}`) |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * Follows next_page_token on a market-data endpoint whose answer keeps a symbol-keyed map under `key` |
| 66 | * (bars, quotes, trades, snapshots), until `limit` items or `maxPages` pages. Arrays per symbol are |
| 67 | * concatenated, objects merged. |
| 68 | */ |
| 69 | export async function paged( |
| 70 | ctx: BackendContext, |
| 71 | path: string, |
| 72 | query: Query, |
| 73 | key: string, |
| 74 | limit: number, |
| 75 | pageMax: number, |
| 76 | maxPages = 10, |
| 77 | ): Promise<{ data: Record<string, unknown>; truncated: boolean }> { |
| 78 | const data: Record<string, unknown> = {} |
| 79 | let count = 0 |
| 80 | let token: string | undefined |
| 81 | for (let page = 0; page < maxPages; page++) { |
| 82 | const answer = (await alpaca(ctx, { |
| 83 | host: DATA_HOST, |
| 84 | path, |
| 85 | query: { ...query, limit: Math.min(pageMax, limit - count), page_token: token }, |
| 86 | })) as Record<string, unknown> | null |
| 87 | const map = (answer?.[key] ?? {}) as Record<string, unknown> |
| 88 | for (const [symbol, value] of Object.entries(map)) { |
| 89 | if (Array.isArray(value)) { |
| 90 | const have = (data[symbol] as unknown[] | undefined) ?? [] |
| 91 | data[symbol] = have.concat(value) |
| 92 | count += value.length |
| 93 | } else { |
| 94 | data[symbol] = value |
| 95 | count += 1 |
| 96 | } |
| 97 | } |
| 98 | token = (answer?.next_page_token as string | null | undefined) ?? undefined |
| 99 | if (!token || count >= limit) return { data, truncated: Boolean(token) } |
| 100 | } |
| 101 | return { data, truncated: Boolean(token) } |
| 102 | } |