diff --git a/src/ib-client.ts b/src/ib-client.ts index 57ec714..a43f2c9 100644 --- a/src/ib-client.ts +++ b/src/ib-client.ts @@ -739,17 +739,100 @@ export class IBClient { } } + private normalizeAccountId(account: any): string | undefined { + if (!account) { + return undefined; + } + + if (typeof account === "string") { + return account.trim() || undefined; + } + + const id = account.id ?? account.accountId ?? account.account_id ?? account.acctId ?? account.account; + return typeof id === "string" && id.trim() ? id.trim() : undefined; + } + + private extractAccountIds(data: any): string[] { + const candidates = [ + ...(Array.isArray(data) ? data : []), + ...(Array.isArray(data?.accounts) ? data.accounts : []), + ...(Array.isArray(data?.accountIds) ? data.accountIds : []), + data?.selectedAccount, + data?.selected_account, + ]; + + return [...new Set( + candidates + .map((account) => this.normalizeAccountId(account)) + .filter((accountId): accountId is string => Boolean(accountId)) + )]; + } + + private extractOrders(data: any): any[] { + if (Array.isArray(data)) { + return data; + } + + if (Array.isArray(data?.orders)) { + return data.orders; + } + + return []; + } + + private async getOrderAccountIds(): Promise { + const accountSources = [ + { label: "/iserver/accounts", fetch: () => this.client.get("/iserver/accounts") }, + { label: "/portfolio/accounts", fetch: () => this.client.get("/portfolio/accounts") }, + ]; + + for (const source of accountSources) { + try { + const response = await source.fetch(); + const accountIds = this.extractAccountIds(response.data); + if (accountIds.length > 0) { + return accountIds; + } + } catch (error) { + Logger.warn(`[ORDERS] Failed to discover accounts via ${source.label}:`, error); + } + } + + return []; + } + async getOrders(accountId?: string): Promise { try { const url = "/iserver/account/orders"; - const params: any = {}; if (accountId) { - params.accountId = accountId; + const response = await this.client.get(url, { params: { accountId } }); + return response.data; } - const response = await this.client.get(url, { params }); - return response.data; + const accountIds = await this.getOrderAccountIds(); + if (accountIds.length === 0) { + Logger.warn("[ORDERS] Could not discover account IDs; falling back to unscoped orders request"); + const response = await this.client.get(url, { params: {} }); + return response.data; + } + + const accountResults = []; + const orders: any[] = []; + + for (const discoveredAccountId of accountIds) { + const response = await this.client.get(url, { params: { accountId: discoveredAccountId } }); + accountResults.push({ + accountId: discoveredAccountId, + data: response.data, + }); + orders.push(...this.extractOrders(response.data)); + } + + return { + orders, + accountResults, + }; } catch (error) { Logger.error("Failed to get orders:", error); diff --git a/test/ib-client.test.ts b/test/ib-client.test.ts index 70ce84e..f1d9aba 100644 --- a/test/ib-client.test.ts +++ b/test/ib-client.test.ts @@ -477,15 +477,59 @@ describe('IBClient', () => { }); describe('getOrders', () => { - it('should fetch all orders', async () => { + it('should fetch orders for all discovered trading accounts', async () => { + const mockClient = vi.mocked(axios.create).mock.results[0].value; + const firstAccountOrders = [{ orderId: '123', status: 'Filled' }]; + const secondAccountOrders = [{ orderId: '456', status: 'Submitted' }]; + + mockClient.get + .mockResolvedValueOnce({ data: { accounts: ['U12345', { accountId: 'U67890' }], selectedAccount: 'U12345' } }) + .mockResolvedValueOnce({ data: { orders: firstAccountOrders } }) + .mockResolvedValueOnce({ data: { orders: secondAccountOrders } }); + + const result = await client.getOrders(); + + expect(mockClient.get).toHaveBeenNthCalledWith(1, '/iserver/accounts'); + expect(mockClient.get).toHaveBeenNthCalledWith(2, '/iserver/account/orders', { params: { accountId: 'U12345' } }); + expect(mockClient.get).toHaveBeenNthCalledWith(3, '/iserver/account/orders', { params: { accountId: 'U67890' } }); + expect(result.orders).toEqual([...firstAccountOrders, ...secondAccountOrders]); + expect(result.accountResults).toEqual([ + { accountId: 'U12345', data: { orders: firstAccountOrders } }, + { accountId: 'U67890', data: { orders: secondAccountOrders } }, + ]); + }); + + it('should fall back to portfolio accounts when iserver account discovery fails', async () => { const mockClient = vi.mocked(axios.create).mock.results[0].value; const mockOrders = [{ orderId: '123', status: 'Filled' }]; - mockClient.get.mockResolvedValueOnce({ data: mockOrders }); + mockClient.get + .mockRejectedValueOnce(new Error('iserver accounts unavailable')) + .mockResolvedValueOnce({ data: [{ id: 'U12345' }] }) + .mockResolvedValueOnce({ data: { orders: mockOrders } }); + + const result = await client.getOrders(); + + expect(mockClient.get).toHaveBeenNthCalledWith(1, '/iserver/accounts'); + expect(mockClient.get).toHaveBeenNthCalledWith(2, '/portfolio/accounts'); + expect(mockClient.get).toHaveBeenNthCalledWith(3, '/iserver/account/orders', { params: { accountId: 'U12345' } }); + expect(result.orders).toEqual(mockOrders); + }); + + it('should fall back to an unscoped orders request when account discovery returns no accounts', async () => { + const mockClient = vi.mocked(axios.create).mock.results[0].value; + const mockOrders = [{ orderId: '123', status: 'Filled' }]; + + mockClient.get + .mockResolvedValueOnce({ data: { accounts: [] } }) + .mockResolvedValueOnce({ data: [] }) + .mockResolvedValueOnce({ data: mockOrders }); const result = await client.getOrders(); - expect(mockClient.get).toHaveBeenCalledWith('/iserver/account/orders', { params: {} }); + expect(mockClient.get).toHaveBeenNthCalledWith(1, '/iserver/accounts'); + expect(mockClient.get).toHaveBeenNthCalledWith(2, '/portfolio/accounts'); + expect(mockClient.get).toHaveBeenNthCalledWith(3, '/iserver/account/orders', { params: {} }); expect(result).toEqual(mockOrders); });