Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/snaptrade-readonly-uta.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions packages/uta-broker-snaptrade/package.json
Original file line number Diff line number Diff line change
@@ -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" }
}
7 changes: 7 additions & 0 deletions packages/uta-broker-snaptrade/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }) {
return Object.assign(SnapTradeBroker.fromConfig(config), { brokerEngine: BROKER_ENGINE })
}
1 change: 1 addition & 0 deletions packages/uta-broker-snaptrade/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "../..", "noEmit": true }, "include": ["src/**/*.ts"] }
2 changes: 2 additions & 0 deletions packages/uta-broker-snaptrade/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [])] } })
33 changes: 32 additions & 1 deletion packages/uta-protocol/src/brokers/preset-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 ----
Expand Down
2 changes: 1 addition & 1 deletion packages/uta-protocol/src/brokers/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions scripts/build-broker-packs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const packageNames: Record<InstallableBrokerEngine, string> = {
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 })
Expand Down
1 change: 1 addition & 0 deletions services/uta/src/domain/trading/brokers/presets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const SAMPLE_CONFIGS: Record<string, Record<string, unknown>> = {
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' },
Expand Down
1 change: 1 addition & 0 deletions services/uta/src/domain/trading/brokers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ const workspaceEntries: Record<InstallableBrokerEngine, string> = {
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<BrokerEngine, Promise<BrokerEngineEntry>>()
Expand Down
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Loading