Alpaca connector
@qtrader/alpaca-connector
Talk to Alpaca from Claude Code: account, positions, orders (list, place, cancel), latest quote, and historical bars to CSV, using Node's built-in fetch. Paper trading by default. Live orders need ALPACA_LIVE=1 plus your yes in chat for each order. Not financial advice.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// Alpaca Trading and Market Data REST client using Node's built-in fetch. No dependencies.
- 3// Paper trading by default. Live only when ALPACA_LIVE=1 AND each order or cancel carries
- 4// --confirm-live <token>, which Claude adds only after the user says yes in chat.
- 5// Not financial advice.
- 6//
- 7// Usage: node scripts/alpaca.mjs <command> [options]
- 8// account
- 9// positions
- 10// orders [--status open|closed|all] [--limit 50] [--symbols AAPL,MSFT] [--after ISO] [--until ISO] [--direction asc|desc]
- 11// place --symbol AAPL --side buy|sell (--qty 10 | --notional 500) [--type market] [--tif day]
- 12// [--limit-price 0] [--stop-price 0] [--trail-percent 0 | --trail-price 0] [--extended-hours] [--client-order-id ID]
- 13// cancel --id <order-uuid>
- 14// quote --symbol AAPL [--feed iex]
- 15// bars --symbol AAPL --timeframe 1Day --start 2025-01-01 [--end 2025-12-31] [--adjustment all] [--feed iex] [--max-pages 20]
- 16// Global: --dry-run (print the request, send nothing), --out <file> (write output to a file), --confirm-live <token>
- 17import { mkdirSync, realpathSync, writeFileSync } from "node:fs";
- 18import { dirname } from "node:path";
- 19import { fileURLToPath } from "node:url";
- 21export const HOSTS = {
- 22 paper: "https://paper-api.alpaca.markets",
- 23 live: "https://api.alpaca.markets",
- 24 data: "https://data.alpaca.markets",
- 25};
- 27const COMMANDS = ["account", "positions", "orders", "place", "cancel", "quote", "bars"];
- 28const BOOLEAN_FLAGS = new Set(["dry-run", "extended-hours", "help"]);
- 29const ORDER_TYPES = ["market", "limit", "stop", "stop_limit", "trailing_stop"];
- 30const TIFS = ["day", "gtc", "opg", "cls", "ioc", "fok"];
- 31const TIMEFRAME = /^([1-9]|[1-5][0-9])(Min|T)$|^([1-9]|1[0-9]|2[0-3])(Hour|H)$|^1(Day|D|Week|W)$|^(1|2|3|4|6|12)(Month|M)$/;
- 32const SYMBOL = /^[A-Z][A-Z0-9.]{0,9}$/;
- 33const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
- 34const DATE = /^\d{4}-\d{2}-\d{2}(T[0-9:.]+(Z|[+-]\d{2}:\d{2}))?$/;
- 36export class UsageError extends Error {}
- 38export function parseArgs(argv) {
- 39 const [command, ...rest] = argv;
- 40 if (!command || command === "--help" || command === "help") return { command: "help", opts: {} };
- 41 if (!COMMANDS.includes(command)) throw new UsageError(`unknown command "${command}". Use one of: ${COMMANDS.join(", ")}`);
- 42 const opts = {};
- 43 for (let i = 0; i < rest.length; i++) {
- 44 const a = rest[i];
- 45 if (!a.startsWith("--")) throw new UsageError(`unexpected argument "${a}"`);
- 46 const key = a.slice(2);
- 47 if (BOOLEAN_FLAGS.has(key)) {
- 48 opts[key] = true;
- 49 continue;
- 50 }
- 51 const v = rest[i + 1];
- 52 if (v === undefined || v.startsWith("--")) throw new UsageError(`--${key} needs a value`);
- 53 opts[key] = v;
- 54 i++;
- 55 }
- 56 return { command, opts };
- 57}
- 59export function modeFromEnv(env) {
- 60 return env.ALPACA_LIVE === "1" ? "live" : "paper";
- 61}
- 63function positiveNumber(v, name) {
- 64 const n = Number(v);
- 65 if (!Number.isFinite(n) || n <= 0) throw new UsageError(`--${name} must be a positive number, got "${v}"`);
- 66 return String(v);
- 67}
- 69function symbolOf(opts) {
- 70 if (!opts.symbol) throw new UsageError("--symbol is required");
- 71 const s = String(opts.symbol).toUpperCase();
- 72 if (!SYMBOL.test(s)) throw new UsageError(`--symbol "${opts.symbol}" does not look like a stock ticker`);
- 73 return s;
- 74}
- 76function dateOpt(opts, name) {
- 77 if (opts[name] === undefined) return undefined;
- 78 if (!DATE.test(opts[name])) throw new UsageError(`--${name} must be YYYY-MM-DD or RFC-3339, got "${opts[name]}"`);
- 79 return opts[name];
- 80}
- 82// Build the order body Alpaca expects (POST /v2/orders). Throws UsageError on combinations Alpaca rejects.
- 83export function buildOrderBody(opts) {
- 84 const symbol = symbolOf(opts);
- 85 const side = opts.side;
- 86 if (side !== "buy" && side !== "sell") throw new UsageError("--side must be buy or sell");
- 87 const type = opts.type ?? "market";
- 88 if (!ORDER_TYPES.includes(type)) throw new UsageError(`--type must be one of ${ORDER_TYPES.join(", ")}`);
- 89 const tif = opts.tif ?? "day";
- 90 if (!TIFS.includes(tif)) throw new UsageError(`--tif must be one of ${TIFS.join(", ")}`);
- 91 if ((opts.qty === undefined) === (opts.notional === undefined)) throw new UsageError("give exactly one of --qty or --notional");
- 92 const body = { symbol, side, type, time_in_force: tif };
- 93 if (opts.qty !== undefined) {
- 94 body.qty = positiveNumber(opts.qty, "qty");
- 95 if (!Number.isInteger(Number(body.qty)) && !(type === "market" && tif === "day")) {
- 96 throw new UsageError("fractional --qty only works with --type market and --tif day");
- 97 }
- 98 } else {
- 99 body.notional = positiveNumber(opts.notional, "notional");
- 100 if (type !== "market" || tif !== "day") throw new UsageError("--notional only works with --type market and --tif day");
- 101 }
- 102 if (type === "limit" || type === "stop_limit") {
- 103 if (opts["limit-price"] === undefined) throw new UsageError(`--limit-price is required for ${type} orders`);
- 104 body.limit_price = positiveNumber(opts["limit-price"], "limit-price");
- 105 } else if (opts["limit-price"] !== undefined) throw new UsageError(`--limit-price does not apply to ${type} orders`);
- 106 if (type === "stop" || type === "stop_limit") {
- 107 if (opts["stop-price"] === undefined) throw new UsageError(`--stop-price is required for ${type} orders`);
- 108 body.stop_price = positiveNumber(opts["stop-price"], "stop-price");
- 109 } else if (opts["stop-price"] !== undefined) throw new UsageError(`--stop-price does not apply to ${type} orders`);
- 110 if (type === "trailing_stop") {
- 111 const hasPct = opts["trail-percent"] !== undefined;
- 112 const hasPrice = opts["trail-price"] !== undefined;
- 113 if (hasPct === hasPrice) throw new UsageError("trailing_stop needs exactly one of --trail-percent or --trail-price");
- 114 if (hasPct) body.trail_percent = positiveNumber(opts["trail-percent"], "trail-percent");
- 115 else body.trail_price = positiveNumber(opts["trail-price"], "trail-price");
- 116 }
- 117 if (opts["extended-hours"]) {
- 118 if (type !== "limit" || (tif !== "day" && tif !== "gtc")) throw new UsageError("--extended-hours only works with --type limit and --tif day or gtc");
- 119 body.extended_hours = true;
- 120 }
- 121 if (opts["client-order-id"] !== undefined) {
- 122 if (String(opts["client-order-id"]).length > 128) throw new UsageError("--client-order-id is at most 128 characters");
- 123 body.client_order_id = String(opts["client-order-id"]);
- 124 }
- 125 return body;
- 126}
- 128// The token the user must approve for a live order or cancel. Deterministic, so the
- 129// confirmation is tied to this exact order: a different symbol, side, or size needs a new yes.
- 130export function confirmToken(command, payload) {
- 131 if (command === "cancel") return `LIVE-CANCEL-${payload.id}`;
- 132 const size = payload.qty !== undefined ? payload.qty : `$${payload.notional}`;
- 133 const price = payload.limit_price ? `@${payload.limit_price}` : payload.stop_price ? `@stop${payload.stop_price}` : "";
- 134 return `LIVE-${payload.side.toUpperCase()}-${size}-${payload.symbol}-${payload.type.toUpperCase()}${price}`;
- 135}
- 137export function describeOrder(b) {
- 138 const size = b.qty !== undefined ? `${b.qty} shares` : `$${b.notional} of`;
- 139 const px = b.limit_price ? ` at ${b.limit_price}` : "";
- 140 const stop = b.stop_price ? ` stop ${b.stop_price}` : "";
- 141 const trail = b.trail_percent ? ` trail ${b.trail_percent}%` : b.trail_price ? ` trail $${b.trail_price}` : "";
- 142 return `${b.side.toUpperCase()} ${size} ${b.symbol}, ${b.type}${px}${stop}${trail}, ${b.time_in_force}`;
- 143}
- 145function query(params) {
- 146 const q = new URLSearchParams();
- 147 for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") q.set(k, String(v));
- 148 const s = q.toString();
- 149 return s ? `?${s}` : "";
- 150}
- 152// Returns { method, url, body?, mode, needsConfirm, token? } for a parsed command.
- 153export function buildRequest(command, opts, env) {
- 154 const mode = modeFromEnv(env);
- 155 const trade = HOSTS[mode];
- 156 switch (command) {
- 157 case "account":
- 158 return { mode, method: "GET", url: `${trade}/v2/account` };
- 159 case "positions":
- 160 return { mode, method: "GET", url: `${trade}/v2/positions` };
- 161 case "orders": {
- 162 const status = opts.status ?? "open";
- 163 if (!["open", "closed", "all"].includes(status)) throw new UsageError("--status must be open, closed, or all");
- 164 const limit = opts.limit ?? "50";
- 165 if (!Number.isInteger(Number(limit)) || Number(limit) < 1 || Number(limit) > 500) throw new UsageError("--limit must be 1 to 500");
- 166 const direction = opts.direction ?? "desc";
- 167 if (!["asc", "desc"].includes(direction)) throw new UsageError("--direction must be asc or desc");
- 168 const symbols = opts.symbols
- 169 ? String(opts.symbols)
- 170 .split(",")
- 171 .map((s) => symbolOf({ symbol: s.trim() }))
- 172 .join(",")
- 173 : undefined;
- 174 return {
- 175 mode,
- 176 method: "GET",
- 177 url: `${trade}/v2/orders${query({ status, limit, direction, symbols, after: dateOpt(opts, "after"), until: dateOpt(opts, "until") })}`,
- 178 };
- 179 }
- 180 case "place": {
- 181 const body = buildOrderBody(opts);
- 182 return { mode, method: "POST", url: `${trade}/v2/orders`, body, needsConfirm: mode === "live", token: confirmToken("place", body), summary: describeOrder(body) };
- 183 }
- 184 case "cancel": {
- 185 if (!opts.id || !UUID.test(opts.id)) throw new UsageError("--id must be an Alpaca order id (a UUID)");
- 186 return { mode, method: "DELETE", url: `${trade}/v2/orders/${opts.id}`, needsConfirm: mode === "live", token: confirmToken("cancel", { id: opts.id }), summary: `CANCEL order ${opts.id}` };
- 187 }
- 188 case "quote": {
- 189 const symbol = symbolOf(opts);
- 190 return { mode, method: "GET", url: `${HOSTS.data}/v2/stocks/${symbol}/quotes/latest${query({ feed: opts.feed ?? "iex" })}` };
- 191 }
- 192 case "bars": {
- 193 const symbol = symbolOf(opts);
- 194 const timeframe = opts.timeframe ?? "1Day";
- 195 if (!TIMEFRAME.test(timeframe)) throw new UsageError(`--timeframe "${timeframe}" is not valid. Examples: 1Min, 15Min, 1Hour, 1Day, 1Week, 1Month`);
- 196 const start = dateOpt(opts, "start");
- 197 if (!start) throw new UsageError("--start is required for bars (YYYY-MM-DD)");
- 198 const adjustment = opts.adjustment ?? "all";
- 199 for (const a of adjustment.split(",")) {
- 200 if (!["raw", "split", "dividend", "spin-off", "all"].includes(a)) throw new UsageError(`--adjustment "${a}" is not valid`);
- 201 }
- 202 return {
- 203 mode,
- 204 method: "GET",
- 205 url: `${HOSTS.data}/v2/stocks/${symbol}/bars${query({ timeframe, start, end: dateOpt(opts, "end"), adjustment, feed: opts.feed ?? "iex", limit: 10000, sort: "asc" })}`,
- 206 symbol,
- 207 };
- 208 }
- 209 default:
- 210 throw new UsageError(`unknown command ${command}`);
- 211 }
- 212}
- 214export function authHeaders(env) {
- 215 const id = env.ALPACA_API_KEY_ID;
- 216 const secret = env.ALPACA_API_SECRET_KEY;
- 217 return { "APCA-API-KEY-ID": id ?? "", "APCA-API-SECRET-KEY": secret ?? "", Accept: "application/json" };
- 218}
- 220// What --dry-run prints: never the secret, and only the last 4 characters of the key id.
- 221export function redactedView(req, env) {
- 222 const id = env.ALPACA_API_KEY_ID;
- 223 const view = {
- 224 mode: req.mode,
- 225 method: req.method,
- 226 url: req.url,
- 227 headers: {
- 228 "APCA-API-KEY-ID": id ? `****${id.slice(-4)}` : "<missing ALPACA_API_KEY_ID>",
- 229 "APCA-API-SECRET-KEY": env.ALPACA_API_SECRET_KEY ? "<redacted>" : "<missing ALPACA_API_SECRET_KEY>",
- 230 },
- 231 };
- 232 if (req.body) view.body = req.body;
- 233 if (req.summary) view.summary = req.summary;
- 234 if (req.needsConfirm) view.live_confirmation_token = req.token;
- 235 return view;
- 236}
- 238export function barsToCsv(bars) {
- 239 const lines = ["timestamp,open,high,low,close,volume,trade_count,vwap"];
- 240 for (const b of bars) lines.push([b.t, b.o, b.h, b.l, b.c, b.v, b.n ?? "", b.vw ?? ""].join(","));
- 241 return lines.join("\n") + "\n";
- 242}
- 244async function send(req, env, fetchImpl, { retries = 2 } = {}) {
- 245 const init = { method: req.method, headers: authHeaders(env), signal: AbortSignal.timeout(20000) };
- 246 if (req.body) {
- 247 init.headers["Content-Type"] = "application/json";
- 248 init.body = JSON.stringify(req.body);
- 249 }
- 250 for (let attempt = 0; ; attempt++) {
- 251 const res = await fetchImpl(req.url, init);
- 252 if (res.status === 429 && req.method === "GET" && attempt < retries) {
- 253 const wait = Number(res.headers?.get?.("retry-after")) || 2 ** attempt;
- 254 await new Promise((r) => setTimeout(r, wait * 1000));
- 255 continue;
- 256 }
- 257 const text = res.status === 204 ? "" : await res.text();
- 258 let data = null;
- 259 try {
- 260 data = text ? JSON.parse(text) : null;
- 261 } catch {
- 262 data = { raw: text };
- 263 }
- 264 if (!res.ok) {
- 265 const msg = data?.message ?? text ?? "";
- 266 const hint =
- 267 res.status === 401 || res.status === 403
- 268 ? " Check ALPACA_API_KEY_ID and ALPACA_API_SECRET_KEY, that the keys match the mode (paper keys only work on paper), and for market data that your plan covers the feed (try --feed iex)."
- 269 : res.status === 422
- 270 ? " Alpaca rejected the order parameters."
- 271 : "";
- 272 const err = new Error(`Alpaca returned HTTP ${res.status}: ${msg}${hint}`);
- 273 err.status = res.status;
- 274 throw err;
- 275 }
- 276 return data;
- 277 }
- 278}
- 280// Follows next_page_token until the end or maxPages. Returns all bars in order.
- 281export async function fetchAllBars(req, env, fetchImpl, maxPages = 20) {
- 282 const all = [];
- 283 let token = null;
- 284 for (let page = 0; page < maxPages; page++) {
- 285 const url = token ? `${req.url}&page_token=${encodeURIComponent(token)}` : req.url;
- 286 const data = await send({ ...req, url }, env, fetchImpl);
- 287 all.push(...(data?.bars ?? []));
- 288 token = data?.next_page_token ?? null;
- 289 if (!token) return { bars: all, truncated: false };
- 290 }
- 291 return { bars: all, truncated: true };
- 292}
- 294const HELP = `Usage: node scripts/alpaca.mjs <account|positions|orders|place|cancel|quote|bars> [options]
- 295Paper trading unless ALPACA_LIVE=1. Live orders and cancels also need --confirm-live <token>.
- 296Add --dry-run to print the request without sending it. Not financial advice.`;
- 298export async function run(argv, env, { fetchImpl = globalThis.fetch, write = (s) => process.stdout.write(s), log = (s) => process.stderr.write(s + "\n") } = {}) {
- 299 const { command, opts } = parseArgs(argv);
- 300 if (command === "help" || opts.help) {
- 301 write(HELP + "\n");
- 302 return 0;
- 303 }
- 304 const req = buildRequest(command, opts, env);
- 305 const emit = (text) => {
- 306 if (opts.out) {
- 307 const outPath = opts.out;
- 308 mkdirSync(dirname(outPath) || ".", { recursive: true });
- 309 writeFileSync(outPath, text);
- 310 log(`Wrote ${outPath}`);
- 311 } else write(text);
- 312 };
- 313 if (opts["dry-run"]) {
- 314 emit(JSON.stringify({ dry_run: true, ...redactedView(req, env) }, null, 2) + "\n");
- 315 return 0;
- 316 }
- 317 if (req.needsConfirm && opts["confirm-live"] !== req.token) {
- 318 log(`LIVE MODE (real money). Not sent: ${req.summary}`);
- 319 log(`Ask the user to confirm this exact action in chat. If they say yes, rerun with: --confirm-live ${req.token}`);
- 320 return 3;
- 321 }
- 322 if (!env.ALPACA_API_KEY_ID || !env.ALPACA_API_SECRET_KEY) {
- 323 log("Missing ALPACA_API_KEY_ID or ALPACA_API_SECRET_KEY. Set them in your shell (paper keys from the Alpaca dashboard). Never paste keys into chat.");
- 324 return 2;
- 325 }
- 326 if (req.mode === "live") log("LIVE MODE: api.alpaca.markets");
- 327 if (command === "bars") {
- 328 const maxPages = Number(opts["max-pages"] ?? 20);
- 329 const { bars, truncated } = await fetchAllBars(req, env, fetchImpl, maxPages);
- 330 emit(barsToCsv(bars));
- 331 log(`${bars.length} bars for ${req.symbol}${truncated ? ` (stopped at ${maxPages} pages; raise --max-pages or narrow the dates)` : ""}`);
- 332 return 0;
- 333 }
- 334 const data = await send(req, env, fetchImpl);
- 335 if (command === "cancel") {
- 336 emit(JSON.stringify({ canceled: opts.id, mode: req.mode }, null, 2) + "\n");
- 337 return 0;
- 338 }
- 339 emit(JSON.stringify(data, null, 2) + "\n");
- 340 return 0;
- 341}
- 343function isMain() {
- 344 try {
- 345 return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
- 346 } catch {
- 347 return false;
- 348 }
- 349}
- 351if (isMain()) {
- 352 run(process.argv.slice(2), process.env).then(
- 353 (code) => process.exit(code),
- 354 (e) => {
- 355 console.error(e instanceof UsageError ? `Usage error: ${e.message}\n${HELP}` : e.message);
- 356 process.exit(e instanceof UsageError ? 2 : 1);
- 357 },
- 358 );
- 359}
