Skip to content

Commit 8186259

Browse files
committed
feat(trading): add read-only Schwab portfolio support
Add Schwab portfolio UTA config, token handling, and portfolio reads. Keep the broker read-only with no order placement support.
1 parent c825284 commit 8186259

10 files changed

Lines changed: 1135 additions & 3 deletions

File tree

packages/uta-protocol/src/brokers/preset-catalog.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { createHash, randomBytes } from 'node:crypto'
1515

1616
// ==================== Types ====================
1717

18-
export type BrokerEngine = 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'mock'
18+
export type BrokerEngine = 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'schwab' | 'mock'
1919

2020
export interface ModeOption {
2121
id: string
@@ -432,6 +432,47 @@ export const LONGBRIDGE_PRESET: BrokerPresetDef = {
432432
isPaper: (d) => d.mode === 'paper',
433433
}
434434

435+
export const SCHWAB_PRESET: BrokerPresetDef = {
436+
id: 'schwab',
437+
label: 'Schwab (Portfolio)',
438+
description: 'Charles Schwab Trader API — OAuth-backed, read-only portfolio access for US securities accounts.',
439+
category: 'recommended',
440+
hint: 'Schwab is portfolio-only here: fill `clientId`, `clientSecret`, and either a `refreshToken` or a `tokenPath` pointing at a Schwab OAuth token JSON. OpenAlice will refresh the access token automatically and persist the updated token back to that file. If your Schwab login spans multiple linked accounts, set `accountNumber` to pin one account; otherwise OpenAlice aggregates them.',
441+
defaultName: 'schwab-portfolio',
442+
badge: 'CS',
443+
badgeColor: 'text-blue-400',
444+
engine: 'schwab',
445+
guardCategory: 'securities',
446+
zodSchema: z.object({
447+
clientId: z.string().min(1).describe('Client ID'),
448+
clientSecret: z.string().min(1).describe('Client Secret'),
449+
refreshToken: z.string().min(1).optional().describe('Refresh Token (optional if tokenPath points at a token JSON)'),
450+
tokenPath: z.string().min(1).optional().describe('OAuth Token File Path'),
451+
accountNumber: z.string().optional().describe('Account Number (optional; aggregates all linked accounts if blank)'),
452+
}).superRefine((data, ctx) => {
453+
if (!data.refreshToken && !data.tokenPath) {
454+
ctx.addIssue({
455+
code: z.ZodIssueCode.custom,
456+
path: ['refreshToken'],
457+
message: 'Provide either refreshToken or tokenPath',
458+
})
459+
}
460+
}),
461+
subtitleFields: [
462+
{ field: 'accountNumber', prefix: 'Schwab · ' },
463+
],
464+
writeOnlyFields: ['clientSecret', 'refreshToken'],
465+
fingerprintFields: ['clientId', 'accountNumber'],
466+
toEngineConfig: (d) => ({
467+
clientId: d.clientId,
468+
clientSecret: d.clientSecret,
469+
...(d.refreshToken ? { refreshToken: d.refreshToken } : {}),
470+
...(d.tokenPath ? { tokenPath: d.tokenPath } : {}),
471+
...(d.accountNumber ? { accountNumber: d.accountNumber } : {}),
472+
}),
473+
isPaper: () => false,
474+
}
475+
435476
// ==================== Other ecosystem brokers (lower-tier, isolated) ====================
436477

437478
export const LEVERUP_PRESET: BrokerPresetDef = {
@@ -508,6 +549,7 @@ export const BROKER_PRESET_CATALOG: BrokerPresetDef[] = [
508549
IBKR_PRESET,
509550
ALPACA_PRESET,
510551
LONGBRIDGE_PRESET,
552+
SCHWAB_PRESET,
511553
HYPERLIQUID_PRESET,
512554
// ---- Crypto ----
513555
BINANCE_PRESET,

packages/uta-protocol/src/brokers/presets.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export interface SerializedBrokerPreset {
2424
defaultName: string
2525
badge: string
2626
badgeColor: string
27-
engine: 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'mock'
27+
engine: 'ccxt' | 'alpaca' | 'ibkr' | 'leverup' | 'longbridge' | 'schwab' | 'mock'
2828
guardCategory: 'crypto' | 'securities'
2929
modes?: ModeOption[]
3030
subtitleFields: SubtitleSegment[]

services/uta/src/domain/trading/brokers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,7 @@ export type { CcxtBrokerConfig } from './ccxt/index.js'
4444
// IBKR
4545
export { IbkrBroker } from './ibkr/index.js'
4646
export type { IbkrBrokerConfig } from './ibkr/index.js'
47+
48+
// Schwab
49+
export { SchwabBroker } from './schwab/index.js'
50+
export type { SchwabBrokerConfig } from './schwab/index.js'

services/uta/src/domain/trading/brokers/presets.spec.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
ALPACA_PRESET,
2626
IBKR_PRESET,
2727
LONGBRIDGE_PRESET,
28+
SCHWAB_PRESET,
2829
CCXT_CUSTOM_PRESET,
2930
SIMULATOR_PRESET,
3031
BUILTIN_BROKER_PRESETS,
@@ -43,6 +44,7 @@ const SAMPLE_CONFIGS: Record<string, Record<string, unknown>> = {
4344
alpaca: { mode: 'paper', apiKey: 'k', apiSecret: 's' },
4445
'ibkr-tws': { host: '127.0.0.1', port: 7497, clientId: 0 },
4546
longbridge: { mode: 'live', appKey: 'k', appSecret: 's', accessToken: 't' },
47+
schwab: { clientId: 'cid', clientSecret: 'secret', refreshToken: 'refresh' },
4648
'ccxt-custom': { exchange: 'kucoin', apiKey: 'k', secret: 's' },
4749
'leverup-monad': {
4850
mode: 'testnet',
@@ -156,6 +158,16 @@ describe('preset → engine config translation', () => {
156158
expect(cfg.paper).toBe(false)
157159
})
158160

161+
it('Schwab passes OAuth fields straight through to the engine', () => {
162+
const cfg = SCHWAB_PRESET.toEngineConfig({ clientId: 'cid', clientSecret: 'secret', refreshToken: 'refresh', accountNumber: '1234' })
163+
expect(cfg).toMatchObject({
164+
clientId: 'cid',
165+
clientSecret: 'secret',
166+
refreshToken: 'refresh',
167+
accountNumber: '1234',
168+
})
169+
})
170+
159171
it('IBKR passes host/port/clientId straight through', () => {
160172
const cfg = IBKR_PRESET.toEngineConfig({ host: '10.0.0.5', port: 7496, clientId: 7 })
161173
expect(cfg).toMatchObject({ host: '10.0.0.5', port: 7496, clientId: 7 })
@@ -234,6 +246,13 @@ describe('BUILTIN_BROKER_PRESETS', () => {
234246
expect(props.secret.writeOnly).toBe(true)
235247
expect(props.password.writeOnly).toBe(true)
236248
})
249+
250+
it('Schwab marks secret fields writeOnly', () => {
251+
const schwab = BUILTIN_BROKER_PRESETS.find(p => p.id === 'schwab')!
252+
const props = (schwab.schema as { properties: Record<string, { writeOnly?: boolean }> }).properties
253+
expect(props.clientSecret.writeOnly).toBe(true)
254+
expect(props.refreshToken.writeOnly).toBe(true)
255+
})
237256
})
238257

239258
// ==================== Derived UTA id ====================

services/uta/src/domain/trading/brokers/registry.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { AlpacaBroker } from './alpaca/AlpacaBroker.js'
1616
import { IbkrBroker } from './ibkr/IbkrBroker.js'
1717
import { LeverupBroker } from './others/leverup/index.js'
1818
import { LongbridgeBroker } from './longbridge/index.js'
19+
import { SchwabBroker } from './schwab/index.js'
1920
import { MockBroker } from './mock/MockBroker.js'
2021
import type { BrokerEngine } from '@traderalice/uta-protocol'
2122

@@ -48,6 +49,10 @@ export const BROKER_ENGINE_REGISTRY: Record<BrokerEngine, BrokerEngineEntry> = {
4849
configSchema: LongbridgeBroker.configSchema,
4950
fromConfig: LongbridgeBroker.fromConfig,
5051
},
52+
schwab: {
53+
configSchema: SchwabBroker.configSchema,
54+
fromConfig: SchwabBroker.fromConfig,
55+
},
5156
mock: {
5257
configSchema: MockBroker.configSchema,
5358
fromConfig: MockBroker.fromConfig,
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises'
3+
import { existsSync } from 'node:fs'
4+
import { tmpdir } from 'node:os'
5+
import { join, resolve } from 'node:path'
6+
import Decimal from 'decimal.js'
7+
import { Contract } from '@traderalice/ibkr'
8+
import '../../contract-ext.js'
9+
10+
let home: string
11+
let savedHome: string | undefined
12+
let tokenPath: string
13+
14+
async function loadBrokerModule() {
15+
vi.resetModules()
16+
process.env['OPENALICE_HOME'] = home
17+
const [broker, sealing] = await Promise.all([
18+
import('./SchwabBroker.js'),
19+
import('@/core/sealing.js'),
20+
])
21+
return { SchwabBroker: broker.SchwabBroker, sealing }
22+
}
23+
24+
describe('SchwabBroker', () => {
25+
let fetchMock: ReturnType<typeof vi.fn>
26+
27+
beforeEach(async () => {
28+
savedHome = process.env['OPENALICE_HOME']
29+
home = await mkdtemp(resolve(tmpdir(), 'openalice-schwab-'))
30+
tokenPath = join(home, 'data', 'trading', 'schwab-test', 'schwab-token.json')
31+
fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
32+
const url = String(input)
33+
if (url.includes('/v1/oauth/token')) {
34+
return new Response(JSON.stringify({
35+
access_token: 'access-1',
36+
refresh_token: 'refresh-1',
37+
expires_in: 1800,
38+
token_type: 'Bearer',
39+
}), { status: 200, headers: { 'content-type': 'application/json' } })
40+
}
41+
if (url.includes('/trader/v1/accounts')) {
42+
return new Response(JSON.stringify([
43+
{
44+
accountNumber: '123456789',
45+
currentBalances: {
46+
cashBalance: '1000.00',
47+
liquidationValue: '1500.00',
48+
buyingPower: '2000.00',
49+
},
50+
positions: [
51+
{
52+
symbol: 'AAPL',
53+
longQuantity: 1,
54+
averagePrice: '450.00',
55+
marketValue: '500.00',
56+
currentDayProfitLoss: '50.00',
57+
instrument: {
58+
assetType: 'EQUITY',
59+
symbol: 'AAPL',
60+
description: 'Apple Inc.',
61+
exchange: 'NASDAQ',
62+
currency: 'USD',
63+
},
64+
},
65+
],
66+
},
67+
]), { status: 200, headers: { 'content-type': 'application/json' } })
68+
}
69+
throw new Error(`unexpected fetch: ${url} ${init?.method ?? 'GET'}`)
70+
})
71+
vi.stubGlobal('fetch', fetchMock)
72+
})
73+
74+
afterEach(async () => {
75+
if (savedHome === undefined) delete process.env['OPENALICE_HOME']
76+
else process.env['OPENALICE_HOME'] = savedHome
77+
vi.unstubAllGlobals()
78+
vi.restoreAllMocks()
79+
vi.resetModules()
80+
})
81+
82+
it('refreshes token, seals the token file, and reads account / positions', async () => {
83+
const { SchwabBroker, sealing } = await loadBrokerModule()
84+
const broker = new SchwabBroker({
85+
id: 'schwab-test',
86+
clientId: 'cid',
87+
clientSecret: 'secret',
88+
refreshToken: 'refresh-initial',
89+
})
90+
91+
await expect(broker.init()).resolves.toBeUndefined()
92+
expect(fetchMock).toHaveBeenCalled()
93+
94+
const account = await broker.getAccount()
95+
expect(account.baseCurrency).toBe('USD')
96+
expect(account.netLiquidation).toBe('1500')
97+
expect(account.totalCashValue).toBe('1000')
98+
expect(account.unrealizedPnL).toBe('50')
99+
100+
const positions = await broker.getPositions()
101+
expect(positions).toHaveLength(1)
102+
expect(positions[0].contract.symbol).toBe('AAPL')
103+
expect(positions[0].contract.secType).toBe('STK')
104+
expect(positions[0].quantity.toString()).toBe('1')
105+
106+
const persisted = JSON.parse(await readFile(tokenPath, 'utf-8'))
107+
expect(sealing.isSealedEnvelope(persisted)).toBe(true)
108+
expect(JSON.stringify(persisted)).not.toContain('access-1')
109+
expect(JSON.stringify(persisted)).not.toContain('refresh-1')
110+
if (process.platform !== 'win32') {
111+
expect((await stat(tokenPath)).mode & 0o777).toBe(0o600)
112+
}
113+
})
114+
115+
it('loads a pre-sealed token file without calling the refresh endpoint', async () => {
116+
const { SchwabBroker, sealing } = await loadBrokerModule()
117+
await mkdir(resolve(tokenPath, '..'), { recursive: true })
118+
await writeFile(tokenPath, JSON.stringify(await sealing.seal({
119+
access_token: 'cached-access',
120+
refresh_token: 'cached-refresh',
121+
expires_at: new Date(Date.now() + 3_600_000).toISOString(),
122+
}), null, 2) + '\n')
123+
124+
const broker = new SchwabBroker({
125+
id: 'schwab-test',
126+
clientId: 'cid',
127+
clientSecret: 'secret',
128+
tokenPath: './schwab-token.json',
129+
})
130+
131+
await expect(broker.init()).resolves.toBeUndefined()
132+
expect(fetchMock.mock.calls.some(([input]) => String(input).includes('/v1/oauth/token'))).toBe(false)
133+
expect(fetchMock.mock.calls.some(([input]) => String(input).includes('/trader/v1/accounts'))).toBe(true)
134+
})
135+
136+
it('refuses tokenPath values outside the account trading data dir', async () => {
137+
const { SchwabBroker } = await loadBrokerModule()
138+
const broker = new SchwabBroker({
139+
id: 'schwab-test',
140+
clientId: 'cid',
141+
clientSecret: 'secret',
142+
refreshToken: 'refresh',
143+
tokenPath: join(home, 'escape.json'),
144+
})
145+
146+
await expect(broker.init()).rejects.toThrow(/tokenPath must stay inside/i)
147+
expect(existsSync(join(home, 'escape.json'))).toBe(false)
148+
})
149+
150+
it('uses hashValue as a fallback account selector', async () => {
151+
const { SchwabBroker } = await loadBrokerModule()
152+
fetchMock.mockImplementation(async (input: RequestInfo | URL) => {
153+
const url = String(input)
154+
if (url.includes('/v1/oauth/token')) {
155+
return new Response(JSON.stringify({
156+
access_token: 'access-1',
157+
refresh_token: 'refresh-1',
158+
expires_in: 1800,
159+
token_type: 'Bearer',
160+
}), { status: 200, headers: { 'content-type': 'application/json' } })
161+
}
162+
if (url.includes('/trader/v1/accounts')) {
163+
return new Response(JSON.stringify([
164+
{
165+
accountNumber: 'primary-account',
166+
hashValue: 'shadow-account',
167+
currentBalances: {
168+
cashBalance: '1000.00',
169+
liquidationValue: '1500.00',
170+
},
171+
positions: [
172+
{
173+
symbol: 'MSFT',
174+
longQuantity: 2,
175+
marketValue: '600.00',
176+
instrument: {
177+
assetType: 'EQUITY',
178+
symbol: 'MSFT',
179+
description: 'Microsoft Corp.',
180+
exchange: 'NASDAQ',
181+
currency: 'USD',
182+
},
183+
},
184+
],
185+
},
186+
]), { status: 200, headers: { 'content-type': 'application/json' } })
187+
}
188+
throw new Error(`unexpected fetch: ${url}`)
189+
})
190+
191+
const broker = new SchwabBroker({
192+
id: 'schwab-test',
193+
clientId: 'cid',
194+
clientSecret: 'secret',
195+
refreshToken: 'refresh-initial',
196+
accountNumber: 'shadow-account',
197+
})
198+
199+
await expect(broker.init()).resolves.toBeUndefined()
200+
const positions = await broker.getPositions()
201+
expect(positions).toHaveLength(1)
202+
expect(positions[0].contract.symbol).toBe('MSFT')
203+
})
204+
205+
it('round-trips option native keys', async () => {
206+
const { SchwabBroker } = await loadBrokerModule()
207+
const broker = new SchwabBroker({
208+
clientId: 'cid',
209+
clientSecret: 'secret',
210+
refreshToken: 'refresh',
211+
tokenPath,
212+
})
213+
const contract = new Contract()
214+
contract.symbol = 'AAPL'
215+
contract.secType = 'OPT'
216+
contract.exchange = 'SMART'
217+
contract.currency = 'USD'
218+
contract.lastTradeDateOrContractMonth = '20261217'
219+
contract.right = 'C'
220+
contract.strike = 150
221+
contract.multiplier = '100'
222+
223+
const nativeKey = broker.getNativeKey(contract)
224+
const restored = broker.resolveNativeKey(nativeKey)
225+
expect(restored.secType).toBe('OPT')
226+
expect(restored.symbol).toBe('AAPL')
227+
expect(restored.lastTradeDateOrContractMonth).toBe('20261217')
228+
expect(restored.right).toBe('C')
229+
expect(restored.strike).toBe(150)
230+
expect(restored.multiplier).toBe('100')
231+
})
232+
233+
it('refuses writes with a clear read-only error', async () => {
234+
const { SchwabBroker } = await loadBrokerModule()
235+
const broker = new SchwabBroker({
236+
clientId: 'cid',
237+
clientSecret: 'secret',
238+
refreshToken: 'refresh',
239+
tokenPath,
240+
})
241+
const result = await broker.placeOrder(new Contract(), {} as never)
242+
expect(result.success).toBe(false)
243+
expect(result.error).toMatch(/read-only/i)
244+
})
245+
})

0 commit comments

Comments
 (0)