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// Offline self-test: argument parsing, request building, live gating, redaction, pagination,
  3. 3// and CSV output. Uses a fake fetch, so it never touches the network.
  4. 4// Usage: node scripts/selftest.mjs (exits 1 on any failure)
  5. 5import { readFileSync } from "node:fs";
  6. 6import { dirname, join } from "node:path";
  7. 7import { fileURLToPath } from "node:url";
  8. 8import { barsToCsv, buildOrderBody, buildRequest, confirmToken, parseArgs, redactedView, run, UsageError } from "./alpaca.mjs";
  9. 9
  10. 10const here = dirname(fileURLToPath(import.meta.url));
  11. 11let passed = 0;
  12. 12let failed = 0;
  13. 13function eq(actual, expected, label) {
  14. 14 const a = JSON.stringify(actual);
  15. 15 const e = JSON.stringify(expected);
  16. 16 if (a === e) passed++;
  17. 17 else {
  18. 18 failed++;
  19. 19 console.error(`FAIL ${label}\n expected ${e}\n got ${a}`);
  20. 20 }
  21. 22function usageError(fn, text, label) {
  22. 23 try {
  23. 24 fn();
  24. 25 failed++;
  25. 26 console.error(`FAIL ${label}: expected a usage error`);
  26. 27 } catch (e) {
  27. 28 if (e instanceof UsageError && e.message.includes(text)) passed++;
  28. 29 else {
  29. 30 failed++;
  30. 31 console.error(`FAIL ${label}: got "${e.message}"`);
  31. 32 }
  32. 33 }
  33. 36const FAKE = { ALPACA_API_KEY_ID: "PKTESTKEYID0000WXYZ", ALPACA_API_SECRET_KEY: "fake-secret-for-tests-only" };
  34. 37const LIVE = { ...FAKE, ALPACA_LIVE: "1" };
  35. 39// Parsing
  36. 40eq(parseArgs(["orders", "--status", "all", "--dry-run"]), { command: "orders", opts: { status: "all", "dry-run": true } }, "parse flags");
  37. 41usageError(() => parseArgs(["buy"]), "unknown command", "unknown command");
  38. 42usageError(() => parseArgs(["quote", "--symbol"]), "needs a value", "missing value");
  39. 44// Hosts and paper default
  40. 45eq(buildRequest("account", {}, FAKE).url, "https://paper-api.alpaca.markets/v2/account", "paper by default");
  41. 46eq(buildRequest("account", {}, { ...FAKE, ALPACA_LIVE: "true" }).mode, "paper", "only ALPACA_LIVE=1 means live");
  42. 47eq(buildRequest("account", {}, LIVE).url, "https://api.alpaca.markets/v2/account", "live host");
  43. 48eq(buildRequest("positions", {}, FAKE).url, "https://paper-api.alpaca.markets/v2/positions", "positions");
  44. 49eq(
  45. 50 buildRequest("orders", { status: "closed", limit: "10", symbols: "aapl, msft" }, FAKE).url,
  46. 51 "https://paper-api.alpaca.markets/v2/orders?status=closed&limit=10&direction=desc&symbols=AAPL%2CMSFT",
  47. 52 "orders query",
  48. 53);
  49. 54usageError(() => buildRequest("orders", { limit: "900" }, FAKE), "1 to 500", "orders limit");
  50. 55eq(buildRequest("quote", { symbol: "spy" }, FAKE).url, "https://data.alpaca.markets/v2/stocks/SPY/quotes/latest?feed=iex", "quote");
  51. 56eq(
  52. 57 buildRequest("bars", { symbol: "SPY", timeframe: "1Day", start: "2025-01-01", end: "2025-06-30" }, FAKE).url,
  53. 58 "https://data.alpaca.markets/v2/stocks/SPY/bars?timeframe=1Day&start=2025-01-01&end=2025-06-30&adjustment=all&feed=iex&limit=10000&sort=asc",
  54. 59 "bars query",
  55. 60);
  56. 61usageError(() => buildRequest("bars", { symbol: "SPY", timeframe: "7Day", start: "2025-01-01" }, FAKE), "not valid", "bad timeframe");
  57. 62usageError(() => buildRequest("bars", { symbol: "SPY" }, FAKE), "--start is required", "bars needs start");
  58. 63usageError(() => buildRequest("quote", { symbol: "SPY; rm" }, FAKE), "ticker", "symbol validation");
  59. 64usageError(() => buildRequest("cancel", { id: "abc" }, FAKE), "UUID", "cancel id");
  60. 66// Orders
  61. 67eq(buildOrderBody({ symbol: "aapl", side: "buy", qty: "10" }), { symbol: "AAPL", side: "buy", type: "market", time_in_force: "day", qty: "10" }, "market order");
  62. 68eq(
  63. 69 buildOrderBody({ symbol: "AAPL", side: "sell", qty: "5", type: "limit", "limit-price": "210.5", tif: "gtc" }),
  64. 70 { symbol: "AAPL", side: "sell", type: "limit", time_in_force: "gtc", qty: "5", limit_price: "210.5" },
  65. 71 "limit order",
  66. 72);
  67. 73eq(buildOrderBody({ symbol: "SPY", side: "buy", notional: "250" }).notional, "250", "notional order");
  68. 74usageError(() => buildOrderBody({ symbol: "SPY", side: "buy" }), "exactly one of --qty or --notional", "qty or notional");
  69. 75usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", qty: "1", notional: "5" }), "exactly one", "not both");
  70. 76usageError(() => buildOrderBody({ symbol: "SPY", side: "hold", qty: "1" }), "--side", "side");
  71. 77usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", qty: "1", type: "limit" }), "--limit-price is required", "limit needs price");
  72. 78usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", qty: "1", type: "stop_limit", "limit-price": "5" }), "--stop-price is required", "stop_limit needs stop");
  73. 79usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", qty: "0.5", tif: "gtc" }), "fractional", "fractional rules");
  74. 80usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", notional: "100", type: "limit", "limit-price": "1" }), "--notional only", "notional rules");
  75. 81usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", qty: "-3" }), "positive", "negative qty");
  76. 82usageError(() => buildOrderBody({ symbol: "SPY", side: "sell", qty: "1", type: "trailing_stop" }), "trail", "trailing needs trail");
  77. 83usageError(() => buildOrderBody({ symbol: "SPY", side: "buy", qty: "1", "extended-hours": true }), "--extended-hours", "extended hours rules");
  78. 85// Confirmation tokens are tied to the exact order
  79. 86eq(confirmToken("place", { side: "buy", qty: "10", symbol: "AAPL", type: "market" }), "LIVE-BUY-10-AAPL-MARKET", "token market");
  80. 87eq(confirmToken("place", { side: "sell", qty: "5", symbol: "AAPL", type: "limit", limit_price: "210.5" }), "LIVE-SELL-5-AAPL-LIMIT@210.5", "token limit");
  81. 88eq(confirmToken("place", { side: "buy", notional: "250", symbol: "SPY", type: "market" }), "LIVE-BUY-$250-SPY-MARKET", "token notional");
  82. 90// Redaction: the secret never appears in dry-run output
  83. 91const view = JSON.stringify(redactedView(buildRequest("place", { symbol: "AAPL", side: "buy", qty: "1" }, LIVE), LIVE));
  84. 92eq(view.includes(FAKE.ALPACA_API_SECRET_KEY), false, "secret not printed");
  85. 93eq(view.includes("PKTESTKEYID0000"), false, "key id masked");
  86. 94eq(view.includes("****WXYZ"), true, "key id last 4 shown");
  87. 95eq(view.includes("LIVE-BUY-1-AAPL-MARKET"), true, "live dry-run shows the token to confirm");
  88. 97// run(): fake fetch records every call
  89. 98function fakeFetch(responses) {
  90. 99 const calls = [];
  91. 100 const fn = async (url, init) => {
  92. 101 calls.push({ url, init });
  93. 102 const r = responses.shift() ?? { status: 200, body: {} };
  94. 103 return {
  95. 104 ok: r.status >= 200 && r.status < 300,
  96. 105 status: r.status,
  97. 106 headers: { get: () => null },
  98. 107 text: async () => (r.body === undefined ? "" : JSON.stringify(r.body)),
  99. 108 };
  100. 109 };
  101. 110 fn.calls = calls;
  102. 111 return fn;
  103. 113const sink = () => {
  104. 114 const out = [];
  105. 115 return { out, write: (s) => out.push(s), log: (s) => out.push(s) };
  106. 116};
  107. 118// Live order without the token: blocked, no request sent
  108. 120 const f = fakeFetch([]);
  109. 121 const s = sink();
  110. 122 const code = await run(["place", "--symbol", "AAPL", "--side", "buy", "--qty", "10"], LIVE, { fetchImpl: f, ...s });
  111. 123 eq(code, 3, "live order without confirm exits 3");
  112. 124 eq(f.calls.length, 0, "live order without confirm sends nothing");
  113. 125 eq(s.out.join("\n").includes("--confirm-live LIVE-BUY-10-AAPL-MARKET"), true, "prints the token to ask about");
  114. 127// Live order with the wrong token: still blocked
  115. 129 const f = fakeFetch([]);
  116. 130 const code = await run(["place", "--symbol", "AAPL", "--side", "buy", "--qty", "100", "--confirm-live", "LIVE-BUY-10-AAPL-MARKET"], LIVE, { fetchImpl: f, ...sink() });
  117. 131 eq([code, f.calls.length], [3, 0], "token for a different size does not work");
  118. 133// Live order with the right token: sent to the live host with auth headers
  119. 135 const f = fakeFetch([{ status: 200, body: { id: "x", status: "accepted" } }]);
  120. 136 const code = await run(["place", "--symbol", "AAPL", "--side", "buy", "--qty", "10", "--confirm-live", "LIVE-BUY-10-AAPL-MARKET"], LIVE, { fetchImpl: f, ...sink() });
  121. 137 eq(code, 0, "confirmed live order exits 0");
  122. 138 eq(f.calls[0].url, "https://api.alpaca.markets/v2/orders", "confirmed live order url");
  123. 139 eq(JSON.parse(f.calls[0].init.body), { symbol: "AAPL", side: "buy", type: "market", time_in_force: "day", qty: "10" }, "order body");
  124. 140 eq(f.calls[0].init.headers["APCA-API-KEY-ID"], FAKE.ALPACA_API_KEY_ID, "key header");
  125. 142// Paper order: no token needed
  126. 144 const f = fakeFetch([{ status: 200, body: { id: "y" } }]);
  127. 145 const code = await run(["place", "--symbol", "SPY", "--side", "sell", "--qty", "1"], FAKE, { fetchImpl: f, ...sink() });
  128. 146 eq([code, f.calls[0].url, f.calls[0].init.method], [0, "https://paper-api.alpaca.markets/v2/orders", "POST"], "paper order");
  129. 148// Dry run sends nothing even with keys set
  130. 150 const f = fakeFetch([]);
  131. 151 const s = sink();
  132. 152 const code = await run(["cancel", "--id", "61e69015-8549-4bfd-b9c3-01e75843f47d", "--dry-run"], FAKE, { fetchImpl: f, ...s });
  133. 153 eq([code, f.calls.length], [0, 0], "dry run sends nothing");
  134. 154 eq(JSON.parse(s.out[0]).method, "DELETE", "cancel is DELETE");
  135. 156// Missing keys
  136. 158 const code = await run(["account"], {}, { fetchImpl: fakeFetch([]), ...sink() });
  137. 159 eq(code, 2, "missing keys exits 2");
  138. 161// HTTP errors carry Alpaca's message and a hint
  139. 163 const f = fakeFetch([{ status: 403, body: { message: "forbidden" } }]);
  140. 164 try {
  141. 165 await run(["account"], FAKE, { fetchImpl: f, ...sink() });
  142. 166 failed++;
  143. 167 console.error("FAIL 403 should throw");
  144. 168 } catch (e) {
  145. 169 eq(e.message.startsWith("Alpaca returned HTTP 403: forbidden"), true, "403 message");
  146. 170 }
  147. 172// Bars pagination and CSV
  148. 174 const page1 = JSON.parse(readFileSync(join(here, "../examples/bars-page1.json"), "utf8"));
  149. 175 const page2 = JSON.parse(readFileSync(join(here, "../examples/bars-page2.json"), "utf8"));
  150. 176 const f = fakeFetch([
  151. 177 { status: 200, body: page1 },
  152. 178 { status: 200, body: page2 },
  153. 179 ]);
  154. 180 const s = sink();
  155. 181 const code = await run(["bars", "--symbol", "TEST", "--start", "2026-01-02"], FAKE, { fetchImpl: f, ...s });
  156. 182 eq(code, 0, "bars exits 0");
  157. 183 eq(f.calls.length, 2, "follows next_page_token");
  158. 184 eq(f.calls[1].url.endsWith("&page_token=" + encodeURIComponent(page1.next_page_token)), true, "sends page_token");
  159. 185 const csv = s.out[0].trim().split("\n");
  160. 186 eq(csv[0], "timestamp,open,high,low,close,volume,trade_count,vwap", "csv header");
  161. 187 eq(csv.length, 1 + page1.bars.length + page2.bars.length, "csv rows");
  162. 188 eq(csv[1], "2026-01-02T05:00:00Z,100,101.5,99.2,101,120000,850,100.6", "csv first row");
  163. 190eq(barsToCsv([]), "timestamp,open,high,low,close,volume,trade_count,vwap\n", "empty bars");
  164. 192console.log(`${passed} passed, ${failed} failed`);
  165. 193process.exit(failed ? 1 : 0);