From 89ea99c26ca188eda83758a82b1dfe8ecbee1e0d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 18:31:57 +0000 Subject: [PATCH] kbot-finance: add read-only Alpaca brokerage adapter Fills the "brokerage API" adapter slot referenced in the kbot-finance issue template and design-partner SOW, following the same five-file pattern (types/client/commands/index + live smoke test) as the Polymarket and EDGAR adapters. - src/adapters/alpaca/: account, positions, and order-status commands against the Alpaca Trading API. Defaults to the paper-trading base URL; no order placement in v1 (read-only, matching the Polymarket adapter's read-first wedge and the SOW's compliance-sign-off gate for live brokerage execution). - src/tools/alpaca-query.ts: wires account/positions/orders queries through the content-addressed envelope, regulatory verifier, and hash-chained audit log, mirroring polymarket-query.ts / edgar-query.ts. - alpaca_query registered in kbotFinanceTools and exported from the package's public surface (src/index.ts, package.json exports). - test/alpaca.test.ts: offline unit tests (normalization, missing- credentials error path, mocked-engine tool wiring, audit-log integrity). - test/alpaca.live.test.ts: live smoke against the real paper-trading API, skipped (not failed) when KBOT_FINANCE_ALPACA_KEY_ID / KBOT_FINANCE_ALPACA_SECRET_KEY aren't configured, in addition to the existing KBOT_FINANCE_OFFLINE gate. - README + issue template updated to list the new adapter. --- .github/ISSUE_TEMPLATE/kbot-finance.md | 1 + packages/kbot-finance/README.md | 36 ++- packages/kbot-finance/package-lock.json | 7 +- packages/kbot-finance/package.json | 8 +- .../src/adapters/alpaca/client.ts | 87 ++++++ .../src/adapters/alpaca/commands.ts | 39 +++ .../kbot-finance/src/adapters/alpaca/index.ts | 3 + .../kbot-finance/src/adapters/alpaca/types.ts | 109 +++++++ packages/kbot-finance/src/index.ts | 2 + packages/kbot-finance/src/kbot-tool.ts | 97 +++++++ .../kbot-finance/src/tools/alpaca-query.ts | 269 ++++++++++++++++++ .../kbot-finance/test/alpaca.live.test.ts | 58 ++++ packages/kbot-finance/test/alpaca.test.ts | 195 +++++++++++++ packages/kbot-finance/test/kbot-tool.test.ts | 18 ++ 14 files changed, 918 insertions(+), 11 deletions(-) create mode 100644 packages/kbot-finance/src/adapters/alpaca/client.ts create mode 100644 packages/kbot-finance/src/adapters/alpaca/commands.ts create mode 100644 packages/kbot-finance/src/adapters/alpaca/index.ts create mode 100644 packages/kbot-finance/src/adapters/alpaca/types.ts create mode 100644 packages/kbot-finance/src/tools/alpaca-query.ts create mode 100644 packages/kbot-finance/test/alpaca.live.test.ts create mode 100644 packages/kbot-finance/test/alpaca.test.ts diff --git a/.github/ISSUE_TEMPLATE/kbot-finance.md b/.github/ISSUE_TEMPLATE/kbot-finance.md index a7be65402..2ac638681 100644 --- a/.github/ISSUE_TEMPLATE/kbot-finance.md +++ b/.github/ISSUE_TEMPLATE/kbot-finance.md @@ -30,6 +30,7 @@ Which part of the package? - [ ] Regulatory verifier (`src/verifier/`) - [ ] Polymarket adapter - [ ] SEC EDGAR adapter +- [ ] Alpaca brokerage adapter - [ ] MCP server (`src/mcp-server.ts`) - [ ] kbot integration (`src/kbot-tool.ts`) - [ ] Annex IV exporter (`src/exporters/annex-iv.ts`) diff --git a/packages/kbot-finance/README.md b/packages/kbot-finance/README.md index dba62762f..e72eb6934 100644 --- a/packages/kbot-finance/README.md +++ b/packages/kbot-finance/README.md @@ -20,10 +20,10 @@ The open-source substrate for AI agents operating in audited environments — content-addressed request envelopes, hash-chained append-only audit log, jurisdiction-aware regulatory verifier (rules-as-code), MCP server, and -engine adapters (Polymarket, SEC EDGAR, more coming). The AI Intelligence -Layer never produces the source-of-truth number — deterministic engines -do, humans approve at material gates, every action is replayable -byte-for-byte under audit. +engine adapters (Polymarket, SEC EDGAR, Alpaca brokerage, more coming). The +AI Intelligence Layer never produces the source-of-truth number — +deterministic engines do, humans approve at material gates, every action +is replayable byte-for-byte under audit. Apache 2.0. Node 22+. Replit-importable. @@ -48,9 +48,10 @@ A reference implementation of three layers that together form an AI-Native Capital Markets Operating System: 1. **Deterministic engine adapters** — call known-good engines (Polymarket - Gamma in v0.1; QuantLib, NautilusTrader, Aeron, alts-NAV in later versions). - The AI agent cannot compute the number — it can only request one inside a - content-addressed envelope. + Gamma and SEC EDGAR in v0.1; Alpaca brokerage read-only in v0.2; QuantLib, + NautilusTrader, Aeron, alts-NAV in later versions). The AI agent cannot + compute the number — it can only request one inside a content-addressed + envelope. 2. **Regulatory verifier** — Norm-AI-pattern rules-as-code. Every action passes through before reaching the engine. Failures emit adverse-action @@ -82,10 +83,18 @@ cd packages/kbot-finance npm install npm run demo # live end-to-end npm test # unit + integration -npm run test:live # explicit live-smoke against Gamma +npm run test:live # explicit live-smoke against Gamma + Alpaca (skips Alpaca without keys) KBOT_FINANCE_OFFLINE=1 npm test # CI without network ``` +The Alpaca adapter needs a free paper-trading key pair +(`KBOT_FINANCE_ALPACA_KEY_ID` + `KBOT_FINANCE_ALPACA_SECRET_KEY`, or the +`APCA_API_KEY_ID` / `APCA_API_SECRET_KEY` convention Alpaca's own SDKs use) +— sign up at [alpaca.markets](https://alpaca.markets). Defaults to the +paper-trading endpoint; set `KBOT_FINANCE_ALPACA_BASE` to switch to live +only after a compliance sign-off, per the read-only-unless-signed-off +pattern this package uses for every brokerage adapter. + ## Architecture (one diagram) ``` @@ -133,8 +142,12 @@ import { makeKellyCapRule, // Engines polymarket, + edgar, + alpaca, // Tools polymarketQuery, + edgarQuery, + alpacaQuery, } from "@kernel.chat/kbot-finance"; ``` @@ -181,8 +194,14 @@ src/ client.ts # HTTPS client; never throws across boundary commands.ts # listMarkets / getMarket / listEvents index.ts + edgar/ + types.ts / client.ts / commands.ts / index.ts # SEC filings, read-only + alpaca/ + types.ts / client.ts / commands.ts / index.ts # brokerage, read-only tools/ polymarket-query.ts # The kbot-shaped tool wiring all layers + edgar-query.ts + alpaca-query.ts demo.ts # End-to-end script (npm run demo) index.ts # Public surface test/ @@ -191,6 +210,7 @@ test/ verifier.test.ts governance.test.ts polymarket.live.test.ts # LIVE SMOKE — hits real Gamma + alpaca.live.test.ts # LIVE SMOKE — hits real Alpaca paper API ``` ## Strategic positioning diff --git a/packages/kbot-finance/package-lock.json b/packages/kbot-finance/package-lock.json index 9a90f2926..2ab908d22 100644 --- a/packages/kbot-finance/package-lock.json +++ b/packages/kbot-finance/package-lock.json @@ -1,16 +1,19 @@ { "name": "@kernel.chat/kbot-finance", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@kernel.chat/kbot-finance", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0" }, + "bin": { + "kbot-finance": "dist/cli.js" + }, "devDependencies": { "@types/node": "^22.0.0", "tsx": "^4.19.0", diff --git a/packages/kbot-finance/package.json b/packages/kbot-finance/package.json index 7e0f567aa..23e751156 100644 --- a/packages/kbot-finance/package.json +++ b/packages/kbot-finance/package.json @@ -42,6 +42,10 @@ "import": "./dist/adapters/edgar/index.js", "types": "./dist/adapters/edgar/index.d.ts" }, + "./adapters/alpaca": { + "import": "./dist/adapters/alpaca/index.js", + "types": "./dist/adapters/alpaca/index.d.ts" + }, "./exporters/annex-iv": { "import": "./dist/exporters/annex-iv.js", "types": "./dist/exporters/annex-iv.d.ts" @@ -54,7 +58,7 @@ "mcp": "tsx src/cli.ts mcp", "test": "vitest run", "test:watch": "vitest", - "test:live": "vitest run --reporter=verbose test/polymarket.live.test.ts", + "test:live": "vitest run --reporter=verbose test/polymarket.live.test.ts test/alpaca.live.test.ts", "typecheck": "tsc --noEmit", "prepublishOnly": "npm run build && npm test" }, @@ -93,6 +97,8 @@ "audit", "mcp", "polymarket", + "alpaca", + "brokerage", "compliance-as-code" ], "dependencies": { diff --git a/packages/kbot-finance/src/adapters/alpaca/client.ts b/packages/kbot-finance/src/adapters/alpaca/client.ts new file mode 100644 index 000000000..bd641e1ba --- /dev/null +++ b/packages/kbot-finance/src/adapters/alpaca/client.ts @@ -0,0 +1,87 @@ +import { + getAlpacaBase, + getAlpacaCredentials, + type AlpacaOutcome, + type AlpacaError, +} from "./types.js"; + +/** Low-level Alpaca Trading API HTTP client. Returns discriminated unions; never throws. */ +export async function alpacaGet( + path: string, + params: Record = {}, + options: { baseUrl?: string; timeoutMs?: number } = {}, +): Promise> { + const credentials = getAlpacaCredentials(); + if (!credentials) { + return err({ + code: "missing_credentials", + message: + "Set KBOT_FINANCE_ALPACA_KEY_ID + KBOT_FINANCE_ALPACA_SECRET_KEY (or APCA_API_KEY_ID + APCA_API_SECRET_KEY) — a free paper-trading key pair from alpaca.markets works.", + }); + } + + const base = options.baseUrl ?? getAlpacaBase(); + const url = new URL(path.startsWith("/") ? path.slice(1) : path, base + "/"); + for (const [k, v] of Object.entries(params)) { + if (v === undefined) continue; + url.searchParams.set(k, String(v)); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 10_000); + + try { + const res = await fetch(url, { + method: "GET", + headers: { + Accept: "application/json", + "User-Agent": "kbot-finance/0.1", + "APCA-API-KEY-ID": credentials.keyId, + "APCA-API-SECRET-KEY": credentials.secretKey, + }, + signal: controller.signal, + }); + if (res.status === 401 || res.status === 403) { + return err({ + code: "unauthorized", + message: `${res.status} from Alpaca — check the key pair matches the base URL (paper vs live)`, + status: res.status, + }); + } + if (res.status === 404) { + return err({ code: "not_found", message: `404 ${url.pathname}`, status: 404 }); + } + if (res.status === 429) { + return err({ code: "rate_limited", message: "429 from Alpaca", status: 429 }); + } + if (!res.ok) { + const body = await safeText(res); + return err({ code: "http", message: `HTTP ${res.status}`, status: res.status, body }); + } + try { + const value = (await res.json()) as T; + return { ok: true, value }; + } catch (parseErr) { + return err({ + code: "parse", + message: `JSON parse failed: ${(parseErr as Error).message}`, + }); + } + } catch (netErr) { + return err({ code: "network", message: (netErr as Error).message }); + } finally { + clearTimeout(timeout); + } +} + +function err(error: AlpacaError): AlpacaOutcome { + return { ok: false, error }; +} + +async function safeText(res: Response): Promise { + try { + return (await res.text()).slice(0, 512); + } catch { + return ""; + } +} diff --git a/packages/kbot-finance/src/adapters/alpaca/commands.ts b/packages/kbot-finance/src/adapters/alpaca/commands.ts new file mode 100644 index 000000000..18d87468a --- /dev/null +++ b/packages/kbot-finance/src/adapters/alpaca/commands.ts @@ -0,0 +1,39 @@ +import { alpacaGet } from "./client.js"; +import type { AlpacaAccount, AlpacaPosition, AlpacaOrder, AlpacaOutcome } from "./types.js"; + +/** + * Read-only commands against the Alpaca Trading API. + * + * Order placement intentionally not in v0.1 — read first, governed write + * second, same wedge the Polymarket adapter uses. A brokerage engine is the + * highest-stakes adapter this package ships; it stays read-only until a + * material-gate approval flow for order placement exists. + */ + +export async function getAccount(): Promise> { + return alpacaGet("/v2/account"); +} + +export async function listPositions(): Promise>> { + return alpacaGet>("/v2/positions"); +} + +export async function getPosition(symbol: string): Promise> { + return alpacaGet(`/v2/positions/${encodeURIComponent(symbol)}`); +} + +export async function listOrders( + params: { status?: "open" | "closed" | "all"; limit?: number } = {}, +): Promise>> { + return alpacaGet>("/v2/orders", { + status: params.status ?? "open", + limit: params.limit ?? 25, + }); +} + +/** Alpaca returns numeric fields as strings. Decode once at the normalization boundary. */ +export function decodeNumeric(raw: string | undefined): number | null { + if (raw === undefined) return null; + const n = Number(raw); + return Number.isFinite(n) ? n : null; +} diff --git a/packages/kbot-finance/src/adapters/alpaca/index.ts b/packages/kbot-finance/src/adapters/alpaca/index.ts new file mode 100644 index 000000000..23a7b5b52 --- /dev/null +++ b/packages/kbot-finance/src/adapters/alpaca/index.ts @@ -0,0 +1,3 @@ +export * from "./types.js"; +export * from "./commands.js"; +export { alpacaGet } from "./client.js"; diff --git a/packages/kbot-finance/src/adapters/alpaca/types.ts b/packages/kbot-finance/src/adapters/alpaca/types.ts new file mode 100644 index 000000000..7c889389b --- /dev/null +++ b/packages/kbot-finance/src/adapters/alpaca/types.ts @@ -0,0 +1,109 @@ +/** + * Alpaca Trading API types. + * + * Read-only subset calibrated to the account/positions/orders endpoints. + * Alpaca's paper-trading environment is free to sign up for and is the + * default base URL here — a brokerage adapter that defaults to live + * order-eligible credentials would be the wrong failure mode. + * + * Reference: https://docs.alpaca.markets/reference/getaccount + */ + +export interface AlpacaAccount { + readonly id?: string; + readonly account_number?: string; + readonly status?: string; + readonly currency?: string; + readonly cash?: string; + readonly portfolio_value?: string; + readonly equity?: string; + readonly last_equity?: string; + readonly buying_power?: string; + readonly regt_buying_power?: string; + readonly daytrading_buying_power?: string; + readonly pattern_day_trader?: boolean; + readonly trading_blocked?: boolean; + readonly account_blocked?: boolean; + readonly created_at?: string; +} + +export interface AlpacaPosition { + readonly asset_id?: string; + readonly symbol?: string; + readonly exchange?: string; + readonly asset_class?: string; + readonly side?: string; + readonly qty?: string; + readonly avg_entry_price?: string; + readonly current_price?: string; + readonly market_value?: string; + readonly cost_basis?: string; + readonly unrealized_pl?: string; + readonly unrealized_plpc?: string; + readonly change_today?: string; +} + +export interface AlpacaOrder { + readonly id?: string; + readonly client_order_id?: string; + readonly symbol?: string; + readonly asset_class?: string; + readonly side?: string; + readonly type?: string; + readonly qty?: string; + readonly filled_qty?: string; + readonly filled_avg_price?: string; + readonly status?: string; + readonly submitted_at?: string; + readonly filled_at?: string; + readonly canceled_at?: string; +} + +/** Adapter outcome — discriminated union. Never throws across the boundary. */ +export type AlpacaOutcome = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: AlpacaError }; + +export interface AlpacaError { + readonly code: + | "network" + | "http" + | "parse" + | "not_found" + | "rate_limited" + | "unauthorized" + | "missing_credentials"; + readonly message: string; + readonly status?: number; + readonly body?: string; +} + +/** + * Paper trading is the default base — a brokerage adapter must not silently + * default to a live-order-eligible endpoint. Set KBOT_FINANCE_ALPACA_BASE + * to switch to https://api.alpaca.markets once a compliance officer has + * signed off on live use, per the read-only-unless-signed-off pattern this + * package follows for every brokerage/pricing engine adapter. + */ +export const ALPACA_PAPER_BASE = "https://paper-api.alpaca.markets"; +export const ALPACA_LIVE_BASE = "https://api.alpaca.markets"; +export const ALPACA_ADAPTER_VERSION = "alpaca-adapter@0.1.0"; + +export function getAlpacaBase(): string { + return process.env["KBOT_FINANCE_ALPACA_BASE"] ?? ALPACA_PAPER_BASE; +} + +/** + * Alpaca credentials. Namespaced KBOT_FINANCE_ALPACA_* first; falls back to + * the APCA_API_KEY_ID / APCA_API_SECRET_KEY convention Alpaca's own SDKs and + * CLI use, so operators who already have Alpaca configured don't have to + * duplicate keys. + */ +export function getAlpacaCredentials(): { keyId: string; secretKey: string } | null { + const keyId = + process.env["KBOT_FINANCE_ALPACA_KEY_ID"] ?? process.env["APCA_API_KEY_ID"]; + const secretKey = + process.env["KBOT_FINANCE_ALPACA_SECRET_KEY"] ?? process.env["APCA_API_SECRET_KEY"]; + if (!keyId || !secretKey) return null; + return { keyId, secretKey }; +} diff --git a/packages/kbot-finance/src/index.ts b/packages/kbot-finance/src/index.ts index 26542ef2b..7d41929b4 100644 --- a/packages/kbot-finance/src/index.ts +++ b/packages/kbot-finance/src/index.ts @@ -27,6 +27,8 @@ export * from "./governance.js"; export * from "./verifier/index.js"; export * as polymarket from "./adapters/polymarket/index.js"; export * as edgar from "./adapters/edgar/index.js"; +export * as alpaca from "./adapters/alpaca/index.js"; export * from "./tools/polymarket-query.js"; export * from "./tools/edgar-query.js"; +export * from "./tools/alpaca-query.js"; export * from "./exporters/annex-iv.js"; diff --git a/packages/kbot-finance/src/kbot-tool.ts b/packages/kbot-finance/src/kbot-tool.ts index 7ab28fb91..476eeeabf 100644 --- a/packages/kbot-finance/src/kbot-tool.ts +++ b/packages/kbot-finance/src/kbot-tool.ts @@ -28,6 +28,7 @@ import { } from "./verifier/index.js"; import { polymarketQuery } from "./tools/polymarket-query.js"; import { edgarQuery } from "./tools/edgar-query.js"; +import { alpacaQuery } from "./tools/alpaca-query.js"; import { exportAnnexIv } from "./exporters/annex-iv.js"; import { writeFile } from "node:fs/promises"; @@ -303,6 +304,101 @@ const edgarQueryTool: KbotToolDefinition = { }, }; +/** + * alpaca_query — read-only access to an Alpaca brokerage account (account + * info, positions, order status) via the full kbot-finance audit substrate. + * Same wiring shape as polymarket-query.ts / edgar-query.ts. Defaults to + * Alpaca's paper-trading endpoint; no order placement in v1 — the AI layer + * cannot move money through this tool. + */ +const alpacaQueryTool: KbotToolDefinition = { + name: "alpaca_query", + description: + "Query an Alpaca brokerage account — mode=account for account info, mode=positions for all open positions, mode=position_by_symbol for one position, mode=orders for recent order status. Read-only, defaults to the paper-trading endpoint. Requires KBOT_FINANCE_ALPACA_KEY_ID + KBOT_FINANCE_ALPACA_SECRET_KEY (or APCA_API_KEY_ID + APCA_API_SECRET_KEY). AI never places orders through this tool. Audit log at ~/.kbot/audit/polymarket.jsonl.", + parameters: { + mode: { + type: "string", + description: "'account', 'positions', 'position_by_symbol', or 'orders'.", + required: true, + }, + symbol: { + type: "string", + description: "Ticker symbol (required when mode='position_by_symbol').", + }, + status: { + type: "string", + description: "'open', 'closed', or 'all' when mode='orders'. Default 'open'.", + default: "open", + }, + limit: { + type: "number", + description: "Max orders to return when mode='orders'. Default 25.", + default: 25, + }, + jurisdiction: { + type: "string", + description: "Verifier jurisdiction tag (US/EU/UK/SG/HK/UAE/GLOBAL). Default US.", + default: "US", + }, + }, + tier: "free", + async execute(args) { + try { + const mode = args["mode"]; + if ( + mode !== "account" && + mode !== "positions" && + mode !== "position_by_symbol" && + mode !== "orders" + ) { + return `Error: mode must be 'account', 'positions', 'position_by_symbol', or 'orders' (got: ${String(mode)})`; + } + const symbol = typeof args["symbol"] === "string" ? args["symbol"] : undefined; + if (mode === "position_by_symbol" && !symbol) { + return "Error: symbol is required when mode='position_by_symbol'"; + } + const status = + typeof args["status"] === "string" ? (args["status"] as "open" | "closed" | "all") : "open"; + const limit = typeof args["limit"] === "number" ? args["limit"] : 25; + const jurisdiction = + typeof args["jurisdiction"] === "string" + ? (args["jurisdiction"] as "US" | "EU" | "UK" | "SG" | "HK" | "UAE" | "GLOBAL") + : "US"; + + const auditLog = await getAuditLog(); + const result = await alpacaQuery( + { + mode, + ...(symbol ? { symbol } : {}), + status, + limit, + data_as_of: nowISO(), + }, + { + auditLog, + rules: defaultRules(), + verifierContext: { session_id: sessionId(), state: {}, jurisdiction }, + }, + ); + + if (!result.ok) { + return `Error (${result.stage}): ${JSON.stringify(result.detail, null, 2)}`; + } + + const summary = { + request_hash: result.response.request_hash, + engine_version: result.response.engine_version, + produced_at: result.response.produced_at, + byte_identical_replayable: result.response.byte_identical_replayable, + ...result.response.value, + }; + return JSON.stringify(summary, null, 2); + } catch (e) { + return `Error: ${(e as Error).message}`; + } + }, +}; + /** * annex_iv_export — emit an EU AI Act Annex IV technical-documentation * bundle from the audit log. Same artifact satisfies Fed SR 26-02 @@ -372,6 +468,7 @@ const annexIvExportTool: KbotToolDefinition = { export const kbotFinanceTools: readonly KbotToolDefinition[] = [ polymarketQueryTool, edgarQueryTool, + alpacaQueryTool, annexIvExportTool, auditLogVerifyTool, ]; diff --git a/packages/kbot-finance/src/tools/alpaca-query.ts b/packages/kbot-finance/src/tools/alpaca-query.ts new file mode 100644 index 000000000..8e61921d8 --- /dev/null +++ b/packages/kbot-finance/src/tools/alpaca-query.ts @@ -0,0 +1,269 @@ +import { + sealEnvelope, + sha256, + canonicalize, + type ContentAddressedRequest, + type ContentAddressedResponse, + type JsonValue, +} from "../envelope.js"; +import { AppendOnlyAuditLog } from "../audit-log.js"; +import { runVerifier, type Rule, type VerifierContext } from "../verifier/index.js"; +import { + getAccount, + listPositions, + getPosition, + listOrders, + decodeNumeric, + ALPACA_ADAPTER_VERSION, + type AlpacaAccount, + type AlpacaPosition, + type AlpacaOrder, +} from "../adapters/alpaca/index.js"; + +/** + * alpaca_query — the kbot tool entry point for the brokerage engine adapter. + * + * Same wiring shape as polymarket-query.ts / edgar-query.ts: content-addressed + * envelope, regulatory verifier, hash-chained audit log, then the engine call. + * Read-only — account, positions, and order status. No order placement; the + * AI layer cannot move money through this tool. + */ + +const SCHEMA_HASH = sha256( + canonicalize({ + type: "object", + fields: { + mode: { + type: "string", + enum: ["account", "positions", "position_by_symbol", "orders"], + }, + symbol: { type: "string", optional: true }, + status: { type: "string", optional: true }, + limit: { type: "number", optional: true }, + }, + } as JsonValue), +); + +export interface AlpacaQueryInputs { + readonly mode: "account" | "positions" | "position_by_symbol" | "orders"; + readonly symbol?: string; + readonly status?: "open" | "closed" | "all"; + readonly limit?: number; + /** ISO 8601 UTC. The agent supplies "as of"; the engine doesn't time-travel today. */ + readonly data_as_of: string; +} + +export interface NormalizedAccount { + readonly id: string | null; + readonly account_number: string | null; + readonly status: string | null; + readonly currency: string | null; + readonly cash: number | null; + readonly portfolio_value: number | null; + readonly equity: number | null; + readonly buying_power: number | null; + readonly pattern_day_trader: boolean | null; + readonly trading_blocked: boolean | null; + readonly account_blocked: boolean | null; +} + +export interface NormalizedPosition { + readonly symbol: string | null; + readonly side: string | null; + readonly qty: number | null; + readonly avg_entry_price: number | null; + readonly current_price: number | null; + readonly market_value: number | null; + readonly cost_basis: number | null; + readonly unrealized_pl: number | null; + readonly unrealized_plpc: number | null; +} + +export interface NormalizedOrder { + readonly id: string | null; + readonly symbol: string | null; + readonly side: string | null; + readonly type: string | null; + readonly qty: number | null; + readonly filled_qty: number | null; + readonly filled_avg_price: number | null; + readonly status: string | null; + readonly submitted_at: string | null; +} + +export interface AlpacaQueryValue { + readonly mode: "account" | "positions" | "position_by_symbol" | "orders"; + readonly account?: NormalizedAccount; + readonly positions?: ReadonlyArray; + readonly orders?: ReadonlyArray; +} + +function normalizeAccount(a: AlpacaAccount): NormalizedAccount { + return { + id: a.id ?? null, + account_number: a.account_number ?? null, + status: a.status ?? null, + currency: a.currency ?? null, + cash: decodeNumeric(a.cash), + portfolio_value: decodeNumeric(a.portfolio_value), + equity: decodeNumeric(a.equity), + buying_power: decodeNumeric(a.buying_power), + pattern_day_trader: a.pattern_day_trader ?? null, + trading_blocked: a.trading_blocked ?? null, + account_blocked: a.account_blocked ?? null, + }; +} + +function normalizePosition(p: AlpacaPosition): NormalizedPosition { + return { + symbol: p.symbol ?? null, + side: p.side ?? null, + qty: decodeNumeric(p.qty), + avg_entry_price: decodeNumeric(p.avg_entry_price), + current_price: decodeNumeric(p.current_price), + market_value: decodeNumeric(p.market_value), + cost_basis: decodeNumeric(p.cost_basis), + unrealized_pl: decodeNumeric(p.unrealized_pl), + unrealized_plpc: decodeNumeric(p.unrealized_plpc), + }; +} + +function normalizeOrder(o: AlpacaOrder): NormalizedOrder { + return { + id: o.id ?? null, + symbol: o.symbol ?? null, + side: o.side ?? null, + type: o.type ?? null, + qty: decodeNumeric(o.qty), + filled_qty: decodeNumeric(o.filled_qty), + filled_avg_price: decodeNumeric(o.filled_avg_price), + status: o.status ?? null, + submitted_at: o.submitted_at ?? null, + }; +} + +export interface AlpacaQueryDeps { + readonly auditLog: AppendOnlyAuditLog; + readonly rules: ReadonlyArray; + readonly verifierContext: VerifierContext; + /** Override for testing. Defaults to the real Alpaca client. */ + readonly engine?: { + getAccount: typeof getAccount; + listPositions: typeof listPositions; + getPosition: typeof getPosition; + listOrders: typeof listOrders; + }; +} + +export type AlpacaQueryResult = + | { readonly ok: true; readonly response: ContentAddressedResponse } + | { readonly ok: false; readonly stage: "verifier" | "engine"; readonly detail: JsonValue }; + +export async function alpacaQuery( + inputs: AlpacaQueryInputs, + deps: AlpacaQueryDeps, +): Promise { + const engine = deps.engine ?? { getAccount, listPositions, getPosition, listOrders }; + + const operation = + inputs.mode === "account" + ? "alpaca.account" + : inputs.mode === "positions" + ? "alpaca.positions" + : inputs.mode === "position_by_symbol" + ? "alpaca.position_by_symbol" + : "alpaca.orders"; + + const request: ContentAddressedRequest = { + operation, + engine_version: ALPACA_ADAPTER_VERSION, + schema_hash: SCHEMA_HASH, + inputs: inputs as unknown as JsonValue, + data_as_of: inputs.data_as_of, + }; + + const verifier_report = runVerifier( + deps.rules, + { + operation: request.operation, + inputs: request.inputs, + materiality: "informational", + }, + deps.verifierContext, + ); + + await deps.auditLog.append({ + action: "verifier_check", + subject: request.operation, + session_id: deps.verifierContext.session_id, + payload: verifier_report as unknown as JsonValue, + }); + + if (!verifier_report.ok) { + return { ok: false, stage: "verifier", detail: verifier_report as unknown as JsonValue }; + } + + await deps.auditLog.append({ + action: "engine_request", + subject: request.operation, + session_id: deps.verifierContext.session_id, + payload: request as unknown as JsonValue, + }); + + const sealed = await sealEnvelope( + request, + async () => { + if (inputs.mode === "account") { + const r = await engine.getAccount(); + if (!r.ok) throw new Error(`alpaca.account failed: ${r.error.code}: ${r.error.message}`); + return { mode: "account" as const, account: normalizeAccount(r.value) }; + } + if (inputs.mode === "positions") { + const r = await engine.listPositions(); + if (!r.ok) throw new Error(`alpaca.positions failed: ${r.error.code}: ${r.error.message}`); + return { mode: "positions" as const, positions: r.value.map(normalizePosition) }; + } + if (inputs.mode === "position_by_symbol") { + if (!inputs.symbol) throw new Error("symbol required for mode=position_by_symbol"); + const r = await engine.getPosition(inputs.symbol); + if (!r.ok) { + throw new Error( + `alpaca.position_by_symbol failed: ${r.error.code}: ${r.error.message}`, + ); + } + return { mode: "position_by_symbol" as const, positions: [normalizePosition(r.value)] }; + } + const r = await engine.listOrders({ status: inputs.status ?? "open", limit: inputs.limit ?? 25 }); + if (!r.ok) throw new Error(`alpaca.orders failed: ${r.error.code}: ${r.error.message}`); + return { mode: "orders" as const, orders: r.value.map(normalizeOrder) }; + }, + // Account state, positions, and order status all change with every fill — + // the honesty primitive: declare replay as non-deterministic, same as the + // Polymarket and EDGAR adapters over live HTTPS. + { byte_identical_replayable: false }, + ).catch((e: Error) => ({ error: e.message }) as const); + + if ("error" in sealed) { + await deps.auditLog.append({ + action: "incident", + subject: request.operation, + session_id: deps.verifierContext.session_id, + payload: { error: sealed.error }, + }); + return { ok: false, stage: "engine", detail: { error: sealed.error } }; + } + + await deps.auditLog.append({ + action: "engine_response", + subject: request.operation, + session_id: deps.verifierContext.session_id, + payload: { + request_hash: sealed.request_hash, + produced_at: sealed.produced_at, + byte_identical_replayable: sealed.byte_identical_replayable, + result_count: sealed.value.positions?.length ?? sealed.value.orders?.length ?? (sealed.value.account ? 1 : 0), + }, + }); + + return { ok: true, response: sealed }; +} diff --git a/packages/kbot-finance/test/alpaca.live.test.ts b/packages/kbot-finance/test/alpaca.live.test.ts new file mode 100644 index 000000000..2a44fd64d --- /dev/null +++ b/packages/kbot-finance/test/alpaca.live.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { getAccount, getAlpacaCredentials } from "../src/adapters/alpaca/index.js"; +import { AppendOnlyAuditLog } from "../src/audit-log.js"; +import { makePositionLimitRule } from "../src/verifier/index.js"; +import { alpacaQuery } from "../src/tools/alpaca-query.js"; + +/** + * LIVE SMOKE — hits the real Alpaca paper-trading API. + * + * Per CONTRIBUTING.md: stub-driven unit tests pass against the spec, not + * reality. Unlike Polymarket/EDGAR, this adapter requires a free + * paper-trading key pair — skipped (not failed) when credentials aren't + * configured, in addition to the KBOT_FINANCE_OFFLINE gate used by every + * other adapter's live test. + */ + +const OFFLINE = process.env["KBOT_FINANCE_OFFLINE"] === "1"; +const HAS_CREDENTIALS = getAlpacaCredentials() !== null; + +describe.skipIf(OFFLINE || !HAS_CREDENTIALS)("Alpaca paper-trading live smoke", () => { + it("GET /v2/account returns a usable response", async () => { + const r = await getAccount(); + expect(r.ok).toBe(true); + if (!r.ok) return; + expect(typeof r.value.id).toBe("string"); + }, 30_000); + + it("end-to-end: query + verifier + audit log integrity", async () => { + const dir = await mkdtemp(join(tmpdir(), "kbot-finance-alpaca-live-")); + const path = join(dir, "audit.jsonl"); + try { + const auditLog = await AppendOnlyAuditLog.open(path); + const rules = [ + makePositionLimitRule({ default_max_size: 10_000, default_max_notional: 50_000 }), + ]; + const result = await alpacaQuery( + { mode: "account", data_as_of: new Date().toISOString() }, + { + auditLog, + rules, + verifierContext: { session_id: "live-test", state: {}, jurisdiction: "US" }, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.response.request_hash).toHaveLength(64); + + const integrity = await AppendOnlyAuditLog.verify(path); + expect(integrity.ok).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, 30_000); +}); diff --git a/packages/kbot-finance/test/alpaca.test.ts b/packages/kbot-finance/test/alpaca.test.ts new file mode 100644 index 000000000..a0709c527 --- /dev/null +++ b/packages/kbot-finance/test/alpaca.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { decodeNumeric, alpacaGet, type AlpacaAccount } from "../src/adapters/alpaca/index.js"; +import { AppendOnlyAuditLog } from "../src/audit-log.js"; +import { makePositionLimitRule } from "../src/verifier/index.js"; +import { alpacaQuery } from "../src/tools/alpaca-query.js"; + +describe("Alpaca adapter", () => { + it("decodes numeric string fields", () => { + expect(decodeNumeric("1234.56")).toBe(1234.56); + expect(decodeNumeric("0")).toBe(0); + expect(decodeNumeric(undefined)).toBeNull(); + expect(decodeNumeric("not-a-number")).toBeNull(); + }); + + it("alpacaGet returns missing_credentials when no key pair is configured", async () => { + const savedKeyId = process.env["KBOT_FINANCE_ALPACA_KEY_ID"]; + const savedSecret = process.env["KBOT_FINANCE_ALPACA_SECRET_KEY"]; + const savedApcaKeyId = process.env["APCA_API_KEY_ID"]; + const savedApcaSecret = process.env["APCA_API_SECRET_KEY"]; + delete process.env["KBOT_FINANCE_ALPACA_KEY_ID"]; + delete process.env["KBOT_FINANCE_ALPACA_SECRET_KEY"]; + delete process.env["APCA_API_KEY_ID"]; + delete process.env["APCA_API_SECRET_KEY"]; + try { + const r = await alpacaGet("/v2/account"); + expect(r.ok).toBe(false); + if (r.ok) return; + expect(r.error.code).toBe("missing_credentials"); + } finally { + if (savedKeyId !== undefined) process.env["KBOT_FINANCE_ALPACA_KEY_ID"] = savedKeyId; + if (savedSecret !== undefined) process.env["KBOT_FINANCE_ALPACA_SECRET_KEY"] = savedSecret; + if (savedApcaKeyId !== undefined) process.env["APCA_API_KEY_ID"] = savedApcaKeyId; + if (savedApcaSecret !== undefined) process.env["APCA_API_SECRET_KEY"] = savedApcaSecret; + } + }); +}); + +describe("alpacaQuery tool wiring", () => { + let dir: string; + let auditLog: AppendOnlyAuditLog; + const rules = [makePositionLimitRule({ default_max_size: 10_000, default_max_notional: 50_000 })]; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "kbot-finance-alpaca-test-")); + auditLog = await AppendOnlyAuditLog.open(join(dir, "audit.jsonl")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + it("mode=account normalizes numeric fields and seals a replayable-false envelope", async () => { + const result = await alpacaQuery( + { mode: "account", data_as_of: new Date().toISOString() }, + { + auditLog, + rules, + verifierContext: { session_id: "test", state: {}, jurisdiction: "US" }, + engine: { + getAccount: async () => ({ + ok: true, + value: { id: "abc123", status: "ACTIVE", currency: "USD", cash: "1000.50", equity: "2000.75" }, + }), + listPositions: async () => ({ ok: true, value: [] }), + getPosition: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listOrders: async () => ({ ok: true, value: [] }), + }, + }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.response.byte_identical_replayable).toBe(false); + expect(result.response.value.account?.cash).toBe(1000.5); + expect(result.response.value.account?.equity).toBe(2000.75); + expect(result.response.request_hash).toHaveLength(64); + }); + + it("mode=positions normalizes an array of positions", async () => { + const result = await alpacaQuery( + { mode: "positions", data_as_of: new Date().toISOString() }, + { + auditLog, + rules, + verifierContext: { session_id: "test", state: {}, jurisdiction: "US" }, + engine: { + getAccount: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listPositions: async () => ({ + ok: true, + value: [ + { + symbol: "AAPL", + side: "long", + qty: "10", + avg_entry_price: "150.00", + current_price: "155.00", + market_value: "1550.00", + cost_basis: "1500.00", + unrealized_pl: "50.00", + unrealized_plpc: "0.0333", + }, + ], + }), + getPosition: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listOrders: async () => ({ ok: true, value: [] }), + }, + }, + ); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.response.value.positions).toHaveLength(1); + expect(result.response.value.positions?.[0]?.symbol).toBe("AAPL"); + expect(result.response.value.positions?.[0]?.qty).toBe(10); + }); + + it("mode=position_by_symbol requires symbol and errors clearly without it", async () => { + const result = await alpacaQuery( + { mode: "position_by_symbol", data_as_of: new Date().toISOString() }, + { + auditLog, + rules, + verifierContext: { session_id: "test", state: {}, jurisdiction: "US" }, + engine: { + getAccount: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listPositions: async () => ({ ok: true, value: [] }), + getPosition: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listOrders: async () => ({ ok: true, value: [] }), + }, + }, + ); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.stage).toBe("engine"); + }); + + it("mode=orders normalizes order status", async () => { + const result = await alpacaQuery( + { mode: "orders", status: "all", data_as_of: new Date().toISOString() }, + { + auditLog, + rules, + verifierContext: { session_id: "test", state: {}, jurisdiction: "US" }, + engine: { + getAccount: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listPositions: async () => ({ ok: true, value: [] }), + getPosition: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listOrders: async () => ({ + ok: true, + value: [ + { + id: "order-1", + symbol: "AAPL", + side: "buy", + type: "market", + qty: "5", + filled_qty: "5", + filled_avg_price: "155.00", + status: "filled", + submitted_at: "2026-01-01T00:00:00Z", + }, + ], + }), + }, + }, + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.response.value.orders).toHaveLength(1); + expect(result.response.value.orders?.[0]?.status).toBe("filled"); + }); + + it("audit log stays hash-chain intact across a full alpacaQuery call", async () => { + await alpacaQuery( + { mode: "account", data_as_of: new Date().toISOString() }, + { + auditLog, + rules, + verifierContext: { session_id: "test", state: {}, jurisdiction: "US" }, + engine: { + getAccount: async () => ({ ok: true, value: { id: "abc123" } }), + listPositions: async () => ({ ok: true, value: [] }), + getPosition: async () => ({ ok: false, error: { code: "not_found", message: "n/a" } }), + listOrders: async () => ({ ok: true, value: [] }), + }, + }, + ); + const integrity = await AppendOnlyAuditLog.verify(join(dir, "audit.jsonl")); + expect(integrity.ok).toBe(true); + }); +}); diff --git a/packages/kbot-finance/test/kbot-tool.test.ts b/packages/kbot-finance/test/kbot-tool.test.ts index c84f116d9..398f5cd4c 100644 --- a/packages/kbot-finance/test/kbot-tool.test.ts +++ b/packages/kbot-finance/test/kbot-tool.test.ts @@ -20,6 +20,7 @@ describe("kbot-finance tool registry surface", () => { it("exports the v0.2 tool set", () => { const names = kbotFinanceTools.map((t) => t.name).sort(); expect(names).toEqual([ + "alpaca_query", "annex_iv_export", "audit_log_verify", "edgar_query", @@ -51,6 +52,23 @@ describe("kbot-finance tool registry surface", () => { expect(r).toContain("market_id is required"); }); + it("alpaca_query rejects invalid mode with a clear Error message", async () => { + const tool = kbotFinanceTools.find((t) => t.name === "alpaca_query"); + expect(tool).toBeDefined(); + if (!tool) return; + const r = await tool.execute({ mode: "nonsense" }); + expect(r.startsWith("Error:")).toBe(true); + expect(r).toContain("mode must be"); + }); + + it("alpaca_query requires symbol when mode=position_by_symbol", async () => { + const tool = kbotFinanceTools.find((t) => t.name === "alpaca_query"); + if (!tool) return; + const r = await tool.execute({ mode: "position_by_symbol" }); + expect(r.startsWith("Error:")).toBe(true); + expect(r).toContain("symbol is required"); + }); + it("audit_log_verify reports ok on a fresh log path", async () => { const tool = kbotFinanceTools.find((t) => t.name === "audit_log_verify"); if (!tool) return;