Alpaca: Alpaca brokerage plugin: stock/option market data, account, positions, orders, and order placement/cancellation, with watchlist, account, and blotter views.
| 1 | import { defineSource, type BackendContext } from '@jaspers-ai/sdk' |
| 2 | import { z } from 'zod' |
| 3 | import { DATA_HOST, TRADING_HOSTS, alpaca } from './client.ts' |
| 4 | import { mid, num, orderRow, parseOcc, str } from './rows.ts' |
| 5 | import { 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 | |
| 9 | export const LIVE_ORDER_DISCLAIMER = |
| 10 | 'Live order on your brokerage account. This is your trade, at your risk — see jsprai.com/terms §7.' |
| 11 | export const LIVE_ORDER_LIVE_KEY = 'live-order-approval' |
| 12 | const APPROVAL_FILE = 'live-approval.json' |
| 13 | |
| 14 | export 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 | |
| 26 | interface ApprovalRecord { |
| 27 | connectionId: string |
| 28 | connection_id: string |
| 29 | timestamp: string |
| 30 | approved_at: string |
| 31 | } |
| 32 | |
| 33 | interface ApprovalState { |
| 34 | connections: Record<string, ApprovalRecord> |
| 35 | } |
| 36 | |
| 37 | interface WaitingApproval { |
| 38 | pending: PendingLiveOrder |
| 39 | resolve: (approved: boolean) => void |
| 40 | } |
| 41 | |
| 42 | let activeApproval: WaitingApproval | null = null |
| 43 | |
| 44 | function 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 | |
| 48 | function fmtUsd(value: number): string { |
| 49 | return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` |
| 50 | } |
| 51 | |
| 52 | async 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 | |
| 65 | async 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 | |
| 71 | async 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 | |
| 82 | async 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 | |
| 88 | async 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 | |
| 107 | export 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 | |
| 130 | async 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 | |
| 181 | export 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 | |
| 202 | export 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 | |
| 273 | export 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 | }) |