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