Jaspers Hub

jaspers / alpaca

pluginOfficialReviewed★ 0↓ 0

Alpaca: Alpaca brokerage plugin: stock/option market data, account, positions, orders, and order placement/cancellation, with watchlist, account, and blotter views.

12 KB
1import { defineSource, type BackendContext } from '@jaspers-ai/sdk'
2import { z } from 'zod'
3import { DATA_HOST, TRADING_HOSTS, alpaca } from './client.ts'
4import { mid, num, orderRow, parseOcc, str } from './rows.ts'
5import { liveAccount, trading } from './trading.ts'
6
7// Sources that change the account. Never run from a view's draw: only on the user's explicit ask.
8
9export const LIVE_ORDER_DISCLAIMER =
10 'Live order on your brokerage account. This is your trade, at your risk — see jsprai.com/terms §7.'
11export const LIVE_ORDER_LIVE_KEY = 'live-order-approval'
12const APPROVAL_FILE = 'live-approval.json'
13
14export interface PendingLiveOrder {
15 id: string
16 connectionId: string
17 symbol: string
18 side: 'buy' | 'sell'
19 quantity: string
20 orderType: string
21 estimatedValue: string
22 disclaimer: string
23 createdAt: string
24}
25
26interface ApprovalRecord {
27 connectionId: string
28 connection_id: string
29 timestamp: string
30 approved_at: string
31}
32
33interface ApprovalState {
34 connections: Record<string, ApprovalRecord>
35}
36
37interface WaitingApproval {
38 pending: PendingLiveOrder
39 resolve: (approved: boolean) => void
40}
41
42let activeApproval: WaitingApproval | null = null
43
44function approvalToken(): string {
45 return `live-order-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}-${Math.random().toString(36).slice(2, 12)}`
46}
47
48function fmtUsd(value: number): string {
49 return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
50}
51
52async function readApprovalState(ctx: BackendContext): Promise<ApprovalState> {
53 try {
54 const raw = await ctx.files.read(APPROVAL_FILE)
55 const parsed = JSON.parse(raw) as { connections?: Record<string, ApprovalRecord> }
56 if (parsed && typeof parsed === 'object' && parsed.connections && typeof parsed.connections === 'object') {
57 return { connections: parsed.connections }
58 }
59 } catch {
60 // Missing or unreadable file means no connection has been approved yet.
61 }
62 return { connections: {} }
63}
64
65async function hasLiveApproval(ctx: BackendContext, connectionId: string): Promise<boolean> {
66 const state = await readApprovalState(ctx)
67 const entry = state.connections[connectionId]
68 return Boolean(entry && typeof entry.timestamp === 'string' && entry.timestamp.length > 0)
69}
70
71async function recordLiveApproval(ctx: BackendContext, connectionId: string, timestamp: string): Promise<void> {
72 const state = await readApprovalState(ctx)
73 state.connections[connectionId] = {
74 connectionId,
75 connection_id: connectionId,
76 timestamp,
77 approved_at: timestamp,
78 }
79 await ctx.files.write(APPROVAL_FILE, `${JSON.stringify(state, null, 2)}\n`)
80}
81
82async function liveConnectionId(ctx: BackendContext): Promise<string> {
83 const acct = (await trading(ctx, true, '/account')) as Record<string, unknown> | null
84 const id = str(acct?.id) ?? str(acct?.account_number)
85 return id ? `alpaca/live:${id}` : 'alpaca/live'
86}
87
88async function latestPrice(ctx: BackendContext, symbol: string, isOption: boolean): Promise<number | null> {
89 const base = isOption ? '/v1beta1/options' : '/v2/stocks'
90 const query = { symbols: symbol, feed: isOption ? 'indicative' : 'iex' }
91 try {
92 const trades = (await alpaca(ctx, { host: DATA_HOST, path: `${base}/trades/latest`, query })) as {
93 trades?: Record<string, Record<string, unknown>>
94 } | null
95 const tradePrice = num(trades?.trades?.[symbol]?.p)
96 if (tradePrice !== null && tradePrice > 0) return tradePrice
97 const quotes = (await alpaca(ctx, { host: DATA_HOST, path: `${base}/quotes/latest`, query })) as {
98 quotes?: Record<string, Record<string, unknown>>
99 } | null
100 const q = quotes?.quotes?.[symbol]
101 return mid(num(q?.bp), num(q?.ap)) ?? num(q?.ap) ?? num(q?.bp)
102 } catch {
103 return null
104 }
105}
106
107export async function estimateOrderValue(
108 ctx: BackendContext,
109 order: {
110 symbol: string
111 qty?: number
112 notional?: number
113 type: string
114 limit_price?: number
115 stop_price?: number
116 },
117): Promise<string> {
118 if (order.notional !== undefined) return fmtUsd(order.notional)
119 if (order.qty === undefined) return 'Market price'
120 const symbol = order.symbol.trim().toUpperCase()
121 const isOption = parseOcc(symbol).underlying !== null
122 const multiplier = isOption ? 100 : 1
123 const unitPrice = order.limit_price ?? order.stop_price ?? (await latestPrice(ctx, symbol, isOption))
124 if (unitPrice !== null && Number.isFinite(unitPrice) && unitPrice > 0) {
125 return fmtUsd(order.qty * unitPrice * multiplier)
126 }
127 return 'Market price'
128}
129
130async function requireFirstLiveTradeApproval(
131 ctx: BackendContext,
132 order: {
133 symbol: string
134 side: 'buy' | 'sell'
135 qty?: number
136 notional?: number
137 type: string
138 limit_price?: number
139 stop_price?: number
140 },
141): Promise<void> {
142 const connectionId = await liveConnectionId(ctx)
143 if (await hasLiveApproval(ctx, connectionId)) return
144
145 const symbol = order.symbol.trim().toUpperCase()
146 const quantity = order.qty !== undefined ? String(order.qty) : `${fmtUsd(order.notional ?? 0)} notional`
147 const estimatedValue = await estimateOrderValue(ctx, { ...order, symbol })
148 const pending: PendingLiveOrder = {
149 id: approvalToken(),
150 connectionId,
151 symbol,
152 side: order.side,
153 quantity,
154 orderType: order.type,
155 estimatedValue,
156 disclaimer: LIVE_ORDER_DISCLAIMER,
157 createdAt: new Date().toISOString(),
158 }
159
160 if (activeApproval) {
161 activeApproval.resolve(false)
162 activeApproval = null
163 }
164
165 ctx.live.set(LIVE_ORDER_LIVE_KEY, pending)
166 ctx.notify(
167 `Approve first live Alpaca order (${pending.side.toUpperCase()} ${pending.quantity} ${pending.symbol}, ${pending.orderType}, est. ${pending.estimatedValue}) in the Alpaca panel.`,
168 )
169
170 const approved = await new Promise<boolean>((resolve) => {
171 activeApproval = { pending, resolve }
172 })
173
174 if (!approved) {
175 throw new Error('Live order declined by user.')
176 }
177
178 await recordLiveApproval(ctx, connectionId, new Date().toISOString())
179}
180
181export const confirmLiveOrder = defineSource({
182 description: 'Internal view action: approves or declines the pending first live order on a connection.',
183 internal: true,
184 ttlMs: 0,
185 input: z.object({
186 id: z.string().min(1),
187 approved: z.boolean(),
188 }),
189 async run(args, ctx) {
190 const current = activeApproval
191 if (!current || current.pending.id !== args.id) {
192 if (!current) ctx.live.delete(LIVE_ORDER_LIVE_KEY)
193 return { handled: false }
194 }
195 activeApproval = null
196 ctx.live.delete(LIVE_ORDER_LIVE_KEY)
197 current.resolve(args.approved)
198 return { handled: true, approved: args.approved }
199 },
200})
201
202export const placeOrder = defineSource({
203 description:
204 'PLACE ORDER (paper by default): submits a REAL order to the configured Alpaca account, the paper account unless live_account is true. Only run when the user explicitly asks to place this order. Give qty or notional (not both). The first live order (live_account: true) on a connection requires the user to click Approve in an open Alpaca view (place alpaca/account or alpaca/orders first if none is open). Answers the order row.',
205 hosts: [DATA_HOST, ...TRADING_HOSTS],
206 ttlMs: 0,
207 input: z.object({
208 symbol: z.string().min(1).describe('Stock symbol or OCC option symbol.'),
209 qty: z.number().positive().optional().describe('Shares or contracts. Fractional shares only for market/day orders.'),
210 notional: z.number().positive().optional().describe('Dollar amount instead of qty (market/day orders only).'),
211 side: z.enum(['buy', 'sell']),
212 type: z.enum(['market', 'limit', 'stop', 'stop_limit', 'trailing_stop']).default('market'),
213 time_in_force: z.enum(['day', 'gtc', 'opg', 'cls', 'ioc', 'fok']).default('day'),
214 limit_price: z.number().positive().optional().describe('For limit and stop_limit.'),
215 stop_price: z.number().positive().optional().describe('For stop and stop_limit.'),
216 trail_price: z.number().positive().optional().describe('For trailing_stop: trail in dollars.'),
217 trail_percent: z.number().positive().optional().describe('For trailing_stop: trail in percent.'),
218 extended_hours: z.boolean().optional().describe('Allow pre/after-market fills (limit, day only).'),
219 client_order_id: z.string().max(128).optional().describe('Your own id for the order.'),
220 order_class: z.enum(['simple', 'bracket', 'oco', 'oto']).optional(),
221 take_profit_limit_price: z.number().positive().optional().describe('Bracket/OCO take-profit limit price.'),
222 stop_loss_stop_price: z.number().positive().optional().describe('Bracket/OCO/OTO stop-loss stop price.'),
223 stop_loss_limit_price: z.number().positive().optional().describe('Optional stop-loss limit price.'),
224 live_account: liveAccount,
225 }),
226 async run(args, ctx) {
227 if ((args.qty === undefined) === (args.notional === undefined)) throw new Error('Give exactly one of qty or notional.')
228 if ((args.type === 'limit' || args.type === 'stop_limit') && args.limit_price === undefined)
229 throw new Error(`A ${args.type} order needs limit_price.`)
230 if ((args.type === 'stop' || args.type === 'stop_limit') && args.stop_price === undefined)
231 throw new Error(`A ${args.type} order needs stop_price.`)
232 if (args.type === 'trailing_stop' && (args.trail_price === undefined) === (args.trail_percent === undefined))
233 throw new Error('A trailing_stop order needs exactly one of trail_price or trail_percent.')
234
235 const symbol = args.symbol.trim().toUpperCase()
236 if (args.live_account) {
237 await requireFirstLiveTradeApproval(ctx, {
238 symbol,
239 side: args.side,
240 qty: args.qty,
241 notional: args.notional,
242 type: args.type,
243 limit_price: args.limit_price,
244 stop_price: args.stop_price,
245 })
246 }
247
248 const s = (n: number | undefined) => (n === undefined ? undefined : String(n))
249 const body: Record<string, unknown> = {
250 symbol,
251 qty: s(args.qty),
252 notional: s(args.notional),
253 side: args.side,
254 type: args.type,
255 time_in_force: args.time_in_force,
256 limit_price: s(args.limit_price),
257 stop_price: s(args.stop_price),
258 trail_price: s(args.trail_price),
259 trail_percent: s(args.trail_percent),
260 extended_hours: args.extended_hours,
261 client_order_id: args.client_order_id,
262 order_class: args.order_class,
263 }
264 if (args.take_profit_limit_price !== undefined) body.take_profit = { limit_price: s(args.take_profit_limit_price) }
265 if (args.stop_loss_stop_price !== undefined)
266 body.stop_loss = { stop_price: s(args.stop_loss_stop_price), limit_price: s(args.stop_loss_limit_price) }
267 for (const key of Object.keys(body)) if (body[key] === undefined) delete body[key]
268 const order = (await trading(ctx, args.live_account, '/orders', { method: 'POST', body })) as Record<string, unknown>
269 return [{ account: args.live_account ? 'live' : 'paper', ...orderRow(order) }]
270 },
271})
272
273export const cancelOrder = defineSource({
274 description:
275 "CANCEL ORDER: cancels a REAL open order on the configured Alpaca account (paper unless live_account is true), by order_id, or every open order with cancel_all: true. Only run on the user's explicit ask. Answers a status row per order.",
276 hosts: TRADING_HOSTS,
277 ttlMs: 0,
278 input: z.object({
279 order_id: z.string().optional().describe('The order id to cancel.'),
280 cancel_all: z.boolean().default(false).describe('Cancel EVERY open order instead. Needs no order_id.'),
281 live_account: liveAccount,
282 }),
283 async run(args, ctx) {
284 const account = args.live_account ? 'live' : 'paper'
285 if (args.cancel_all) {
286 // 207 Multi-Status: one entry per open order, each with its own HTTP status.
287 const answer = ((await trading(ctx, args.live_account, '/orders', { method: 'DELETE' })) ?? []) as {
288 id?: string
289 status?: number
290 }[]
291 if (answer.length === 0)
292 return [{ account, order_id: null, cancelled: false, http_status: 207, message: 'No open orders.' }]
293 return answer.map((r) => ({
294 account,
295 order_id: r.id ?? null,
296 cancelled: r.status === 200 || r.status === 204,
297 http_status: r.status ?? null,
298 message: r.status === 200 || r.status === 204 ? 'Cancel requested.' : 'Not cancelled.',
299 }))
300 }
301 if (!args.order_id) throw new Error('Give order_id, or cancel_all: true to cancel every open order.')
302 await trading(ctx, args.live_account, `/orders/${encodeURIComponent(args.order_id)}`, { method: 'DELETE' })
303 return [{ account, order_id: args.order_id, cancelled: true, http_status: 204, message: 'Cancel requested.' }]
304 },
305})