Skip to content
SlashClone
Made by@qtrader

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.

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