diff --git a/docs/snaptrade-readonly-uta.md b/docs/snaptrade-readonly-uta.md new file mode 100644 index 000000000..42763372c --- /dev/null +++ b/docs/snaptrade-readonly-uta.md @@ -0,0 +1,90 @@ +# SnapTrade read-only UTA design + +## Goal + +Connect a SnapTrade Personal account to OpenAlice as one or more **read-only** +UTAs. The integration is intended for portfolio monitoring and research; it +must never submit, amend, cancel, or stage a brokerage order. + +## Why a dedicated broker pack + +SnapTrade is a brokerage-account aggregation API. A single Personal API key +can expose several brokerage connections and several accounts under each +connection. OpenAlice's UTA model is intentionally one account per UTA, so the +adapter must make account identity explicit instead of treating a Personal key +as one aggregate trading account. + +The integration belongs in `services/uta/` and an optional `snaptrade` Broker +Pack. Credentials remain in UTA's sealed account configuration; workspace +skills and scheduled agents receive only the normalized read surface exposed by +`alice-uta`. + +## Account setup + +1. The user enters a SnapTrade Personal `clientId` and `consumerKey` through a + sensitive Trading UI form. Both fields are write-only and sealed at rest. +2. UTA signs a read-only request to enumerate SnapTrade connections and their + accounts. +3. The UI displays only `INVESTMENT` accounts and asks the user which accounts + to add. Each selected SnapTrade account becomes a separate UTA with its + immutable SnapTrade `accountId` in its fingerprint. +4. A connection that is disabled, degraded, or missing is not silently + retained as healthy. The user receives a reconnect action that opens the + provider's Connection Portal. + +The first release must not auto-create UTAs from every discovered account: +the user must explicitly select them. This prevents a linked cash, line of +credit, retirement, or crypto account from unexpectedly entering a trading +workflow. + +## Read contract + +The pack maps these SnapTrade reads into `IBroker`: + +- account balances and buying power; +- stock-like equity positions (stocks, ETFs, ADRs, CEFs, and mutual funds), + including fractional quantity, cost basis, price, and currency. Options, + futures, crypto, and cash-equivalent instruments loud-refuse until their + dedicated contract mappings are implemented; +- recent/open orders and single-order lookup; +- connection status and `data_freshness_mode`. + +`getCapabilities()` declares US securities and no order types. Every mutation +method (`placeOrder`, `modifyOrder`, `cancelOrder`, and `closePosition`) returns +a permanent `BrokerError('CONFIG', 'SnapTrade accounts are read-only')` before +making any network request. The Trading UI must render these accounts as +read-only and omit staging controls. + +## Freshness and monitoring policy + +Each successful account read records both the connection state and +`data_freshness_mode` supplied by SnapTrade. + +- `realtime`: eligible for the configured intraday monitor after a successful + independent scheduled preflight. +- `delayed`, missing, or stale: research/daily-review only; never eligible for + a 15-minute risk alert claiming current broker coverage. +- disabled connection or failed read: mark the UTA degraded and publish a + Chinese alert that names the excluded account. + +The monitor reports its covered account IDs in every alert. It must not combine +Robinhood and OKX values when either source is degraded. + +## Security and validation + +- Never log or serialize `consumerKey`, OAuth tokens, raw request signatures, + or full account numbers. +- Use SnapTrade Personal authentication only; do not register a commercial + SnapTrade user or store a `userSecret`. +- Unit-test request signing, response mapping, read-only mutation rejection, + multi-account identity, disabled-connection handling, and freshness gating. +- Acceptance uses a dedicated read-only Personal key and validates one + `realtime` account without placing any order. + +## Rollout + +1. Land the protocol, pack, and UI account-selection work behind the optional + Broker Pack installation boundary. +2. Validate on a user-authorized read-only Robinhood connection. +3. Enable unattended monitoring only after the scheduled preflight can read + the configured UTA without interactive MCP confirmation. diff --git a/packages/uta-broker-snaptrade/package.json b/packages/uta-broker-snaptrade/package.json new file mode 100644 index 000000000..a25879e64 --- /dev/null +++ b/packages/uta-broker-snaptrade/package.json @@ -0,0 +1,7 @@ +{ + "name": "@traderalice/uta-broker-snaptrade", "version": "0.1.0", "private": true, "type": "module", + "exports": { ".": { "openalice-source": "./src/index.ts", "import": "./dist/index.js" } }, "files": ["dist"], + "scripts": { "build": "tsup", "typecheck": "tsc --noEmit" }, + "dependencies": { "@traderalice/ibkr": "workspace:*", "@traderalice/uta-protocol": "workspace:*", "decimal.js": "^10.6.0", "zod": "^4.3.6" }, + "devDependencies": { "@types/node": "^22.13.4", "tsup": "^8.5.1", "typescript": "^5.9.3" } +} diff --git a/packages/uta-broker-snaptrade/src/index.ts b/packages/uta-broker-snaptrade/src/index.ts new file mode 100644 index 000000000..05e955930 --- /dev/null +++ b/packages/uta-broker-snaptrade/src/index.ts @@ -0,0 +1,7 @@ +import { SnapTradeBroker } from '../../../services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.js' +export const BROKER_PACK_API_VERSION = 1 +export const BROKER_ENGINE = 'snaptrade' +export const configSchema = SnapTradeBroker.configSchema +export function createBroker(config: { id: string; label?: string; brokerConfig: Record }) { + return Object.assign(SnapTradeBroker.fromConfig(config), { brokerEngine: BROKER_ENGINE }) +} diff --git a/packages/uta-broker-snaptrade/tsconfig.json b/packages/uta-broker-snaptrade/tsconfig.json new file mode 100644 index 000000000..507b7806f --- /dev/null +++ b/packages/uta-broker-snaptrade/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "../..", "noEmit": true }, "include": ["src/**/*.ts"] } diff --git a/packages/uta-broker-snaptrade/tsup.config.ts b/packages/uta-broker-snaptrade/tsup.config.ts new file mode 100644 index 000000000..3913b8b82 --- /dev/null +++ b/packages/uta-broker-snaptrade/tsup.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from 'tsup' +export default defineConfig({ entry: { index: 'src/index.ts' }, format: ['esm'], outDir: 'dist', target: 'node20', clean: true, sourcemap: true, splitting: false, skipNodeModulesBundle: true, noExternal: [/^@traderalice\//, /^@bufbuild\/protobuf(?:\/|$)/], esbuildOptions: (options) => { options.conditions = ['openalice-source', ...(options.conditions ?? [])] } }) diff --git a/packages/uta-protocol/src/brokers/preset-catalog.ts b/packages/uta-protocol/src/brokers/preset-catalog.ts index 2d89a68ed..ea67271e7 100644 --- a/packages/uta-protocol/src/brokers/preset-catalog.ts +++ b/packages/uta-protocol/src/brokers/preset-catalog.ts @@ -15,7 +15,7 @@ import { createHash, randomBytes } from 'node:crypto' // ==================== Types ==================== -export type BrokerEngine = 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'mock' +export type BrokerEngine = 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'snaptrade' | 'mock' export interface ModeOption { id: string @@ -494,6 +494,36 @@ export const SIMULATOR_PRESET: BrokerPresetDef = { isPaper: () => true, } +/** + * SnapTrade deliberately exposes an observation-only adapter. The connection + * must have been created through SnapTrade OAuth beforehand and must report + * `type=read` plus `data_freshness_mode=realtime` during broker init. + */ +export const SNAPTRADE_PRESET: BrokerPresetDef = { + id: 'snaptrade', + label: 'SnapTrade (read-only)', + description: 'Read-only securities monitoring through SnapTrade. No order operation is available.', + category: 'recommended', + hint: 'Connect your broker in SnapTrade first. Enter the Personal API Client ID, Consumer Key, Connection ID, and the specific account ID. The adapter refuses disabled, delayed, or trade-enabled connections.', + defaultName: 'snaptrade-securities', + badge: 'ST', + badgeColor: 'text-info', + engine: 'snaptrade', + guardCategory: 'securities', + zodSchema: z.object({ + clientId: z.string().min(1).describe('SnapTrade Client ID'), + consumerKey: z.string().min(1).describe('SnapTrade Consumer Key'), + authorizationId: z.string().min(1).describe('Connection ID'), + accountId: z.string().min(1).describe('Account ID'), + baseCurrency: z.string().length(3).default('USD').describe('Base Currency'), + }), + subtitleFields: [{ field: 'baseCurrency', prefix: 'SnapTrade · ' }], + writeOnlyFields: ['clientId', 'consumerKey'], + fingerprintFields: ['clientId', 'authorizationId', 'accountId'], + toEngineConfig: (d) => ({ clientId: d.clientId, consumerKey: d.consumerKey, authorizationId: d.authorizationId, accountId: d.accountId, baseCurrency: d.baseCurrency }), + isPaper: () => false, +} + // ==================== Catalog ==================== // Order matters — the wizard renders presets top-down within each @@ -507,6 +537,7 @@ export const BROKER_PRESET_CATALOG: BrokerPresetDef[] = [ // prototype was modeled on its API). IBKR_PRESET, ALPACA_PRESET, + SNAPTRADE_PRESET, LONGBRIDGE_PRESET, HYPERLIQUID_PRESET, // ---- Crypto ---- diff --git a/packages/uta-protocol/src/brokers/presets.ts b/packages/uta-protocol/src/brokers/presets.ts index 439952f70..926e5b287 100644 --- a/packages/uta-protocol/src/brokers/presets.ts +++ b/packages/uta-protocol/src/brokers/presets.ts @@ -24,7 +24,7 @@ export interface SerializedBrokerPreset { defaultName: string badge: string badgeColor: string - engine: 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'mock' + engine: 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'snaptrade' | 'mock' guardCategory: 'crypto' | 'securities' modes?: ModeOption[] subtitleFields: SubtitleSegment[] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5e22b8c40..fa457566f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,31 @@ importers: specifier: ^5.9.3 version: 5.9.3 + packages/uta-broker-snaptrade: + dependencies: + '@traderalice/ibkr': + specifier: workspace:* + version: link:../ibkr + '@traderalice/uta-protocol': + specifier: workspace:* + version: link:../uta-protocol + decimal.js: + specifier: ^10.6.0 + version: 10.6.0 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.19.15 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/uta-protocol: dependencies: '@traderalice/ibkr': diff --git a/scripts/build-broker-packs.ts b/scripts/build-broker-packs.ts index 43e01dc52..388d4042e 100644 --- a/scripts/build-broker-packs.ts +++ b/scripts/build-broker-packs.ts @@ -31,6 +31,7 @@ const packageNames: Record = { ibkr: '@traderalice/uta-broker-ibkr', leverup: '@traderalice/uta-broker-leverup', longbridge: '@traderalice/uta-broker-longbridge', + snaptrade: '@traderalice/uta-broker-snaptrade', } await rm(outDir, { recursive: true, force: true }) diff --git a/services/uta/src/domain/trading/brokers/presets.spec.ts b/services/uta/src/domain/trading/brokers/presets.spec.ts index 1d566b28f..6bed011e3 100644 --- a/services/uta/src/domain/trading/brokers/presets.spec.ts +++ b/services/uta/src/domain/trading/brokers/presets.spec.ts @@ -41,6 +41,7 @@ const SAMPLE_CONFIGS: Record> = { hyperliquid: { mode: 'live', walletAddress: '0xabc', privateKey: 'pk' }, bitget: { mode: 'live', apiKey: 'k', secret: 's', password: 'p' }, alpaca: { mode: 'paper', apiKey: 'k', apiSecret: 's' }, + snaptrade: { clientId: 'client', consumerKey: 'consumer', authorizationId: 'authorization', accountId: 'account', baseCurrency: 'USD' }, 'ibkr-tws': { host: '127.0.0.1', port: 7497, clientId: 0 }, longbridge: { mode: 'live', appKey: 'k', appSecret: 's', accessToken: 't' }, 'ccxt-custom': { exchange: 'kucoin', apiKey: 'k', secret: 's' }, diff --git a/services/uta/src/domain/trading/brokers/registry.ts b/services/uta/src/domain/trading/brokers/registry.ts index 13a82052f..c7bff3c95 100644 --- a/services/uta/src/domain/trading/brokers/registry.ts +++ b/services/uta/src/domain/trading/brokers/registry.ts @@ -47,6 +47,7 @@ const workspaceEntries: Record = { ibkr: 'packages/uta-broker-ibkr/src/index.ts', leverup: 'packages/uta-broker-leverup/src/index.ts', longbridge: 'packages/uta-broker-longbridge/src/index.ts', + snaptrade: 'packages/uta-broker-snaptrade/src/index.ts', } const cache = new Map>() diff --git a/services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.spec.ts b/services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.spec.ts new file mode 100644 index 000000000..5f3b36a9a --- /dev/null +++ b/services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' +import { Contract, Order } from '@traderalice/ibkr' +import { SnapTradeBroker } from './SnapTradeBroker.js' +import { SnapTradeClient } from './snaptrade-client.js' + +function brokerWith(fetchImpl: typeof fetch) { + return new SnapTradeBroker({ clientId: 'client', consumerKey: 'secret', authorizationId: 'auth-1', accountId: 'account-1' }, new SnapTradeClient({ clientId: 'client', consumerKey: 'secret' }, fetchImpl, () => 1_700_000_000_000)) +} + +describe('SnapTradeBroker', () => { + it('accepts only realtime read connections and reads fractional stock positions', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: 'auth-1', brokerage: { slug: 'ROBINHOOD' }, type: 'read', disabled: false, data_freshness_mode: 'realtime' }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ id: 'auth-1', brokerage: { slug: 'ROBINHOOD' }, type: 'read', disabled: false, data_freshness_mode: 'realtime' }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([{ currency: { code: 'USD' }, cash: 543.29, buying_power: 543.29 }]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ results: [{ instrument: { id: 'i-1', kind: 'stock', symbol: 'AMZN', currency: 'USD' }, units: '2.026794', price: '246.97', cost_basis: '246.70' }] }), { status: 200 })) + const broker = brokerWith(fetchImpl) + await broker.init() + const account = await broker.getAccount() + expect(account.totalCashValue).toBe('543.29') + expect(account.netLiquidation).toBe('1043.84731418') + expect(fetchImpl.mock.calls.map(([, init]) => (init as RequestInit).method)).toEqual(['GET', 'GET', 'GET', 'GET']) + }) + + it('never sends a write request', async () => { + const fetchImpl = vi.fn() + const broker = brokerWith(fetchImpl) + const contract = new Contract(); contract.symbol = 'AMZN'; contract.localSymbol = 'AMZN'; contract.secType = 'STK'; contract.exchange = 'SMART'; contract.currency = 'USD' + await expect(broker.placeOrder(contract, new Order())).rejects.toMatchObject({ code: 'CONFIG', permanent: true }) + await expect(broker.cancelOrder('order-1')).rejects.toMatchObject({ code: 'CONFIG', permanent: true }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it('rejects delayed connections before account coverage starts', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify([{ id: 'auth-1', brokerage: { slug: 'ROBINHOOD' }, type: 'read', disabled: false, data_freshness_mode: 'delayed' }]), { status: 200 })) + await expect(brokerWith(fetchImpl).init()).rejects.toMatchObject({ code: 'AUTH', permanent: true }) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.ts b/services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.ts new file mode 100644 index 000000000..6adbb5aa0 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/snaptrade/SnapTradeBroker.ts @@ -0,0 +1,187 @@ +/** Read-only UTA adapter for a single SnapTrade securities account. */ +import { z } from 'zod' +import Decimal from 'decimal.js' +import { Contract, ContractDescription, ContractDetails, Order, OrderState } from '@traderalice/ibkr' +import { + BrokerError, + type AccountCapabilities, + type AccountInfo, + type BrokerConfigField, + type IBroker, + type MarketClock, + type OpenOrder, + type PlaceOrderResult, + type Position, + type Quote, + type TpSlParams, +} from '../types.js' +import { buildContract } from '../contract-builder.js' +import { SnapTradeClient, type SnapTradePersonalCredentials } from './snaptrade-client.js' +import { + assessSnapTradeConnection, + mapSnapTradeEquityPosition, + type SnapTradeOrder, + type SnapTradeRawPosition, +} from './snaptrade-read-model.js' + +export interface SnapTradeBrokerConfig extends SnapTradePersonalCredentials { + id?: string + label?: string + /** SnapTrade authorization id: used to prove this connection is read + realtime. */ + authorizationId: string + /** Immutable SnapTrade account id selected from that authorization. */ + accountId: string + baseCurrency?: string +} + +const OPEN_STATUSES = new Set(['PENDING', 'QUEUED', 'ACCEPTED', 'PARTIAL', 'TRIGGERED', 'ACTIVATED', 'CANCEL_PENDING', 'REPLACE_PENDING']) + +export class SnapTradeBroker implements IBroker { + static configSchema = z.object({ + clientId: z.string().min(1), + consumerKey: z.string().min(1), + authorizationId: z.string().min(1), + accountId: z.string().min(1), + baseCurrency: z.string().length(3).default('USD'), + }) + + static configFields: BrokerConfigField[] = [ + { name: 'clientId', type: 'password', label: 'SnapTrade Client ID', required: true, sensitive: true }, + { name: 'consumerKey', type: 'password', label: 'SnapTrade Consumer Key', required: true, sensitive: true }, + { name: 'authorizationId', type: 'text', label: 'Connection ID', required: true, description: 'The read-only, realtime SnapTrade connection ID.' }, + { name: 'accountId', type: 'text', label: 'Account ID', required: true, description: 'One securities account under that connection.' }, + { name: 'baseCurrency', type: 'text', label: 'Base Currency', default: 'USD' }, + ] + + static fromConfig(config: { id: string; label?: string; brokerConfig: Record }): SnapTradeBroker { + const parsed = SnapTradeBroker.configSchema.parse(config.brokerConfig) + return new SnapTradeBroker({ ...parsed, id: config.id, label: config.label }) + } + + readonly brokerEngine = 'snaptrade' + readonly id: string + readonly label: string + private readonly client: SnapTradeClient + private readonly config: Required> + + constructor(config: SnapTradeBrokerConfig, client?: SnapTradeClient) { + this.id = config.id ?? `snaptrade-${config.accountId}` + this.label = config.label ?? 'SnapTrade Securities (read-only)' + this.config = { authorizationId: config.authorizationId, accountId: config.accountId, baseCurrency: (config.baseCurrency ?? 'USD').toUpperCase() } + this.client = client ?? new SnapTradeClient({ clientId: config.clientId, consumerKey: config.consumerKey }) + } + + async init(): Promise { + await this.assertRealtimeReadConnection() + } + + /** Re-check at every top-level read: a connection can be disabled or downgraded after init. */ + private async assertRealtimeReadConnection(): Promise { + try { + const connection = (await this.client.listConnections()).find((item) => item.id === this.config.authorizationId) + if (!connection) throw new BrokerError('AUTH', `SnapTrade connection ${this.config.authorizationId} was not found`) + const readiness = assessSnapTradeConnection(connection) + if (!readiness.eligible) { + throw new BrokerError('AUTH', `SnapTrade connection is not eligible for unattended monitoring: ${readiness.reason}`) + } + } catch (err) { + throw BrokerError.from(err, 'AUTH') + } + } + + async close(): Promise {} + + async searchContracts(pattern: string): Promise { + if (!pattern) return [] + await this.assertRealtimeReadConnection() + const needle = pattern.toUpperCase() + const positions = await this.client.getAllAccountPositions(this.config.accountId) + return positions.results + .filter((p) => p.instrument.symbol.toUpperCase().includes(needle) || (p.instrument.description ?? '').toUpperCase().includes(needle)) + .map((p) => this.contractFromPosition(p)) + .map((contract) => { const d = new ContractDescription(); d.contract = contract; return d }) + } + + async getContractDetails(query: Contract): Promise { + const symbol = query.localSymbol || query.symbol + if (!symbol) return null + await this.assertRealtimeReadConnection() + const found = (await this.client.getAllAccountPositions(this.config.accountId)).results + .find((p) => p.instrument.symbol === symbol || p.instrument.raw_symbol === symbol) + if (!found) return null + const details = new ContractDetails() + details.contract = this.contractFromPosition(found) + details.validExchanges = found.instrument.exchange ?? 'SMART' + details.stockType = 'COMMON' + return details + } + + private refuseWrite(): never { + throw new BrokerError('CONFIG', 'SnapTrade adapter is permanently read-only: order placement, modification, cancellation, and position closing are disabled') + } + async placeOrder(_contract: Contract, _order: Order, _tpsl?: TpSlParams): Promise { return this.refuseWrite() } + async modifyOrder(_orderId: string, _changes: Partial): Promise { return this.refuseWrite() } + async cancelOrder(_orderId: string): Promise { return this.refuseWrite() } + async closePosition(_contract: Contract, _quantity?: Decimal): Promise { return this.refuseWrite() } + + async getAccount(): Promise { + try { + await this.assertRealtimeReadConnection() + const [balances, positions] = await Promise.all([this.client.getAccountBalances(this.config.accountId), this.getPositionsUnsafe()]) + const balance = balances.find((b) => b.currency.code.toUpperCase() === this.config.baseCurrency) + if (!balance) throw new BrokerError('EXCHANGE', `SnapTrade did not return a ${this.config.baseCurrency} cash balance`) + const cash = new Decimal(balance.cash ?? 0) + const marketValue = positions.reduce((total, p) => total.plus(p.marketValue), cash) + const unrealizedPnL = positions.reduce((total, p) => total.plus(p.unrealizedPnL), new Decimal(0)) + return { baseCurrency: this.config.baseCurrency, netLiquidation: marketValue.toString(), totalCashValue: cash.toString(), unrealizedPnL: unrealizedPnL.toString(), buyingPower: String(balance.buying_power ?? balance.cash ?? 0) } + } catch (err) { throw BrokerError.from(err) } + } + + async getPositions(): Promise { + try { await this.assertRealtimeReadConnection(); return await this.getPositionsUnsafe() } + catch (err) { throw BrokerError.from(err) } + } + private async getPositionsUnsafe(): Promise { return (await this.client.getAllAccountPositions(this.config.accountId)).results.map(mapSnapTradeEquityPosition) } + + private mapOrder(raw: SnapTradeOrder): OpenOrder { + const symbol = raw.universal_symbol?.raw_symbol ?? raw.universal_symbol?.symbol + if (!symbol) throw new BrokerError('EXCHANGE', `SnapTrade order ${raw.brokerage_order_id} has no symbol`) + const contract = buildContract({ symbol, localSymbol: raw.universal_symbol?.symbol ?? symbol, secType: 'STK', exchange: 'SMART', currency: this.config.baseCurrency }) + const order = new Order() + order.orderType = raw.order_type?.toUpperCase() === 'MARKET' ? 'MKT' : raw.order_type?.toUpperCase() === 'LIMIT' ? 'LMT' : raw.order_type ?? '' + order.totalQuantity = new Decimal(raw.total_quantity ?? raw.open_quantity ?? 0) + order.orderId = Number(raw.brokerage_order_id) || 0 + const orderState = new OrderState(); orderState.status = raw.status + return { contract, order, orderState, orderId: raw.brokerage_order_id } + } + async getOrders(orderIds: string[]): Promise { + await this.assertRealtimeReadConnection() + const wanted = new Set(orderIds) + return (await this.client.getAccountOrders(this.config.accountId, 90)).filter((o) => wanted.has(o.brokerage_order_id)).map((o) => this.mapOrder(o)) + } + async getOrder(orderId: string): Promise { + await this.assertRealtimeReadConnection() + const found = (await this.client.getAccountOrders(this.config.accountId, 90)).find((o) => o.brokerage_order_id === orderId) + return found ? this.mapOrder(found) : null + } + async getOpenOrders(): Promise { await this.assertRealtimeReadConnection(); return (await this.client.getAccountOrders(this.config.accountId, 90)).filter((o) => OPEN_STATUSES.has(o.status.toUpperCase())).map((o) => this.mapOrder(o)) } + + async getQuote(contract: Contract): Promise { + await this.assertRealtimeReadConnection() + const symbol = contract.localSymbol || contract.symbol + const snapshot = await this.client.getAllAccountPositions(this.config.accountId) + const found = snapshot.results.find((p) => p.instrument.symbol === symbol || p.instrument.raw_symbol === symbol) + if (!found?.price) throw new BrokerError('EXCHANGE', `SnapTrade has no held-position price for ${symbol}; this read-only adapter does not provide standalone quotes`) + return { contract: this.contractFromPosition(found), last: found.price, bid: found.price, ask: found.price, volume: '0', timestamp: snapshot.data_freshness?.as_of ? new Date(snapshot.data_freshness.as_of) : new Date() } + } + + async getMarketClock(): Promise { throw new BrokerError('CONFIG', 'SnapTrade read-only adapter does not expose an exchange clock') } + getCapabilities(): AccountCapabilities { return { supportedSecTypes: ['STK'], supportedOrderTypes: [] } } + getNativeKey(contract: Contract): string { return contract.localSymbol || contract.symbol } + resolveNativeKey(nativeKey: string): Contract { return buildContract({ symbol: nativeKey, localSymbol: nativeKey, secType: 'STK', exchange: 'SMART', currency: this.config.baseCurrency }) } + + private contractFromPosition(raw: SnapTradeRawPosition): Contract { + // Re-use the strict position mapper so unsupported derivatives never become fake stocks. + return mapSnapTradeEquityPosition(raw).contract + } +} diff --git a/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-client.spec.ts b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-client.spec.ts new file mode 100644 index 000000000..91d131da2 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-client.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it, vi } from 'vitest' +import { SnapTradeApiError, SnapTradeClient, signSnapTradeRequest } from './snaptrade-client.js' + +describe('SnapTradeClient', () => { + it('signs canonical nested request data with the Personal consumer key', () => { + const signature = signSnapTradeRequest({ + path: '/api/v1/example', + query: 'clientId=client×tamp=1', + content: { z: 1, a: { y: 2, b: 3 } }, + }, 'secret') + + expect(signature).toBe(signSnapTradeRequest({ + path: '/api/v1/example', + query: 'clientId=client×tamp=1', + content: { a: { b: 3, y: 2 }, z: 1 }, + }, 'secret')) + }) + + it('sends Personal credentials only as clientId plus a request signature', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response(JSON.stringify({ results: [] }), { status: 200 })) + const client = new SnapTradeClient({ clientId: 'client id', consumerKey: 'secret' }, fetchImpl, () => 1_700_000_000_000) + + await expect(client.get('/accounts', [['broker', 'ROBINHOOD']])).resolves.toEqual({ results: [] }) + expect(fetchImpl).toHaveBeenCalledWith( + 'https://api.snaptrade.com/accounts?broker=ROBINHOOD&clientId=client%20id×tamp=1700000000', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Accept: 'application/json', Signature: expect.any(String) }), + }), + ) + }) + + it('preserves provider request IDs in failures without logging credentials', async () => { + const fetchImpl = vi.fn().mockResolvedValue(new Response('disabled', { + status: 403, + headers: { 'x-request-id': 'request-123' }, + })) + const client = new SnapTradeClient({ clientId: 'client', consumerKey: 'secret' }, fetchImpl, () => 1_700_000_000_000) + + await expect(client.get('/accounts')).rejects.toEqual(expect.objectContaining({ + status: 403, + requestId: 'request-123', + })) + }) + + it('uses only read endpoints for connection discovery and position reads', async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify({ results: [] }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + const client = new SnapTradeClient({ clientId: 'client', consumerKey: 'secret' }, fetchImpl, () => 1_700_000_000_000) + + await client.listConnections() + await client.getAllAccountPositions('account/id') + await client.getAccountBalances('account/id') + await client.getAccountOrders('account/id', 90) + + expect(fetchImpl.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://api.snaptrade.com/authorizations?clientId=client×tamp=1700000000', + 'https://api.snaptrade.com/accounts/account%2Fid/positions/all?clientId=client×tamp=1700000000', + 'https://api.snaptrade.com/accounts/account%2Fid/balances?clientId=client×tamp=1700000000', + 'https://api.snaptrade.com/accounts/account%2Fid/orders?days=90&clientId=client×tamp=1700000000', + ]) + expect(fetchImpl.mock.calls.map(([, init]) => (init as RequestInit).method)).toEqual(['GET', 'GET', 'GET', 'GET']) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-client.ts b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-client.ts new file mode 100644 index 000000000..e69325bed --- /dev/null +++ b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-client.ts @@ -0,0 +1,137 @@ +import { createHmac } from 'node:crypto' +import type { SnapTradeBalance, SnapTradeConnection, SnapTradeOrder, SnapTradePositionResponse } from './snaptrade-read-model.js' + +export interface SnapTradePersonalCredentials { + clientId: string + consumerKey: string +} + +export interface SnapTradeRequestOptions { + method?: 'GET' | 'POST' | 'DELETE' + /** Exact query sequence for a provider endpoint, excluding auth fields. */ + query?: readonly [string, string][] + body?: unknown +} + +export class SnapTradeApiError extends Error { + constructor( + readonly status: number, + message: string, + readonly requestId?: string, + ) { + super(message) + this.name = 'SnapTradeApiError' + } +} + +/** + * Minimal signed-request client for SnapTrade Personal accounts. + * + * It deliberately does not expose an order-placement helper. Consumers must + * use its read-only `get` method until a separately reviewed trading design + * exists. Personal keys identify the account owner directly: no userId or + * userSecret is ever accepted or sent. + */ +export class SnapTradeClient { + static readonly apiOrigin = 'https://api.snaptrade.com' + + constructor( + private readonly credentials: SnapTradePersonalCredentials, + private readonly fetchImpl: typeof fetch = fetch, + private readonly now: () => number = Date.now, + ) {} + + async get(path: string, query: readonly [string, string][] = []): Promise { + return this.request(path, { method: 'GET', query }) + } + + /** List provider connections for this Personal key. Never registers a user. */ + async listConnections(): Promise { + return this.get('/authorizations') + } + + /** Read the unified v2 position endpoint for one immutable SnapTrade account. */ + async getAllAccountPositions(accountId: string): Promise { + if (!accountId) throw new Error('SnapTrade accountId is required') + return this.get(`/accounts/${encodeURIComponent(accountId)}/positions/all`) + } + + async getAccountBalances(accountId: string): Promise { + return this.get(`/accounts/${requiredPathSegment(accountId, 'accountId')}/balances`) + } + + async getAccountOrders(accountId: string, days = 30): Promise { + if (!Number.isInteger(days) || days < 1 || days > 90) throw new Error('SnapTrade order lookback must be an integer from 1 to 90 days') + return this.get(`/accounts/${requiredPathSegment(accountId, 'accountId')}/orders`, [['days', String(days)]]) + } + + async request(path: string, options: SnapTradeRequestOptions = {}): Promise { + if (!path.startsWith('/')) throw new Error('SnapTrade request path must start with /') + if (!this.credentials.clientId || !this.credentials.consumerKey) { + throw new Error('SnapTrade clientId and consumerKey are required') + } + + const method = options.method ?? 'GET' + const timestamp = Math.floor(this.now() / 1000).toString() + const query = [ + ...(options.query ?? []), + ['clientId', this.credentials.clientId] as [string, string], + ['timestamp', timestamp] as [string, string], + ] + const rawQuery = query.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`).join('&') + const content = options.body && isNonEmptyObject(options.body) ? options.body : null + const signature = signSnapTradeRequest({ path, query: rawQuery, content }, this.credentials.consumerKey) + const response = await this.fetchImpl(`${SnapTradeClient.apiOrigin}${path}?${rawQuery}`, { + method, + headers: { + Accept: 'application/json', + Signature: signature, + ...(content ? { 'Content-Type': 'application/json' } : {}), + }, + ...(content ? { body: JSON.stringify(content) } : {}), + }) + + if (!response.ok) { + const detail = await response.text() + throw new SnapTradeApiError( + response.status, + `SnapTrade request failed (${response.status})${detail ? `: ${detail}` : ''}`, + response.headers.get('x-request-id') ?? undefined, + ) + } + return await response.json() as T + } +} + +export function signSnapTradeRequest( + payload: { path: string; query: string; content: unknown }, + consumerKey: string, +): string { + const canonical = canonicalJson({ + content: payload.content && isNonEmptyObject(payload.content) ? payload.content : null, + path: payload.path, + query: payload.query, + }) + return createHmac('sha256', consumerKey).update(canonical, 'utf8').digest('base64') +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)) +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys) + if (!value || typeof value !== 'object') return value + return Object.fromEntries(Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => [key, sortKeys(child)])) +} + +function isNonEmptyObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0 +} + +function requiredPathSegment(value: string, label: string): string { + if (!value) throw new Error(`SnapTrade ${label} is required`) + return encodeURIComponent(value) +} diff --git a/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-read-model.spec.ts b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-read-model.spec.ts new file mode 100644 index 000000000..644560f02 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-read-model.spec.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { assessSnapTradeConnection, mapSnapTradeEquityPosition } from './snaptrade-read-model.js' + +describe('SnapTrade read model', () => { + it('only admits enabled realtime read-only connections to intraday monitoring', () => { + expect(assessSnapTradeConnection({ id: 'rh', brokerage: { slug: 'ROBINHOOD' }, type: 'read', disabled: false, data_freshness_mode: 'realtime' })) + .toEqual({ eligible: true, freshness: 'realtime' }) + expect(assessSnapTradeConnection({ id: 'stale', brokerage: { slug: 'ROBINHOOD' }, type: 'read', disabled: false, data_freshness_mode: 'delayed' })) + .toEqual({ eligible: false, reason: 'delayed' }) + expect(assessSnapTradeConnection({ id: 'disabled', brokerage: { slug: 'ROBINHOOD' }, type: 'read', disabled: true, data_freshness_mode: 'realtime' })) + .toEqual({ eligible: false, reason: 'disabled' }) + }) + + it('maps fractional equity positions without silently treating options as stock', () => { + const position = mapSnapTradeEquityPosition({ + instrument: { id: 'instrument', kind: 'stock', symbol: 'MU', raw_symbol: 'MU', currency: 'USD', exchange: 'XNAS' }, + units: '0.323875', price: '964.47', cost_basis: '926.28', currency: 'USD', + }) + expect(position.contract.secType).toBe('STK') + expect(position.quantity.toString()).toBe('0.323875') + expect(position.unrealizedPnL).toBe('12.36878625') + + expect(() => mapSnapTradeEquityPosition({ + instrument: { id: 'option', kind: 'option', symbol: 'MU 2027 C', currency: 'USD' }, + units: '1', price: '3', cost_basis: '2', currency: 'USD', + })).toThrow(/unsupported kind/) + }) +}) diff --git a/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-read-model.ts b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-read-model.ts new file mode 100644 index 000000000..1927b96b4 --- /dev/null +++ b/services/uta/src/domain/trading/brokers/snaptrade/snaptrade-read-model.ts @@ -0,0 +1,104 @@ +import Decimal from 'decimal.js' +import type { Position } from '../types.js' +import { buildContract, buildPosition } from '../contract-builder.js' + +export interface SnapTradeConnection { + id: string + brokerage: { slug: string; display_name?: string | null } + type: 'read' | 'trade' + disabled: boolean + data_freshness_mode?: 'realtime' | 'delayed' | string +} + +export interface SnapTradePositionResponse { + results: SnapTradeRawPosition[] + data_freshness?: { as_of?: string } +} + +export interface SnapTradeBalance { + currency: { code: string } + cash: number | null + buying_power: number | null +} + +export interface SnapTradeOrder { + brokerage_order_id: string + status: string + open_quantity: string | null + total_quantity: string | null + order_type: string | null + time_in_force: string + universal_symbol?: { symbol: string; raw_symbol?: string | null } | null +} + +export interface SnapTradeRawPosition { + instrument: { + id: string + kind: string + symbol: string + raw_symbol?: string | null + description?: string | null + currency?: string | null + exchange?: string | null + } + units: string | null + price: string | null + cost_basis: string | null + currency?: string | null + cash_equivalent?: boolean +} + +export type SnapTradeConnectionReadiness = + | { eligible: true; freshness: 'realtime' } + | { eligible: false; reason: 'disabled' | 'not_read_only' | 'delayed' | 'unknown_freshness' } + +/** + * The unattended monitor must call this before considering a SnapTrade account + * covered. A successful stale response is explicitly not an eligible result. + */ +export function assessSnapTradeConnection(connection: SnapTradeConnection): SnapTradeConnectionReadiness { + if (connection.disabled) return { eligible: false, reason: 'disabled' } + if (connection.type !== 'read') return { eligible: false, reason: 'not_read_only' } + if (connection.data_freshness_mode === 'realtime') return { eligible: true, freshness: 'realtime' } + if (connection.data_freshness_mode === 'delayed') return { eligible: false, reason: 'delayed' } + return { eligible: false, reason: 'unknown_freshness' } +} + +/** Map stock-like SnapTrade positions to the UTA model. Options/futures need + * their dedicated contract metadata path and are intentionally rejected here + * rather than being silently misclassified as stock. */ +export function mapSnapTradeEquityPosition(raw: SnapTradeRawPosition): Position { + if (!['stock', 'etf', 'adr', 'cef', 'mutualfund'].includes(raw.instrument.kind)) { + throw new Error(`SnapTrade position ${raw.instrument.symbol} has unsupported kind ${raw.instrument.kind}`) + } + if (raw.cash_equivalent) { + throw new Error(`SnapTrade cash-equivalent position ${raw.instrument.symbol} must be represented by account cash`) + } + if (!raw.units || !raw.price || !raw.cost_basis) { + throw new Error(`SnapTrade position ${raw.instrument.symbol} is missing units, price, or cost basis`) + } + + const quantity = new Decimal(raw.units) + const side = quantity.isNegative() ? 'short' : 'long' + const absoluteQuantity = quantity.abs() + const currency = raw.currency ?? raw.instrument.currency ?? 'USD' + const contract = buildContract({ + symbol: raw.instrument.raw_symbol ?? raw.instrument.symbol, + secType: 'STK', + exchange: raw.instrument.exchange ?? 'SMART', + currency, + localSymbol: raw.instrument.symbol, + description: raw.instrument.description ?? undefined, + }) + + return buildPosition({ + contract, + currency, + side, + quantity: absoluteQuantity, + avgCost: raw.cost_basis, + marketPrice: raw.price, + realizedPnL: '0', + avgCostSource: 'broker', + }) +} diff --git a/src/core/broker-packs.ts b/src/core/broker-packs.ts index f8c98bf99..394ce37b9 100644 --- a/src/core/broker-packs.ts +++ b/src/core/broker-packs.ts @@ -17,6 +17,7 @@ export const INSTALLABLE_BROKER_ENGINES = [ 'ibkr', 'leverup', 'longbridge', + 'snaptrade', ] as const export type InstallableBrokerEngine = typeof INSTALLABLE_BROKER_ENGINES[number] diff --git a/src/webui/routes/trading-config.spec.ts b/src/webui/routes/trading-config.spec.ts index d1f95593a..629c46212 100644 --- a/src/webui/routes/trading-config.spec.ts +++ b/src/webui/routes/trading-config.spec.ts @@ -134,7 +134,7 @@ describe('GET /broker-packs — optional engine requirements', () => { const { status, body } = await req(makeRoutes(), 'GET', '/broker-packs') expect(status).toBe(200) - expect((body as { packs: unknown[] }).packs).toHaveLength(6) + expect((body as { packs: unknown[] }).packs).toHaveLength(7) expect(warn).toHaveBeenCalledWith( expect.stringContaining('legacy-account'), expect.stringMatching(/unknown broker preset/i), diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 1ca8f8f0d..106d44ae9 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -535,7 +535,7 @@ export interface BrokerPreset { defaultName: string badge: string badgeColor: string - engine: 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'mock' + engine: 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'snaptrade' | 'mock' guardCategory: 'crypto' | 'securities' modes?: ModeOption[] subtitleFields: SubtitleField[]