diff --git a/packages/ibkr/src/client/base.ts b/packages/ibkr/src/client/base.ts index 506f8f03..1ba88475 100644 --- a/packages/ibkr/src/client/base.ts +++ b/packages/ibkr/src/client/base.ts @@ -281,6 +281,7 @@ export class EClient { buf = rest const fields = readFields(msg) if (fields.length >= 2) { + clearTimeout(timer) this.conn!.removeListener('data', onData) resolve({ serverVersion: parseInt(fields[0], 10), @@ -293,8 +294,8 @@ export class EClient { this.conn!.on('data', onData) // Timeout after 10 seconds - setTimeout(() => { - this.conn!.removeListener('data', onData) + const timer = setTimeout(() => { + this.conn?.removeListener('data', onData) reject(new Error('Handshake timeout')) }, 10000) }) diff --git a/src/ai-providers/agent-sdk/query.ts b/src/ai-providers/agent-sdk/query.ts index c72bd1ca..6136e33c 100644 --- a/src/ai-providers/agent-sdk/query.ts +++ b/src/ai-providers/agent-sdk/query.ts @@ -213,14 +213,35 @@ export async function askAgentSdk( } else { ok = false resultText = result.errors?.join('\n') ?? `Agent SDK error: ${result.subtype}` + // Log failed results with all available detail + const resultDetail = { subtype: result.subtype, errors: result.errors, result: result.result } + logger.error({ ...resultDetail, turns: result.num_turns, durationMs: result.duration_ms }, 'result_error') + console.error('[agent-sdk] Non-success result:', resultDetail) } logger.info({ subtype: result.subtype, turns: result.num_turns, durationMs: result.duration_ms }, 'result') } } } catch (err) { - logger.error({ error: String(err) }, 'query_error') + // Extract as much detail as possible from the error + const errObj = err instanceof Error ? err : new Error(String(err)) + const details: Record = { + message: errObj.message, + stack: errObj.stack, + } + // SDK errors may carry stderr/stdout/cause as extra properties + for (const key of ['stderr', 'stdout', 'cause', 'code', 'signal'] as const) { + if ((errObj as any)[key] != null) details[key] = (errObj as any)[key] + } + // Enumerate any non-standard properties on the error object + const extraKeys = Object.keys(errObj).filter(k => !(k in details)) + for (const k of extraKeys) details[k] = (errObj as any)[k] + + logger.error(details, 'query_error') + // Also log to console so the developer can see it in the terminal + console.error('[agent-sdk] Claude Code process error:', details) ok = false - resultText = `Agent SDK error: ${err}` + const stderrHint = details.stderr ? `\nstderr: ${details.stderr}` : '' + resultText = `Agent SDK error: ${errObj.message}${stderrHint}` } // Fallback: if result is empty, extract last assistant text diff --git a/src/connectors/web/routes/trading-config.ts b/src/connectors/web/routes/trading-config.ts index d50251b7..450dcd27 100644 --- a/src/connectors/web/routes/trading-config.ts +++ b/src/connectors/web/routes/trading-config.ts @@ -1,90 +1,58 @@ import { Hono } from 'hono' import type { EngineContext } from '../../../core/types.js' import { - readPlatformsConfig, writePlatformsConfig, readAccountsConfig, writeAccountsConfig, - platformConfigSchema, accountConfigSchema, + accountConfigSchema, } from '../../../core/config.js' -import { createPlatformFromConfig, createBrokerFromConfig } from '../../../domain/trading/brokers/factory.js' +import { createBroker } from '../../../domain/trading/brokers/factory.js' + +// ==================== Credential helpers ==================== /** Mask a secret string: show last 4 chars, prefix with "****" */ -function mask(value: string | undefined): string | undefined { - if (!value) return value +function mask(value: string): string { if (value.length <= 4) return '****' return '****' + value.slice(-4) } -/** Trading config CRUD routes: platforms + accounts */ -export function createTradingConfigRoutes(ctx: EngineContext) { - const app = new Hono() +/** Field names that contain sensitive values. Convention-based, not hardcoded per broker. */ +const SENSITIVE = /key|secret|password|token/i - // ==================== Read all ==================== +/** Mask all sensitive string fields in a config object. */ +function maskSecrets>(obj: T): T { + const result = { ...obj } + for (const [k, v] of Object.entries(result)) { + if (typeof v === 'string' && v.length > 0 && SENSITIVE.test(k)) { + ;(result as Record)[k] = mask(v) + } + } + return result +} - app.get('/', async (c) => { - try { - const [platforms, accounts] = await Promise.all([ - readPlatformsConfig(), - readAccountsConfig(), - ]) - // Mask credentials in response - const maskedAccounts = accounts.map((a) => ({ - ...a, - apiKey: mask(a.apiKey), - apiSecret: mask(a.apiSecret), - password: mask(a.password), - })) - return c.json({ platforms, accounts: maskedAccounts }) - } catch (err) { - return c.json({ error: String(err) }, 500) +/** Restore masked values (****...) from existing config. */ +function unmaskSecrets( + body: Record, + existing: Record, +): void { + for (const [k, v] of Object.entries(body)) { + if (typeof v === 'string' && v.startsWith('****') && typeof existing[k] === 'string') { + body[k] = existing[k] } - }) + } +} - // ==================== Platforms CRUD ==================== +// ==================== Routes ==================== - app.put('/platforms/:id', async (c) => { - try { - const id = c.req.param('id') - const body = await c.req.json() - if (body.id !== id) { - return c.json({ error: 'Body id must match URL id' }, 400) - } - const validated = platformConfigSchema.parse(body) - const platforms = await readPlatformsConfig() - const idx = platforms.findIndex((p) => p.id === id) - if (idx >= 0) { - platforms[idx] = validated - } else { - platforms.push(validated) - } - await writePlatformsConfig(platforms) - return c.json(validated) - } catch (err) { - if (err instanceof Error && err.name === 'ZodError') { - return c.json({ error: 'Validation failed', details: JSON.parse(err.message) }, 400) - } - return c.json({ error: String(err) }, 500) - } - }) +/** Trading config CRUD routes: accounts */ +export function createTradingConfigRoutes(ctx: EngineContext) { + const app = new Hono() + + // ==================== Read all ==================== - app.delete('/platforms/:id', async (c) => { + app.get('/', async (c) => { try { - const id = c.req.param('id') - const [platforms, accounts] = await Promise.all([ - readPlatformsConfig(), - readAccountsConfig(), - ]) - const refs = accounts.filter((a) => a.platformId === id) - if (refs.length > 0) { - return c.json({ - error: `Platform "${id}" is referenced by ${refs.length} account(s): ${refs.map((a) => a.id).join(', ')}. Remove them first.`, - }, 400) - } - const filtered = platforms.filter((p) => p.id !== id) - if (filtered.length === platforms.length) { - return c.json({ error: `Platform "${id}" not found` }, 404) - } - await writePlatformsConfig(filtered) - return c.json({ success: true }) + const accounts = await readAccountsConfig() + const maskedAccounts = accounts.map((a) => maskSecrets({ ...a })) + return c.json({ accounts: maskedAccounts }) } catch (err) { return c.json({ error: String(err) }, 500) } @@ -100,23 +68,15 @@ export function createTradingConfigRoutes(ctx: EngineContext) { return c.json({ error: 'Body id must match URL id' }, 400) } - // Resolve masked credentials: if value is masked, keep the existing value + // Restore masked credentials from existing config const accounts = await readAccountsConfig() const existing = accounts.find((a) => a.id === id) if (existing) { - if (body.apiKey && body.apiKey.startsWith('****')) body.apiKey = existing.apiKey - if (body.apiSecret && body.apiSecret.startsWith('****')) body.apiSecret = existing.apiSecret - if (body.password && body.password.startsWith('****')) body.password = existing.password + unmaskSecrets(body, existing as unknown as Record) } const validated = accountConfigSchema.parse(body) - // Validate platformId reference - const platforms = await readPlatformsConfig() - if (!platforms.some((p) => p.id === validated.platformId)) { - return c.json({ error: `Platform "${validated.platformId}" not found` }, 400) - } - const idx = accounts.findIndex((a) => a.id === id) if (idx >= 0) { accounts[idx] = validated @@ -160,23 +120,9 @@ export function createTradingConfigRoutes(ctx: EngineContext) { let broker: { init: () => Promise; getAccount: () => Promise; close: () => Promise } | null = null try { const body = await c.req.json() - const platformConfig = platformConfigSchema.parse(body.platform) - const { apiKey, apiSecret, password } = body.credentials ?? {} - - if (!apiKey || !apiSecret) { - return c.json({ success: false, error: 'API key and secret are required' }, 400) - } - - const platform = createPlatformFromConfig(platformConfig) - broker = createBrokerFromConfig(platform, { - id: '__test__', - platformId: platformConfig.id, - apiKey, - apiSecret, - password, - guards: [], - }) + const accountConfig = accountConfigSchema.parse({ ...body, id: body.id ?? '__test__' }) + broker = createBroker(accountConfig) await broker.init() const account = await broker.getAccount() return c.json({ success: true, account }) diff --git a/src/connectors/web/routes/trading.ts b/src/connectors/web/routes/trading.ts index 5bdb529c..8809cf5a 100644 --- a/src/connectors/web/routes/trading.ts +++ b/src/connectors/web/routes/trading.ts @@ -1,5 +1,43 @@ import { Hono } from 'hono' +import type { Context } from 'hono' import type { EngineContext } from '../../../core/types.js' +import { BrokerError } from '../../../domain/trading/brokers/types.js' +import type { UnifiedTradingAccount } from '../../../domain/trading/UnifiedTradingAccount.js' + +/** Resolve account by :id param, return 404 if not found. */ +function resolveAccount(ctx: EngineContext, c: Context): UnifiedTradingAccount | null { + return ctx.accountManager.get(c.req.param('id')) ?? null +} + +/** + * Execute a data query against a UTA with health-aware error handling. + * - Offline → 503 + nudge recovery + * - Transient error → 503 + * - Permanent error → 500 + */ +async function queryAccount( + c: Context, + account: UnifiedTradingAccount, + fn: () => Promise, +): Promise { + if (account.health === 'offline') { + account.nudgeRecovery() + return c.json({ + error: 'Account temporarily unavailable', + health: account.getHealthInfo(), + }, 503) + } + try { + return c.json(await fn()) + } catch (err) { + const be = err instanceof BrokerError ? err : BrokerError.from(err) + return c.json({ + error: be.message, + code: be.code, + transient: !be.permanent, + }, be.permanent ? 500 : 503) + } +} /** Unified trading routes — works with all account types via AccountManager */ export function createTradingRoutes(ctx: EngineContext) { @@ -14,12 +52,8 @@ export function createTradingRoutes(ctx: EngineContext) { // ==================== Aggregated equity ==================== app.get('/equity', async (c) => { - try { - const equity = await ctx.accountManager.getAggregatedEquity() - return c.json(equity) - } catch (err) { - return c.json({ error: String(err) }, 500) - } + const equity = await ctx.accountManager.getAggregatedEquity() + return c.json(equity) }) // ==================== Per-account routes ==================== @@ -33,67 +67,47 @@ export function createTradingRoutes(ctx: EngineContext) { // Account info app.get('/accounts/:id/account', async (c) => { - const account = ctx.accountManager.get(c.req.param('id')) + const account = resolveAccount(ctx, c) if (!account) return c.json({ error: 'Account not found' }, 404) - try { - return c.json(await account.getAccount()) - } catch (err) { - return c.json({ error: String(err) }, 500) - } + return queryAccount(c, account, () => account.getAccount()) }) // Positions app.get('/accounts/:id/positions', async (c) => { - const account = ctx.accountManager.get(c.req.param('id')) + const account = resolveAccount(ctx, c) if (!account) return c.json({ error: 'Account not found' }, 404) - try { - const positions = await account.getPositions() - return c.json({ positions }) - } catch (err) { - return c.json({ error: String(err) }, 500) - } + return queryAccount(c, account, async () => ({ positions: await account.getPositions() })) }) // Orders app.get('/accounts/:id/orders', async (c) => { - const account = ctx.accountManager.get(c.req.param('id')) + const account = resolveAccount(ctx, c) if (!account) return c.json({ error: 'Account not found' }, 404) - try { - // Default to pending orders if no ids specified + return queryAccount(c, account, async () => { const idsParam = c.req.query('ids') const orderIds = idsParam ? idsParam.split(',') : account.getPendingOrderIds().map(p => p.orderId) const orders = await account.getOrders(orderIds) - return c.json({ orders }) - } catch (err) { - return c.json({ error: String(err) }, 500) - } + return { orders } + }) }) - // Market clock (optional capability) + // Market clock app.get('/accounts/:id/market-clock', async (c) => { - const account = ctx.accountManager.get(c.req.param('id')) + const account = resolveAccount(ctx, c) if (!account) return c.json({ error: 'Account not found' }, 404) - if (!account.getMarketClock) return c.json({ error: 'Market clock not supported' }, 501) - try { - return c.json(await account.getMarketClock()) - } catch (err) { - return c.json({ error: String(err) }, 500) - } + return queryAccount(c, account, () => account.getMarketClock()) }) // Quote app.get('/accounts/:id/quote/:symbol', async (c) => { - const account = ctx.accountManager.get(c.req.param('id')) + const account = resolveAccount(ctx, c) if (!account) return c.json({ error: 'Account not found' }, 404) - try { + return queryAccount(c, account, async () => { const { Contract } = await import('@traderalice/ibkr') const contract = new Contract() contract.symbol = c.req.param('symbol') - const quote = await account.getQuote(contract) - return c.json(quote) - } catch (err) { - return c.json({ error: String(err) }, 500) - } + return account.getQuote(contract) + }) }) // ==================== Per-account wallet/git routes ==================== diff --git a/src/core/config.spec.ts b/src/core/config.spec.ts index 4f5f676a..8ee41b16 100644 --- a/src/core/config.spec.ts +++ b/src/core/config.spec.ts @@ -23,11 +23,8 @@ import { readToolsConfig, readAgentConfig, readMarketDataConfig, - loadTradingConfig, writeConfigSection, - readPlatformsConfig, readAccountsConfig, - writePlatformsConfig, writeAccountsConfig, aiProviderSchema, } from './config.js' @@ -227,100 +224,48 @@ describe('writeConfigSection', () => { }) }) -// ==================== readPlatformsConfig / writeAccountsConfig ==================== - -describe('readPlatformsConfig', () => { - it('returns empty array when file is missing', async () => { - const enoent = new Error('ENOENT') as NodeJS.ErrnoException - enoent.code = 'ENOENT' - mockReadFile.mockRejectedValueOnce(enoent) - const platforms = await readPlatformsConfig() - expect(platforms).toEqual([]) - }) - - it('parses platforms from file', async () => { - fileReturns([{ id: 'bybit-platform', type: 'ccxt', exchange: 'bybit' }]) - const platforms = await readPlatformsConfig() - expect(platforms).toHaveLength(1) - expect(platforms[0].type).toBe('ccxt') - expect((platforms[0] as any).exchange).toBe('bybit') - }) -}) +// ==================== readAccountsConfig / writeAccountsConfig ==================== describe('readAccountsConfig', () => { - it('returns empty array when file is missing', async () => { + it('returns empty array and seeds file when missing', async () => { const enoent = new Error('ENOENT') as NodeJS.ErrnoException enoent.code = 'ENOENT' mockReadFile.mockRejectedValueOnce(enoent) const accounts = await readAccountsConfig() expect(accounts).toEqual([]) + // Should seed empty accounts.json + expect(mockWriteFile).toHaveBeenCalledTimes(1) }) - it('parses accounts from file', async () => { - fileReturns([{ id: 'bybit-main', platformId: 'bybit-platform', apiKey: 'key1', apiSecret: 'sec1' }]) + it('parses ccxt account from file', async () => { + fileReturns([{ id: 'bybit-main', type: 'ccxt', exchange: 'bybit', apiKey: 'key1', apiSecret: 'sec1' }]) const accounts = await readAccountsConfig() expect(accounts).toHaveLength(1) expect(accounts[0].id).toBe('bybit-main') - expect(accounts[0].platformId).toBe('bybit-platform') + expect(accounts[0].type).toBe('ccxt') }) -}) -describe('writePlatformsConfig', () => { - it('writes validated platforms to platforms.json', async () => { - await writePlatformsConfig([{ id: 'alpaca-platform', type: 'alpaca', paper: true }]) - const filePath = mockWriteFile.mock.calls[0][0] as string - expect(filePath).toMatch(/platforms\.json$/) - const written = JSON.parse(mockWriteFile.mock.calls[0][1] as string) - expect(written[0].type).toBe('alpaca') - }) - - it('throws ZodError for invalid platform type', async () => { - await expect( - writePlatformsConfig([{ id: 'bad', type: 'unknown-type' } as any]) - ).rejects.toThrow() - expect(mockWriteFile).not.toHaveBeenCalled() + it('parses alpaca account from file', async () => { + fileReturns([{ id: 'alpaca-paper', type: 'alpaca', paper: true, apiKey: 'k', apiSecret: 's' }]) + const accounts = await readAccountsConfig() + expect(accounts).toHaveLength(1) + expect(accounts[0].type).toBe('alpaca') }) }) describe('writeAccountsConfig', () => { it('writes validated accounts to accounts.json', async () => { - await writeAccountsConfig([{ id: 'acc-1', platformId: 'plat-1', guards: [] }]) + await writeAccountsConfig([{ id: 'acc-1', type: 'alpaca', paper: true, guards: [] }]) const filePath = mockWriteFile.mock.calls[0][0] as string expect(filePath).toMatch(/accounts\.json$/) }) -}) -// ==================== loadTradingConfig ==================== - -describe('loadTradingConfig', () => { - it('returns platforms + accounts directly when both files exist', async () => { - // platforms.json - fileReturns([{ id: 'bybit-p', type: 'ccxt', exchange: 'bybit' }]) - // accounts.json - fileReturns([{ id: 'bybit-main', platformId: 'bybit-p' }]) - - const { platforms, accounts } = await loadTradingConfig() - expect(platforms).toHaveLength(1) - expect(platforms[0].id).toBe('bybit-p') - expect(accounts).toHaveLength(1) - expect(accounts[0].id).toBe('bybit-main') - // No migration write should occur + it('throws ZodError for invalid account type', async () => { + await expect( + writeAccountsConfig([{ id: 'bad', type: 'unknown-type' } as any]) + ).rejects.toThrow() expect(mockWriteFile).not.toHaveBeenCalled() }) - - it('seeds empty arrays when config files are missing', async () => { - fileNotFound() // platforms.json - fileNotFound() // accounts.json - - const { platforms, accounts } = await loadTradingConfig() - expect(platforms).toHaveLength(0) - expect(accounts).toHaveLength(0) - - // Should have written empty platforms.json and accounts.json - const writtenPaths = mockWriteFile.mock.calls.map(c => c[0] as string) - expect(writtenPaths.some(p => p.endsWith('platforms.json'))).toBe(true) - expect(writtenPaths.some(p => p.endsWith('accounts.json'))).toBe(true) - }) }) // ==================== aiProviderSchema (Zod schema validation) ==================== diff --git a/src/core/config.ts b/src/core/config.ts index 4fb5f5dc..86cd90d0 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -205,14 +205,14 @@ export const webSubchannelsSchema = z.array(webSubchannelSchema) export type WebChannel = z.infer -// ==================== Platform + Account Config ==================== +// ==================== Account Config ==================== const guardConfigSchema = z.object({ type: z.string(), options: z.record(z.string(), z.unknown()).default({}), }) -const ccxtPlatformSchema = z.object({ +const ccxtAccountSchema = z.object({ id: z.string(), label: z.string().optional(), type: z.literal('ccxt'), @@ -220,35 +220,42 @@ const ccxtPlatformSchema = z.object({ sandbox: z.boolean().default(false), demoTrading: z.boolean().default(false), options: z.record(z.string(), z.unknown()).optional(), + apiKey: z.string().optional(), + apiSecret: z.string().optional(), + password: z.string().optional(), + guards: z.array(guardConfigSchema).default([]), }).passthrough() -const alpacaPlatformSchema = z.object({ +const alpacaAccountSchema = z.object({ id: z.string(), label: z.string().optional(), type: z.literal('alpaca'), paper: z.boolean().default(true), + apiKey: z.string().optional(), + apiSecret: z.string().optional(), + guards: z.array(guardConfigSchema).default([]), }) -export const platformConfigSchema = z.discriminatedUnion('type', [ - ccxtPlatformSchema, - alpacaPlatformSchema, -]) - -export const platformsFileSchema = z.array(platformConfigSchema) - -export const accountConfigSchema = z.object({ +const ibkrAccountSchema = z.object({ id: z.string(), - platformId: z.string(), label: z.string().optional(), - apiKey: z.string().optional(), - apiSecret: z.string().optional(), - password: z.string().optional(), + type: z.literal('ibkr'), + host: z.string().default('127.0.0.1'), + port: z.number().int().default(7497), + clientId: z.number().int().default(0), + accountId: z.string().optional(), + paper: z.boolean().default(true), guards: z.array(guardConfigSchema).default([]), }) +export const accountConfigSchema = z.discriminatedUnion('type', [ + ccxtAccountSchema, + alpacaAccountSchema, + ibkrAccountSchema, +]) + export const accountsFileSchema = z.array(accountConfigSchema) -export type PlatformConfig = z.infer export type AccountConfig = z.infer // ==================== Unified Config Type ==================== @@ -365,52 +372,17 @@ export async function loadConfig(): Promise { } } -// ==================== Trading Config Loader ==================== - -/** - * Load platform + account config from platforms.json + accounts.json. - * Seeds empty arrays on first run — accounts are created via UI wizard. - */ -export async function loadTradingConfig(): Promise<{ - platforms: PlatformConfig[] - accounts: AccountConfig[] -}> { - const [rawPlatforms, rawAccounts] = await Promise.all([ - loadJsonFile('platforms.json'), - loadJsonFile('accounts.json'), - ]) - - const platforms = platformsFileSchema.parse(rawPlatforms ?? []) - const accounts = accountsFileSchema.parse(rawAccounts ?? []) - - // Seed empty files on first run so user has something to edit - if (rawPlatforms === undefined || rawAccounts === undefined) { - await mkdir(CONFIG_DIR, { recursive: true }) - const writes: Promise[] = [] - if (rawPlatforms === undefined) writes.push(writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(platforms, null, 2) + '\n')) - if (rawAccounts === undefined) writes.push(writeFile(resolve(CONFIG_DIR, 'accounts.json'), JSON.stringify(accounts, null, 2) + '\n')) - await Promise.all(writes) - } - - return { platforms, accounts } -} - -// ==================== Platform / Account file helpers ==================== - -export async function readPlatformsConfig(): Promise { - const raw = await loadJsonFile('platforms.json') - return platformsFileSchema.parse(raw ?? []) -} - -export async function writePlatformsConfig(platforms: PlatformConfig[]): Promise { - const validated = platformsFileSchema.parse(platforms) - await mkdir(CONFIG_DIR, { recursive: true }) - await writeFile(resolve(CONFIG_DIR, 'platforms.json'), JSON.stringify(validated, null, 2) + '\n') -} +// ==================== Account Config Loader ==================== export async function readAccountsConfig(): Promise { const raw = await loadJsonFile('accounts.json') - return accountsFileSchema.parse(raw ?? []) + if (raw === undefined) { + // Seed empty file on first run + await mkdir(CONFIG_DIR, { recursive: true }) + await writeFile(resolve(CONFIG_DIR, 'accounts.json'), '[]\n') + return [] + } + return accountsFileSchema.parse(raw) } export async function writeAccountsConfig(accounts: AccountConfig[]): Promise { diff --git a/src/domain/trading/UnifiedTradingAccount.spec.ts b/src/domain/trading/UnifiedTradingAccount.spec.ts index 80fdc309..0aac3fbf 100644 --- a/src/domain/trading/UnifiedTradingAccount.spec.ts +++ b/src/domain/trading/UnifiedTradingAccount.spec.ts @@ -92,9 +92,9 @@ describe('UTA — operation dispatch', () => { uta.git.commit('buy AAPL') const result = await uta.push() - // Push only returns submitted — never filled expect(result.submitted).toHaveLength(1) expect(result.submitted[0].orderId).toBeDefined() + expect(result.submitted[0].status).toBe('filled') }) it('handles broker error', async () => { @@ -141,13 +141,20 @@ describe('UTA — operation dispatch', () => { }) describe('cancelOrder', () => { - it('calls broker.cancelOrder', async () => { - const spy = vi.spyOn(broker, 'cancelOrder') + it('calls broker.cancelOrder and records as cancelled', async () => { + const orderState = new OrderState() + orderState.status = 'Cancelled' + const spy = vi.spyOn(broker, 'cancelOrder').mockResolvedValue({ + success: true, orderId: 'ord-789', orderState, + }) uta.git.add({ action: 'cancelOrder', orderId: 'ord-789' }) uta.git.commit('cancel order') - await uta.push() + const result = await uta.push() expect(spy).toHaveBeenCalledWith('ord-789', undefined) + expect(result.submitted).toHaveLength(1) + expect(result.submitted[0].status).toBe('cancelled') + expect(result.rejected).toHaveLength(0) }) }) diff --git a/src/domain/trading/UnifiedTradingAccount.ts b/src/domain/trading/UnifiedTradingAccount.ts index e3d8d689..eb70987d 100644 --- a/src/domain/trading/UnifiedTradingAccount.ts +++ b/src/domain/trading/UnifiedTradingAccount.ts @@ -58,7 +58,6 @@ export interface UnifiedTradingAccountOptions { savedState?: GitExportState onCommit?: (state: GitExportState) => void | Promise onHealthChange?: (accountId: string, health: BrokerHealthInfo) => void - platformId?: string } // ==================== Stage param types ==================== @@ -106,7 +105,6 @@ export class UnifiedTradingAccount { readonly label: string readonly broker: IBroker readonly git: TradingGit - readonly platformId?: string private readonly _getState: () => Promise private readonly _onHealthChange?: (accountId: string, health: BrokerHealthInfo) => void @@ -130,7 +128,6 @@ export class UnifiedTradingAccount { this.broker = broker this.id = broker.id this.label = broker.label - this.platformId = options.platformId this._onHealthChange = options.onHealthChange // Wire internals @@ -161,7 +158,7 @@ export class UnifiedTradingAccount { case 'placeOrder': return broker.placeOrder(op.contract, op.order) case 'modifyOrder': - return broker.modifyOrder(op.orderId, op.changes as Parameters[1]) + return broker.modifyOrder(op.orderId, op.changes) case 'closePosition': return broker.closePosition(op.contract, op.quantity) case 'cancelOrder': @@ -254,15 +251,16 @@ export class UnifiedTradingAccount { throw new BrokerError('CONFIG', `Account "${this.label}" is disabled due to configuration error: ${this._lastError}`) } if (this.health === 'offline' && this._recovering) { - throw new Error(`Account "${this.label}" is offline and reconnecting. Try again shortly.`) + throw new BrokerError('NETWORK', `Account "${this.label}" is offline and reconnecting. Try again shortly.`) } try { const result = await fn() this._onSuccess() return result } catch (err) { - this._onFailure(err) - throw err + const brokerErr = BrokerError.from(err) + this._onFailure(brokerErr) + throw brokerErr } } @@ -293,6 +291,13 @@ export class UnifiedTradingAccount { if (prev !== this.health) this._emitHealthChange() } + /** Nudge the recovery loop to retry immediately (e.g., when a data request finds this UTA offline). */ + nudgeRecovery(): void { + if (!this._recovering || this._disabled) return + if (this._recoveryTimer) clearTimeout(this._recoveryTimer) + this._scheduleRecoveryAttempt(0) + } + private _startRecovery(): void { if (this._recovering) return this._recovering = true diff --git a/src/domain/trading/__test__/e2e/README.md b/src/domain/trading/__test__/e2e/README.md new file mode 100644 index 00000000..56c2c426 --- /dev/null +++ b/src/domain/trading/__test__/e2e/README.md @@ -0,0 +1,80 @@ +# Trading E2E Tests + +End-to-end tests that run against real broker APIs (Alpaca paper, Bybit demo, IBKR paper) and MockBroker. + +## Running + +```bash +pnpm test:e2e +``` + +Tests run sequentially (`fileParallelism: false`) because broker APIs are shared resources. + +## File Naming + +| Pattern | Level | Example | +|---------|-------|---------| +| `{broker}.e2e.spec.ts` | Broker API | `alpaca-paper`, `ibkr-paper` — calls `broker.placeOrder()` directly | +| `uta-{broker}.e2e.spec.ts` | UTA (Trading-as-Git) | `uta-alpaca`, `uta-ibkr` — uses `stagePlaceOrder → commit → push` | +| `uta-lifecycle.e2e.spec.ts` | UTA + MockBroker | Pure in-memory, no external deps | + +## Precondition Pattern + +Use `beforeEach(({ skip }) => ...)` for preconditions — **never** `if (!x) return` inside test bodies. + +```typescript +// ✅ Correct — shows as "skipped" in report +beforeEach(({ skip }) => { + if (!broker) skip('no account configured') + if (!marketOpen) skip('market closed') +}) + +it('fetches account', async () => { + const account = await broker!.getAccount() // broker guaranteed non-null +}) + +// ❌ Wrong — shows as "passed" even though nothing ran +it('fetches account', async () => { + if (!broker) return // silent pass, misleading +}) +``` + +For runtime data dependencies inside a test (e.g., contract search fails), use `skip()` from the test context: + +```typescript +it('places order', async ({ skip }) => { + const matches = await broker!.searchContracts('ETH') + const perp = matches.find(...) + if (!perp) skip('ETH perp not found') +}) +``` + +## Market Hours + +- **Crypto (CCXT)**: 24/7, no market hours check needed +- **Equities (Alpaca, IBKR)**: Split into two `describe` groups: + - **Connectivity** — runs any time (getAccount, getPositions, searchContracts, getMarketClock) + - **Trading** — requires market open (getQuote, placeOrder, closePosition) + +Check `broker.getMarketClock().isOpen` in `beforeAll`, skip trading group via `beforeEach`. + +## Setup + +`setup.ts` provides a lazy singleton `getTestAccounts()` that: +1. Reads `accounts.json` +2. Filters for paper/sandbox accounts only via `isPaper()`: + - Alpaca: `paper === true` + - CCXT: `sandbox || demoTrading` + - IBKR: `paper === true` +3. Checks credentials (API key for REST brokers; TCP reachability for local-process brokers like IBKR) +4. Calls `broker.init()` — if init fails, account is skipped with a warning + +Brokers are shared across test files via module-level caching. + +## IBKR-Specific + +IBKR tests require TWS or IB Gateway running with paper trading enabled. Unlike REST-based brokers, IBKR connects via a local TCP socket — no API key is needed. + +If TWS is not running, IBKR tests are automatically skipped (setup checks TCP reachability before attempting connection). + +Default connection: `127.0.0.1:7497` (TWS paper). Override via `accounts.json`. diff --git a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts index 468ceb4f..ac680931 100644 --- a/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/alpaca-paper.e2e.spec.ts @@ -1,13 +1,16 @@ /** * AlpacaBroker e2e — real orders against Alpaca paper trading. * - * Reads Alice's config, picks the first Alpaca paper account. - * If none configured, entire suite skips. + * Split into two groups: + * - Connectivity tests: run any time (account info, positions, search, clock) + * - Trading tests: only when market is open (quotes, orders, close) + * + * Preconditions handled in beforeEach — individual tests don't need skip checks. * * Run: pnpm test:e2e */ -import { describe, it, expect, beforeAll } from 'vitest' +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' import Decimal from 'decimal.js' import { Contract, Order } from '@traderalice/ibkr' import { getTestAccounts, filterByProvider } from './setup.js' @@ -15,60 +18,70 @@ import type { IBroker } from '../../brokers/types.js' import '../../contract-ext.js' let broker: IBroker | null = null +let marketOpen = false beforeAll(async () => { const all = await getTestAccounts() const alpaca = filterByProvider(all, 'alpaca')[0] - if (!alpaca) { - console.log('e2e: No Alpaca paper account configured, skipping') - return - } + if (!alpaca) return broker = alpaca.broker - console.log(`e2e: ${alpaca.label} connected`) + const clock = await broker.getMarketClock() + marketOpen = clock.isOpen + console.log(`e2e: ${alpaca.label} connected (market ${marketOpen ? 'OPEN' : 'CLOSED'})`) }, 60_000) -describe('AlpacaBroker — Paper e2e', () => { - it('has a configured Alpaca paper account (or skips entire suite)', () => { - if (!broker) { - console.log('e2e: skipped — no Alpaca paper account') - return - } - expect(broker).toBeDefined() - }) +// ==================== Connectivity (any time) ==================== + +describe('AlpacaBroker — connectivity', () => { + beforeEach(({ skip }) => { if (!broker) skip('no Alpaca paper account') }) it('fetches account info with positive equity', async () => { - if (!broker) return - const account = await broker.getAccount() + const account = await broker!.getAccount() expect(account.netLiquidation).toBeGreaterThan(0) expect(account.totalCashValue).toBeGreaterThan(0) console.log(` equity: $${account.netLiquidation.toFixed(2)}, cash: $${account.totalCashValue.toFixed(2)}, buying_power: $${account.buyingPower?.toFixed(2)}`) - console.log(` unrealizedPnL: $${account.unrealizedPnL}, realizedPnL: $${account.realizedPnL}, dayTrades: ${account.dayTradesRemaining}`) }) it('fetches market clock', async () => { - if (!broker) return - const clock = await broker.getMarketClock() + const clock = await broker!.getMarketClock() expect(typeof clock.isOpen).toBe('boolean') console.log(` isOpen: ${clock.isOpen}, nextOpen: ${clock.nextOpen?.toISOString()}, nextClose: ${clock.nextClose?.toISOString()}`) }) it('searches AAPL contracts', async () => { - if (!broker) return - const results = await broker.searchContracts('AAPL') + const results = await broker!.searchContracts('AAPL') expect(results.length).toBeGreaterThan(0) expect(results[0].contract.symbol).toBe('AAPL') - // Broker no longer sets aliceId — that's UTA's job - expect(results[0].contract.aliceId).toBeUndefined() console.log(` found: ${results[0].contract.symbol}, secType: ${results[0].contract.secType}`) }) + it('fetches positions with correct types', async () => { + const positions = await broker!.getPositions() + console.log(` ${positions.length} positions total`) + for (const p of positions) { + console.log(` ${p.contract.symbol}: qty=${p.quantity}, avg=${p.avgCost}, mkt=${p.marketPrice}`) + expect(p.quantity).toBeInstanceOf(Decimal) + expect(typeof p.avgCost).toBe('number') + expect(typeof p.marketPrice).toBe('number') + expect(typeof p.unrealizedPnL).toBe('number') + } + }) +}) + +// ==================== Trading (market hours only) ==================== + +describe('AlpacaBroker — trading (market hours)', () => { + beforeEach(({ skip }) => { + if (!broker) skip('no Alpaca paper account') + if (!marketOpen) skip('market closed') + }) + it('fetches AAPL quote with valid prices', async () => { - if (!broker) return const contract = new Contract() contract.aliceId = 'alpaca-AAPL' contract.symbol = 'AAPL' - const quote = await broker.getQuote(contract) + const quote = await broker!.getQuote(contract) expect(quote.last).toBeGreaterThan(0) expect(quote.bid).toBeGreaterThan(0) expect(quote.ask).toBeGreaterThan(0) @@ -77,8 +90,6 @@ describe('AlpacaBroker — Paper e2e', () => { }) it('places market buy 1 AAPL → success with UUID orderId', async () => { - if (!broker) return - const contract = new Contract() contract.aliceId = 'alpaca-AAPL' contract.symbol = 'AAPL' @@ -90,25 +101,15 @@ describe('AlpacaBroker — Paper e2e', () => { order.totalQuantity = new Decimal('1') order.tif = 'DAY' - const result = await broker.placeOrder(contract, order) - console.log(` placeOrder raw:`, JSON.stringify({ - success: result.success, - orderId: result.orderId, - orderState: result.orderState?.status, - error: result.error, - })) + const result = await broker!.placeOrder(contract, order) + console.log(` placeOrder: success=${result.success}, orderId=${result.orderId}, status=${result.orderState?.status}`) expect(result.success).toBe(true) expect(result.orderId).toBeDefined() - // Alpaca order IDs are UUIDs like "b0b6dd9d-8b9b-..." expect(result.orderId!.length).toBeGreaterThan(10) - console.log(` orderId: ${result.orderId} (length=${result.orderId!.length})`) }, 15_000) it('queries order by ID after place', async () => { - if (!broker) return - - // Place a fresh order to get an ID const contract = new Contract() contract.aliceId = 'alpaca-AAPL' contract.symbol = 'AAPL' @@ -120,59 +121,41 @@ describe('AlpacaBroker — Paper e2e', () => { order.totalQuantity = new Decimal('1') order.tif = 'DAY' - const placed = await broker.placeOrder(contract, order) - if (!placed.orderId) { console.log(' no orderId returned, skipping'); return } + const placed = await broker!.placeOrder(contract, order) + expect(placed.orderId).toBeDefined() - // Wait for fill await new Promise(r => setTimeout(r, 2000)) - const detail = await broker.getOrder(placed.orderId) - console.log(` getOrder(${placed.orderId}):`, detail ? JSON.stringify({ - symbol: detail.contract.symbol, - action: detail.order.action, - qty: detail.order.totalQuantity.toString(), - status: detail.orderState.status, - orderId_number: detail.order.orderId, - }) : 'null') + const detail = await broker!.getOrder(placed.orderId!) + console.log(` getOrder: status=${detail?.orderState.status}`) expect(detail).not.toBeNull() if (detail) { expect(detail.orderState.status).toBe('Filled') - // Bug check: order.orderId should NOT be NaN or meaningless - console.log(` order.orderId (IBKR number field): ${detail.order.orderId} — parseInt('${placed.orderId}') = ${parseInt(placed.orderId, 10)}`) } }, 15_000) it('verifies AAPL position exists after buy', async () => { - if (!broker) return - const positions = await broker.getPositions() + const positions = await broker!.getPositions() const aapl = positions.find(p => p.contract.symbol === 'AAPL') expect(aapl).toBeDefined() if (aapl) { - console.log(` AAPL position: ${aapl.quantity} ${aapl.side}, avg=$${aapl.avgCost}, mkt=$${aapl.marketPrice}, unrealPnL=$${aapl.unrealizedPnL}`) + console.log(` AAPL: ${aapl.quantity} ${aapl.side}, avg=$${aapl.avgCost}, mkt=$${aapl.marketPrice}`) expect(aapl.quantity.toNumber()).toBeGreaterThan(0) - expect(aapl.avgCost).toBeGreaterThan(0) - expect(aapl.marketPrice).toBeGreaterThan(0) } }) it('closes AAPL position', async () => { - if (!broker) return - const contract = new Contract() contract.aliceId = 'alpaca-AAPL' contract.symbol = 'AAPL' - // Close all AAPL — use native full close - const result = await broker.closePosition(contract) - console.log(` closePosition: success=${result.success}, orderId=${result.orderId}, error=${result.error}`) + const result = await broker!.closePosition(contract) + console.log(` closePosition: success=${result.success}, error=${result.error}`) expect(result.success).toBe(true) }, 15_000) it('getOrders with known IDs', async () => { - if (!broker) return - - // Place + wait + query const contract = new Contract() contract.aliceId = 'alpaca-AAPL' contract.symbol = 'AAPL' @@ -184,33 +167,16 @@ describe('AlpacaBroker — Paper e2e', () => { order.totalQuantity = new Decimal('1') order.tif = 'DAY' - const placed = await broker.placeOrder(contract, order) - if (!placed.orderId) return + const placed = await broker!.placeOrder(contract, order) + expect(placed.orderId).toBeDefined() await new Promise(r => setTimeout(r, 2000)) - const orders = await broker.getOrders([placed.orderId]) - console.log(` getOrders([${placed.orderId}]): ${orders.length} results`) + const orders = await broker!.getOrders([placed.orderId!]) + console.log(` getOrders: ${orders.length} results`) expect(orders.length).toBe(1) - if (orders[0]) { - console.log(` order: ${orders[0].contract.symbol} ${orders[0].order.action} ${orders[0].orderState.status}`) - } // Clean up - await broker.closePosition(contract) + await broker!.closePosition(contract) }, 15_000) - - it('fetches positions with correct types', async () => { - if (!broker) return - const positions = await broker.getPositions() - console.log(` ${positions.length} positions total`) - for (const p of positions) { - console.log(` ${p.contract.symbol}: qty=${p.quantity} (type=${typeof p.quantity.toNumber()}), avg=${p.avgCost} (type=${typeof p.avgCost}), mkt=${p.marketPrice}`) - // Verify quantity is actually a Decimal - expect(p.quantity).toBeInstanceOf(Decimal) - expect(typeof p.avgCost).toBe('number') - expect(typeof p.marketPrice).toBe('number') - expect(typeof p.unrealizedPnL).toBe('number') - } - }) }) diff --git a/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts index 8e3fd4ca..78719cc8 100644 --- a/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/ccxt-bybit.e2e.spec.ts @@ -7,7 +7,7 @@ * Run: pnpm test:e2e */ -import { describe, it, expect, beforeAll } from 'vitest' +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' import Decimal from 'decimal.js' import { Order } from '@traderalice/ibkr' import { getTestAccounts, filterByProvider } from './setup.js' @@ -28,30 +28,23 @@ beforeAll(async () => { }, 60_000) describe('CcxtBroker — Bybit e2e', () => { - it('has a configured Bybit account (or skips entire suite)', () => { - if (!broker) { - console.log('e2e: skipped — no Bybit account') - return - } - expect(broker).toBeDefined() - }) + beforeEach(({ skip }) => { if (!broker) skip('no Bybit account') }) it('fetches account info with positive equity', async () => { - if (!broker) return const account = await broker.getAccount() expect(account.netLiquidation).toBeGreaterThan(0) console.log(` equity: $${account.netLiquidation.toFixed(2)}, cash: $${account.totalCashValue.toFixed(2)}`) }) it('fetches positions', async () => { - if (!broker) return + const positions = await broker.getPositions() expect(Array.isArray(positions)).toBe(true) console.log(` ${positions.length} open positions`) }) it('searches ETH contracts', async () => { - if (!broker) return + const results = await broker.searchContracts('ETH') expect(results.length).toBeGreaterThan(0) const perp = results.find(r => r.contract.localSymbol?.includes('USDT:USDT')) @@ -59,12 +52,10 @@ describe('CcxtBroker — Bybit e2e', () => { console.log(` found ${results.length} ETH contracts, perp: ${perp!.contract.localSymbol}`) }) - it('places market buy 0.01 ETH → execution returned', async () => { - if (!broker) return - - const matches = await broker.searchContracts('ETH') + it('places market buy 0.01 ETH → execution returned', async ({ skip }) => { + const matches = await broker!.searchContracts('ETH') const ethPerp = matches.find(m => m.contract.localSymbol?.includes('USDT:USDT')) - if (!ethPerp) { console.log(' no ETH/USDT perp, skipping'); return } + if (!ethPerp) skip('ETH/USDT perp not found') // Diagnostic: see raw CCXT createOrder response const exchange = (broker as any).exchange @@ -97,40 +88,36 @@ describe('CcxtBroker — Bybit e2e', () => { }, 30_000) it('verifies ETH position exists after buy', async () => { - if (!broker) return + const positions = await broker.getPositions() const ethPos = positions.find(p => p.contract.symbol === 'ETH') expect(ethPos).toBeDefined() console.log(` ETH position: ${ethPos!.quantity} ${ethPos!.side}`) }) - it('closes ETH position with reduceOnly', async () => { - if (!broker) return - - const matches = await broker.searchContracts('ETH') + it('closes ETH position with reduceOnly', async ({ skip }) => { + const matches = await broker!.searchContracts('ETH') const ethPerp = matches.find(m => m.contract.localSymbol?.includes('USDT:USDT')) - if (!ethPerp) return + if (!ethPerp) skip('ETH/USDT perp not found') const result = await broker.closePosition(ethPerp.contract, new Decimal('0.01')) expect(result.success).toBe(true) console.log(` close orderId=${result.orderId}, success=${result.success}`) }, 15_000) - it('queries order by ID', async () => { - if (!broker) return - + it('queries order by ID', async ({ skip }) => { // Place a small order to get an orderId - const matches = await broker.searchContracts('ETH') + const matches = await broker!.searchContracts('ETH') const ethPerp = matches.find(m => m.contract.localSymbol?.includes('USDT:USDT')) - if (!ethPerp) return + if (!ethPerp) skip('ETH/USDT perp not found') const order = new Order() order.action = 'BUY' order.orderType = 'MKT' order.totalQuantity = new Decimal('0.01') - const placed = await broker.placeOrder(ethPerp.contract, order) - if (!placed.orderId) return + const placed = await broker!.placeOrder(ethPerp!.contract, order) + if (!placed.orderId) skip('no orderId returned') // Wait for exchange to settle — Bybit needs time before order appears in closed list await new Promise(r => setTimeout(r, 5000)) diff --git a/src/domain/trading/__test__/e2e/ccxt-raw-diagnostic.e2e.spec.ts b/src/domain/trading/__test__/e2e/ccxt-raw-diagnostic.e2e.spec.ts index ffc9657f..c98f3dbf 100644 --- a/src/domain/trading/__test__/e2e/ccxt-raw-diagnostic.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/ccxt-raw-diagnostic.e2e.spec.ts @@ -3,7 +3,7 @@ * Purpose: understand what Bybit demoTrading actually returns. */ -import { describe, it, beforeAll } from 'vitest' +import { describe, it, beforeAll, beforeEach } from 'vitest' import type { Exchange } from 'ccxt' import { getTestAccounts, filterByProvider } from './setup.js' @@ -18,8 +18,9 @@ beforeAll(async () => { }, 60_000) describe('Raw CCXT Bybit diagnostic', () => { + beforeEach(({ skip }) => { if (!exchange) skip('no Bybit account') }) + it('createOrder → inspect full response', async () => { - if (!exchange) return const result = await exchange.createOrder('ETH/USDT:USDT', 'market', 'buy', 0.01) console.log('\n=== createOrder response ===') @@ -47,7 +48,7 @@ describe('Raw CCXT Bybit diagnostic', () => { }, 15_000) it('fetchClosedOrders → inspect ids and format', async () => { - if (!exchange) return + const closed = await exchange.fetchClosedOrders('ETH/USDT:USDT', undefined, 5) console.log(`\n=== fetchClosedOrders: ${closed.length} orders ===`) @@ -67,7 +68,7 @@ describe('Raw CCXT Bybit diagnostic', () => { }, 15_000) it('fetchOpenOrders → inspect', async () => { - if (!exchange) return + const open = await exchange.fetchOpenOrders('ETH/USDT:USDT') console.log(`\n=== fetchOpenOrders: ${open.length} orders ===`) @@ -83,7 +84,7 @@ describe('Raw CCXT Bybit diagnostic', () => { }, 15_000) it('compare orderId format: spot vs perp', async () => { - if (!exchange) return + const hasSpot = !!exchange.markets['ETH/USDT'] const hasPerp = !!exchange.markets['ETH/USDT:USDT'] @@ -106,7 +107,7 @@ describe('Raw CCXT Bybit diagnostic', () => { }, 30_000) it('check market.id vs market.symbol for ETH perps', async () => { - if (!exchange) return + const candidates = Object.values(exchange.markets).filter( m => m.base === 'ETH' && m.quote === 'USDT', ) @@ -117,7 +118,7 @@ describe('Raw CCXT Bybit diagnostic', () => { }) it('fetchClosedOrders: no limit vs with since', async () => { - if (!exchange) return + // 1. No limit — how many do we get? const noLimit = await exchange.fetchClosedOrders('ETH/USDT:USDT') diff --git a/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts b/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts new file mode 100644 index 00000000..921477f3 --- /dev/null +++ b/src/domain/trading/__test__/e2e/ibkr-paper.e2e.spec.ts @@ -0,0 +1,162 @@ +/** + * IbkrBroker e2e — real calls against TWS/IB Gateway paper trading. + * + * Split into two groups: + * - Connectivity tests: run any time (account info, positions, search, clock) + * - Trading tests: only when market is open (quotes, orders, close) + * + * Requires TWS or IB Gateway running with paper trading enabled. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import Decimal from 'decimal.js' +import { Contract, Order } from '@traderalice/ibkr' +import { getTestAccounts, filterByProvider } from './setup.js' +import type { IBroker } from '../../brokers/types.js' +import '../../contract-ext.js' + +let broker: IBroker | null = null +let marketOpen = false + +beforeAll(async () => { + const all = await getTestAccounts() + const ibkr = filterByProvider(all, 'ibkr')[0] + if (!ibkr) return + broker = ibkr.broker + const clock = await broker.getMarketClock() + marketOpen = clock.isOpen + console.log(`e2e: ${ibkr.label} connected (market ${marketOpen ? 'OPEN' : 'CLOSED'})`) +}, 60_000) + +// ==================== Connectivity (any time) ==================== + +describe('IbkrBroker — connectivity', () => { + beforeEach(({ skip }) => { if (!broker) skip('no IBKR paper account') }) + + it('fetches account info with positive equity', async () => { + const account = await broker!.getAccount() + expect(account.netLiquidation).toBeGreaterThan(0) + expect(account.totalCashValue).toBeGreaterThan(0) + console.log(` equity: $${account.netLiquidation.toFixed(2)}, cash: $${account.totalCashValue.toFixed(2)}, buying_power: $${account.buyingPower?.toFixed(2)}`) + }) + + it('fetches market clock', async () => { + const clock = await broker!.getMarketClock() + expect(typeof clock.isOpen).toBe('boolean') + console.log(` isOpen: ${clock.isOpen}`) + }) + + it('searches AAPL contracts', async () => { + const results = await broker!.searchContracts('AAPL') + expect(results.length).toBeGreaterThan(0) + expect(results[0].contract.symbol).toBe('AAPL') + console.log(` found: ${results[0].contract.symbol}, secType: ${results[0].contract.secType}`) + }) + + it('fetches AAPL contract details with conId', async () => { + const query = new Contract() + query.symbol = 'AAPL' + query.secType = 'STK' + query.exchange = 'SMART' + query.currency = 'USD' + + const details = await broker!.getContractDetails(query) + expect(details).not.toBeNull() + expect(details!.contract.conId).toBeGreaterThan(0) + expect(details!.contract.symbol).toBe('AAPL') + console.log(` conId: ${details!.contract.conId}, longName: ${details!.longName}, primaryExchange: ${details!.contract.primaryExchange}`) + }) + + it('fetches positions with correct types', async () => { + const positions = await broker!.getPositions() + console.log(` ${positions.length} positions total`) + for (const p of positions) { + console.log(` ${p.contract.symbol}: qty=${p.quantity}, avg=${p.avgCost}, mkt=${p.marketPrice}`) + expect(p.quantity).toBeInstanceOf(Decimal) + expect(typeof p.avgCost).toBe('number') + expect(typeof p.marketPrice).toBe('number') + expect(typeof p.unrealizedPnL).toBe('number') + } + }) +}) + +// ==================== Trading (market hours only) ==================== + +describe('IbkrBroker — trading (market hours)', () => { + beforeEach(({ skip }) => { + if (!broker) skip('no IBKR paper account') + if (!marketOpen) skip('market closed') + }) + + it('fetches AAPL quote with valid prices', async () => { + const contract = new Contract() + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'SMART' + contract.currency = 'USD' + + const quote = await broker!.getQuote(contract) + expect(quote.last).toBeGreaterThan(0) + expect(quote.bid).toBeGreaterThan(0) + expect(quote.ask).toBeGreaterThan(0) + console.log(` AAPL: last=$${quote.last}, bid=$${quote.bid}, ask=$${quote.ask}, vol=${quote.volume}`) + }) + + it('places market buy 1 AAPL → success with numeric orderId', async () => { + const contract = new Contract() + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'SMART' + contract.currency = 'USD' + + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal('1') + order.tif = 'DAY' + + const result = await broker!.placeOrder(contract, order) + console.log(` placeOrder: success=${result.success}, orderId=${result.orderId}, status=${result.orderState?.status}`) + + expect(result.success).toBe(true) + expect(result.orderId).toBeDefined() + }, 15_000) + + it('queries order by ID after place', async () => { + const contract = new Contract() + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'SMART' + contract.currency = 'USD' + + const order = new Order() + order.action = 'BUY' + order.orderType = 'MKT' + order.totalQuantity = new Decimal('1') + order.tif = 'DAY' + + const placed = await broker!.placeOrder(contract, order) + expect(placed.orderId).toBeDefined() + + await new Promise(r => setTimeout(r, 3000)) + + const detail = await broker!.getOrder(placed.orderId!) + console.log(` getOrder: status=${detail?.orderState.status}`) + + expect(detail).not.toBeNull() + }, 20_000) + + it('closes AAPL position', async () => { + const contract = new Contract() + contract.symbol = 'AAPL' + contract.secType = 'STK' + contract.exchange = 'SMART' + contract.currency = 'USD' + + const result = await broker!.closePosition(contract) + console.log(` closePosition: success=${result.success}, error=${result.error}`) + expect(result.success).toBe(true) + }, 15_000) +}) diff --git a/src/domain/trading/__test__/e2e/setup.ts b/src/domain/trading/__test__/e2e/setup.ts index c6245b21..6d9a437c 100644 --- a/src/domain/trading/__test__/e2e/setup.ts +++ b/src/domain/trading/__test__/e2e/setup.ts @@ -1,24 +1,55 @@ /** * E2E test setup — shared, lazily-initialized broker instances. * - * Uses the same code path as main.ts: loadTradingConfig → createPlatformFromConfig - * → createBrokerFromConfig. Only selects accounts on sandbox/paper/demoTrading platforms. + * Uses the same code path as main.ts: readAccountsConfig → createBroker. + * Only selects accounts in paper/sandbox/demo environments (isPaper check). * * Singleton: first call loads config + inits all brokers. Subsequent calls * return the same instances. Requires fileParallelism: false in vitest config. */ -import { loadTradingConfig } from '@/core/config.js' +import net from 'node:net' +import { readAccountsConfig, type AccountConfig } from '@/core/config.js' import type { IBroker } from '../../brokers/types.js' -import { createPlatformFromConfig, createBrokerFromConfig } from '../../brokers/factory.js' +import { createBroker } from '../../brokers/factory.js' export interface TestAccount { id: string label: string - provider: 'ccxt' | 'alpaca' + provider: AccountConfig['type'] broker: IBroker } +// ==================== Safety ==================== + +/** Unified paper/sandbox check — E2E only runs non-live accounts. */ +function isPaper(acct: AccountConfig): boolean { + switch (acct.type) { + case 'alpaca': return acct.paper + case 'ccxt': return acct.sandbox || acct.demoTrading + case 'ibkr': return acct.paper + } +} + +/** Check whether API credentials are configured (not applicable for all broker types). */ +function hasCredentials(acct: AccountConfig): boolean { + switch (acct.type) { + case 'alpaca': + case 'ccxt': return !!acct.apiKey + case 'ibkr': return true // no API key — auth via TWS/Gateway login + } +} + +/** TCP reachability check (for brokers that connect to a local process). */ +function isTcpReachable(host: string, port: number, timeoutMs = 2000): Promise { + return new Promise((resolve) => { + const socket = new net.Socket() + const timer = setTimeout(() => { socket.destroy(); resolve(false) }, timeoutMs) + socket.connect(port, host, () => { clearTimeout(timer); socket.destroy(); resolve(true) }) + socket.on('error', () => { clearTimeout(timer); resolve(false) }) + }) +} + // ==================== Lazy singleton ==================== let cached: Promise | null = null @@ -33,22 +64,23 @@ export function getTestAccounts(): Promise { } async function initAll(): Promise { - const { platforms, accounts } = await loadTradingConfig() - const platformMap = new Map(platforms.map(p => [p.id, p])) + const accounts = await readAccountsConfig() const result: TestAccount[] = [] for (const acct of accounts) { - const platCfg = platformMap.get(acct.platformId) - if (!platCfg) continue + if (!isPaper(acct)) continue + if (!hasCredentials(acct)) continue - const isSafe = - (platCfg.type === 'ccxt' && (platCfg.sandbox || platCfg.demoTrading)) || - (platCfg.type === 'alpaca' && platCfg.paper) - if (!isSafe) continue - if (!acct.apiKey) continue + // IBKR: check TWS/Gateway reachability before attempting connect + if (acct.type === 'ibkr') { + const reachable = await isTcpReachable(acct.host ?? '127.0.0.1', acct.port ?? 7497) + if (!reachable) { + console.warn(`e2e setup: ${acct.id} — TWS not reachable at ${acct.host ?? '127.0.0.1'}:${acct.port ?? 7497}, skipping`) + continue + } + } - const platform = createPlatformFromConfig(platCfg) - const broker = createBrokerFromConfig(platform, acct) + const broker = createBroker(acct) try { await broker.init() @@ -60,7 +92,7 @@ async function initAll(): Promise { result.push({ id: acct.id, label: acct.label ?? acct.id, - provider: platCfg.type, + provider: acct.type, broker, }) } @@ -69,6 +101,6 @@ async function initAll(): Promise { } /** Filter test accounts by provider type. */ -export function filterByProvider(accounts: TestAccount[], provider: 'ccxt' | 'alpaca'): TestAccount[] { +export function filterByProvider(accounts: TestAccount[], provider: AccountConfig['type']): TestAccount[] { return accounts.filter(a => a.provider === provider) } diff --git a/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts new file mode 100644 index 00000000..0cd3b1cb --- /dev/null +++ b/src/domain/trading/__test__/e2e/uta-alpaca.e2e.spec.ts @@ -0,0 +1,100 @@ +/** + * UTA — Alpaca paper lifecycle e2e. + * + * Full Trading-as-Git flow: stage → commit → push → sync → verify + * against Alpaca paper trading (US equities). + * + * Skips when market is closed — Alpaca paper won't fill orders outside trading hours. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import { getTestAccounts, filterByProvider } from './setup.js' +import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' +import type { IBroker } from '../../brokers/types.js' +import '../../contract-ext.js' + +describe('UTA — Alpaca lifecycle (AAPL)', () => { + let broker: IBroker | null = null + let marketOpen = false + + beforeAll(async () => { + const all = await getTestAccounts() + const alpaca = filterByProvider(all, 'alpaca')[0] + if (!alpaca) return + broker = alpaca.broker + const clock = await broker.getMarketClock() + marketOpen = clock.isOpen + console.log(`UTA Alpaca: market ${marketOpen ? 'OPEN' : 'CLOSED'}`) + }, 60_000) + + beforeEach(({ skip }) => { + if (!broker) skip('no Alpaca paper account') + if (!marketOpen) skip('market closed') + }) + + it('buy → sync → verify → close → sync → verify', async () => { + const uta = new UnifiedTradingAccount(broker!) + + // Record initial state + const initialPositions = await broker!.getPositions() + const initialAaplQty = initialPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 + console.log(` initial AAPL qty=${initialAaplQty}`) + + // === Stage + Commit + Push: buy 1 AAPL === + const addResult = uta.stagePlaceOrder({ + aliceId: `${uta.id}|AAPL`, + symbol: 'AAPL', + side: 'buy', + type: 'market', + qty: 1, + }) + expect(addResult.staged).toBe(true) + + const commitResult = uta.commit('e2e: buy 1 AAPL') + expect(commitResult.prepared).toBe(true) + console.log(` committed: hash=${commitResult.hash}`) + + const pushResult = await uta.push() + console.log(` pushed: submitted=${pushResult.submitted.length}, status=${pushResult.submitted[0]?.status}`) + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + expect(pushResult.submitted[0].orderId).toBeDefined() + + // === Sync: depends on whether fill was synchronous === + if (pushResult.submitted[0].status === 'submitted') { + const sync1 = await uta.sync({ delayMs: 2000 }) + console.log(` sync1: updatedCount=${sync1.updatedCount}`) + expect(sync1.updatedCount).toBe(1) + expect(sync1.updates[0].currentStatus).toBe('filled') + } else { + console.log(` sync1: skipped (already ${pushResult.submitted[0].status} at push time)`) + } + + // === Verify: position exists === + const state1 = await uta.getState() + const aaplPos = state1.positions.find(p => p.contract.symbol === 'AAPL') + expect(aaplPos).toBeDefined() + expect(aaplPos!.quantity.toNumber()).toBe(initialAaplQty + 1) + + // === Close 1 AAPL === + uta.stageClosePosition({ aliceId: `${uta.id}|AAPL`, qty: 1 }) + uta.commit('e2e: close 1 AAPL') + const closePush = await uta.push() + console.log(` close pushed: status=${closePush.submitted[0]?.status}`) + expect(closePush.submitted).toHaveLength(1) + + if (closePush.submitted[0].status === 'submitted') { + const sync2 = await uta.sync({ delayMs: 2000 }) + expect(sync2.updatedCount).toBe(1) + } + + // === Verify: position back to initial === + const finalPositions = await broker!.getPositions() + const finalAaplQty = finalPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 + expect(finalAaplQty).toBe(initialAaplQty) + + expect(uta.log().length).toBeGreaterThanOrEqual(2) + }, 60_000) +}) diff --git a/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts new file mode 100644 index 00000000..ea333347 --- /dev/null +++ b/src/domain/trading/__test__/e2e/uta-bybit.e2e.spec.ts @@ -0,0 +1,119 @@ +/** + * UTA — Bybit demo lifecycle e2e. + * + * Full Trading-as-Git flow: stage → commit → push → sync → verify + * against Bybit demo trading (crypto perps, 24/7). + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import { getTestAccounts, filterByProvider } from './setup.js' +import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' +import type { IBroker } from '../../brokers/types.js' +import '../../contract-ext.js' + +describe('UTA — Bybit lifecycle (ETH perp)', () => { + let broker: IBroker | null = null + let ethAliceId: string = '' + + beforeAll(async () => { + const all = await getTestAccounts() + const bybit = filterByProvider(all, 'ccxt').find(a => a.id.includes('bybit')) + if (!bybit) { + console.log('e2e: No Bybit demo account, skipping') + return + } + broker = bybit.broker + + const results = await broker.searchContracts('ETH') + const perp = results.find(r => r.contract.localSymbol?.includes('USDT:USDT')) + if (!perp) { + console.log('e2e: No ETH/USDT perp found, skipping') + broker = null + return + } + const nativeKey = perp.contract.localSymbol! + ethAliceId = `${bybit.id}|${nativeKey}` + console.log(`UTA Bybit: ETH perp aliceId=${ethAliceId}`) + }, 60_000) + + beforeEach(({ skip }) => { if (!broker) skip('no Bybit demo account') }) + + it('buy → sync → verify → close → sync → verify', async () => { + const uta = new UnifiedTradingAccount(broker!) + + // Record initial state + const initialPositions = await broker!.getPositions() + const initialEthQty = initialPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 + console.log(` initial ETH qty=${initialEthQty}`) + + // === Stage + Commit + Push: buy 0.01 ETH === + const addResult = uta.stagePlaceOrder({ + aliceId: ethAliceId, + side: 'buy', + type: 'market', + qty: 0.01, + }) + expect(addResult.staged).toBe(true) + console.log(` staged: ok`) + + const commitResult = uta.commit('e2e: buy 0.01 ETH') + expect(commitResult.prepared).toBe(true) + console.log(` committed: hash=${commitResult.hash}`) + + const pushResult = await uta.push() + console.log(` pushed: submitted=${pushResult.submitted.length}, rejected=${pushResult.rejected.length}, status=${pushResult.submitted[0]?.status}`) + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + + const buyOrderId = pushResult.submitted[0].orderId + console.log(` orderId: ${buyOrderId}`) + expect(buyOrderId).toBeDefined() + + // === Sync: may or may not have updates depending on whether fill was synchronous === + if (pushResult.submitted[0].status === 'submitted') { + const sync1 = await uta.sync({ delayMs: 3000 }) + console.log(` sync1: updatedCount=${sync1.updatedCount}`) + expect(sync1.updatedCount).toBe(1) + expect(sync1.updates[0].currentStatus).toBe('filled') + } else { + console.log(` sync1: skipped (already ${pushResult.submitted[0].status} at push time)`) + } + + // === Verify: position exists === + const state1 = await uta.getState() + const ethPos = state1.positions.find(p => p.contract.aliceId === ethAliceId) + console.log(` state: ETH qty=${ethPos?.quantity}, pending=${state1.pendingOrders.length}`) + expect(ethPos).toBeDefined() + expect(state1.pendingOrders).toHaveLength(0) + + // === Stage + Commit + Push: close 0.01 ETH === + uta.stageClosePosition({ aliceId: ethAliceId, qty: 0.01 }) + uta.commit('e2e: close 0.01 ETH') + const closePush = await uta.push() + console.log(` close pushed: submitted=${closePush.submitted.length}, status=${closePush.submitted[0]?.status}`) + expect(closePush.submitted).toHaveLength(1) + + // === Sync: same — depends on fill timing === + if (closePush.submitted[0].status === 'submitted') { + const sync2 = await uta.sync({ delayMs: 3000 }) + console.log(` sync2: updatedCount=${sync2.updatedCount}`) + expect(sync2.updatedCount).toBe(1) + expect(sync2.updates[0].currentStatus).toBe('filled') + } else { + console.log(` sync2: skipped (already ${closePush.submitted[0].status} at push time)`) + } + + // === Verify: net change should be ~0 === + const finalPositions = await broker!.getPositions() + const finalEthQty = finalPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 + console.log(` final ETH qty=${finalEthQty} (initial was ${initialEthQty})`) + expect(Math.abs(finalEthQty - initialEthQty)).toBeLessThan(0.02) + + // === Log: 2 commits === + const history = uta.log() + console.log(` log: ${history.length} commits — [${history.map(h => h.message).join(', ')}]`) + expect(history.length).toBeGreaterThanOrEqual(2) + }, 60_000) +}) diff --git a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts index d6385a1e..61ab43ff 100644 --- a/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-ccxt-bybit.e2e.spec.ts @@ -7,7 +7,7 @@ * Run: pnpm test:e2e */ -import { describe, it, expect, beforeAll } from 'vitest' +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' import { getTestAccounts, filterByProvider } from './setup.js' import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' import type { IBroker } from '../../brokers/types.js' @@ -37,11 +37,11 @@ describe('UTA — Bybit demo (ETH perp)', () => { console.log(`UTA Bybit: aliceId=${ethAliceId}`) }, 60_000) - it('buy → sync → close → sync (full lifecycle)', async () => { - if (!broker) { console.log('e2e: skipped'); return } + beforeEach(({ skip }) => { if (!broker) skip('no Bybit demo account') }) - const uta = new UnifiedTradingAccount(broker) - const initialPositions = await broker.getPositions() + it('buy → sync → close → sync (full lifecycle)', async () => { + const uta = new UnifiedTradingAccount(broker!) + const initialPositions = await broker!.getPositions() const initialQty = initialPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 console.log(` initial ETH qty=${initialQty}`) @@ -51,13 +51,17 @@ describe('UTA — Bybit demo (ETH perp)', () => { const pushResult = await uta.push() expect(pushResult.submitted).toHaveLength(1) expect(pushResult.rejected).toHaveLength(0) - console.log(` pushed: orderId=${pushResult.submitted[0].orderId}`) - - // Sync: confirm fill - const sync1 = await uta.sync({ delayMs: 3000 }) - expect(sync1.updatedCount).toBe(1) - expect(sync1.updates[0].currentStatus).toBe('filled') - console.log(` sync1: filled`) + console.log(` pushed: orderId=${pushResult.submitted[0].orderId}, status=${pushResult.submitted[0].status}`) + + // Sync: depends on whether fill was synchronous + if (pushResult.submitted[0].status === 'submitted') { + const sync1 = await uta.sync({ delayMs: 3000 }) + expect(sync1.updatedCount).toBe(1) + expect(sync1.updates[0].currentStatus).toBe('filled') + console.log(` sync1: filled`) + } else { + console.log(` sync1: skipped (already ${pushResult.submitted[0].status} at push time)`) + } // Verify position const state = await uta.getState() @@ -71,33 +75,33 @@ describe('UTA — Bybit demo (ETH perp)', () => { const closePush = await uta.push() expect(closePush.submitted).toHaveLength(1) - const sync2 = await uta.sync({ delayMs: 3000 }) - expect(sync2.updatedCount).toBe(1) - expect(sync2.updates[0].currentStatus).toBe('filled') - console.log(` close: filled`) + if (closePush.submitted[0].status === 'submitted') { + const sync2 = await uta.sync({ delayMs: 3000 }) + expect(sync2.updatedCount).toBe(1) + expect(sync2.updates[0].currentStatus).toBe('filled') + console.log(` close: filled`) + } else { + console.log(` close: already ${closePush.submitted[0].status} at push time`) + } // Verify final qty - const finalPositions = await broker.getPositions() + const finalPositions = await broker!.getPositions() const finalQty = finalPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 expect(Math.abs(finalQty - initialQty)).toBeLessThan(0.02) console.log(` final ETH qty=${finalQty} (initial=${initialQty})`) - // Log: at least 4 commits (buy, sync, close, sync) const log = uta.log({ limit: 10 }) - expect(log.length).toBeGreaterThanOrEqual(4) + expect(log.length).toBeGreaterThanOrEqual(2) console.log(` log: ${log.length} commits`) }, 60_000) it('reject records user-rejected commit and clears staging', async () => { - if (!broker) { console.log('e2e: skipped'); return } - - const uta = new UnifiedTradingAccount(broker) + const uta = new UnifiedTradingAccount(broker!) // Stage + Commit (but don't push) uta.stagePlaceOrder({ aliceId: ethAliceId, side: 'buy', type: 'market', qty: 0.01 }) const commitResult = uta.commit('e2e: buy to be rejected') expect(commitResult.prepared).toBe(true) - console.log(` committed: hash=${commitResult.hash}`) // Verify staging has content const statusBefore = uta.status() @@ -109,7 +113,6 @@ describe('UTA — Bybit demo (ETH perp)', () => { expect(rejectResult.operationCount).toBe(1) expect(rejectResult.message).toContain('[rejected]') expect(rejectResult.message).toContain('user declined') - console.log(` rejected: hash=${rejectResult.hash}, message="${rejectResult.message}"`) // Verify staging is cleared const statusAfter = uta.status() @@ -120,32 +123,24 @@ describe('UTA — Bybit demo (ETH perp)', () => { const log = uta.log({ limit: 5 }) const rejectedCommit = log.find(c => c.hash === rejectResult.hash) expect(rejectedCommit).toBeDefined() - expect(rejectedCommit!.message).toContain('[rejected]') expect(rejectedCommit!.operations[0].status).toBe('user-rejected') - console.log(` log entry: ${rejectedCommit!.operations[0].status}`) - // Show the full commit const fullCommit = uta.show(rejectResult.hash) - expect(fullCommit).not.toBeNull() expect(fullCommit!.results[0].status).toBe('user-rejected') expect(fullCommit!.results[0].error).toBe('user declined') - console.log(` show: results[0].error="${fullCommit!.results[0].error}"`) }, 30_000) it('reject without reason still works', async () => { - if (!broker) { console.log('e2e: skipped'); return } - - const uta = new UnifiedTradingAccount(broker) + const uta = new UnifiedTradingAccount(broker!) uta.stagePlaceOrder({ aliceId: ethAliceId, side: 'sell', type: 'limit', qty: 0.01, price: 99999 }) uta.commit('e2e: sell to be rejected silently') const result = await uta.reject() expect(result.operationCount).toBe(1) expect(result.message).toContain('[rejected]') - expect(result.message).not.toContain('—') // no reason suffix + expect(result.message).not.toContain('—') const fullCommit = uta.show(result.hash) expect(fullCommit!.results[0].error).toBe('Rejected by user') - console.log(` rejected without reason: ok`) }, 15_000) }) diff --git a/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts new file mode 100644 index 00000000..21c1eb4f --- /dev/null +++ b/src/domain/trading/__test__/e2e/uta-ibkr.e2e.spec.ts @@ -0,0 +1,100 @@ +/** + * UTA — IBKR paper lifecycle e2e. + * + * Full Trading-as-Git flow: stage → commit → push → sync → verify + * against IBKR paper trading (US equities via TWS/Gateway). + * + * Skips when market is closed — TWS paper won't fill orders outside trading hours. + * + * Run: pnpm test:e2e + */ + +import { describe, it, expect, beforeAll, beforeEach } from 'vitest' +import { getTestAccounts, filterByProvider } from './setup.js' +import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' +import type { IBroker } from '../../brokers/types.js' +import '../../contract-ext.js' + +describe('UTA — IBKR lifecycle (AAPL)', () => { + let broker: IBroker | null = null + let marketOpen = false + + beforeAll(async () => { + const all = await getTestAccounts() + const ibkr = filterByProvider(all, 'ibkr')[0] + if (!ibkr) return + broker = ibkr.broker + const clock = await broker.getMarketClock() + marketOpen = clock.isOpen + console.log(`UTA IBKR: market ${marketOpen ? 'OPEN' : 'CLOSED'}`) + }, 60_000) + + beforeEach(({ skip }) => { + if (!broker) skip('no IBKR paper account') + if (!marketOpen) skip('market closed') + }) + + it('buy → sync → verify → close → sync → verify', async () => { + const uta = new UnifiedTradingAccount(broker!) + + // Record initial state + const initialPositions = await broker!.getPositions() + const initialAaplQty = initialPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 + console.log(` initial AAPL qty=${initialAaplQty}`) + + // === Stage + Commit + Push: buy 1 AAPL === + const addResult = uta.stagePlaceOrder({ + aliceId: `${uta.id}|AAPL`, + symbol: 'AAPL', + side: 'buy', + type: 'market', + qty: 1, + }) + expect(addResult.staged).toBe(true) + + const commitResult = uta.commit('e2e: buy 1 AAPL') + expect(commitResult.prepared).toBe(true) + console.log(` committed: hash=${commitResult.hash}`) + + const pushResult = await uta.push() + console.log(` pushed: submitted=${pushResult.submitted.length}, status=${pushResult.submitted[0]?.status}`) + expect(pushResult.submitted).toHaveLength(1) + expect(pushResult.rejected).toHaveLength(0) + expect(pushResult.submitted[0].orderId).toBeDefined() + + // === Sync: depends on whether fill was synchronous === + if (pushResult.submitted[0].status === 'submitted') { + const sync1 = await uta.sync({ delayMs: 3000 }) + console.log(` sync1: updatedCount=${sync1.updatedCount}`) + expect(sync1.updatedCount).toBe(1) + expect(sync1.updates[0].currentStatus).toBe('filled') + } else { + console.log(` sync1: skipped (already ${pushResult.submitted[0].status} at push time)`) + } + + // === Verify: position exists === + const state1 = await uta.getState() + const aaplPos = state1.positions.find(p => p.contract.symbol === 'AAPL') + expect(aaplPos).toBeDefined() + expect(aaplPos!.quantity.toNumber()).toBe(initialAaplQty + 1) + + // === Close 1 AAPL === + uta.stageClosePosition({ aliceId: `${uta.id}|AAPL`, qty: 1 }) + uta.commit('e2e: close 1 AAPL') + const closePush = await uta.push() + console.log(` close pushed: status=${closePush.submitted[0]?.status}`) + expect(closePush.submitted).toHaveLength(1) + + if (closePush.submitted[0].status === 'submitted') { + const sync2 = await uta.sync({ delayMs: 3000 }) + expect(sync2.updatedCount).toBe(1) + } + + // === Verify: position back to initial === + const finalPositions = await broker!.getPositions() + const finalAaplQty = finalPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 + expect(finalAaplQty).toBe(initialAaplQty) + + expect(uta.log().length).toBeGreaterThanOrEqual(2) + }, 60_000) +}) diff --git a/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts index eb425c41..162784c8 100644 --- a/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts +++ b/src/domain/trading/__test__/e2e/uta-lifecycle.e2e.spec.ts @@ -49,15 +49,17 @@ describe('UTA — full trading lifecycle', () => { expect(account.totalCashValue).toBe(100_000 - 10 * 150) }) - it('market buy → sync confirms filled', async () => { + it('market buy fills at push time — no sync needed', async () => { uta.stagePlaceOrder({ aliceId: 'mock-paper|AAPL', symbol: 'AAPL', side: 'buy', type: 'market', qty: 10 }) uta.commit('buy AAPL') - await uta.push() + const pushResult = await uta.push() + + // Market order fills synchronously — status is 'filled' at push time + expect(pushResult.submitted[0].status).toBe('filled') - // Sync detects fill (MockBroker market orders are internally filled) + // Sync has nothing to do (order already resolved) const syncResult = await uta.sync() - expect(syncResult.updatedCount).toBe(1) - expect(syncResult.updates[0].currentStatus).toBe('filled') + expect(syncResult.updatedCount).toBe(0) }) it('getState reflects positions and pending orders', async () => { diff --git a/src/domain/trading/__test__/e2e/uta-real-broker.e2e.spec.ts b/src/domain/trading/__test__/e2e/uta-real-broker.e2e.spec.ts deleted file mode 100644 index 2a44ad51..00000000 --- a/src/domain/trading/__test__/e2e/uta-real-broker.e2e.spec.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * UTA e2e — full Trading-as-Git lifecycle against real brokers. - * - * Tests the complete flow: stage → commit → push → sync → verify - * against Alpaca paper (US equities) and Bybit demo (crypto perps). - * - * Each platform is a single sequential test to avoid cascading failures. - * - * Run: pnpm test:e2e - */ - -import { describe, it, expect, beforeAll } from 'vitest' -import { getTestAccounts, filterByProvider } from './setup.js' -import { UnifiedTradingAccount } from '../../UnifiedTradingAccount.js' -import type { IBroker } from '../../brokers/types.js' -import '../../contract-ext.js' - -// ==================== Alpaca — AAPL lifecycle ==================== - -describe('UTA — Alpaca paper (AAPL)', () => { - let broker: IBroker | null = null - - beforeAll(async () => { - const all = await getTestAccounts() - const alpaca = filterByProvider(all, 'alpaca')[0] - if (!alpaca) { - console.log('e2e: No Alpaca paper account, skipping UTA Alpaca tests') - return - } - broker = alpaca.broker - }, 60_000) - - it('full lifecycle: buy → sync → verify → close → sync → verify', async () => { - if (!broker) { console.log('e2e: skipped — no Alpaca paper account'); return } - - const uta = new UnifiedTradingAccount(broker) - - // Record initial state - const initialPositions = await broker.getPositions() - const initialAaplQty = initialPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 - console.log(` initial AAPL qty=${initialAaplQty}`) - - // === Stage + Commit + Push: buy 1 AAPL === - const addResult = uta.stagePlaceOrder({ - aliceId: `${uta.id}|AAPL`, - symbol: 'AAPL', - side: 'buy', - type: 'market', - qty: 1, - }) - expect(addResult.staged).toBe(true) - console.log(` staged: ok`) - - const commitResult = uta.commit('e2e: buy 1 AAPL') - expect(commitResult.prepared).toBe(true) - console.log(` committed: hash=${commitResult.hash}`) - - const pushResult = await uta.push() - console.log(` pushed: submitted=${pushResult.submitted.length}, rejected=${pushResult.rejected.length}`) - expect(pushResult.submitted).toHaveLength(1) - expect(pushResult.rejected).toHaveLength(0) - - const buyOrderId = pushResult.submitted[0].orderId - console.log(` orderId: ${buyOrderId}`) - expect(buyOrderId).toBeDefined() - - // === Sync: confirm fill === - const sync1 = await uta.sync({ delayMs: 2000 }) - console.log(` sync1: updatedCount=${sync1.updatedCount}, updates=${JSON.stringify(sync1.updates.map(u => ({ s: u.symbol, from: u.previousStatus, to: u.currentStatus })))}`) - expect(sync1.updatedCount).toBe(1) - expect(sync1.updates[0].currentStatus).toBe('filled') - - // === Verify: position exists, no pending === - const state1 = await uta.getState() - const aaplPos = state1.positions.find(p => p.contract.symbol === 'AAPL') - console.log(` state: AAPL qty=${aaplPos?.quantity}, pending=${state1.pendingOrders.length}`) - expect(aaplPos).toBeDefined() - expect(aaplPos!.quantity.toNumber()).toBe(initialAaplQty + 1) - expect(state1.pendingOrders).toHaveLength(0) - - // === Stage + Commit + Push: close 1 AAPL === - uta.stageClosePosition({ aliceId: `${uta.id}|AAPL`, qty: 1 }) - uta.commit('e2e: close 1 AAPL') - const closePush = await uta.push() - console.log(` close pushed: submitted=${closePush.submitted.length}`) - expect(closePush.submitted).toHaveLength(1) - - // === Sync: confirm close fill === - const sync2 = await uta.sync({ delayMs: 2000 }) - console.log(` sync2: updatedCount=${sync2.updatedCount}`) - expect(sync2.updatedCount).toBe(1) - expect(sync2.updates[0].currentStatus).toBe('filled') - - // === Verify: position back to initial === - const finalPositions = await broker.getPositions() - const finalAaplQty = finalPositions.find(p => p.contract.symbol === 'AAPL')?.quantity.toNumber() ?? 0 - console.log(` final AAPL qty=${finalAaplQty} (initial was ${initialAaplQty})`) - expect(finalAaplQty).toBe(initialAaplQty) - - // === Log: 2 commits === - const history = uta.log() - console.log(` log: ${history.length} commits — [${history.map(h => h.message).join(', ')}]`) - expect(history.length).toBeGreaterThanOrEqual(2) - }, 60_000) -}) - -// ==================== Bybit — ETH perp lifecycle ==================== - -describe('UTA — Bybit demo (ETH perp)', () => { - let broker: IBroker | null = null - let ethAliceId: string = '' - - beforeAll(async () => { - const all = await getTestAccounts() - const bybit = filterByProvider(all, 'ccxt').find(a => a.id.includes('bybit')) - if (!bybit) { - console.log('e2e: No Bybit demo account, skipping UTA Bybit tests') - return - } - broker = bybit.broker - - const results = await broker.searchContracts('ETH') - const perp = results.find(r => r.contract.localSymbol?.includes('USDT:USDT')) - if (!perp) { - console.log('e2e: No ETH/USDT perp found, skipping') - broker = null - return - } - // Construct aliceId in new format: {utaId}|{nativeKey} - const nativeKey = perp.contract.localSymbol! - ethAliceId = `${bybit.id}|${nativeKey}` - console.log(`UTA Bybit: ETH perp aliceId=${ethAliceId}`) - }, 60_000) - - it('full lifecycle: buy → sync → verify → close → sync → verify', async () => { - if (!broker) { console.log('e2e: skipped — no Bybit demo account'); return } - - const uta = new UnifiedTradingAccount(broker) - - // Record initial state - const initialPositions = await broker.getPositions() - const initialEthQty = initialPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 - console.log(` initial ETH qty=${initialEthQty}`) - - // === Stage + Commit + Push: buy 0.01 ETH === - const addResult = uta.stagePlaceOrder({ - aliceId: ethAliceId, - side: 'buy', - type: 'market', - qty: 0.01, - }) - expect(addResult.staged).toBe(true) - console.log(` staged: ok`) - - const commitResult = uta.commit('e2e: buy 0.01 ETH') - expect(commitResult.prepared).toBe(true) - console.log(` committed: hash=${commitResult.hash}`) - - const pushResult = await uta.push() - console.log(` pushed: submitted=${pushResult.submitted.length}, rejected=${pushResult.rejected.length}`) - expect(pushResult.submitted).toHaveLength(1) - expect(pushResult.rejected).toHaveLength(0) - - const buyOrderId = pushResult.submitted[0].orderId - console.log(` orderId: ${buyOrderId}`) - expect(buyOrderId).toBeDefined() - - // === Sync: confirm fill (Bybit needs more time) === - const sync1 = await uta.sync({ delayMs: 3000 }) - console.log(` sync1: updatedCount=${sync1.updatedCount}, updates=${JSON.stringify(sync1.updates.map(u => ({ s: u.symbol, from: u.previousStatus, to: u.currentStatus })))}`) - expect(sync1.updatedCount).toBe(1) - expect(sync1.updates[0].currentStatus).toBe('filled') - - // === Verify: position exists === - const state1 = await uta.getState() - const ethPos = state1.positions.find(p => p.contract.aliceId === ethAliceId) - console.log(` state: ETH qty=${ethPos?.quantity}, pending=${state1.pendingOrders.length}`) - expect(ethPos).toBeDefined() - expect(state1.pendingOrders).toHaveLength(0) - - // === Stage + Commit + Push: close 0.01 ETH === - uta.stageClosePosition({ aliceId: ethAliceId, qty: 0.01 }) - uta.commit('e2e: close 0.01 ETH') - const closePush = await uta.push() - console.log(` close pushed: submitted=${closePush.submitted.length}`) - expect(closePush.submitted).toHaveLength(1) - - // === Sync: confirm close fill === - const sync2 = await uta.sync({ delayMs: 3000 }) - console.log(` sync2: updatedCount=${sync2.updatedCount}`) - expect(sync2.updatedCount).toBe(1) - expect(sync2.updates[0].currentStatus).toBe('filled') - - // === Verify: we bought 0.01 then closed 0.01, net change should be ~0 === - const finalPositions = await broker.getPositions() - const finalEthQty = finalPositions.find(p => p.contract.localSymbol?.includes('USDT:USDT'))?.quantity.toNumber() ?? 0 - console.log(` final ETH qty=${finalEthQty} (initial was ${initialEthQty})`) - // Allow tolerance for residual positions from other test runs - expect(Math.abs(finalEthQty - initialEthQty)).toBeLessThan(0.02) - - // === Log: 2 commits === - const history = uta.log() - console.log(` log: ${history.length} commits — [${history.map(h => h.message).join(', ')}]`) - expect(history.length).toBeGreaterThanOrEqual(2) - }, 60_000) -}) diff --git a/src/domain/trading/account-manager.spec.ts b/src/domain/trading/account-manager.spec.ts index 683dd779..49fe8059 100644 --- a/src/domain/trading/account-manager.spec.ts +++ b/src/domain/trading/account-manager.spec.ts @@ -8,8 +8,8 @@ import { } from './brokers/mock/index.js' import './contract-ext.js' -function makeUta(broker: MockBroker, platformId?: string): UnifiedTradingAccount { - return new UnifiedTradingAccount(broker, { platformId }) +function makeUta(broker: MockBroker): UnifiedTradingAccount { + return new UnifiedTradingAccount(broker) } describe('AccountManager', () => { @@ -63,14 +63,6 @@ describe('AccountManager', () => { expect(list[1].id).toBe('a2') }) - it('includes platformId when provided', () => { - manager.add(makeUta(new MockBroker({ id: 'a1' }), 'alpaca-paper')) - manager.add(makeUta(new MockBroker({ id: 'a2' }))) - - const list = manager.listAccounts() - expect(list[0].platformId).toBe('alpaca-paper') - expect(list[1].platformId).toBeUndefined() - }) }) // ==================== resolve ==================== diff --git a/src/domain/trading/account-manager.ts b/src/domain/trading/account-manager.ts index 753899c2..4c9cd529 100644 --- a/src/domain/trading/account-manager.ts +++ b/src/domain/trading/account-manager.ts @@ -15,7 +15,6 @@ import './contract-ext.js' export interface AccountSummary { id: string label: string - platformId?: string capabilities: AccountCapabilities health: BrokerHealthInfo } @@ -72,7 +71,6 @@ export class AccountManager { return Array.from(this.entries.values()).map((uta) => ({ id: uta.id, label: uta.label, - platformId: uta.platformId, capabilities: uta.getCapabilities(), health: uta.getHealthInfo(), })) @@ -120,23 +118,19 @@ export class AccountManager { // ---- Cross-account aggregation ---- - /** Throttle: only warn once per account per 5 minutes */ - private equityWarnedAt = new Map() - private static readonly EQUITY_WARN_INTERVAL_MS = 5 * 60_000 - async getAggregatedEquity(): Promise { const results = await Promise.all( Array.from(this.entries.values()).map(async (uta) => { + // Unhealthy UTA → skip data query, nudge recovery to retry sooner + if (uta.health !== 'healthy') { + uta.nudgeRecovery() + return { id: uta.id, label: uta.label, health: uta.health, info: null } + } try { const info = await uta.getAccount() return { id: uta.id, label: uta.label, health: uta.health, info } - } catch (err) { - const now = Date.now() - const lastWarned = this.equityWarnedAt.get(uta.id) ?? 0 - if (now - lastWarned > AccountManager.EQUITY_WARN_INTERVAL_MS) { - console.warn(`getAggregatedEquity: ${uta.id} failed, skipping:`, err) - this.equityWarnedAt.set(uta.id, now) - } + } catch { + // Healthy UTA failed this call — _callBroker already updated health + SSE return { id: uta.id, label: uta.label, health: uta.health, info: null } } }), @@ -180,8 +174,16 @@ export class AccountManager { const results = await Promise.all( targets.map(async (uta) => { - const descriptions = await uta.searchContracts(pattern) - return { accountId: uta.id, results: descriptions } + if (uta.health !== 'healthy') { + uta.nudgeRecovery() + return { accountId: uta.id, results: [] as ContractDescription[] } + } + try { + const descriptions = await uta.searchContracts(pattern) + return { accountId: uta.id, results: descriptions } + } catch { + return { accountId: uta.id, results: [] as ContractDescription[] } + } }), ) diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts index 1fad616e..f08e1307 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.spec.ts @@ -274,24 +274,27 @@ describe('AlpacaBroker — modifyOrder()', () => { describe('AlpacaBroker — cancelOrder()', () => { beforeEach(() => vi.clearAllMocks()) - it('returns true on success', async () => { + it('returns PlaceOrderResult with Cancelled status on success', async () => { const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) ;(acc as any).client = { cancelOrder: vi.fn().mockResolvedValue(undefined), } const result = await acc.cancelOrder('ord-1') - expect(result).toBe(true) + expect(result.success).toBe(true) + expect(result.orderId).toBe('ord-1') + expect(result.orderState?.status).toBe('Cancelled') }) - it('returns false on API failure', async () => { + it('returns PlaceOrderResult with error on API failure', async () => { const acc = new AlpacaBroker({ apiKey: 'k', secretKey: 's', paper: true }) ;(acc as any).client = { cancelOrder: vi.fn().mockRejectedValue(new Error('Cannot cancel')), } const result = await acc.cancelOrder('ord-1') - expect(result).toBe(false) + expect(result.success).toBe(false) + expect(result.error).toBe('Cannot cancel') }) }) diff --git a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts index 9c1a6164..3ef70914 100644 --- a/src/domain/trading/brokers/alpaca/AlpacaBroker.ts +++ b/src/domain/trading/brokers/alpaca/AlpacaBroker.ts @@ -193,10 +193,10 @@ export class AlpacaBroker implements IBroker { } } - async modifyOrder(orderId: string, changes: Order): Promise { + async modifyOrder(orderId: string, changes: Partial): Promise { try { const patch: Record = {} - if (!changes.totalQuantity.equals(UNSET_DECIMAL)) patch.qty = parseFloat(changes.totalQuantity.toString()) + if (changes.totalQuantity != null && !changes.totalQuantity.equals(UNSET_DECIMAL)) patch.qty = parseFloat(changes.totalQuantity.toString()) if (changes.lmtPrice !== UNSET_DOUBLE) patch.limit_price = changes.lmtPrice if (changes.auxPrice !== UNSET_DOUBLE) patch.stop_price = changes.auxPrice if (changes.trailingPercent !== UNSET_DOUBLE) patch.trail = changes.trailingPercent @@ -214,12 +214,14 @@ export class AlpacaBroker implements IBroker { } } - async cancelOrder(orderId: string): Promise { + async cancelOrder(orderId: string): Promise { try { await this.client.cancelOrder(orderId) - return true - } catch { - return false + const orderState = new OrderState() + orderState.status = 'Cancelled' + return { success: true, orderId, orderState } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } } } @@ -260,40 +262,47 @@ export class AlpacaBroker implements IBroker { // ---- Queries ---- async getAccount(): Promise { - const [account, positions] = await Promise.all([ - this.client.getAccount() as Promise, - this.client.getPositions() as Promise, - ]) + try { + const [account, positions] = await Promise.all([ + this.client.getAccount() as Promise, + this.client.getPositions() as Promise, + ]) - // Alpaca account API doesn't provide unrealizedPnL — aggregate from positions with Decimal - const unrealizedPnL = positions.reduce( - (sum, p) => sum.plus(new Decimal(p.unrealized_pl)), - new Decimal(0), - ).toNumber() + // Alpaca account API doesn't provide unrealizedPnL — aggregate from positions with Decimal + const unrealizedPnL = positions.reduce( + (sum, p) => sum.plus(new Decimal(p.unrealized_pl)), + new Decimal(0), + ).toNumber() - return { - netLiquidation: parseFloat(account.equity), - totalCashValue: parseFloat(account.cash), - unrealizedPnL, - buyingPower: parseFloat(account.buying_power), - dayTradesRemaining: account.daytrade_count != null ? Math.max(0, 3 - account.daytrade_count) : undefined, + return { + netLiquidation: parseFloat(account.equity), + totalCashValue: parseFloat(account.cash), + unrealizedPnL, + buyingPower: parseFloat(account.buying_power), + dayTradesRemaining: account.daytrade_count != null ? Math.max(0, 3 - account.daytrade_count) : undefined, + } + } catch (err) { + throw BrokerError.from(err) } } async getPositions(): Promise { - const raw = await this.client.getPositions() as AlpacaPositionRaw[] - - return raw.map(p => ({ - contract: makeContract(p.symbol), - side: p.side === 'long' ? 'long' as const : 'short' as const, - quantity: new Decimal(p.qty), - avgCost: parseFloat(p.avg_entry_price), - marketPrice: parseFloat(p.current_price), - marketValue: Math.abs(parseFloat(p.market_value)), - unrealizedPnL: parseFloat(p.unrealized_pl), - realizedPnL: 0, - leverage: 1, - })) + try { + const raw = await this.client.getPositions() as AlpacaPositionRaw[] + + return raw.map(p => ({ + contract: makeContract(p.symbol), + side: p.side === 'long' ? 'long' as const : 'short' as const, + quantity: new Decimal(p.qty), + avgCost: parseFloat(p.avg_entry_price), + marketPrice: parseFloat(p.current_price), + marketValue: Math.abs(parseFloat(p.market_value)), + unrealizedPnL: parseFloat(p.unrealized_pl), + realizedPnL: 0, + })) + } catch (err) { + throw BrokerError.from(err) + } } async getOrders(orderIds: string[]): Promise { @@ -316,17 +325,21 @@ export class AlpacaBroker implements IBroker { async getQuote(contract: Contract): Promise { const symbol = resolveSymbol(contract) - if (!symbol) throw new Error('Cannot resolve contract to Alpaca symbol') + if (!symbol) throw new BrokerError('EXCHANGE', 'Cannot resolve contract to Alpaca symbol') - const snapshot = await this.client.getSnapshot(symbol) as AlpacaSnapshotRaw + try { + const snapshot = await this.client.getSnapshot(symbol) as AlpacaSnapshotRaw - return { - contract: makeContract(symbol), - last: snapshot.LatestTrade.Price, - bid: snapshot.LatestQuote.BidPrice, - ask: snapshot.LatestQuote.AskPrice, - volume: snapshot.DailyBar.Volume, - timestamp: new Date(snapshot.LatestTrade.Timestamp), + return { + contract: makeContract(symbol), + last: snapshot.LatestTrade.Price, + bid: snapshot.LatestQuote.BidPrice, + ask: snapshot.LatestQuote.AskPrice, + volume: snapshot.DailyBar.Volume, + timestamp: new Date(snapshot.LatestTrade.Timestamp), + } + } catch (err) { + throw BrokerError.from(err) } } @@ -340,12 +353,16 @@ export class AlpacaBroker implements IBroker { } async getMarketClock(): Promise { - const clock = await this.client.getClock() as AlpacaClockRaw - return { - isOpen: clock.is_open, - nextOpen: new Date(clock.next_open), - nextClose: new Date(clock.next_close), - timestamp: new Date(clock.timestamp), + try { + const clock = await this.client.getClock() as AlpacaClockRaw + return { + isOpen: clock.is_open, + nextOpen: new Date(clock.next_open), + nextClose: new Date(clock.next_close), + timestamp: new Date(clock.timestamp), + } + } catch (err) { + throw BrokerError.from(err) } } diff --git a/src/domain/trading/brokers/alpaca/AlpacaPlatform.ts b/src/domain/trading/brokers/alpaca/AlpacaPlatform.ts deleted file mode 100644 index b6c50002..00000000 --- a/src/domain/trading/brokers/alpaca/AlpacaPlatform.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { IPlatform, PlatformCredentials } from '../factory.js' -import { AlpacaBroker } from './AlpacaBroker.js' - -export interface AlpacaPlatformConfig { - id: string - label?: string - paper: boolean -} - -export class AlpacaPlatform implements IPlatform { - readonly id: string - readonly label: string - readonly providerType = 'alpaca' - - private readonly config: AlpacaPlatformConfig - - constructor(config: AlpacaPlatformConfig) { - this.config = config - this.id = config.id - this.label = config.label ?? (config.paper ? 'Alpaca Paper' : 'Alpaca Live') - } - - createAccount(credentials: PlatformCredentials): AlpacaBroker { - return new AlpacaBroker({ - id: credentials.id, - label: credentials.label, - apiKey: credentials.apiKey ?? '', - secretKey: credentials.apiSecret ?? '', - paper: this.config.paper, - }) - } -} diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts index 8b166ce4..0c907a50 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.spec.ts @@ -164,21 +164,24 @@ describe('CcxtBroker — cancelOrder cache', () => { expect((acc as any).exchange.cancelOrder).toHaveBeenCalledWith('order-not-cached', undefined) }) - it('returns false when exchange.cancelOrder throws (cache miss causes undefined symbol)', async () => { + it('returns PlaceOrderResult with error when exchange.cancelOrder throws', async () => { const acc = makeAccount() setInitialized(acc, {}) ;(acc as any).exchange.cancelOrder = vi.fn().mockRejectedValue(new Error('symbol required')) const result = await acc.cancelOrder('order-not-cached') - expect(result).toBe(false) + expect(result.success).toBe(false) + expect(result.error).toBe('symbol required') }) - it('calls exchange.cancelOrder with correct symbol when orderId is cached', async () => { + it('returns PlaceOrderResult with Cancelled status when orderId is cached', async () => { const acc = makeAccount() setInitialized(acc, {}) ;(acc as any).orderSymbolCache.set('order-123', 'BTC/USDT:USDT') ;(acc as any).exchange.cancelOrder = vi.fn().mockResolvedValue({}) const result = await acc.cancelOrder('order-123') - expect(result).toBe(true) + expect(result.success).toBe(true) + expect(result.orderId).toBe('order-123') + expect(result.orderState?.status).toBe('Cancelled') expect((acc as any).exchange.cancelOrder).toHaveBeenCalledWith('order-123', 'BTC/USDT:USDT') }) }) @@ -656,7 +659,6 @@ describe('CcxtBroker — getPositions', () => { expect(positions[0].side).toBe('long') expect(positions[0].avgCost).toBe(58000) expect(positions[0].marketPrice).toBe(60000) - expect(positions[0].leverage).toBe(5) }) it('skips zero-size positions', async () => { diff --git a/src/domain/trading/brokers/ccxt/CcxtBroker.ts b/src/domain/trading/brokers/ccxt/CcxtBroker.ts index de7b865f..70ee1740 100644 --- a/src/domain/trading/brokers/ccxt/CcxtBroker.ts +++ b/src/domain/trading/brokers/ccxt/CcxtBroker.ts @@ -20,12 +20,9 @@ import { type OpenOrder, type Quote, type MarketClock, - type FundingRate, - type OrderBook, - type OrderBookLevel, } from '../types.js' import '../../contract-ext.js' -import type { CcxtBrokerConfig, CcxtMarket } from './ccxt-types.js' +import type { CcxtBrokerConfig, CcxtMarket, FundingRate, OrderBook, OrderBookLevel } from './ccxt-types.js' import { MAX_INIT_RETRIES, INIT_RETRY_BASE_MS } from './ccxt-types.js' import { ccxtTypeToSecType, @@ -68,7 +65,7 @@ export class CcxtBroker implements IBroker { const exchanges = ccxt as unknown as Record) => Exchange> const ExchangeClass = exchanges[config.exchange] if (!ExchangeClass) { - throw new Error(`Unknown CCXT exchange: ${config.exchange}`) + throw new BrokerError('CONFIG', `Unknown CCXT exchange: ${config.exchange}`) } // Default: skip option markets to reduce concurrent requests during loadMarkets @@ -101,7 +98,7 @@ export class CcxtBroker implements IBroker { private ensureInit(): void { if (!this.initialized) { - throw new Error(`CcxtBroker[${this.id}] not initialized. Call init() first.`) + throw new BrokerError('CONFIG', `CcxtBroker[${this.id}] not initialized. Call init() first.`) } } @@ -152,15 +149,12 @@ export class CcxtBroker implements IBroker { try { await this.exchange.loadMarkets() } catch (err) { - throw new Error( - `Failed to connect to ${this.exchangeName} — check network connectivity. ` + - `${err instanceof Error ? err.message : String(err)}`, - ) + throw BrokerError.from(err, 'NETWORK') } const marketCount = Object.keys(this.exchange.markets).length if (marketCount === 0) { - throw new Error(`CcxtBroker[${this.id}]: failed to load any markets`) + throw new BrokerError('NETWORK', `CcxtBroker[${this.id}]: failed to load any markets`) } this.initialized = true console.log(`CcxtBroker[${this.id}]: connected (${this.exchangeName}, ${marketCount} markets loaded)`) @@ -296,23 +290,23 @@ export class CcxtBroker implements IBroker { } } - async cancelOrder(orderId: string): Promise { + async cancelOrder(orderId: string): Promise { this.ensureInit() - try { const ccxtSymbol = this.orderSymbolCache.get(orderId) await this.exchange.cancelOrder(orderId, ccxtSymbol) - return true - } catch { - return false + const orderState = new OrderState() + orderState.status = 'Cancelled' + return { success: true, orderId, orderState } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } } } - async modifyOrder(orderId: string, changes: Order): Promise { + async modifyOrder(orderId: string, changes: Partial): Promise { this.ensureInit() - try { const ccxtSymbol = this.orderSymbolCache.get(orderId) if (!ccxtSymbol) { @@ -321,7 +315,7 @@ export class CcxtBroker implements IBroker { // editOrder requires type and side — fetch the original order to fill in defaults const original = await this.exchange.fetchOrder(orderId, ccxtSymbol) - const qty = !changes.totalQuantity.equals(UNSET_DECIMAL) ? parseFloat(changes.totalQuantity.toString()) : original.amount + const qty = changes.totalQuantity != null && !changes.totalQuantity.equals(UNSET_DECIMAL) ? parseFloat(changes.totalQuantity.toString()) : original.amount const price = changes.lmtPrice !== UNSET_DOUBLE ? changes.lmtPrice : original.price const result = await this.exchange.editOrder( @@ -373,71 +367,74 @@ export class CcxtBroker implements IBroker { async getAccount(): Promise { this.ensureInit() + try { + const [balance, rawPositions] = await Promise.all([ + this.exchange.fetchBalance(), + this.exchange.fetchPositions(), + ]) + + const bal = balance as unknown as Record> + const total = parseFloat(String(bal['total']?.['USDT'] ?? bal['total']?.['USD'] ?? 0)) + const free = parseFloat(String(bal['free']?.['USDT'] ?? bal['free']?.['USD'] ?? 0)) + const used = parseFloat(String(bal['used']?.['USDT'] ?? bal['used']?.['USD'] ?? 0)) + + let unrealizedPnL = 0 + let realizedPnL = 0 + for (const p of rawPositions) { + unrealizedPnL += parseFloat(String(p.unrealizedPnl ?? 0)) + realizedPnL += parseFloat(String((p as unknown as Record).realizedPnl ?? 0)) + } - const [balance, rawPositions] = await Promise.all([ - this.exchange.fetchBalance(), - this.exchange.fetchPositions(), - ]) - - const bal = balance as unknown as Record> - const total = parseFloat(String(bal['total']?.['USDT'] ?? bal['total']?.['USD'] ?? 0)) - const free = parseFloat(String(bal['free']?.['USDT'] ?? bal['free']?.['USD'] ?? 0)) - const used = parseFloat(String(bal['used']?.['USDT'] ?? bal['used']?.['USD'] ?? 0)) - - let unrealizedPnL = 0 - let realizedPnL = 0 - for (const p of rawPositions) { - unrealizedPnL += parseFloat(String(p.unrealizedPnl ?? 0)) - realizedPnL += parseFloat(String((p as unknown as Record).realizedPnl ?? 0)) - } - - return { - netLiquidation: total, - totalCashValue: free, - unrealizedPnL, - realizedPnL, - initMarginReq: used, + return { + netLiquidation: total, + totalCashValue: free, + unrealizedPnL, + realizedPnL, + initMarginReq: used, + } + } catch (err) { + throw BrokerError.from(err) } } async getPositions(): Promise { this.ensureInit() + try { + const raw = await this.exchange.fetchPositions() + const result: Position[] = [] + + for (const p of raw) { + const market = this.markets[p.symbol] + if (!market) continue + + // Use Decimal arithmetic to avoid IEEE 754 precision loss (e.g. 0.51 → 0.50999...) + const contracts = new Decimal(String(p.contracts ?? 0)).abs() + const contractSize = new Decimal(String(p.contractSize ?? 1)) + const quantity = contracts.mul(contractSize) + if (quantity.isZero()) continue + + const markPrice = parseFloat(String(p.markPrice ?? 0)) + const entryPrice = parseFloat(String(p.entryPrice ?? 0)) + const marketValue = quantity.toNumber() * markPrice + const unrealizedPnL = parseFloat(String(p.unrealizedPnl ?? 0)) + + result.push({ + contract: marketToContract(market, this.exchangeName), + side: p.side === 'long' ? 'long' : 'short', + quantity, + avgCost: entryPrice, + marketPrice: markPrice, + marketValue, + unrealizedPnL, + realizedPnL: parseFloat(String((p as unknown as Record).realizedPnl ?? 0)), + }) + } - const raw = await this.exchange.fetchPositions() - const result: Position[] = [] - - for (const p of raw) { - const market = this.markets[p.symbol] - if (!market) continue - - // Use Decimal arithmetic to avoid IEEE 754 precision loss (e.g. 0.51 → 0.50999...) - const contracts = new Decimal(String(p.contracts ?? 0)).abs() - const contractSize = new Decimal(String(p.contractSize ?? 1)) - const quantity = contracts.mul(contractSize) - if (quantity.isZero()) continue - - const markPrice = parseFloat(String(p.markPrice ?? 0)) - const entryPrice = parseFloat(String(p.entryPrice ?? 0)) - const marketValue = quantity.toNumber() * markPrice - const unrealizedPnL = parseFloat(String(p.unrealizedPnl ?? 0)) - - result.push({ - contract: marketToContract(market, this.exchangeName), - side: p.side === 'long' ? 'long' : 'short', - quantity, - avgCost: entryPrice, - marketPrice: markPrice, - marketValue, - unrealizedPnL, - realizedPnL: parseFloat(String((p as unknown as Record).realizedPnl ?? 0)), - leverage: parseFloat(String(p.leverage ?? 1)), - margin: parseFloat(String(p.initialMargin ?? p.collateral ?? 0)), - liquidationPrice: parseFloat(String(p.liquidationPrice ?? 0)) || undefined, - }) + return result + } catch (err) { + throw BrokerError.from(err) } - - return result } async getOrders(orderIds: string[]): Promise { @@ -504,22 +501,26 @@ export class CcxtBroker implements IBroker { this.ensureInit() const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) - if (!ccxtSymbol) throw new Error('Cannot resolve contract to CCXT symbol') + if (!ccxtSymbol) throw new BrokerError('EXCHANGE', 'Cannot resolve contract to CCXT symbol') - const ticker = await this.exchange.fetchTicker(ccxtSymbol) - const market = this.markets[ccxtSymbol] + try { + const ticker = await this.exchange.fetchTicker(ccxtSymbol) + const market = this.markets[ccxtSymbol] - return { - contract: market - ? marketToContract(market, this.exchangeName) - : contract, - last: ticker.last ?? 0, - bid: ticker.bid ?? 0, - ask: ticker.ask ?? 0, - volume: ticker.baseVolume ?? 0, - high: ticker.high ?? undefined, - low: ticker.low ?? undefined, - timestamp: new Date(ticker.timestamp ?? Date.now()), + return { + contract: market + ? marketToContract(market, this.exchangeName) + : contract, + last: ticker.last ?? 0, + bid: ticker.bid ?? 0, + ask: ticker.ask ?? 0, + volume: ticker.baseVolume ?? 0, + high: ticker.high ?? undefined, + low: ticker.low ?? undefined, + timestamp: new Date(ticker.timestamp ?? Date.now()), + } + } catch (err) { + throw BrokerError.from(err) } } @@ -545,19 +546,23 @@ export class CcxtBroker implements IBroker { this.ensureInit() const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) - if (!ccxtSymbol) throw new Error('Cannot resolve contract to CCXT symbol') + if (!ccxtSymbol) throw new BrokerError('EXCHANGE', 'Cannot resolve contract to CCXT symbol') - const funding = await this.exchange.fetchFundingRate(ccxtSymbol) - const market = this.markets[ccxtSymbol] + try { + const funding = await this.exchange.fetchFundingRate(ccxtSymbol) + const market = this.markets[ccxtSymbol] - return { - contract: market - ? marketToContract(market, this.exchangeName) - : contract, - fundingRate: funding.fundingRate ?? 0, - nextFundingTime: funding.fundingDatetime ? new Date(funding.fundingDatetime) : undefined, - previousFundingRate: funding.previousFundingRate ?? undefined, - timestamp: new Date(funding.timestamp ?? Date.now()), + return { + contract: market + ? marketToContract(market, this.exchangeName) + : contract, + fundingRate: funding.fundingRate ?? 0, + nextFundingTime: funding.fundingDatetime ? new Date(funding.fundingDatetime) : undefined, + previousFundingRate: funding.previousFundingRate ?? undefined, + timestamp: new Date(funding.timestamp ?? Date.now()), + } + } catch (err) { + throw BrokerError.from(err) } } @@ -565,18 +570,22 @@ export class CcxtBroker implements IBroker { this.ensureInit() const ccxtSymbol = contractToCcxt(contract, this.markets, this.exchangeName) - if (!ccxtSymbol) throw new Error('Cannot resolve contract to CCXT symbol') + if (!ccxtSymbol) throw new BrokerError('EXCHANGE', 'Cannot resolve contract to CCXT symbol') - const book = await this.exchange.fetchOrderBook(ccxtSymbol, limit) - const market = this.markets[ccxtSymbol] + try { + const book = await this.exchange.fetchOrderBook(ccxtSymbol, limit) + const market = this.markets[ccxtSymbol] - return { - contract: market - ? marketToContract(market, this.exchangeName) - : contract, - bids: book.bids.map(([p, a]) => [p ?? 0, a ?? 0] as OrderBookLevel), - asks: book.asks.map(([p, a]) => [p ?? 0, a ?? 0] as OrderBookLevel), - timestamp: new Date(book.timestamp ?? Date.now()), + return { + contract: market + ? marketToContract(market, this.exchangeName) + : contract, + bids: book.bids.map(([p, a]) => [p ?? 0, a ?? 0] as OrderBookLevel), + asks: book.asks.map(([p, a]) => [p ?? 0, a ?? 0] as OrderBookLevel), + timestamp: new Date(book.timestamp ?? Date.now()), + } + } catch (err) { + throw BrokerError.from(err) } } } diff --git a/src/domain/trading/brokers/ccxt/CcxtPlatform.ts b/src/domain/trading/brokers/ccxt/CcxtPlatform.ts deleted file mode 100644 index bf52cb2f..00000000 --- a/src/domain/trading/brokers/ccxt/CcxtPlatform.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { IPlatform, PlatformCredentials } from '../factory.js' -import { CcxtBroker } from './CcxtBroker.js' - -export interface CcxtPlatformConfig { - id: string - label?: string - exchange: string - sandbox: boolean - demoTrading?: boolean - options?: Record -} - -export class CcxtPlatform implements IPlatform { - readonly id: string - readonly label: string - readonly providerType: string - - private readonly config: CcxtPlatformConfig - - constructor(config: CcxtPlatformConfig) { - this.config = config - this.id = config.id - this.providerType = config.exchange - const exchangeLabel = config.exchange.charAt(0).toUpperCase() + config.exchange.slice(1) - this.label = config.label ?? `${exchangeLabel} (${config.sandbox ? 'testnet' : 'live'})` - } - - createAccount(credentials: PlatformCredentials): CcxtBroker { - return new CcxtBroker({ - id: credentials.id, - label: credentials.label, - exchange: this.config.exchange, - apiKey: credentials.apiKey ?? '', - apiSecret: credentials.apiSecret ?? '', - password: credentials.password, - sandbox: this.config.sandbox, - demoTrading: this.config.demoTrading, - options: this.config.options, - }) - } -} diff --git a/src/domain/trading/brokers/ccxt/ccxt-tools.ts b/src/domain/trading/brokers/ccxt/ccxt-tools.ts index d33f3b5f..b91fefdb 100644 --- a/src/domain/trading/brokers/ccxt/ccxt-tools.ts +++ b/src/domain/trading/brokers/ccxt/ccxt-tools.ts @@ -28,7 +28,7 @@ export function createCcxtProviderTools(manager: AccountManager) { return { getFundingRate: tool({ - description: `Query the current funding rate for a perpetual contract. + description: `Query the current funding rate for a perpetual contract (CCXT/crypto accounts only). Returns: - fundingRate: current/latest funding rate (e.g. 0.0001 = 0.01%) @@ -53,7 +53,7 @@ Use searchContracts first to get the aliceId.`, }), getOrderBook: tool({ - description: `Query the order book (market depth) for a contract. + description: `Query the order book (market depth) for a contract (CCXT/crypto accounts only). Returns bids and asks sorted by price. Each level is [price, amount]. Use this to evaluate liquidity and potential slippage before placing large orders. diff --git a/src/domain/trading/brokers/ccxt/ccxt-types.ts b/src/domain/trading/brokers/ccxt/ccxt-types.ts index d854639f..d5f4719d 100644 --- a/src/domain/trading/brokers/ccxt/ccxt-types.ts +++ b/src/domain/trading/brokers/ccxt/ccxt-types.ts @@ -23,3 +23,33 @@ export interface CcxtMarket { export const MAX_INIT_RETRIES = 8 export const INIT_RETRY_BASE_MS = 500 + +// ==================== CCXT-specific types (not part of IBroker) ==================== + +import type { Contract } from '@traderalice/ibkr' +import type { Position } from '../types.js' + +/** Position with crypto-specific fields (leverage, margin, liquidation). */ +export interface CcxtPosition extends Position { + leverage?: number + margin?: number + liquidationPrice?: number +} + +export interface FundingRate { + contract: Contract + fundingRate: number + nextFundingTime?: Date + previousFundingRate?: number + timestamp: Date +} + +/** [price, amount] */ +export type OrderBookLevel = [price: number, amount: number] + +export interface OrderBook { + contract: Contract + bids: OrderBookLevel[] + asks: OrderBookLevel[] + timestamp: Date +} diff --git a/src/domain/trading/brokers/factory.ts b/src/domain/trading/brokers/factory.ts index c81ce487..0dad641d 100644 --- a/src/domain/trading/brokers/factory.ts +++ b/src/domain/trading/brokers/factory.ts @@ -1,94 +1,46 @@ /** - * Broker Factory — creates broker instances from config. + * Broker Factory — creates broker instances from account config. * - * IPlatform defines HOW to connect (exchange type, sandbox, etc.). - * Multiple accounts can share one platform, each with individual credentials. + * Single function: AccountConfig → IBroker. No intermediate platform layer. */ import type { IBroker } from './types.js' -import { CcxtPlatform } from './ccxt/CcxtPlatform.js' -import { AlpacaPlatform } from './alpaca/AlpacaPlatform.js' -import type { PlatformConfig, AccountConfig } from '../../../core/config.js' +import { CcxtBroker } from './ccxt/CcxtBroker.js' +import { AlpacaBroker } from './alpaca/AlpacaBroker.js' +import { IbkrBroker } from './ibkr/IbkrBroker.js' +import type { AccountConfig } from '../../../core/config.js' -// ==================== Platform ==================== - -/** Credentials passed to IPlatform.createAccount(). */ -export interface PlatformCredentials { - id: string - label?: string - apiKey?: string - apiSecret?: string - password?: string -} - -export interface IPlatform { - /** Unique platform id, e.g. "bybit-swap", "alpaca-paper". */ - readonly id: string - - /** Human-readable name, e.g. "Bybit USDT Perps". */ - readonly label: string - - /** - * Provider class tag. Matches IBroker.provider on created accounts. - * CcxtPlatform → exchange name (e.g. "bybit"). - * AlpacaPlatform → "alpaca". - */ - readonly providerType: string - - /** Create a new IBroker instance from per-account credentials. */ - createAccount(credentials: PlatformCredentials): IBroker -} - -// ==================== Config → Platform/Broker helpers ==================== - -/** Create an IPlatform from a parsed PlatformConfig. */ -export function createPlatformFromConfig(config: PlatformConfig): IPlatform { +/** Create an IBroker from a merged account config. */ +export function createBroker(config: AccountConfig): IBroker { switch (config.type) { case 'ccxt': - return new CcxtPlatform({ + return new CcxtBroker({ id: config.id, label: config.label, exchange: config.exchange, sandbox: config.sandbox, demoTrading: config.demoTrading, options: config.options, + apiKey: config.apiKey ?? '', + apiSecret: config.apiSecret ?? '', + password: config.password, }) case 'alpaca': - return new AlpacaPlatform({ + return new AlpacaBroker({ id: config.id, label: config.label, + apiKey: config.apiKey ?? '', + secretKey: config.apiSecret ?? '', paper: config.paper, }) - } -} - -/** Create an IBroker from a platform + account config. */ -export function createBrokerFromConfig( - platform: IPlatform, - accountConfig: AccountConfig, -): IBroker { - const credentials: PlatformCredentials = { - id: accountConfig.id, - label: accountConfig.label, - apiKey: accountConfig.apiKey, - apiSecret: accountConfig.apiSecret, - password: accountConfig.password, - } - return platform.createAccount(credentials) -} - -/** Validate that all account platformId references resolve to a known platform. */ -export function validatePlatformRefs( - platforms: IPlatform[], - accounts: AccountConfig[], -): void { - const platformIds = new Set(platforms.map((p) => p.id)) - for (const acc of accounts) { - if (!platformIds.has(acc.platformId)) { - throw new Error( - `Account "${acc.id}" references unknown platformId "${acc.platformId}". ` + - `Available platforms: ${[...platformIds].join(', ')}`, - ) - } + case 'ibkr': + return new IbkrBroker({ + id: config.id, + label: config.label, + host: config.host, + port: config.port, + clientId: config.clientId, + accountId: config.accountId, + }) } } diff --git a/src/domain/trading/brokers/ibkr/IbkrBroker.ts b/src/domain/trading/brokers/ibkr/IbkrBroker.ts new file mode 100644 index 00000000..8e459d74 --- /dev/null +++ b/src/domain/trading/brokers/ibkr/IbkrBroker.ts @@ -0,0 +1,291 @@ +/** + * IbkrBroker — IBroker adapter for Interactive Brokers TWS/Gateway. + * + * Bridges the callback-based @traderalice/ibkr SDK to the Promise-based + * IBroker interface via RequestBridge. + * + * Key differences from Alpaca/CCXT brokers: + * - Single TCP socket with reqId multiplexing (not REST) + * - No API key — auth handled by TWS/Gateway GUI login + * - IBKR Contract/Order types ARE our native types — zero translation + * - Order IDs are numeric, assigned by TWS (nextValidId) + */ + +import Decimal from 'decimal.js' +import { + EClient, + Contract, + Order, + OrderCancel, + OrderState, + type ContractDescription, + type ContractDetails, +} from '@traderalice/ibkr' +import { + BrokerError, + type IBroker, + type AccountCapabilities, + type AccountInfo, + type Position, + type PlaceOrderResult, + type OpenOrder, + type Quote, + type MarketClock, +} from '../types.js' +import '../../contract-ext.js' +import { RequestBridge } from './request-bridge.js' +import { resolveSymbol } from './ibkr-contracts.js' +import type { IbkrBrokerConfig, AccountDownloadResult } from './ibkr-types.js' + +export class IbkrBroker implements IBroker { + readonly id: string + readonly label: string + + private bridge: RequestBridge + private client: EClient + private readonly config: IbkrBrokerConfig + private accountId: string | null = null + + constructor(config: IbkrBrokerConfig) { + this.config = config + this.id = config.id ?? 'ibkr' + this.label = config.label ?? 'Interactive Brokers' + this.bridge = new RequestBridge() + this.client = new EClient(this.bridge) + } + + // ==================== Lifecycle ==================== + + async init(): Promise { + const host = this.config.host ?? '127.0.0.1' + const port = this.config.port ?? 7497 + const clientId = this.config.clientId ?? 0 + + try { + await this.bridge.waitForConnect(this.client, host, port, clientId) + } catch (err) { + throw BrokerError.from(err, 'NETWORK') + } + + // Resolve account ID + this.accountId = this.config.accountId ?? this.bridge.getAccountId() + if (!this.accountId) { + throw new BrokerError('CONFIG', 'No account detected from TWS/Gateway. Set accountId in config for multi-account setups.') + } + + // Verify connection by fetching account data + try { + await this.getAccount() + console.log(`IbkrBroker[${this.id}]: connected (account=${this.accountId}, host=${host}:${port}, clientId=${clientId})`) + } catch (err) { + throw BrokerError.from(err, 'NETWORK') + } + } + + async close(): Promise { + this.client.disconnect() + } + + // ==================== Contract search ==================== + + async searchContracts(pattern: string): Promise { + if (!pattern) return [] + const reqId = this.bridge.allocReqId() + const promise = this.bridge.request(reqId) + this.client.reqMatchingSymbols(reqId, pattern) + return promise + } + + async getContractDetails(query: Contract): Promise { + const reqId = this.bridge.allocReqId() + const promise = this.bridge.requestCollector(reqId) + this.client.reqContractDetails(reqId, query) + const results = await promise + return results[0] ?? null + } + + // ==================== Trading operations ==================== + + async placeOrder(contract: Contract, order: Order): Promise { + try { + const orderId = this.bridge.getNextOrderId() + const promise = this.bridge.requestOrder(orderId) + this.client.placeOrder(orderId, contract, order) + const result = await promise + return { + success: true, + orderId: String(orderId), + orderState: result.orderState, + } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async modifyOrder(orderId: string, changes: Partial): Promise { + try { + // IBKR modifies orders by re-calling placeOrder with the same orderId + const original = await this.getOrder(orderId) + if (!original) { + return { success: false, error: `Order ${orderId} not found` } + } + + // Merge changes into the original order + const mergedOrder = original.order + if (changes.totalQuantity != null) mergedOrder.totalQuantity = changes.totalQuantity + if (changes.lmtPrice != null) mergedOrder.lmtPrice = changes.lmtPrice + if (changes.auxPrice != null) mergedOrder.auxPrice = changes.auxPrice + if (changes.tif) mergedOrder.tif = changes.tif + if (changes.orderType) mergedOrder.orderType = changes.orderType + if (changes.trailingPercent != null) mergedOrder.trailingPercent = changes.trailingPercent + + const numericId = parseInt(orderId, 10) + const promise = this.bridge.requestOrder(numericId) + this.client.placeOrder(numericId, original.contract, mergedOrder) + const result = await promise + + return { + success: true, + orderId, + orderState: result.orderState, + } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async cancelOrder(orderId: string, orderCancel?: OrderCancel): Promise { + try { + const numericId = parseInt(orderId, 10) + const promise = this.bridge.requestOrder(numericId) + this.client.cancelOrder(numericId, orderCancel ?? new OrderCancel()) + await promise + + const os = new OrderState() + os.status = 'Cancelled' + return { success: true, orderId, orderState: os } + } catch (err) { + return { success: false, error: err instanceof Error ? err.message : String(err) } + } + } + + async closePosition(contract: Contract, quantity?: Decimal): Promise { + const symbol = resolveSymbol(contract) + if (!symbol) { + return { success: false, error: 'Cannot resolve contract symbol' } + } + + // Find current position to determine side + const positions = await this.getPositions() + const pos = positions.find(p => + (contract.conId && p.contract.conId === contract.conId) || + resolveSymbol(p.contract) === symbol, + ) + if (!pos) { + return { success: false, error: `No position for ${symbol}` } + } + + const order = new Order() + order.action = pos.side === 'long' ? 'SELL' : 'BUY' + order.orderType = 'MKT' + order.totalQuantity = quantity ?? pos.quantity + order.tif = 'DAY' + + return this.placeOrder(contract, order) + } + + // ==================== Queries ==================== + + async getAccount(): Promise { + const download = await this.downloadAccount() + + return { + netLiquidation: parseFloat(download.values.get('NetLiquidation') ?? '0'), + totalCashValue: parseFloat(download.values.get('TotalCashValue') ?? '0'), + unrealizedPnL: parseFloat(download.values.get('UnrealizedPnL') ?? '0'), + realizedPnL: parseFloat(download.values.get('RealizedPnL') ?? '0'), + buyingPower: parseFloat(download.values.get('BuyingPower') ?? '0'), + initMarginReq: parseFloat(download.values.get('InitMarginReq') ?? '0'), + maintMarginReq: parseFloat(download.values.get('MaintMarginReq') ?? '0'), + dayTradesRemaining: parseInt(download.values.get('DayTradesRemaining') ?? '0', 10), + } + } + + async getPositions(): Promise { + const download = await this.downloadAccount() + return download.positions + } + + async getOrders(orderIds: string[]): Promise { + const allOrders = await this.bridge.requestOpenOrders() + return allOrders + .filter(o => orderIds.includes(String(o.order.orderId))) + .map(o => ({ + contract: o.contract, + order: o.order, + orderState: o.orderState, + })) + } + + async getOrder(orderId: string): Promise { + const results = await this.getOrders([orderId]) + return results[0] ?? null + } + + async getQuote(contract: Contract): Promise { + const reqId = this.bridge.allocReqId() + const promise = this.bridge.requestSnapshot(reqId) + this.client.reqMktData(reqId, contract, '', true, false, []) + const snap = await promise + + return { + contract, + last: snap.last ?? 0, + bid: snap.bid ?? 0, + ask: snap.ask ?? 0, + volume: snap.volume ?? 0, + high: snap.high, + low: snap.low, + timestamp: snap.lastTimestamp ? new Date(snap.lastTimestamp * 1000) : new Date(), + } + } + + async getMarketClock(): Promise { + const serverTime = await this.bridge.requestCurrentTime() + const now = new Date(serverTime * 1000) + + // NYSE hours: Mon-Fri 9:30-16:00 ET + const etParts = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/New_York', + hour: 'numeric', + minute: 'numeric', + hour12: false, + weekday: 'short', + }).formatToParts(now) + + const weekday = etParts.find(p => p.type === 'weekday')?.value + const hour = parseInt(etParts.find(p => p.type === 'hour')?.value ?? '0', 10) + const minute = parseInt(etParts.find(p => p.type === 'minute')?.value ?? '0', 10) + + const isWeekday = !['Sat', 'Sun'].includes(weekday ?? '') + const timeMinutes = hour * 60 + minute + const isOpen = isWeekday && timeMinutes >= 570 && timeMinutes < 960 // 9:30-16:00 + + return { isOpen, timestamp: now } + } + + // ==================== Capabilities ==================== + + getCapabilities(): AccountCapabilities { + return { + supportedSecTypes: ['STK', 'OPT', 'FUT', 'FOP', 'CASH', 'WAR', 'BOND'], + supportedOrderTypes: ['MKT', 'LMT', 'STP', 'STP LMT', 'TRAIL', 'MOC', 'LOC', 'REL'], + } + } + + // ==================== Internal ==================== + + private downloadAccount(): Promise { + return this.bridge.requestAccountDownload(this.accountId!) + } +} diff --git a/src/domain/trading/brokers/ibkr/ibkr-contracts.ts b/src/domain/trading/brokers/ibkr/ibkr-contracts.ts new file mode 100644 index 00000000..2872041a --- /dev/null +++ b/src/domain/trading/brokers/ibkr/ibkr-contracts.ts @@ -0,0 +1,80 @@ +/** + * Contract helpers and IBKR error classification. + * + * Unlike Alpaca/CCXT, IBKR contracts ARE our native Contract type — + * no translation layer is needed. Helpers just ensure required fields are set. + */ + +import { Contract } from '@traderalice/ibkr' +import { BrokerError, type BrokerErrorCode } from '../types.js' +import '../../contract-ext.js' + +/** Build a standard IBKR Contract (defaults: STK + SMART + USD). */ +export function makeContract( + symbol: string, + secType = 'STK', + exchange = 'SMART', + currency = 'USD', +): Contract { + const c = new Contract() + c.symbol = symbol + c.secType = secType + c.exchange = exchange + c.currency = currency + return c +} + +/** + * Resolve a Contract to a display symbol string. + * Prefers localSymbol > symbol. Returns null if neither is set. + */ +export function resolveSymbol(contract: Contract): string | null { + return contract.localSymbol || contract.symbol || null +} + +// ==================== IBKR error classification ==================== + +/** + * Classify an IBKR TWS error code into a BrokerError. + * + * TWS errors follow a numeric code system: + * - Codes < 1000: request-level errors (order rejected, contract not found, etc.) + * - Codes 1100-1300: system/connectivity events + * - Codes >= 2000: informational (data farm status, market data messages) + */ +export function classifyIbkrError(code: number, msg: string): BrokerError { + const classified = classifyCode(code, msg) + return new BrokerError(classified, `IBKR error ${code}: ${msg}`) +} + +function classifyCode(code: number, msg: string): BrokerErrorCode { + // Network / connectivity + if (code === 502) return 'NETWORK' // Couldn't connect to TWS + if (code === 504) return 'NETWORK' // Not connected + if (code === 1100) return 'NETWORK' // Connectivity between IB and TWS has been lost + if (code === 1101) return 'NETWORK' // Connectivity restored (data maintained) + if (code === 1102) return 'NETWORK' // Connectivity restored (data lost) + + // Authentication + if (code === 326) return 'AUTH' // Unable to connect as client ID is already in use + + // Market closed — check message content for TWS order warnings + if (code === 399 && /outside.*trading.*hours/i.test(msg)) return 'MARKET_CLOSED' + if (/market.*closed|not.*open|trading.*halt/i.test(msg)) return 'MARKET_CLOSED' + + // Exchange-level rejections + if (code === 200) return 'EXCHANGE' // No security definition found + if (code === 201) return 'EXCHANGE' // Order rejected + if (code === 202) return 'EXCHANGE' // Order cancelled + if (code === 103) return 'EXCHANGE' // Duplicate order id + if (code === 104) return 'EXCHANGE' // Can't modify a filled order + if (code === 105) return 'EXCHANGE' // Order being modified doesn't match + if (code === 110) return 'EXCHANGE' // Price does not conform to min tick + if (code === 135) return 'EXCHANGE' // Can't find order + if (code === 136) return 'EXCHANGE' // Can't cancel order (already cancelled/filled) + if (code === 161) return 'EXCHANGE' // Cancel attempted when not connected + if (code === 162) return 'EXCHANGE' // Historical data query error + if (code === 354) return 'EXCHANGE' // Market data not subscribed + + return 'UNKNOWN' +} diff --git a/src/domain/trading/brokers/ibkr/ibkr-types.ts b/src/domain/trading/brokers/ibkr/ibkr-types.ts new file mode 100644 index 00000000..04225d04 --- /dev/null +++ b/src/domain/trading/brokers/ibkr/ibkr-types.ts @@ -0,0 +1,57 @@ +/** + * IbkrBroker configuration and internal types. + * + * IBKR has no API key/secret — authentication is handled by TWS/Gateway login. + * Config only needs connection parameters. + */ + +import type { Contract, Order, OrderState } from '@traderalice/ibkr' +import type { Position } from '../types.js' + +// ==================== Config ==================== + +export interface IbkrBrokerConfig { + id?: string + label?: string + /** TWS/Gateway host. Default: 127.0.0.1 */ + host?: string + /** TWS/Gateway port. Default: 7497 (TWS paper) */ + port?: number + /** Client ID (0-32). Default: 0 */ + clientId?: number + /** IB account code (e.g. "DU12345"). Auto-detected from managedAccounts if omitted. */ + accountId?: string +} + +// ==================== Internal bridge types ==================== + +/** Pending request entry in the reqId-based map. */ +export interface PendingRequest { + resolve: (value: T) => void + reject: (error: Error) => void + timer: ReturnType +} + +/** Accumulated tick data for a snapshot quote request. */ +export interface TickSnapshot { + bid?: number + ask?: number + last?: number + volume?: number + high?: number + low?: number + lastTimestamp?: number +} + +/** Result of an account download (reqAccountUpdates round-trip). */ +export interface AccountDownloadResult { + values: Map + positions: Position[] +} + +/** Collected open order from openOrder callback. */ +export interface CollectedOpenOrder { + contract: Contract + order: Order + orderState: OrderState +} diff --git a/src/domain/trading/brokers/ibkr/index.ts b/src/domain/trading/brokers/ibkr/index.ts new file mode 100644 index 00000000..b769577f --- /dev/null +++ b/src/domain/trading/brokers/ibkr/index.ts @@ -0,0 +1,2 @@ +export { IbkrBroker } from './IbkrBroker.js' +export type { IbkrBrokerConfig } from './ibkr-types.js' diff --git a/src/domain/trading/brokers/ibkr/request-bridge.ts b/src/domain/trading/brokers/ibkr/request-bridge.ts new file mode 100644 index 00000000..9404200f --- /dev/null +++ b/src/domain/trading/brokers/ibkr/request-bridge.ts @@ -0,0 +1,543 @@ +/** + * RequestBridge — callback→Promise bridging layer for IBKR TWS API. + * + * Extends DefaultEWrapper to intercept TWS callbacks and route them + * to pending Promises. Three routing modes: + * + * A) reqId-based: symbolSamples, contractDetails, accountSummary, tickSnapshot + * B) orderId-based: openOrder, orderStatus (for placeOrder/cancelOrder) + * C) Single-slot: accountDownload (updatePortfolio/updateAccountValue), openOrders batch + */ + +import Decimal from 'decimal.js' +import { + DefaultEWrapper, + NO_VALID_ID, + TickTypeEnum, + Contract as ContractClass, + Order as OrderClass, + OrderState as OrderStateClass, + type Contract, + type ContractDescription, + type ContractDetails, + type Order, + type OrderState, + type EClient, + type TickAttrib, +} from '@traderalice/ibkr' +import { BrokerError } from '../types.js' +import { classifyIbkrError } from './ibkr-contracts.js' +import type { + PendingRequest, + TickSnapshot, + AccountDownloadResult, + CollectedOpenOrder, +} from './ibkr-types.js' + +const DEFAULT_TIMEOUT_MS = 10_000 +const ACCOUNT_DOWNLOAD_TIMEOUT_MS = 20_000 + +export class RequestBridge extends DefaultEWrapper { + // ---- State ---- + private nextReqId_ = 10_000 + private nextOrderId_ = 0 + private accountId_: string | null = null + private client_: EClient | null = null + + // ---- Mode A: reqId-based pending requests ---- + private pending = new Map() + private collectors = new Map() + + // ---- Mode A: tick snapshot accumulators ---- + private snapshots = new Map() + + // ---- Mode B: orderId-based pending requests ---- + private orderPending = new Map>() + + // ---- Mode C: single-slot collectors ---- + private accountDownload: { + positions: Array<{ + contract: Contract + side: 'long' | 'short' + quantity: Decimal + avgCost: number + marketPrice: number + marketValue: number + unrealizedPnL: number + realizedPnL: number + }> + values: Map + resolve: (result: AccountDownloadResult) => void + reject: (err: Error) => void + timer: ReturnType + } | null = null + private accountDownloadLock: Promise | null = null + + private openOrdersCollector: { + orders: CollectedOpenOrder[] + resolve: (orders: CollectedOpenOrder[]) => void + reject: (err: Error) => void + timer: ReturnType + } | null = null + + // ---- Connection handshake ---- + private connectResolve: (() => void) | null = null + private connectReject: ((err: Error) => void) | null = null + + // ---- Current time request ---- + private currentTimePending: PendingRequest | null = null + + // ==================== Public API ==================== + + /** Store reference to the EClient for unsubscribe calls. */ + setClient(client: EClient): void { + this.client_ = client + } + + /** Allocate a unique reqId (starts at 10000 to avoid orderId range). */ + allocReqId(): number { + return this.nextReqId_++ + } + + /** Get and increment the next valid order ID. */ + getNextOrderId(): number { + return this.nextOrderId_++ + } + + /** Get the auto-detected account ID from managedAccounts callback. */ + getAccountId(): string | null { + return this.accountId_ + } + + // ---- Connection ---- + + /** Connect the EClient and wait for nextValidId (indicates TWS is ready). */ + async waitForConnect( + client: EClient, + host: string, + port: number, + clientId: number, + timeoutMs = 15_000, + ): Promise { + this.client_ = client + + const promise = new Promise((resolve, reject) => { + this.connectResolve = resolve + this.connectReject = reject + setTimeout(() => { + this.connectResolve = null + this.connectReject = null + reject(new BrokerError('NETWORK', `Connection to TWS/Gateway timed out after ${timeoutMs}ms`)) + }, timeoutMs) + }) + + await client.connect(host, port, clientId) + return promise + } + + // ---- Mode A: reqId-based requests ---- + + /** Register a pending request that resolves with a single value. */ + request(reqId: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(reqId) + reject(new BrokerError('NETWORK', `Request ${reqId} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + this.pending.set(reqId, { resolve: resolve as (v: unknown) => void, reject, timer }) + }) + } + + /** Register a pending request that collects multiple callbacks into an array. */ + requestCollector(reqId: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + this.collectors.set(reqId, []) + return this.request(reqId, timeoutMs) + } + + /** Register a snapshot market data request. */ + requestSnapshot(reqId: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + this.snapshots.set(reqId, {}) + return this.request(reqId, timeoutMs) + } + + // ---- Mode B: orderId-based requests ---- + + /** Register a pending order request (waits for openOrder callback). */ + requestOrder(orderId: number, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.orderPending.delete(orderId) + reject(new BrokerError('NETWORK', `Order ${orderId} timed out after ${timeoutMs}ms`)) + }, timeoutMs) + this.orderPending.set(orderId, { resolve, reject, timer }) + }) + } + + // ---- Mode C: single-slot requests ---- + + /** Request account download (positions + account values). Serial access via lock. */ + async requestAccountDownload(acctCode: string, timeoutMs = ACCOUNT_DOWNLOAD_TIMEOUT_MS): Promise { + // Queue behind any in-flight download + if (this.accountDownloadLock) { + await this.accountDownloadLock.catch(() => {}) + } + + const promise = new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.accountDownload = null + this.client_?.reqAccountUpdates(false, acctCode) + reject(new BrokerError('NETWORK', `Account download timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + this.accountDownload = { + positions: [], + values: new Map(), + resolve, + reject, + timer, + } + }) + + this.accountDownloadLock = promise.finally(() => { + this.accountDownloadLock = null + }) + + this.client_!.reqAccountUpdates(true, acctCode) + return promise + } + + /** Request all open orders (batch collector). */ + requestOpenOrders(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.openOrdersCollector = null + reject(new BrokerError('NETWORK', `Open orders request timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + this.openOrdersCollector = { orders: [], resolve, reject, timer } + this.client_!.reqOpenOrders() + }) + } + + /** Request current TWS server time. */ + requestCurrentTime(timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.currentTimePending = null + reject(new BrokerError('NETWORK', `currentTime request timed out`)) + }, timeoutMs) + + this.currentTimePending = { resolve: resolve as (v: unknown) => void, reject, timer } + this.client_!.reqCurrentTime() + }) + } + + // ==================== Internal helpers ==================== + + private resolveRequest(reqId: number, value: unknown): void { + const entry = this.pending.get(reqId) + if (!entry) return + clearTimeout(entry.timer) + this.pending.delete(reqId) + this.collectors.delete(reqId) + this.snapshots.delete(reqId) + entry.resolve(value) + } + + private rejectRequest(reqId: number, error: Error): void { + const entry = this.pending.get(reqId) + if (!entry) return + clearTimeout(entry.timer) + this.pending.delete(reqId) + this.collectors.delete(reqId) + this.snapshots.delete(reqId) + entry.reject(error) + } + + private pushCollector(reqId: number, item: unknown): void { + this.collectors.get(reqId)?.push(item) + } + + private resolveCollector(reqId: number): void { + this.resolveRequest(reqId, this.collectors.get(reqId) ?? []) + } + + private resolveOrderRequest(orderId: number, value: CollectedOpenOrder): void { + const entry = this.orderPending.get(orderId) + if (!entry) return + clearTimeout(entry.timer) + this.orderPending.delete(orderId) + entry.resolve(value) + } + + private rejectOrderRequest(orderId: number, error: Error): void { + const entry = this.orderPending.get(orderId) + if (!entry) return + clearTimeout(entry.timer) + this.orderPending.delete(orderId) + entry.reject(error) + } + + private rejectAll(error: Error): void { + for (const [, entry] of this.pending) { + clearTimeout(entry.timer) + entry.reject(error) + } + this.pending.clear() + this.collectors.clear() + this.snapshots.clear() + + for (const [, entry] of this.orderPending) { + clearTimeout(entry.timer) + entry.reject(error) + } + this.orderPending.clear() + + if (this.accountDownload) { + clearTimeout(this.accountDownload.timer) + this.accountDownload.reject(error) + this.accountDownload = null + } + + if (this.openOrdersCollector) { + clearTimeout(this.openOrdersCollector.timer) + this.openOrdersCollector.reject(error) + this.openOrdersCollector = null + } + + if (this.currentTimePending) { + clearTimeout(this.currentTimePending.timer) + this.currentTimePending.reject(error) + this.currentTimePending = null + } + } + + // ==================== EWrapper callback overrides ==================== + + // ---- Connection ---- + + override nextValidId(orderId: number): void { + this.nextOrderId_ = orderId + // Resolve the connect promise (TWS is ready) + if (this.connectResolve) { + this.connectResolve() + this.connectResolve = null + this.connectReject = null + } + } + + override managedAccounts(accountsList: string): void { + const accounts = accountsList.split(',').map(s => s.trim()).filter(Boolean) + this.accountId_ = accounts[0] ?? null + } + + override connectionClosed(): void { + this.rejectAll(new BrokerError('NETWORK', 'Connection to TWS/Gateway lost')) + + if (this.connectReject) { + this.connectReject(new BrokerError('NETWORK', 'Connection to TWS/Gateway closed during handshake')) + this.connectResolve = null + this.connectReject = null + } + } + + // ---- Error routing ---- + + override error(reqId: number, _errorTime: number, errorCode: number, errorString: string): void { + // Informational messages (code >= 2000) — data farm status, etc. + if (errorCode >= 2000) return + + // System-level errors (reqId === -1) — connectivity events + if (reqId === NO_VALID_ID) { + if (errorCode === 502 || errorCode === 504 || errorCode === 1100) { + // These will be followed by connectionClosed() which rejects all + } + return + } + + // Request-specific errors — reject the corresponding pending Promise + const brokerError = classifyIbkrError(errorCode, errorString) + + // Try reqId-based first, then orderId-based + if (this.pending.has(reqId)) { + this.rejectRequest(reqId, brokerError) + } else if (this.orderPending.has(reqId)) { + this.rejectOrderRequest(reqId, brokerError) + } + } + + // ---- Contract search (symbolSamples) ---- + + override symbolSamples(_reqId: number, contractDescriptions: ContractDescription[]): void { + this.resolveRequest(_reqId, contractDescriptions) + } + + // ---- Contract details (collector) ---- + + override contractDetails(reqId: number, cd: ContractDetails): void { + this.pushCollector(reqId, cd) + } + + override contractDetailsEnd(reqId: number): void { + this.resolveCollector(reqId) + } + + // ---- Account summary (collector using Map) ---- + + override accountSummary(reqId: number, _account: string, tag: string, value: string, _currency: string): void { + // For accountSummary we use the collectors map but store a Map + let map = this.collectors.get(reqId) as unknown as Map | undefined + if (!map) { + map = new Map() + this.collectors.set(reqId, map as unknown as unknown[]) + } + map.set(tag, value) + } + + override accountSummaryEnd(reqId: number): void { + // Resolve with the Map (stored in collectors slot) + this.resolveRequest(reqId, this.collectors.get(reqId) ?? new Map()) + } + + // ---- Account download (updatePortfolio + updateAccountValue) ---- + + override updatePortfolio( + contract: Contract, + position: Decimal, + marketPrice: number, + marketValue: number, + averageCost: number, + unrealizedPNL: number, + realizedPNL: number, + _accountName: string, + ): void { + if (!this.accountDownload) return + if (position.isZero()) return // no position + + this.accountDownload.positions.push({ + contract, + side: position.greaterThan(0) ? 'long' : 'short', + quantity: position.abs(), + avgCost: averageCost, + marketPrice, + marketValue: Math.abs(marketValue), + unrealizedPnL: unrealizedPNL, + realizedPnL: realizedPNL, + }) + } + + override updateAccountValue(key: string, val: string, _currency: string, _accountName: string): void { + this.accountDownload?.values.set(key, val) + } + + override accountDownloadEnd(_accountName: string): void { + if (!this.accountDownload) return + clearTimeout(this.accountDownload.timer) + + const result: AccountDownloadResult = { + values: this.accountDownload.values, + positions: this.accountDownload.positions, + } + + this.accountDownload.resolve(result) + this.accountDownload = null + + // Unsubscribe + this.client_?.reqAccountUpdates(false, _accountName) + } + + // ---- Market data snapshot ---- + + override tickPrice(reqId: number, tickType: number, price: number, _attrib: TickAttrib): void { + const snap = this.snapshots.get(reqId) + if (!snap) return + + switch (tickType) { + case TickTypeEnum.BID: snap.bid = price; break + case TickTypeEnum.ASK: snap.ask = price; break + case TickTypeEnum.LAST: snap.last = price; break + case TickTypeEnum.HIGH: snap.high = price; break + case TickTypeEnum.LOW: snap.low = price; break + } + } + + override tickSize(reqId: number, tickType: number, size: Decimal): void { + const snap = this.snapshots.get(reqId) + if (!snap) return + + if (tickType === TickTypeEnum.VOLUME) { + snap.volume = size.toNumber() + } + } + + override tickString(reqId: number, tickType: number, value: string): void { + const snap = this.snapshots.get(reqId) + if (!snap) return + + // TickType 45 = LAST_TIMESTAMP + if (tickType === 45) { + snap.lastTimestamp = parseInt(value, 10) + } + } + + override tickSnapshotEnd(reqId: number): void { + const snap = this.snapshots.get(reqId) ?? {} + this.snapshots.delete(reqId) + this.resolveRequest(reqId, snap) + } + + // ---- Orders ---- + + override openOrder(orderId: number, contract: Contract, order: Order, orderState: OrderState): void { + const collected: CollectedOpenOrder = { contract, order, orderState } + + // Route to pending order request (placeOrder/modifyOrder) + if (this.orderPending.has(orderId)) { + this.resolveOrderRequest(orderId, collected) + } + + // Also collect for openOrders batch + this.openOrdersCollector?.orders.push(collected) + } + + override orderStatus( + orderId: number, + status: string, + _filled: Decimal, + _remaining: Decimal, + _avgFillPrice: number, + _permId: number, + _parentId: number, + _lastFillPrice: number, + _clientId: number, + _whyHeld: string, + _mktCapPrice: number, + ): void { + // For cancel requests, we wait for status 'Cancelled' + if (this.orderPending.has(orderId) && status === 'Cancelled') { + const os = new OrderStateClass() + os.status = 'Cancelled' + this.resolveOrderRequest(orderId, { + contract: new ContractClass(), + order: new OrderClass(), + orderState: os, + }) + } + } + + override openOrderEnd(): void { + if (!this.openOrdersCollector) return + clearTimeout(this.openOrdersCollector.timer) + this.openOrdersCollector.resolve(this.openOrdersCollector.orders) + this.openOrdersCollector = null + } + + // ---- Current time ---- + + override currentTime(time: number): void { + if (!this.currentTimePending) return + clearTimeout(this.currentTimePending.timer) + this.currentTimePending.resolve(time) + this.currentTimePending = null + } +} diff --git a/src/domain/trading/brokers/index.ts b/src/domain/trading/brokers/index.ts index 69b586c0..d0fbc9db 100644 --- a/src/domain/trading/brokers/index.ts +++ b/src/domain/trading/brokers/index.ts @@ -6,26 +6,22 @@ export type { OpenOrder, AccountInfo, Quote, - FundingRate, - OrderBookLevel, - OrderBook, MarketClock, AccountCapabilities, } from './types.js' // Factory -export type { IPlatform, PlatformCredentials } from './factory.js' -export { createPlatformFromConfig, createBrokerFromConfig, validatePlatformRefs } from './factory.js' +export { createBroker } from './factory.js' // Alpaca export { AlpacaBroker } from './alpaca/index.js' export type { AlpacaBrokerConfig } from './alpaca/index.js' -export { AlpacaPlatform } from './alpaca/AlpacaPlatform.js' -export type { AlpacaPlatformConfig } from './alpaca/AlpacaPlatform.js' // CCXT export { CcxtBroker } from './ccxt/index.js' export { createCcxtProviderTools } from './ccxt/index.js' export type { CcxtBrokerConfig } from './ccxt/index.js' -export { CcxtPlatform } from './ccxt/CcxtPlatform.js' -export type { CcxtPlatformConfig } from './ccxt/CcxtPlatform.js' + +// IBKR +export { IbkrBroker } from './ibkr/index.js' +export type { IbkrBrokerConfig } from './ibkr/index.js' diff --git a/src/domain/trading/brokers/mock/MockBroker.spec.ts b/src/domain/trading/brokers/mock/MockBroker.spec.ts index 347e9b3c..f74dde08 100644 --- a/src/domain/trading/brokers/mock/MockBroker.spec.ts +++ b/src/domain/trading/brokers/mock/MockBroker.spec.ts @@ -213,15 +213,18 @@ describe('cancelOrder', () => { const placed = await broker.placeOrder(contract, order) const cancelled = await broker.cancelOrder(placed.orderId!) - expect(cancelled).toBe(true) + expect(cancelled.success).toBe(true) + expect(cancelled.orderId).toBe(placed.orderId) + expect(cancelled.orderState?.status).toBe('Cancelled') const brokerOrder = await broker.getOrder(placed.orderId!) expect(brokerOrder!.orderState.status).toBe('Cancelled') }) - it('returns false for unknown order', async () => { + it('returns error for unknown order', async () => { const result = await broker.cancelOrder('nonexistent') - expect(result).toBe(false) + expect(result.success).toBe(false) + expect(result.error).toContain('nonexistent') }) }) diff --git a/src/domain/trading/brokers/mock/MockBroker.ts b/src/domain/trading/brokers/mock/MockBroker.ts index ded86929..6c960485 100644 --- a/src/domain/trading/brokers/mock/MockBroker.ts +++ b/src/domain/trading/brokers/mock/MockBroker.ts @@ -92,7 +92,6 @@ export function makePosition(overrides: Partial = {}): Position { marketValue: 1600, unrealizedPnL: 100, realizedPnL: 0, - leverage: 1, ...overrides, } } @@ -251,14 +250,14 @@ export class MockBroker implements IBroker { return { success: true, orderId, orderState } } - async modifyOrder(orderId: string, changes: Order): Promise { + async modifyOrder(orderId: string, changes: Partial): Promise { this._record('modifyOrder', [orderId, changes]) const internal = this._orders.get(orderId) if (!internal || internal.status !== 'Submitted') { return { success: false, error: `Order ${orderId} not found or not pending` } } - if (!changes.totalQuantity.equals(UNSET_DECIMAL)) { + if (changes.totalQuantity != null && !changes.totalQuantity.equals(UNSET_DECIMAL)) { internal.order.totalQuantity = changes.totalQuantity } if (changes.lmtPrice !== UNSET_DOUBLE) { @@ -273,12 +272,16 @@ export class MockBroker implements IBroker { return { success: true, orderId, orderState } } - async cancelOrder(orderId: string): Promise { + async cancelOrder(orderId: string): Promise { this._record('cancelOrder', [orderId]) const internal = this._orders.get(orderId) - if (!internal || internal.status !== 'Submitted') return false + if (!internal || internal.status !== 'Submitted') { + return { success: false, error: `Order ${orderId} not found or not pending` } + } internal.status = 'Cancelled' - return true + const orderState = new OrderState() + orderState.status = 'Cancelled' + return { success: true, orderId, orderState } } async closePosition(contract: Contract, quantity?: Decimal): Promise { diff --git a/src/domain/trading/brokers/types.ts b/src/domain/trading/brokers/types.ts index cb1efa8c..ae775747 100644 --- a/src/domain/trading/brokers/types.ts +++ b/src/domain/trading/brokers/types.ts @@ -13,11 +13,12 @@ import '../contract-ext.js' // ==================== Errors ==================== -export type BrokerErrorCode = 'CONFIG' | 'AUTH' | 'NETWORK' | 'EXCHANGE' | 'UNKNOWN' +export type BrokerErrorCode = 'CONFIG' | 'AUTH' | 'NETWORK' | 'EXCHANGE' | 'MARKET_CLOSED' | 'UNKNOWN' /** - * Structured broker error. `permanent` errors (CONFIG, AUTH) will not be retried - * by UTA's recovery loop — transient errors (NETWORK, EXCHANGE) will. + * Structured broker error. + * - `permanent` errors (CONFIG, AUTH) disable the account — will not be retried. + * - Transient errors (NETWORK, EXCHANGE, MARKET_CLOSED) trigger auto-recovery. */ export class BrokerError extends Error { readonly code: BrokerErrorCode @@ -29,6 +30,33 @@ export class BrokerError extends Error { this.code = code this.permanent = code === 'CONFIG' || code === 'AUTH' } + + /** Wrap any error as a BrokerError, classifying by message patterns. */ + static from(err: unknown, fallbackCode: BrokerErrorCode = 'UNKNOWN'): BrokerError { + if (err instanceof BrokerError) return err + const msg = err instanceof Error ? err.message : String(err) + const code = BrokerError.classifyMessage(msg) ?? fallbackCode + const be = new BrokerError(code, msg) + if (err instanceof Error) be.cause = err + return be + } + + /** Infer error code from common message patterns. */ + private static classifyMessage(msg: string): BrokerErrorCode | null { + const m = msg.toLowerCase() + // Market closed — check before AUTH to avoid 403 misclassification + if (/market.?closed|not.?open|trading.?halt|outside.?trading.?hours/i.test(m)) return 'MARKET_CLOSED' + // Network / infrastructure + if (/timeout|etimedout|econnrefused|econnreset|socket hang up|enotfound|fetch failed/i.test(m)) return 'NETWORK' + if (/429|rate.?limit|too many requests/i.test(m)) return 'NETWORK' + if (/502|503|504|service.?unavailable|bad.?gateway/i.test(m)) return 'NETWORK' + // Authentication (401 only — 403 can mean market closed or permission denied) + if (/401|unauthorized|invalid.?key|invalid.?signature|authentication/i.test(m)) return 'AUTH' + // Exchange-level rejections + if (/403|forbidden/i.test(m)) return 'EXCHANGE' + if (/insufficient|not.?enough|margin/i.test(m)) return 'EXCHANGE' + return null + } } // ==================== Position ==================== @@ -46,9 +74,6 @@ export interface Position { marketValue: number unrealizedPnL: number realizedPnL: number - leverage?: number - margin?: number - liquidationPrice?: number } // ==================== Order result ==================== @@ -97,24 +122,6 @@ export interface Quote { timestamp: Date } -export interface FundingRate { - contract: Contract - fundingRate: number - nextFundingTime?: Date - previousFundingRate?: number - timestamp: Date -} - -/** [price, amount] */ -export type OrderBookLevel = [price: number, amount: number] - -export interface OrderBook { - contract: Contract - bids: OrderBookLevel[] - asks: OrderBookLevel[] - timestamp: Date -} - export interface MarketClock { isOpen: boolean nextOpen?: Date @@ -168,8 +175,8 @@ export interface IBroker { // ---- Trading operations (IBKR Order as source of truth) ---- placeOrder(contract: Contract, order: Order): Promise - modifyOrder(orderId: string, changes: Order): Promise - cancelOrder(orderId: string, orderCancel?: OrderCancel): Promise + modifyOrder(orderId: string, changes: Partial): Promise + cancelOrder(orderId: string, orderCancel?: OrderCancel): Promise closePosition(contract: Contract, quantity?: Decimal): Promise // ---- Queries ---- diff --git a/src/domain/trading/contract-ext.ts b/src/domain/trading/contract-ext.ts index 1ed77286..20d38f57 100644 --- a/src/domain/trading/contract-ext.ts +++ b/src/domain/trading/contract-ext.ts @@ -6,6 +6,12 @@ * * Constructed by UTA layer (not broker). Broker uses symbol/localSymbol for resolution. * The @traderalice/ibkr package stays a pure IBKR replica. + * + * localSymbol semantics by broker: + * - IBKR: exchange-native symbol (e.g., "AAPL", "ESZ4") + * - Alpaca: ticker symbol (e.g., "AAPL") + * - CCXT: unified market symbol (e.g., "ETH/USDT:USDT") + * UTA uses localSymbol as nativeKey in aliceId: "{utaId}|{nativeKey}" */ import '@traderalice/ibkr' diff --git a/src/domain/trading/git/TradingGit.spec.ts b/src/domain/trading/git/TradingGit.spec.ts index 81b0bf1f..8d5cb936 100644 --- a/src/domain/trading/git/TradingGit.spec.ts +++ b/src/domain/trading/git/TradingGit.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import Decimal from 'decimal.js' -import { Contract, Order } from '@traderalice/ibkr' +import { Contract, Order, OrderState } from '@traderalice/ibkr' import { TradingGit } from './TradingGit.js' import type { TradingGitConfig } from './interfaces.js' import type { Operation, GitState } from './types.js' @@ -224,6 +224,105 @@ describe('TradingGit', () => { expect(result.submitted).toHaveLength(1) expect(result.rejected).toHaveLength(0) }) + + it('maps Filled orderState to filled status', async () => { + const orderState = new OrderState() + orderState.status = 'Filled' + const filledConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: 'order-filled', + orderState, + }), + }) + const gitFilled = new TradingGit(filledConfig) + + gitFilled.add(buyOp()) + gitFilled.commit('market buy') + const result = await gitFilled.push() + + expect(result.submitted).toHaveLength(1) + expect(result.submitted[0].status).toBe('filled') + expect(result.rejected).toHaveLength(0) + }) + + it('maps Cancelled orderState to cancelled status', async () => { + const orderState = new OrderState() + orderState.status = 'Cancelled' + const cancelConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: 'order-cancel', + orderState, + }), + }) + const gitCancel = new TradingGit(cancelConfig) + + gitCancel.add({ action: 'cancelOrder', orderId: 'order-cancel' }) + gitCancel.commit('cancel order') + const result = await gitCancel.push() + + expect(result.submitted).toHaveLength(1) + expect(result.submitted[0].status).toBe('cancelled') + expect(result.rejected).toHaveLength(0) + }) + + it('defaults to submitted when no orderState', async () => { + const noStateConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: 'order-async', + }), + }) + const gitAsync = new TradingGit(noStateConfig) + + gitAsync.add(buyOp()) + gitAsync.commit('async limit') + const result = await gitAsync.push() + + expect(result.submitted).toHaveLength(1) + expect(result.submitted[0].status).toBe('submitted') + }) + + it('maps Inactive orderState to rejected status', async () => { + const orderState = new OrderState() + orderState.status = 'Inactive' + const inactiveConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: 'order-inactive', + orderState, + }), + }) + const gitInactive = new TradingGit(inactiveConfig) + + gitInactive.add(buyOp()) + gitInactive.commit('rejected by exchange') + const result = await gitInactive.push() + + // Inactive maps to rejected — but success is still true from broker + // so it lands in submitted (success-based), with status 'rejected' + expect(result.submitted).toHaveLength(1) + expect(result.submitted[0].status).toBe('rejected') + }) + + it('records failed cancelOrder in rejected array', async () => { + const failConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: false, + error: 'Order not found', + }), + }) + const gitFail = new TradingGit(failConfig) + + gitFail.add({ action: 'cancelOrder', orderId: 'nonexistent' }) + gitFail.commit('cancel unknown') + const result = await gitFail.push() + + expect(result.rejected).toHaveLength(1) + expect(result.rejected[0].error).toBe('Order not found') + expect(result.submitted).toHaveLength(0) + }) }) // ==================== log ==================== @@ -447,6 +546,26 @@ describe('TradingGit', () => { expect(gitP.getPendingOrderIds()).toHaveLength(0) }) + + it('excludes orders that were filled at push time (no sync needed)', async () => { + const orderState = new OrderState() + orderState.status = 'Filled' + const filledConfig = makeConfig({ + executeOperation: vi.fn().mockResolvedValue({ + success: true, + orderId: 'mkt-1', + orderState, + }), + }) + const gitP = new TradingGit(filledConfig) + + gitP.add(buyOp('AAPL')) + gitP.commit('market buy') + await gitP.push() + + // Filled at push time → should NOT appear as pending + expect(gitP.getPendingOrderIds()).toHaveLength(0) + }) }) // ==================== simulatePriceChange ==================== diff --git a/src/domain/trading/git/TradingGit.ts b/src/domain/trading/git/TradingGit.ts index f0ead315..d2d048f5 100644 --- a/src/domain/trading/git/TradingGit.ts +++ b/src/domain/trading/git/TradingGit.ts @@ -12,6 +12,7 @@ import type { CommitHash, Operation, OperationResult, + OperationStatus, AddResult, CommitPrepareResult, PushResult, @@ -134,8 +135,8 @@ export class TradingGit implements ITradingGit { this.pendingMessage = null this.pendingHash = null - const submitted = results.filter((r) => r.status === 'submitted') - const rejected = results.filter((r) => r.status === 'rejected' || !r.success) + const rejected = results.filter((r) => !r.success) + const submitted = results.filter((r) => r.success) return { hash, message, operationCount: operations.length, submitted, rejected } } @@ -609,14 +610,23 @@ export class TradingGit implements ITradingGit { const orderId = rawObj.orderId as string | undefined const orderState = rawObj.orderState as OperationResult['orderState'] - // Push only knows submitted or rejected — actual fill status comes from sync return { action: op.action, success: true, orderId, - status: 'submitted', + status: this.mapOrderStatus(orderState), orderState, raw, } } + + /** Map IBKR-style OrderState.status to OperationStatus. */ + private mapOrderStatus(orderState?: { status?: string }): OperationStatus { + switch (orderState?.status) { + case 'Filled': return 'filled' + case 'Cancelled': return 'cancelled' + case 'Inactive': return 'rejected' + default: return 'submitted' + } + } } diff --git a/src/domain/trading/index.ts b/src/domain/trading/index.ts index 9a431ff1..b137b4c5 100644 --- a/src/domain/trading/index.ts +++ b/src/domain/trading/index.ts @@ -21,24 +21,16 @@ export type { OpenOrder, AccountInfo, Quote, - FundingRate, - OrderBookLevel, - OrderBook, MarketClock, AccountCapabilities, } from './brokers/index.js' -export type { IPlatform, PlatformCredentials } from './brokers/index.js' export { - createPlatformFromConfig, - createBrokerFromConfig, - validatePlatformRefs, + createBroker, AlpacaBroker, - AlpacaPlatform, CcxtBroker, - CcxtPlatform, createCcxtProviderTools, } from './brokers/index.js' -export type { AlpacaBrokerConfig, AlpacaPlatformConfig, CcxtBrokerConfig, CcxtPlatformConfig } from './brokers/index.js' +export type { AlpacaBrokerConfig, CcxtBrokerConfig } from './brokers/index.js' // Trading-as-Git export { TradingGit } from './git/index.js' diff --git a/src/main.ts b/src/main.ts index dec377a6..64379dd9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { readFile, writeFile, appendFile, mkdir } from 'fs/promises' import { resolve, dirname } from 'path' // Engine removed — AgentCenter is the top-level AI entry point -import { loadConfig, loadTradingConfig } from './core/config.js' +import { loadConfig, readAccountsConfig } from './core/config.js' import type { Plugin, EngineContext, ReconnectResult } from './core/types.js' import { McpPlugin } from './server/mcp.js' import { TelegramPlugin } from './connectors/telegram/index.js' @@ -13,12 +13,11 @@ import { UnifiedTradingAccount, CcxtBroker, createCcxtProviderTools, - createPlatformFromConfig, - createBrokerFromConfig, - validatePlatformRefs, + createBroker, } from './domain/trading/index.js' import { createTradingTools } from './tool/trading.js' -import type { GitExportState, IPlatform } from './domain/trading/index.js' +import type { GitExportState } from './domain/trading/index.js' +import type { AccountConfig } from './core/config.js' import { Brain } from './domain/brain/index.js' import { createBrainTools } from './tool/brain.js' import type { BrainExportState } from './domain/brain/index.js' @@ -115,43 +114,28 @@ async function main() { const accountManager = new AccountManager() - // ==================== Platform-driven Account Init ==================== + // ==================== Account Init ==================== - const tradingConfig = await loadTradingConfig() - const platformRegistry = new Map() - for (const pc of tradingConfig.platforms) { - platformRegistry.set(pc.id, createPlatformFromConfig(pc)) - } - validatePlatformRefs([...platformRegistry.values()], tradingConfig.accounts) - - /** Create and register a UTA. Broker connection happens asynchronously inside UTA. */ - async function initAccount( - accountCfg: { id: string; platformId: string; guards: Array<{ type: string; options: Record }> }, - platform: IPlatform, - ): Promise { - const broker = createBrokerFromConfig(platform, accountCfg) - const savedState = await loadGitState(accountCfg.id) - const filePath = gitFilePath(accountCfg.id) + const accountConfigs = await readAccountsConfig() + + /** Create and register a UTA from account config. */ + async function initAccount(accCfg: AccountConfig): Promise { + const broker = createBroker(accCfg) + const savedState = await loadGitState(accCfg.id) const uta = new UnifiedTradingAccount(broker, { - guards: accountCfg.guards, + guards: accCfg.guards, savedState, - onCommit: createGitPersister(filePath), + onCommit: createGitPersister(gitFilePath(accCfg.id)), onHealthChange: (accountId, health) => { eventLog.append('account.health', { accountId, ...health }) }, - platformId: accountCfg.platformId, }) accountManager.add(uta) return uta } - for (const accCfg of tradingConfig.accounts) { - if (!accCfg.apiKey) { - console.warn(`Account "${accCfg.id}": no API key configured — skipping. Add credentials in the Trading page or accounts.json.`) - continue - } - const platform = platformRegistry.get(accCfg.platformId)! - await initAccount(accCfg, platform) + for (const accCfg of accountConfigs) { + await initAccount(accCfg) } // ==================== Brain ==================== @@ -323,8 +307,8 @@ async function main() { } reconnectingAccounts.add(accountId) try { - // Re-read trading config to pick up credential/guard changes - const freshTrading = await loadTradingConfig() + // Re-read config to pick up credential/guard changes + const freshAccounts = await readAccountsConfig() // Close old account const currentUta = accountManager.get(accountId) @@ -333,33 +317,18 @@ async function main() { accountManager.remove(accountId) } - // Find this account in fresh config - const accCfg = freshTrading.accounts.find((a) => a.id === accountId) + const accCfg = freshAccounts.find((a) => a.id === accountId) if (!accCfg) { return { success: true, message: `Account "${accountId}" not found in config (removed or disabled)` } } - // Build platform registry from fresh config - const freshPlatforms = new Map() - for (const pc of freshTrading.platforms) { - freshPlatforms.set(pc.id, createPlatformFromConfig(pc)) - } - - const platform = freshPlatforms.get(accCfg.platformId) - if (!platform) { - return { success: false, error: `Platform "${accCfg.platformId}" not found for account "${accountId}"` } - } - - const uta = await initAccount(accCfg, platform) - if (!uta) { - return { success: false, error: `Account "${accountId}" init failed` } - } + const uta = await initAccount(accCfg) // Wait for broker.init() + broker.getAccount() to verify the connection await uta.waitForConnect() // Re-register CCXT-specific tools if this is a CCXT account - if (platform.providerType !== 'alpaca') { + if (accCfg.type === 'ccxt') { toolCenter.register( createCcxtProviderTools(accountManager), 'trading-ccxt', diff --git a/src/tool/trading.ts b/src/tool/trading.ts index 4917ab8e..5dcc1a89 100644 --- a/src/tool/trading.ts +++ b/src/tool/trading.ts @@ -10,8 +10,22 @@ import { tool } from 'ai' import { z } from 'zod' import { Contract } from '@traderalice/ibkr' import type { AccountManager } from '@/domain/trading/account-manager.js' +import { BrokerError } from '@/domain/trading/brokers/types.js' import '@/domain/trading/contract-ext.js' +/** Classify a broker error into a structured response for AI consumption. */ +function handleBrokerError(err: unknown): { error: string; code: string; transient: boolean; hint: string } { + const be = err instanceof BrokerError ? err : BrokerError.from(err) + return { + error: be.message, + code: be.code, + transient: !be.permanent, + hint: be.permanent + ? 'This is a permanent error (configuration or credentials). Do not retry.' + : 'This may be a temporary issue. Wait a few seconds and try this tool again.', + } +} + const sourceDesc = (required: boolean, extra?: string) => { const base = `Account source — matches account id (e.g. "alpaca-paper") or provider (e.g. "alpaca", "ccxt").` const req = required @@ -73,20 +87,26 @@ This is a BROKER-LEVEL search — it queries your connected trading accounts.`, }), getAccount: tool({ - description: 'Query trading account info (netLiquidation, totalCashValue, buyingPower, unrealizedPnL, realizedPnL).', + description: `Query trading account info (netLiquidation, totalCashValue, buyingPower, unrealizedPnL, realizedPnL). +If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)), }), execute: async ({ source }) => { const targets = manager.resolve(source) if (targets.length === 0) return { error: 'No accounts available.' } - const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.getAccount() }))) - return results.length === 1 ? results[0] : results + try { + const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.getAccount() }))) + return results.length === 1 ? results[0] : results + } catch (err) { + return handleBrokerError(err) + } }, }), getPortfolio: tool({ - description: `Query current portfolio holdings. IMPORTANT: If result is an empty array [], you have no holdings.`, + description: `Query current portfolio holdings. IMPORTANT: If result is an empty array [], you have no holdings. +If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)), symbol: z.string().optional().describe('Filter by ticker, or omit for all'), @@ -94,32 +114,36 @@ This is a BROKER-LEVEL search — it queries your connected trading accounts.`, execute: async ({ source, symbol }) => { const targets = manager.resolve(source) if (targets.length === 0) return { positions: [], message: 'No accounts available.' } - const allPositions: Array> = [] - for (const uta of targets) { - const positions = await uta.getPositions() - const accountInfo = await uta.getAccount() - const totalMarketValue = positions.reduce((sum, p) => sum + p.marketValue, 0) - for (const pos of positions) { - if (symbol && symbol !== 'all' && pos.contract.symbol !== symbol) continue - const percentOfEquity = accountInfo.netLiquidation > 0 ? (pos.marketValue / accountInfo.netLiquidation) * 100 : 0 - const percentOfPortfolio = totalMarketValue > 0 ? (pos.marketValue / totalMarketValue) * 100 : 0 - allPositions.push({ - source: uta.id, symbol: pos.contract.symbol, side: pos.side, - quantity: pos.quantity.toNumber(), avgCost: pos.avgCost, marketPrice: pos.marketPrice, - marketValue: pos.marketValue, unrealizedPnL: pos.unrealizedPnL, realizedPnL: pos.realizedPnL, - leverage: pos.leverage, margin: pos.margin, liquidationPrice: pos.liquidationPrice, - percentageOfEquity: `${percentOfEquity.toFixed(1)}%`, - percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`, - }) + try { + const allPositions: Array> = [] + for (const uta of targets) { + const positions = await uta.getPositions() + const accountInfo = await uta.getAccount() + const totalMarketValue = positions.reduce((sum, p) => sum + p.marketValue, 0) + for (const pos of positions) { + if (symbol && symbol !== 'all' && pos.contract.symbol !== symbol) continue + const percentOfEquity = accountInfo.netLiquidation > 0 ? (pos.marketValue / accountInfo.netLiquidation) * 100 : 0 + const percentOfPortfolio = totalMarketValue > 0 ? (pos.marketValue / totalMarketValue) * 100 : 0 + allPositions.push({ + source: uta.id, symbol: pos.contract.symbol, side: pos.side, + quantity: pos.quantity.toNumber(), avgCost: pos.avgCost, marketPrice: pos.marketPrice, + marketValue: pos.marketValue, unrealizedPnL: pos.unrealizedPnL, realizedPnL: pos.realizedPnL, + percentageOfEquity: `${percentOfEquity.toFixed(1)}%`, + percentageOfPortfolio: `${percentOfPortfolio.toFixed(1)}%`, + }) + } } + if (allPositions.length === 0) return { positions: [], message: 'No open positions.' } + return allPositions + } catch (err) { + return handleBrokerError(err) } - if (allPositions.length === 0) return { positions: [], message: 'No open positions.' } - return allPositions }, }), getOrders: tool({ - description: 'Query orders by ID. If no orderIds provided, queries all pending (submitted) orders.', + description: `Query orders by ID. If no orderIds provided, queries all pending (submitted) orders. +If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)), orderIds: z.array(z.string()).optional().describe('Order IDs to query. If omitted, queries all pending orders.'), @@ -127,17 +151,22 @@ This is a BROKER-LEVEL search — it queries your connected trading accounts.`, execute: async ({ source, orderIds }) => { const targets = manager.resolve(source) if (targets.length === 0) return [] - const results = await Promise.all(targets.map(async (uta) => { - const ids = orderIds ?? uta.getPendingOrderIds().map(p => p.orderId) - const orders = await uta.getOrders(ids) - return orders.map((o) => ({ source: uta.id, ...o })) - })) - return results.flat() + try { + const results = await Promise.all(targets.map(async (uta) => { + const ids = orderIds ?? uta.getPendingOrderIds().map(p => p.orderId) + const orders = await uta.getOrders(ids) + return orders.map((o) => ({ source: uta.id, ...o })) + })) + return results.flat() + } catch (err) { + return handleBrokerError(err) + } }, }), getQuote: tool({ - description: 'Query the latest quote/price for a contract.', + description: `Query the latest quote/price for a contract. +If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ aliceId: z.string().describe('Contract identifier from searchContracts'), source: z.string().optional().describe(sourceDesc(false)), @@ -157,13 +186,18 @@ This is a BROKER-LEVEL search — it queries your connected trading accounts.`, }), getMarketClock: tool({ - description: 'Get current market clock status (isOpen, nextOpen, nextClose).', + description: `Get current market clock status (isOpen, nextOpen, nextClose). +If this tool returns an error with transient=true, wait a few seconds and retry once before reporting to the user.`, inputSchema: z.object({ source: z.string().optional().describe(sourceDesc(false)) }), execute: async ({ source }) => { const targets = manager.resolve(source) if (targets.length === 0) return { error: 'No accounts available.' } - const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.getMarketClock() }))) - return results.length === 1 ? results[0] : results + try { + const results = await Promise.all(targets.map(async (uta) => ({ source: uta.id, ...await uta.getMarketClock() }))) + return results.length === 1 ? results[0] : results + } catch (err) { + return handleBrokerError(err) + } }, }), diff --git a/ui/src/api/trading.ts b/ui/src/api/trading.ts index eec3c8c6..3b62c24c 100644 --- a/ui/src/api/trading.ts +++ b/ui/src/api/trading.ts @@ -1,5 +1,5 @@ import { fetchJson } from './client' -import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, PlatformConfig, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult } from './types' +import type { TradingAccount, AccountSummary, AccountInfo, Position, WalletCommitLog, ReconnectResult, AccountConfig, WalletStatus, WalletPushResult, WalletRejectResult, TestConnectionResult } from './types' // ==================== Unified Trading API ==================== @@ -81,31 +81,10 @@ export const tradingApi = { // ==================== Trading Config CRUD ==================== - async loadTradingConfig(): Promise<{ platforms: PlatformConfig[]; accounts: AccountConfig[] }> { + async loadTradingConfig(): Promise<{ accounts: AccountConfig[] }> { return fetchJson('/api/trading/config') }, - async upsertPlatform(platform: PlatformConfig): Promise { - const res = await fetch(`/api/trading/config/platforms/${platform.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(platform), - }) - if (!res.ok) { - const body = await res.json().catch(() => ({})) - throw new Error(body.error || `Failed to save platform (${res.status})`) - } - return res.json() - }, - - async deletePlatform(id: string): Promise { - const res = await fetch(`/api/trading/config/platforms/${id}`, { method: 'DELETE' }) - if (!res.ok) { - const body = await res.json().catch(() => ({})) - throw new Error(body.error || `Failed to delete platform (${res.status})`) - } - }, - async upsertAccount(account: AccountConfig): Promise { const res = await fetch(`/api/trading/config/accounts/${account.id}`, { method: 'PUT', @@ -127,11 +106,11 @@ export const tradingApi = { } }, - async testConnection(platform: PlatformConfig, credentials: { apiKey: string; apiSecret: string; password?: string }): Promise { + async testConnection(account: AccountConfig): Promise { const res = await fetch('/api/trading/config/test-connection', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ platform, credentials }), + body: JSON.stringify(account), }) return res.json() }, diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index e1c01e6d..14a8a817 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -162,7 +162,6 @@ export interface BrokerHealthInfo { export interface AccountSummary { id: string label: string - platformId?: string capabilities: { supportedSecTypes: string[]; supportedOrderTypes: string[] } health: BrokerHealthInfo } @@ -203,9 +202,6 @@ export interface Position { marketValue: number unrealizedPnL: number realizedPnL: number - leverage?: number - margin?: number - liquidationPrice?: number } export interface WalletCommitLog { @@ -270,34 +266,44 @@ export interface ToolCallRecord { // ==================== Trading Config ==================== -export interface CcxtPlatformConfig { +export interface CcxtAccountConfig { id: string label?: string type: 'ccxt' exchange: string sandbox: boolean demoTrading: boolean + options?: Record + apiKey?: string + apiSecret?: string + password?: string + guards: GuardEntry[] } -export interface AlpacaPlatformConfig { +export interface AlpacaAccountConfig { id: string label?: string type: 'alpaca' paper: boolean + apiKey?: string + apiSecret?: string + guards: GuardEntry[] } -export type PlatformConfig = CcxtPlatformConfig | AlpacaPlatformConfig - -export interface AccountConfig { +export interface IbkrAccountConfig { id: string - platformId: string label?: string - apiKey?: string - apiSecret?: string - password?: string + type: 'ibkr' + host: string + port: number + clientId: number + accountId?: string + paper: boolean guards: GuardEntry[] } +export type AccountConfig = CcxtAccountConfig | AlpacaAccountConfig | IbkrAccountConfig + export interface GuardEntry { type: string options: Record diff --git a/ui/src/components/ChatInput.tsx b/ui/src/components/ChatInput.tsx index 0597c133..680e5bf6 100644 --- a/ui/src/components/ChatInput.tsx +++ b/ui/src/components/ChatInput.tsx @@ -38,7 +38,7 @@ export function ChatInput({ disabled, onSend }: ChatInputProps) { }, []) return ( -
+