Jaspers Hub

biseroff / alpaca

pluginReviewed★ 0↓ 3

Alpaca: Alpaca market data, account, positions, and order management, with watchlist, account, and order-blotter views.

4 KB
1import type { BackendContext } from '@jaspers-ai/sdk'
2
3// Alpaca's transport: hosts, the key headers, error messages, and next_page_token paging.
4
5export const DATA_HOST = 'data.alpaca.markets'
6export const PAPER_HOST = 'paper-api.alpaca.markets'
7export const LIVE_HOST = 'api.alpaca.markets'
8export const TRADING_HOSTS = [PAPER_HOST, LIVE_HOST]
9
10export type Query = Record<string, string | number | boolean | undefined | null>
11
12export 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. */
23export 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
46function 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 */
69export 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}