Jaspers Hub

biseroff / alpaca

pluginReviewed★ 0↓ 3

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

8.5 KB
1import { defineSource } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3import type { BackendContext } from '@jaspers-ai/sdk'
4import { DATA_HOST, alpaca, paged, type Query } from './client'
5import { mid, num, str } from './rows'
6
7/** Symbols as Alpaca takes them: upper case, comma separated. */
8export function list(symbols: string[]): string {
9 return symbols.map((s) => s.trim().toUpperCase()).filter(Boolean).join(',')
10}
11
12const symbols = z
13 .array(z.string().min(1))
14 .min(1)
15 .max(100)
16 .describe('Stock symbols, like ["AAPL","MSFT"].')
17const feed = z
18 .enum(['iex', 'sip', 'delayed_sip'])
19 .default('iex')
20 .describe('Data feed: iex (free plan), sip (all US exchanges; recent data needs a paid plan), delayed_sip (SIP, 15 min delayed).')
21const start = z.string().optional().describe('Start, RFC 3339 or YYYY-MM-DD, like 2025-01-02 or 2025-01-02T14:30:00Z.')
22const end = z.string().optional().describe('End, RFC 3339 or YYYY-MM-DD. Leave out for now.')
23const sort = z.enum(['asc', 'desc']).default('asc').describe('Time order.')
24
25type Fields = Record<string, unknown>
26interface Snapshot {
27 latestTrade?: Fields
28 latestQuote?: Fields
29 minuteBar?: Fields
30 dailyBar?: Fields
31 prevDailyBar?: Fields
32}
33
34/** The start Alpaca gets when none is given for bars: 30 days ago, as a date. */
35export function monthAgo(): string {
36 return new Date(Date.now() - 30 * 86400_000).toISOString().slice(0, 10)
37}
38
39/** A paged, symbol-keyed history flattened to rows by `pick`, with meta saying whether it was cut. */
40export async function history(
41 ctx: BackendContext,
42 path: string,
43 key: string,
44 query: Query,
45 limit: number,
46 pick: (item: Record<string, unknown>) => Record<string, unknown>,
47) {
48 const { data, truncated } = await paged(ctx, path, query, key, limit, 10000)
49 const rows: Record<string, unknown>[] = []
50 for (const [symbol, items] of Object.entries(data))
51 for (const item of items as Record<string, unknown>[]) rows.push({ symbol, ...pick(item) })
52 return { rows: rows.slice(0, limit), truncated }
53}
54
55const bar = (b: Record<string, unknown>) => ({
56 t: str(b.t),
57 o: num(b.o),
58 h: num(b.h),
59 l: num(b.l),
60 c: num(b.c),
61 v: num(b.v),
62 n: num(b.n),
63 vw: num(b.vw),
64})
65export { bar as barRow }
66
67export const stockBars = defineSource({
68 description:
69 'Historical OHLCV bars for US stocks from Alpaca. One row per bar: symbol, t (bar start, ISO), o, h, l, c, v (volume), n (trade count), vw (VWAP).',
70 hosts: [DATA_HOST],
71 input: z.object({
72 symbols,
73 timeframe: z.string().default('1Day').describe('Bar size: 1Min, 5Min, 15Min, 30Min, 1Hour, 4Hour, 1Day, 1Week, 1Month.'),
74 start: start.describe('Start, RFC 3339 or YYYY-MM-DD. Defaults to 30 days ago.'),
75 end,
76 limit: z.number().int().min(1).max(10000).default(1000).describe('Most bars in all, across symbols.'),
77 adjustment: z.enum(['raw', 'split', 'dividend', 'all']).default('raw').describe('Corporate action adjustment.'),
78 feed,
79 sort,
80 }),
81 async run(args, ctx) {
82 const query = {
83 symbols: list(args.symbols),
84 timeframe: args.timeframe,
85 start: args.start ?? monthAgo(),
86 end: args.end,
87 adjustment: args.adjustment,
88 feed: args.feed,
89 sort: args.sort,
90 }
91 return history(ctx, '/v2/stocks/bars', 'bars', query, args.limit, bar)
92 },
93})
94
95export const stockQuotes = defineSource({
96 description:
97 'Historical NBBO quotes for US stocks from Alpaca. Rows: symbol, t, bp (bid), bs (bid size), ap (ask), as (ask size), bx, ax (exchanges), c (conditions). Without start Alpaca begins at the start of today.',
98 hosts: [DATA_HOST],
99 input: z.object({
100 symbols,
101 start,
102 end,
103 limit: z.number().int().min(1).max(10000).default(1000).describe('Most quotes in all, across symbols.'),
104 feed,
105 sort,
106 }),
107 async run(args, ctx) {
108 const query = { symbols: list(args.symbols), start: args.start, end: args.end, feed: args.feed, sort: args.sort }
109 return history(ctx, '/v2/stocks/quotes', 'quotes', query, args.limit, (q) => ({
110 t: str(q.t),
111 bp: num(q.bp),
112 bs: num(q.bs),
113 ap: num(q.ap),
114 as: num(q.as),
115 bx: str(q.bx),
116 ax: str(q.ax),
117 c: Array.isArray(q.c) ? q.c.join(',') : str(q.c),
118 }))
119 },
120})
121
122export const stockTrades = defineSource({
123 description:
124 'Historical trades for US stocks from Alpaca. Rows: symbol, t, p (price), s (size), x (exchange), c (conditions), i (trade id). Without start Alpaca begins at the start of today.',
125 hosts: [DATA_HOST],
126 input: z.object({
127 symbols,
128 start,
129 end,
130 limit: z.number().int().min(1).max(10000).default(1000).describe('Most trades in all, across symbols.'),
131 feed,
132 sort,
133 }),
134 async run(args, ctx) {
135 const query = { symbols: list(args.symbols), start: args.start, end: args.end, feed: args.feed, sort: args.sort }
136 return history(ctx, '/v2/stocks/trades', 'trades', query, args.limit, (t) => ({
137 t: str(t.t),
138 p: num(t.p),
139 s: num(t.s),
140 x: str(t.x),
141 c: Array.isArray(t.c) ? t.c.join(',') : str(t.c),
142 i: num(t.i),
143 }))
144 },
145})
146
147export const stockLatestQuote = defineSource({
148 description:
149 'Latest bid/ask quote for US stocks from Alpaca. One row per symbol: symbol, bid, bid_size, ask, ask_size, mid, bid_exchange, ask_exchange, time.',
150 hosts: [DATA_HOST],
151 input: z.object({ symbols, feed }),
152 async run(args, ctx) {
153 const answer = (await alpaca(ctx, {
154 host: DATA_HOST,
155 path: '/v2/stocks/quotes/latest',
156 query: { symbols: list(args.symbols), feed: args.feed },
157 })) as { quotes?: Record<string, Record<string, unknown>> } | null
158 return Object.entries(answer?.quotes ?? {}).map(([symbol, q]) => {
159 const bid = num(q.bp)
160 const ask = num(q.ap)
161 return {
162 symbol,
163 bid,
164 bid_size: num(q.bs),
165 ask,
166 ask_size: num(q.as),
167 mid: mid(bid, ask),
168 bid_exchange: str(q.bx),
169 ask_exchange: str(q.ax),
170 time: str(q.t),
171 }
172 })
173 },
174})
175
176export const stockLatestTrade = defineSource({
177 description: 'Latest trade for US stocks from Alpaca. One row per symbol: symbol, price, size, exchange, conditions, time.',
178 hosts: [DATA_HOST],
179 input: z.object({ symbols, feed }),
180 async run(args, ctx) {
181 const answer = (await alpaca(ctx, {
182 host: DATA_HOST,
183 path: '/v2/stocks/trades/latest',
184 query: { symbols: list(args.symbols), feed: args.feed },
185 })) as { trades?: Record<string, Record<string, unknown>> } | null
186 return Object.entries(answer?.trades ?? {}).map(([symbol, t]) => ({
187 symbol,
188 price: num(t.p),
189 size: num(t.s),
190 exchange: str(t.x),
191 conditions: Array.isArray(t.c) ? t.c.join(',') : str(t.c),
192 time: str(t.t),
193 }))
194 },
195})
196
197export const stockSnapshots = defineSource({
198 description:
199 'Snapshots of US stocks from Alpaca, one row per symbol: last trade price and time, bid/ask, the minute bar close, the daily bar (day_open, day_high, day_low, day_close, day_volume, day_vwap), prev_close, change (last - prev_close), and change_pct (in percent).',
200 hosts: [DATA_HOST],
201 input: z.object({ symbols, feed }),
202 async run(args, ctx) {
203 const answer = (await alpaca(ctx, {
204 host: DATA_HOST,
205 path: '/v2/stocks/snapshots',
206 query: { symbols: list(args.symbols), feed: args.feed },
207 })) as Record<string, Snapshot | null> | null
208 // The multi-symbol endpoint answers a map keyed by symbol; tolerate a { snapshots } wrapper too.
209 const map = ((answer as { snapshots?: Record<string, Snapshot | null> } | null)?.snapshots ?? answer ?? {}) as Record<
210 string,
211 Snapshot | null
212 >
213 return Object.entries(map).map(([symbol, s]) => {
214 const trade = s?.latestTrade ?? {}
215 const quote = s?.latestQuote ?? {}
216 const day = s?.dailyBar ?? {}
217 const prev = s?.prevDailyBar ?? {}
218 const last = num(trade.p) ?? num(day.c)
219 const prevClose = num(prev.c)
220 const change = last !== null && prevClose ? Math.round((last - prevClose) * 10000) / 10000 : null
221 return {
222 symbol,
223 last,
224 last_size: num(trade.s),
225 last_time: str(trade.t),
226 bid: num(quote.bp),
227 bid_size: num(quote.bs),
228 ask: num(quote.ap),
229 ask_size: num(quote.as),
230 minute_close: num(s?.minuteBar?.c),
231 minute_time: str(s?.minuteBar?.t),
232 day_open: num(day.o),
233 day_high: num(day.h),
234 day_low: num(day.l),
235 day_close: num(day.c),
236 day_volume: num(day.v),
237 day_vwap: num(day.vw),
238 day_time: str(day.t),
239 prev_close: prevClose,
240 change,
241 change_pct: change !== null && prevClose ? Math.round((change / prevClose) * 1_000_000) / 10000 : null,
242 }
243 })
244 },
245})