diff --git a/packages/exchange/src/broker/brokerContract.test.ts b/packages/exchange/src/broker/brokerContract.test.ts new file mode 100644 index 000000000..bc24d0f95 --- /dev/null +++ b/packages/exchange/src/broker/brokerContract.test.ts @@ -0,0 +1,505 @@ +import Big from 'big.js'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {OrderPosition, OrderSide, OrderType, type Broker, type Fill} from './Broker.js'; +import {MarketDataSource} from './MarketDataSource.js'; +import {TradingPair} from './TradingPair.js'; +import { + AlpacaAssetClass, + AlpacaOrderSide, + AlpacaOrderStatus, + AlpacaOrderType, +} from './alpaca/api/schema/OrderSchema.js'; +import {TradeUpdateEvent} from './alpaca/api/schema/TradingStreamSchema.js'; +import {Trading212OrderStatus} from './trading212/api/schema/OrderSchema.js'; + +// Shared mock references for the Alpaca stack +const alpacaMethods = { + deleteOrder: vi.fn(), + getAccount: vi.fn(), + getAssets: vi.fn(), + getClock: vi.fn(), + getCryptoBars: vi.fn(), + getCryptoBarsLatest: vi.fn(), + getOrders: vi.fn(), + getPositions: vi.fn(), + getStockBars: vi.fn(), + getStockBarsLatest: vi.fn(), + postOrder: vi.fn(), +}; + +vi.mock('./alpaca/api/AlpacaAPI.js', () => ({ + AlpacaAPI: class { + deleteOrder = alpacaMethods.deleteOrder; + getAccount = alpacaMethods.getAccount; + getAssets = alpacaMethods.getAssets; + getClock = alpacaMethods.getClock; + getCryptoBars = alpacaMethods.getCryptoBars; + getCryptoBarsLatest = alpacaMethods.getCryptoBarsLatest; + getOrders = alpacaMethods.getOrders; + getPositions = alpacaMethods.getPositions; + getStockBars = alpacaMethods.getStockBars; + getStockBarsLatest = alpacaMethods.getStockBarsLatest; + postOrder = alpacaMethods.postOrder; + }, +})); + +vi.mock('./alpaca/AlpacaWebSocket.js', () => ({ + alpacaWebSocket: { + connect: vi.fn(), + subscribeToBars: vi.fn(), + unsubscribeFromBars: vi.fn(), + }, +})); + +const alpacaTradingStream = { + connect: vi.fn(), + disconnect: vi.fn(), + offTradeUpdate: vi.fn(), + onTradeUpdate: vi.fn(), +}; + +vi.mock('./alpaca/AlpacaTradingWebSocket.js', () => ({ + alpacaTradingWebSocket: alpacaTradingStream, +})); + +// Shared mock references for the Trading212 stack +const trading212Methods = { + cancelOrder: vi.fn(), + getAccountCash: vi.fn(), + getAccountInfo: vi.fn(), + getHistoryOrders: vi.fn(), + getHistoryOrdersPage: vi.fn(), + getInstruments: vi.fn(), + getOrders: vi.fn(), + getPositions: vi.fn(), + placeLimitOrder: vi.fn(), + placeMarketOrder: vi.fn(), +}; + +vi.mock('./trading212/api/Trading212API.js', () => ({ + Trading212API: class { + cancelOrder = trading212Methods.cancelOrder; + getAccountCash = trading212Methods.getAccountCash; + getAccountInfo = trading212Methods.getAccountInfo; + getHistoryOrders = trading212Methods.getHistoryOrders; + getHistoryOrdersPage = trading212Methods.getHistoryOrdersPage; + getInstruments = trading212Methods.getInstruments; + getOrders = trading212Methods.getOrders; + getPositions = trading212Methods.getPositions; + placeLimitOrder = trading212Methods.placeLimitOrder; + placeMarketOrder = trading212Methods.placeMarketOrder; + }, +})); + +// Import after mocking +const {AlpacaBroker} = await import('./alpaca/AlpacaBroker.js'); +const {Trading212Broker} = await import('./trading212/Trading212Broker.js'); + +class FakeMarketData extends MarketDataSource { + disconnect = vi.fn(); + getCandles = vi.fn().mockResolvedValue([]); + getLatestCandle = vi.fn(); + unwatchCandles = vi.fn(); + watchCandles = vi.fn().mockResolvedValue('candle-topic'); +} + +const TRADING212_ACCOUNT_INFO = {currencyCode: 'EUR', id: 1}; + +function createAlpacaBroker() { + // An empty crypto-bars probe response marks the symbol as a stock. + alpacaMethods.getCryptoBarsLatest.mockResolvedValue({bars: {}}); + return new AlpacaBroker({apiKey: 'test', apiSecret: 'test', marketData: new FakeMarketData(), usePaperTrading: true}); +} + +function createTrading212Broker() { + return new Trading212Broker({ + apiKey: 'test', + apiSecret: 'test', + marketData: new FakeMarketData(), + usePaperTrading: true, + }); +} + +function createTrading212HistoryFill(options: {filledAt: string; id: number}) { + return { + fill: { + filledAt: options.filledAt, + price: 100, + quantity: 3, + walletImpact: {taxes: [{name: 'CURRENCY_CONVERSION_FEE', quantity: -0.25}]}, + }, + order: {id: options.id, quantity: 3, status: Trading212OrderStatus.FILLED, ticker: 'AAPL_US_EQ'}, + }; +} + +const alpacaTradeUpdateFillMessage = { + event: TradeUpdateEvent.FILL, + order: { + asset_class: AlpacaAssetClass.US_EQUITY, + asset_id: 'test-asset', + canceled_at: null, + client_order_id: 'test-client', + created_at: '2025-01-15T14:30:00.000Z', + expired_at: null, + extended_hours: false, + failed_at: null, + filled_at: '2025-01-15T14:30:01.123Z', + filled_avg_price: '150.25', + filled_qty: '10', + id: 'alpaca-watched-fill', + legs: null, + limit_price: null, + notional: null, + qty: '10', + replaced_at: null, + replaced_by: null, + replaces: null, + side: AlpacaOrderSide.BUY, + status: AlpacaOrderStatus.FILLED, + stop_price: null, + submitted_at: '2025-01-15T14:30:00.001Z', + symbol: 'SHOP', + time_in_force: 'day', + type: AlpacaOrderType.MARKET, + updated_at: '2025-01-15T14:30:01.123Z', + }, + price: '150.25', + qty: '10', + timestamp: '2025-01-15T14:30:01.123Z', +}; + +/** + * One venue-agnostic driver per broker. Each `prime*` method arranges the mocked API so the + * neutral `Broker` call under test can succeed, and returns the ids the contract asserts on. + */ +interface BrokerContractHarness { + createBroker: () => Broker; + emitFill: () => Promise; + /** + * Documented divergence: Alpaca debits fees in the instrument's counter currency, while + * Trading212 debits all fees in the account currency (EUR account here). + */ + expectedFeeAsset: string; + name: string; + pair: TradingPair; + primeCancelableOrders: () => string[]; + primeFees: () => void; + primeFills: () => {newestOrderId: string; oldestOrderId: string}; + primeLimitOrder: () => string; + primeMarketOrder: () => string; + primeWatchOrders: () => void; +} + +const alpacaHarness: BrokerContractHarness = { + createBroker: createAlpacaBroker, + emitFill: async () => { + const registeredCallback = alpacaTradingStream.onTradeUpdate.mock.calls[0]?.[1]; + registeredCallback(alpacaTradeUpdateFillMessage); + }, + expectedFeeAsset: 'USD', + name: AlpacaBroker.NAME, + pair: new TradingPair('SHOP', 'USD'), + primeCancelableOrders: () => { + alpacaMethods.getOrders.mockResolvedValue([{id: 'alpaca-open-1'}, {id: 'alpaca-open-2'}]); + alpacaMethods.deleteOrder.mockResolvedValue(undefined); + return ['alpaca-open-1', 'alpaca-open-2']; + }, + primeFees: () => undefined, + primeFills: () => { + const filledOrder = { + asset_class: AlpacaAssetClass.US_EQUITY, + filled_avg_price: '53.05', + filled_qty: '3', + side: AlpacaOrderSide.BUY, + status: AlpacaOrderStatus.FILLED, + }; + alpacaMethods.getOrders.mockResolvedValue([ + {...filledOrder, created_at: '2025-02-01T10:00:00.000Z', id: 'alpaca-fill-newest'}, + {...filledOrder, created_at: '2025-01-01T10:00:00.000Z', id: 'alpaca-fill-oldest'}, + ]); + return {newestOrderId: 'alpaca-fill-newest', oldestOrderId: 'alpaca-fill-oldest'}; + }, + primeLimitOrder: () => { + alpacaMethods.postOrder.mockResolvedValue({ + id: 'alpaca-limit-1', + limit_price: '100', + notional: null, + qty: '5', + side: AlpacaOrderSide.SELL, + type: AlpacaOrderType.LIMIT, + }); + return 'alpaca-limit-1'; + }, + primeMarketOrder: () => { + alpacaMethods.postOrder.mockResolvedValue({ + id: 'alpaca-market-1', + limit_price: null, + notional: null, + qty: '5', + side: AlpacaOrderSide.BUY, + type: AlpacaOrderType.MARKET, + }); + return 'alpaca-market-1'; + }, + primeWatchOrders: () => { + alpacaTradingStream.connect.mockResolvedValue({connectionId: 'trading-conn', stream: {}}); + }, +}; + +const trading212Harness: BrokerContractHarness = { + createBroker: createTrading212Broker, + emitFill: async () => { + trading212Methods.getHistoryOrdersPage.mockResolvedValue({ + items: [createTrading212HistoryFill({filledAt: '2025-02-01T10:00:00.000Z', id: 500})], + nextPagePath: null, + }); + await vi.advanceTimersByTimeAsync(Trading212Broker.ORDER_POLL_INTERVAL_MS); + await vi.advanceTimersByTimeAsync(0); + }, + expectedFeeAsset: 'EUR', + name: Trading212Broker.NAME, + pair: new TradingPair('AAPL_US_EQ', 'USD'), + primeCancelableOrders: () => { + const openOrder = { + limitPrice: 90, + quantity: 1, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }; + trading212Methods.getOrders.mockResolvedValue([ + {...openOrder, id: 201}, + {...openOrder, id: 202}, + ]); + trading212Methods.cancelOrder.mockResolvedValue(undefined); + return ['201', '202']; + }, + primeFees: () => { + trading212Methods.getAccountInfo.mockResolvedValue(TRADING212_ACCOUNT_INFO); + }, + primeFills: () => { + trading212Methods.getAccountInfo.mockResolvedValue(TRADING212_ACCOUNT_INFO); + trading212Methods.getHistoryOrders.mockResolvedValue([ + createTrading212HistoryFill({filledAt: '2025-02-01T10:00:00.000Z', id: 302}), + createTrading212HistoryFill({filledAt: '2025-01-01T10:00:00.000Z', id: 301}), + ]); + return {newestOrderId: '302', oldestOrderId: '301'}; + }, + primeLimitOrder: () => { + trading212Methods.placeLimitOrder.mockResolvedValue({ + id: 101, + limitPrice: 100, + quantity: -5, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }); + return '101'; + }, + primeMarketOrder: () => { + trading212Methods.placeMarketOrder.mockResolvedValue({ + id: 102, + quantity: 5, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'MARKET', + }); + return '102'; + }, + primeWatchOrders: () => { + // The polling watcher only fires under fake timers; enable them before `watchOrders()` is called. + vi.useFakeTimers(); + trading212Methods.getAccountInfo.mockResolvedValue(TRADING212_ACCOUNT_INFO); + trading212Methods.getInstruments.mockResolvedValue([ + {addedOn: '2020-01-01T00:00:00.000Z', currencyCode: 'USD', name: 'Apple', ticker: 'AAPL_US_EQ', type: 'STOCK'}, + ]); + trading212Methods.getHistoryOrdersPage.mockResolvedValue({items: [], nextPagePath: null}); + }, +}; + +const harnesses: BrokerContractHarness[] = [alpacaHarness, trading212Harness]; + +describe('Broker contract', {concurrent: false}, () => { + describe.each(harnesses)('$name', harness => { + let broker: Broker; + + beforeEach(() => { + vi.clearAllMocks(); + broker = harness.createBroker(); + }); + + afterEach(() => { + broker.disconnect(); + vi.useRealTimers(); + }); + + it('placeLimitOrder returns a neutral PendingLimitOrder', async () => { + const expectedId = harness.primeLimitOrder(); + + const order = await broker.placeLimitOrder(harness.pair, {price: '100', side: OrderSide.SELL, size: '5'}); + + expect(order).toEqual({ + id: expectedId, + pair: harness.pair, + price: '100', + side: OrderSide.SELL, + size: '5', + type: OrderType.LIMIT, + }); + }); + + it('placeMarketOrder returns a neutral PendingMarketOrder for quantity-sized orders', async () => { + const expectedId = harness.primeMarketOrder(); + + const order = await broker.placeMarketOrder(harness.pair, { + side: OrderSide.BUY, + size: '5', + sizeInCounter: false, + }); + + expect(order).toEqual({ + id: expectedId, + pair: harness.pair, + side: OrderSide.BUY, + size: '5', + type: OrderType.MARKET, + }); + }); + + it('cancelOpenOrders resolves to the ids of the canceled orders', async () => { + const expectedIds = harness.primeCancelableOrders(); + + await expect(broker.cancelOpenOrders(harness.pair)).resolves.toEqual(expectedIds); + }); + + it('getFills returns fills newest-first with a neutral shape', async () => { + const {newestOrderId, oldestOrderId} = harness.primeFills(); + + const fills = await broker.getFills(harness.pair); + + expect(fills.map(fill => fill.order_id)).toEqual([newestOrderId, oldestOrderId]); + expect(fills[0]).toMatchObject({ + created_at: expect.any(String), + fee: expect.any(String), + feeAsset: expect.any(String), + pair: harness.pair, + position: OrderPosition.LONG, + price: expect.any(String), + side: OrderSide.BUY, + size: expect.any(String), + }); + expect(new Date(fills[0]?.created_at ?? '').getTime()).toBeGreaterThan( + new Date(fills[1]?.created_at ?? '').getTime() + ); + }); + + it('estimateFee derives its commission from getFeeRate and reports the fee asset', async () => { + harness.primeFees(); + const notional = new Big(1_000); + + const rate = await broker.getFeeRate(harness.pair, OrderType.MARKET); + const estimate = await broker.estimateFee(harness.pair, OrderType.MARKET, notional); + + expect(estimate.commission.eq(notional.times(rate))).toBe(true); + expect(estimate.total.gte(estimate.commission)).toBe(true); + expect(estimate.feeAsset).toBe(harness.expectedFeeAsset); + }); + + it('watchOrders returns a topicId and emits a Fill on that topic', async () => { + harness.primeWatchOrders(); + + const topicId = await broker.watchOrders(); + expect(typeof topicId).toBe('string'); + expect(topicId.length).toBeGreaterThan(0); + + const fillHandler = vi.fn<(fill: Fill) => void>(); + broker.on(topicId, fillHandler); + await harness.emitFill(); + + expect(fillHandler).toHaveBeenCalledTimes(1); + expect(fillHandler).toHaveBeenCalledWith( + expect.objectContaining({ + order_id: expect.any(String), + price: expect.any(String), + side: OrderSide.BUY, + size: expect.any(String), + }) + ); + broker.unwatchOrders(topicId); + }); + }); + + /* + * Where a venue legitimately deviates from the shared semantics, the divergence is asserted + * here — explicitly, per venue — so this file stays the single place where cross-broker + * differences are visible. + */ + describe('documented divergences', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('notional (sizeInCounter) MARKET orders', () => { + it('AlpacaBroker submits them as notional orders', async () => { + alpacaMethods.postOrder.mockResolvedValue({ + id: 'alpaca-notional-1', + notional: '200', + qty: null, + side: AlpacaOrderSide.BUY, + type: AlpacaOrderType.MARKET, + }); + const broker = createAlpacaBroker(); + + const order = await broker.placeMarketOrder(new TradingPair('SHOP', 'USD'), { + side: OrderSide.BUY, + size: '200', + sizeInCounter: true, + }); + + expect(order.size).toBe('200'); + expect(alpacaMethods.postOrder).toHaveBeenCalledWith(expect.objectContaining({notional: '200'})); + }); + + it('Trading212Broker rejects them because its public API only supports quantity orders', async () => { + const broker = createTrading212Broker(); + + await expect( + broker.placeMarketOrder(new TradingPair('AAPL_US_EQ', 'USD'), { + side: OrderSide.BUY, + size: '200', + sizeInCounter: true, + }) + ).rejects.toThrow('Notional (sizeInCounter) orders are not supported by the Trading212 public API.'); + expect(trading212Methods.placeMarketOrder).not.toHaveBeenCalled(); + }); + }); + + describe('fee model', () => { + it('AlpacaBroker charges flat commission rates and never a currency-conversion fee', async () => { + const broker = createAlpacaBroker(); + + const rates = await broker.getFeeRates(new TradingPair('SHOP', 'USD')); + + expect(rates[OrderType.LIMIT]).toEqual(new Big(0.0015)); + expect(rates[OrderType.MARKET]).toEqual(new Big(0.0025)); + expect(rates.CURRENCY_CONVERSION_FEE).toBeUndefined(); + }); + + it('Trading212Broker charges 0% commission plus a conversion fee on cross-currency instruments only', async () => { + trading212Methods.getAccountInfo.mockResolvedValue(TRADING212_ACCOUNT_INFO); + const broker = createTrading212Broker(); + + const crossCurrencyRates = await broker.getFeeRates(new TradingPair('AAPL_US_EQ', 'USD')); + expect(crossCurrencyRates[OrderType.LIMIT]).toEqual(new Big(0)); + expect(crossCurrencyRates[OrderType.MARKET]).toEqual(new Big(0)); + expect(crossCurrencyRates.CURRENCY_CONVERSION_FEE).toEqual(new Big(0.0015)); + + const sameCurrencyRates = await broker.getFeeRates(new TradingPair('SAP_XETRA_EQ', 'EUR')); + expect(sameCurrencyRates.CURRENCY_CONVERSION_FEE).toBeUndefined(); + }); + }); + }); +}); diff --git a/packages/exchange/src/broker/trading212/Trading212Broker.test.ts b/packages/exchange/src/broker/trading212/Trading212Broker.test.ts new file mode 100644 index 000000000..e7620e03c --- /dev/null +++ b/packages/exchange/src/broker/trading212/Trading212Broker.test.ts @@ -0,0 +1,474 @@ +import Big from 'big.js'; +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; +import {OrderSide, OrderType, type Fill} from '../Broker.js'; +import {MarketDataSource} from '../MarketDataSource.js'; +import {TradingPair} from '../TradingPair.js'; +import {Trading212OrderStatus, Trading212TimeValidity} from './api/schema/OrderSchema.js'; + +// Shared mock references +const mockMethods = { + cancelOrder: vi.fn(), + getAccountCash: vi.fn(), + getAccountInfo: vi.fn(), + getHistoryOrders: vi.fn(), + getHistoryOrdersPage: vi.fn(), + getInstruments: vi.fn(), + getOrders: vi.fn(), + getPositions: vi.fn(), + placeLimitOrder: vi.fn(), + placeMarketOrder: vi.fn(), +}; + +vi.mock('./api/Trading212API.js', () => ({ + Trading212API: class { + cancelOrder = mockMethods.cancelOrder; + getAccountCash = mockMethods.getAccountCash; + getAccountInfo = mockMethods.getAccountInfo; + getHistoryOrders = mockMethods.getHistoryOrders; + getHistoryOrdersPage = mockMethods.getHistoryOrdersPage; + getInstruments = mockMethods.getInstruments; + getOrders = mockMethods.getOrders; + getPositions = mockMethods.getPositions; + placeLimitOrder = mockMethods.placeLimitOrder; + placeMarketOrder = mockMethods.placeMarketOrder; + }, +})); + +class FakeMarketData extends MarketDataSource { + disconnect = vi.fn(); + getCandles = vi.fn().mockResolvedValue([]); + getLatestCandle = vi.fn(); + unwatchCandles = vi.fn(); + watchCandles = vi.fn().mockResolvedValue('candle-topic'); +} + +// Import after mocking +const {Trading212Broker} = await import('./Trading212Broker.js'); + +const accountInfoEUR = {currencyCode: 'EUR', id: 1}; +const accountInfoUSD = {currencyCode: 'USD', id: 1}; + +function createFilledHistoryOrder(id: number) { + return { + fill: { + filledAt: `2025-06-02T14:30:${`${id % 60}`.padStart(2, '0')}.000Z`, + price: 100 + id, + quantity: 1, + walletImpact: {taxes: [{name: 'CURRENCY_CONVERSION_FEE', quantity: -0.25}]}, + }, + order: {id, quantity: 1, status: Trading212OrderStatus.FILLED, ticker: 'AAPL_US_EQ'}, + }; +} + +describe('Trading212Broker', {concurrent: false}, () => { + const pair = new TradingPair('AAPL_US_EQ', 'USD'); + let broker: InstanceType; + let marketData: FakeMarketData; + + beforeEach(() => { + vi.clearAllMocks(); + marketData = new FakeMarketData(); + broker = new Trading212Broker({apiKey: 'test', apiSecret: 'test', marketData, usePaperTrading: true}); + }); + + describe('placeLimitOrder', () => { + it('places a DAY order on the extended-hours venue because Trading212 rejects GTC for equity limit orders', async () => { + mockMethods.placeLimitOrder.mockResolvedValue({ + id: 101, + limitPrice: 155.5, + quantity: 2, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }); + + const order = await broker.placeLimitOrder(pair, {price: '155.5', side: OrderSide.BUY, size: '2'}); + + expect(order).toEqual({ + id: '101', + pair, + price: '155.5', + side: OrderSide.BUY, + size: '2', + type: OrderType.LIMIT, + }); + expect(mockMethods.placeLimitOrder).toHaveBeenCalledWith({ + extendedHours: true, + limitPrice: 155.5, + quantity: 2, + ticker: 'AAPL_US_EQ', + timeValidity: Trading212TimeValidity.DAY, + }); + }); + + it('encodes SELL as a negative quantity', async () => { + mockMethods.placeLimitOrder.mockResolvedValue({ + id: 102, + limitPrice: 160, + quantity: -2, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }); + + const order = await broker.placeLimitOrder(pair, {price: '160', side: OrderSide.SELL, size: '2'}); + + expect(order.side).toBe(OrderSide.SELL); + expect(order.size).toBe('2'); + expect(mockMethods.placeLimitOrder).toHaveBeenCalledWith(expect.objectContaining({quantity: -2})); + }); + }); + + describe('placeMarketOrder', () => { + it('places a BUY order with a positive quantity', async () => { + mockMethods.placeMarketOrder.mockResolvedValue({ + id: 103, + quantity: 3, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'MARKET', + }); + + const order = await broker.placeMarketOrder(pair, {side: OrderSide.BUY, size: '3', sizeInCounter: false}); + + expect(order).toEqual({ + id: '103', + pair, + side: OrderSide.BUY, + size: '3', + type: OrderType.MARKET, + }); + expect(mockMethods.placeMarketOrder).toHaveBeenCalledWith({ + extendedHours: true, + quantity: 3, + ticker: 'AAPL_US_EQ', + }); + }); + + it('encodes SELL as a negative quantity', async () => { + mockMethods.placeMarketOrder.mockResolvedValue({ + id: 104, + quantity: -3, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'MARKET', + }); + + const order = await broker.placeMarketOrder(pair, {side: OrderSide.SELL, size: '3', sizeInCounter: false}); + + expect(order.side).toBe(OrderSide.SELL); + expect(order.size).toBe('3'); + expect(mockMethods.placeMarketOrder).toHaveBeenCalledWith(expect.objectContaining({quantity: -3})); + }); + + it('rejects notional (sizeInCounter) orders before hitting the API', async () => { + await expect( + broker.placeMarketOrder(pair, {side: OrderSide.BUY, size: '200', sizeInCounter: true}) + ).rejects.toThrow('Notional (sizeInCounter) orders are not supported by the Trading212 public API.'); + expect(mockMethods.placeMarketOrder).not.toHaveBeenCalled(); + }); + }); + + describe('getOpenOrders', () => { + it('keeps only MARKET/LIMIT quantity-strategy orders of the requested ticker', async () => { + mockMethods.getOrders.mockResolvedValue([ + { + id: 1, + limitPrice: 150, + quantity: 2, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }, + { + id: 2, + quantity: -1, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'MARKET', + }, + { + id: 3, + quantity: 1, + status: Trading212OrderStatus.NEW, + stopPrice: 140, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'STOP', + }, + { + id: 4, + limitPrice: 150, + quantity: 1, + status: Trading212OrderStatus.NEW, + stopPrice: 140, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'STOP_LIMIT', + }, + {id: 5, status: Trading212OrderStatus.NEW, strategy: 'VALUE', ticker: 'AAPL_US_EQ', type: 'MARKET', value: 100}, + { + id: 6, + limitPrice: 10, + quantity: 1, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'TSLA_US_EQ', + type: 'LIMIT', + }, + ]); + + const orders = await broker.getOpenOrders(pair); + + expect(orders).toEqual([ + {id: '1', pair, price: '150', side: OrderSide.BUY, size: '2', type: OrderType.LIMIT}, + {id: '2', pair, side: OrderSide.SELL, size: '1', type: OrderType.MARKET}, + ]); + }); + }); + + describe('cancelOpenOrders', () => { + it('cancels only orders of the requested ticker and returns their ids', async () => { + mockMethods.getOrders.mockResolvedValue([ + { + id: 201, + limitPrice: 90, + quantity: 1, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }, + { + id: 202, + limitPrice: 95, + quantity: 1, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + }, + { + id: 203, + limitPrice: 10, + quantity: 1, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'TSLA_US_EQ', + type: 'LIMIT', + }, + ]); + mockMethods.cancelOrder.mockResolvedValue(undefined); + + const canceledIds = await broker.cancelOpenOrders(pair); + + expect(canceledIds).toEqual(['201', '202']); + expect(mockMethods.cancelOrder).toHaveBeenCalledTimes(2); + expect(mockMethods.cancelOrder).toHaveBeenCalledWith(201); + expect(mockMethods.cancelOrder).toHaveBeenCalledWith(202); + }); + }); + + describe('getFills', () => { + it('maps only FILLED history entries and debits fees in the account currency', async () => { + mockMethods.getAccountInfo.mockResolvedValue(accountInfoEUR); + mockMethods.getHistoryOrders.mockResolvedValue([ + createFilledHistoryOrder(12), + { + fill: null, + order: {id: 11, quantity: 1, status: Trading212OrderStatus.CANCELLED, ticker: 'AAPL_US_EQ'}, + }, + createFilledHistoryOrder(10), + ]); + + const fills = await broker.getFills(pair); + + expect(mockMethods.getHistoryOrders).toHaveBeenCalledWith('AAPL_US_EQ'); + expect(fills.map(fill => fill.order_id)).toEqual(['12', '10']); + expect(fills[0]).toEqual({ + created_at: '2025-06-02T14:30:12.000Z', + fee: '0.25', + feeAsset: 'EUR', + order_id: '12', + pair, + position: 'LONG', + price: '112', + side: OrderSide.BUY, + size: '1', + }); + }); + }); + + describe('fees', () => { + it('charges 0% commission and no conversion fee when the instrument matches the account currency', async () => { + mockMethods.getAccountInfo.mockResolvedValue(accountInfoUSD); + + const rates = await broker.getFeeRates(pair); + + expect(rates[OrderType.LIMIT]).toEqual(new Big(0)); + expect(rates[OrderType.MARKET]).toEqual(new Big(0)); + expect(rates.CURRENCY_CONVERSION_FEE).toBeUndefined(); + }); + + it('adds CURRENCY_CONVERSION_FEE only for cross-currency instruments', async () => { + mockMethods.getAccountInfo.mockResolvedValue(accountInfoEUR); + + const rates = await broker.getFeeRates(pair); + + expect(rates[OrderType.LIMIT]).toEqual(new Big(0)); + expect(rates[OrderType.MARKET]).toEqual(new Big(0)); + expect(rates.CURRENCY_CONVERSION_FEE).toEqual(new Big(0.0015)); + }); + + it('estimates cross-currency fees as conversion-only, debited in the account currency (getFeeAsset override)', async () => { + mockMethods.getAccountInfo.mockResolvedValue(accountInfoEUR); + + const estimate = await broker.estimateFee(pair, OrderType.MARKET, new Big(1_000)); + + expect(estimate.commission.toFixed()).toBe('0'); + expect(estimate.currencyConversion.toFixed()).toBe('1.5'); + expect(estimate.feeAsset).toBe('EUR'); + expect(estimate.total.toFixed()).toBe('1.5'); + }); + + it('estimates a zero fee for same-currency instruments', async () => { + mockMethods.getAccountInfo.mockResolvedValue(accountInfoUSD); + + const estimate = await broker.estimateFee(pair, OrderType.LIMIT, new Big(1_000)); + + expect(estimate.feeAsset).toBe('USD'); + expect(estimate.total.toFixed()).toBe('0'); + }); + }); + + describe('candle delegation', () => { + it('converts the vendor ticker before delegating to the injected market-data source', async () => { + const brkPair = new TradingPair('BRK_B_US_EQ', 'USD'); + const request = { + intervalInMillis: 60_000, + startTimeFirstCandle: '2025-06-02T14:00:00.000Z', + startTimeLastCandle: '2025-06-02T15:00:00.000Z', + }; + + await broker.getCandles(brkPair, request); + + expect(marketData.getCandles).toHaveBeenCalledWith(new TradingPair('BRK.B', 'USD'), request); + }); + }); + + describe('watchOrders', () => { + const instruments = [ + {addedOn: '2020-01-01T00:00:00.000Z', currencyCode: 'USD', name: 'Apple', ticker: 'AAPL_US_EQ', type: 'STOCK'}, + ]; + + beforeEach(() => { + vi.useFakeTimers(); + mockMethods.getAccountInfo.mockResolvedValue(accountInfoEUR); + mockMethods.getInstruments.mockResolvedValue(instruments); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('takes a baseline snapshot so pre-existing fills are not replayed', async () => { + mockMethods.getHistoryOrdersPage.mockResolvedValue({items: [createFilledHistoryOrder(10)], nextPagePath: null}); + const topicId = await broker.watchOrders(1_000); + const fillHandler = vi.fn<(fill: Fill) => void>(); + broker.on(topicId, fillHandler); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + expect(fillHandler).not.toHaveBeenCalled(); + + mockMethods.getHistoryOrdersPage.mockResolvedValue({ + items: [createFilledHistoryOrder(11), createFilledHistoryOrder(10)], + nextPagePath: null, + }); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + expect(fillHandler).toHaveBeenCalledTimes(1); + expect(fillHandler).toHaveBeenCalledWith( + expect.objectContaining({ + feeAsset: 'EUR', + order_id: '11', + pair: new TradingPair('AAPL_US_EQ', 'USD'), + price: '111', + size: '1', + }) + ); + broker.unwatchOrders(topicId); + }); + + it('pages through history until the last seen id so a burst of fills is not truncated', async () => { + mockMethods.getHistoryOrdersPage + .mockResolvedValueOnce({items: [createFilledHistoryOrder(10)], nextPagePath: null}) + .mockResolvedValueOnce({ + items: [createFilledHistoryOrder(13), createFilledHistoryOrder(12)], + nextPagePath: '/api/v0/equity/history/orders?cursor=abc', + }) + .mockResolvedValueOnce({ + items: [createFilledHistoryOrder(11), createFilledHistoryOrder(10)], + nextPagePath: null, + }); + + const topicId = await broker.watchOrders(1_000); + const fillHandler = vi.fn<(fill: Fill) => void>(); + broker.on(topicId, fillHandler); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + expect(mockMethods.getHistoryOrdersPage).toHaveBeenCalledTimes(3); + expect(mockMethods.getHistoryOrdersPage).toHaveBeenNthCalledWith(3, { + nextPath: '/api/v0/equity/history/orders?cursor=abc', + }); + // Fills are emitted oldest-first so consumers see them in execution order. + expect(fillHandler.mock.calls.map(([fill]) => fill.order_id)).toEqual(['11', '12', '13']); + broker.unwatchOrders(topicId); + }); + + it('unwatchOrders stops the polling timer', async () => { + mockMethods.getHistoryOrdersPage.mockResolvedValue({items: [], nextPagePath: null}); + + const topicId = await broker.watchOrders(1_000); + broker.unwatchOrders(topicId); + await vi.advanceTimersByTimeAsync(5_000); + + // Only the baseline snapshot ran; no polling tick fired after unwatching. + expect(mockMethods.getHistoryOrdersPage).toHaveBeenCalledTimes(1); + }); + + it('emits "error" to a registered listener and keeps polling after an API failure', async () => { + mockMethods.getHistoryOrdersPage + .mockResolvedValueOnce({items: [], nextPagePath: null}) + .mockRejectedValueOnce(new Error('Trading212 is down')) + .mockResolvedValueOnce({items: [createFilledHistoryOrder(21)], nextPagePath: null}); + + const topicId = await broker.watchOrders(1_000); + const errorHandler = vi.fn(); + const fillHandler = vi.fn<(fill: Fill) => void>(); + broker.on('error', errorHandler); + broker.on(topicId, fillHandler); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + const [emittedError] = errorHandler.mock.calls[0]; + expect(emittedError).toBeInstanceOf(Error); + expect((emittedError as Error).message).toBe('Trading212 is down'); + expect(fillHandler).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + expect(fillHandler).toHaveBeenCalledWith(expect.objectContaining({order_id: '21'})); + broker.unwatchOrders(topicId); + }); + }); +}); diff --git a/packages/exchange/src/broker/trading212/Trading212BrokerMapper.test.ts b/packages/exchange/src/broker/trading212/Trading212BrokerMapper.test.ts new file mode 100644 index 000000000..190a1f47a --- /dev/null +++ b/packages/exchange/src/broker/trading212/Trading212BrokerMapper.test.ts @@ -0,0 +1,258 @@ +import {describe, expect, it} from 'vitest'; +import {OrderPosition, OrderSide, OrderType} from '../Broker.js'; +import {TradingPair} from '../TradingPair.js'; +import type {HistoryOrder} from './api/schema/HistoryOrderSchema.js'; +import {Trading212OrderStatus, type Order} from './api/schema/OrderSchema.js'; +import {Trading212Broker} from './Trading212Broker.js'; +import {Trading212BrokerMapper} from './Trading212BrokerMapper.js'; + +const pair = new TradingPair('AAPL_US_EQ', 'USD'); + +function createOrder(overrides: Partial = {}): Order { + return { + id: 101, + limitPrice: 155.5, + quantity: 2, + status: Trading212OrderStatus.NEW, + strategy: 'QUANTITY', + ticker: 'AAPL_US_EQ', + type: 'LIMIT', + ...overrides, + }; +} + +describe('Trading212Broker.toMarketDataPair', () => { + it('strips the country and asset-type suffix from a vendor ticker', () => { + const marketDataPair = Trading212Broker.toMarketDataPair(new TradingPair('AAPL_US_EQ', 'USD')); + expect(marketDataPair.base).toBe('AAPL'); + expect(marketDataPair.counter).toBe('USD'); + }); + + it('joins intra-symbol underscores with dots so class shares match data-provider conventions', () => { + const marketDataPair = Trading212Broker.toMarketDataPair(new TradingPair('BRK_B_US_EQ', 'USD')); + expect(marketDataPair.base).toBe('BRK.B'); + expect(marketDataPair.counter).toBe('USD'); + }); + + it('leaves tickers without a vendor suffix untouched', () => { + const marketDataPair = Trading212Broker.toMarketDataPair(new TradingPair('AAPL', 'USD')); + expect(marketDataPair.base).toBe('AAPL'); + }); +}); + +describe('Trading212BrokerMapper', () => { + describe('toPendingOrder', () => { + it('maps a LIMIT BUY order', () => { + const order = createOrder(); + + const pendingOrder = Trading212BrokerMapper.toPendingOrder(order, pair, { + price: '155.5', + side: OrderSide.BUY, + size: '2', + sizeInCounter: false, + type: OrderType.LIMIT, + }); + + expect(pendingOrder).toEqual({ + id: '101', + pair, + price: '155.5', + side: OrderSide.BUY, + size: '2', + type: OrderType.LIMIT, + }); + }); + + it('maps a MARKET SELL order whose sign-encoded quantity is negative to an unsigned size', () => { + const order = createOrder({limitPrice: null, quantity: -3, type: 'MARKET'}); + + const pendingOrder = Trading212BrokerMapper.toPendingOrder(order, pair, { + side: OrderSide.SELL, + size: '3', + sizeInCounter: true, + type: OrderType.MARKET, + }); + + expect(pendingOrder).toEqual({ + id: '101', + pair, + side: OrderSide.SELL, + size: '3', + type: OrderType.MARKET, + }); + }); + + it('throws when Trading212 returns an order without a quantity', () => { + const order = createOrder({quantity: null}); + + expect(() => + Trading212BrokerMapper.toPendingOrder(order, pair, { + price: '155.5', + side: OrderSide.BUY, + size: '2', + sizeInCounter: false, + type: OrderType.LIMIT, + }) + ).toThrowError('Trading212 returned an order without a quantity (id: 101).'); + }); + + it('throws when a LIMIT order comes back without a limitPrice', () => { + const order = createOrder({limitPrice: null}); + + expect(() => + Trading212BrokerMapper.toPendingOrder(order, pair, { + price: '155.5', + side: OrderSide.BUY, + size: '2', + sizeInCounter: false, + type: OrderType.LIMIT, + }) + ).toThrowError('Trading212 returned a LIMIT order without a limitPrice (id: 101).'); + }); + }); + + describe('toOpenOrder', () => { + it('derives BUY from a positive quantity on a LIMIT order', () => { + const order = createOrder({limitPrice: 150, quantity: 2}); + + expect(Trading212BrokerMapper.toOpenOrder(order, pair)).toEqual({ + id: '101', + pair, + price: '150', + side: OrderSide.BUY, + size: '2', + type: OrderType.LIMIT, + }); + }); + + it('derives SELL from a negative quantity on a MARKET order', () => { + const order = createOrder({quantity: -7, type: 'MARKET'}); + + expect(Trading212BrokerMapper.toOpenOrder(order, pair)).toEqual({ + id: '101', + pair, + side: OrderSide.SELL, + size: '7', + type: OrderType.MARKET, + }); + }); + + it('throws when the order has no quantity (VALUE-strategy orders must be filtered by the caller)', () => { + const order = createOrder({quantity: null, value: 100}); + + expect(() => Trading212BrokerMapper.toOpenOrder(order, pair)).toThrowError( + 'Trading212 returned an order without a quantity (id: 101).' + ); + }); + + it('throws when a LIMIT order has no limitPrice', () => { + const order = createOrder({limitPrice: null}); + + expect(() => Trading212BrokerMapper.toOpenOrder(order, pair)).toThrowError( + 'Trading212 returned a LIMIT order without a limitPrice (id: 101).' + ); + }); + }); + + describe('toFilledOrder', () => { + it('maps a filled BUY order and sums the tax lines into a fee in the account currency', () => { + const item: HistoryOrder = { + fill: { + filledAt: '2025-06-02T14:30:01.000Z', + price: 100.5, + quantity: 2, + walletImpact: { + taxes: [ + {name: 'CURRENCY_CONVERSION_FEE', quantity: -0.5}, + {name: 'FRENCH_TRANSACTION_TAX', quantity: -0.25}, + ], + }, + }, + order: { + createdAt: '2025-06-02T14:30:00.000Z', + id: 7, + quantity: 2, + status: Trading212OrderStatus.FILLED, + ticker: 'AAPL_US_EQ', + }, + }; + + const fill = Trading212BrokerMapper.toFilledOrder(item, pair, 'EUR'); + + expect(fill).toEqual({ + created_at: '2025-06-02T14:30:01.000Z', + fee: '0.75', + feeAsset: 'EUR', + order_id: '7', + pair, + position: OrderPosition.LONG, + price: '100.5', + side: OrderSide.BUY, + size: '2', + }); + }); + + it('maps a filled SELL order from a negative fill quantity', () => { + const item: HistoryOrder = { + fill: { + filledAt: '2025-06-02T15:00:00.000Z', + price: 99, + quantity: -3, + walletImpact: {taxes: []}, + }, + order: { + id: 8, + quantity: -3, + status: Trading212OrderStatus.FILLED, + ticker: 'AAPL_US_EQ', + }, + }; + + const fill = Trading212BrokerMapper.toFilledOrder(item, pair, 'EUR'); + + expect(fill.side).toBe(OrderSide.SELL); + expect(fill.size).toBe('3'); + }); + + it('falls back to the order quantity and creation time when the fill omits them', () => { + const item: HistoryOrder = { + fill: {price: 42}, + order: { + createdAt: '2025-06-02T16:00:00.000Z', + id: 9, + quantity: -4, + status: Trading212OrderStatus.FILLED, + ticker: 'AAPL_US_EQ', + }, + }; + + const fill = Trading212BrokerMapper.toFilledOrder(item, pair, 'EUR'); + + expect(fill.created_at).toBe('2025-06-02T16:00:00.000Z'); + expect(fill.fee).toBe('0'); + expect(fill.side).toBe(OrderSide.SELL); + expect(fill.size).toBe('4'); + }); + + it('throws when the order is not filled', () => { + const item: HistoryOrder = { + fill: {price: 42, quantity: 1}, + order: {id: 10, quantity: 1, status: Trading212OrderStatus.CANCELLED, ticker: 'AAPL_US_EQ'}, + }; + + expect(() => Trading212BrokerMapper.toFilledOrder(item, pair, 'EUR')).toThrowError( + 'Order ID "10" is not filled.' + ); + }); + + it('throws when a FILLED entry carries no fill payload', () => { + const item: HistoryOrder = { + order: {id: 11, quantity: 1, status: Trading212OrderStatus.FILLED, ticker: 'AAPL_US_EQ'}, + }; + + expect(() => Trading212BrokerMapper.toFilledOrder(item, pair, 'EUR')).toThrowError( + 'Order ID "11" is not filled.' + ); + }); + }); +}); diff --git a/packages/exchange/src/broker/trading212/api/Trading212API.test.ts b/packages/exchange/src/broker/trading212/api/Trading212API.test.ts new file mode 100644 index 000000000..693048dce --- /dev/null +++ b/packages/exchange/src/broker/trading212/api/Trading212API.test.ts @@ -0,0 +1,162 @@ +import {AxiosError, type AxiosResponse, type InternalAxiosRequestConfig} from 'axios'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {SimplifiedHttpError} from '../../../util/SimplifiedHttpError.js'; +import {Trading212API} from './Trading212API.js'; + +function createAPI(options: {usePaperTrading?: boolean} = {}) { + return new Trading212API({ + apiKey: 'my-key', + apiSecret: 'my-secret', + usePaperTrading: options.usePaperTrading ?? true, + }); +} + +function createHttpError(config: InternalAxiosRequestConfig, status: number) { + const response: AxiosResponse = {config, data: {}, headers: {}, status, statusText: 'Error'}; + return new AxiosError(`Request failed with status code ${status}`, 'ERR_BAD_RESPONSE', config, undefined, response); +} + +function createResponse(config: InternalAxiosRequestConfig, data: unknown): AxiosResponse { + return {config, data, headers: {}, status: 200, statusText: 'OK'}; +} + +/** + * Verifies the per-endpoint retry calibration end-to-end: a request fails with a retryable + * HTTP 503 once per expected delay, and the retry must fire exactly after the calibrated + * wait — not a millisecond earlier. This pins the rate-limit table in `getRetryDelay` + * without exporting the (private) function. + */ +async function expectRetryDelays(options: { + expectedDelays: number[]; + request: (api: Trading212API) => Promise; + successData: unknown; +}) { + vi.useFakeTimers(); + const api = createAPI(); + let attempts = 0; + + api.defaults.adapter = async config => { + attempts++; + if (attempts <= options.expectedDelays.length) { + throw createHttpError(config, 503); + } + return createResponse(config, options.successData); + }; + + const pending = options.request(api); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toBe(1); + + for (const [index, delay] of options.expectedDelays.entries()) { + await vi.advanceTimersByTimeAsync(delay - 1); + expect(attempts).toBe(index + 1); + await vi.advanceTimersByTimeAsync(1); + await vi.advanceTimersByTimeAsync(0); + expect(attempts).toBe(index + 2); + } + + await pending; +} + +const accountCash = {blocked: null, free: 100, invested: 0, pieCash: 0, ppl: 0, result: 0, total: 100}; +const emptyHistoryPage = {items: [], nextPagePath: null}; +const order = {id: 42, status: 'NEW', strategy: 'QUANTITY', ticker: 'AAPL_US_EQ', type: 'LIMIT'}; + +describe('Trading212API', {concurrent: false}, () => { + afterEach(() => { + vi.useRealTimers(); + }); + + describe('authentication', () => { + it('sends a Basic Authorization header built from apiKey and apiSecret', async () => { + const api = createAPI(); + let authorization: unknown; + api.defaults.adapter = async config => { + authorization = config.headers.get('Authorization'); + return createResponse(config, []); + }; + + await api.getOrders(); + + expect(authorization).toBe(`Basic ${Buffer.from('my-key:my-secret').toString('base64')}`); + }); + + it('targets the demo environment when paper trading', () => { + expect(createAPI({usePaperTrading: true}).defaults.baseURL).toBe(Trading212API.URL_DEMO); + }); + + it('targets the live environment otherwise', () => { + expect(createAPI({usePaperTrading: false}).defaults.baseURL).toBe(Trading212API.URL_LIVE); + }); + }); + + describe('retry delays', () => { + it('waits 2s between retries of the account-cash endpoint', async () => { + await expectRetryDelays({ + expectedDelays: [2_000], + request: api => api.getAccountCash(), + successData: accountCash, + }); + }); + + it('waits 5s between retries of the open-orders endpoint', async () => { + await expectRetryDelays({expectedDelays: [5_000], request: api => api.getOrders(), successData: []}); + }); + + it('waits 5s between retries of the portfolio endpoint', async () => { + await expectRetryDelays({expectedDelays: [5_000], request: api => api.getPositions(), successData: []}); + }); + + it('waits 30s between retries of the account-info endpoint', async () => { + await expectRetryDelays({ + expectedDelays: [30_000], + request: api => api.getAccountInfo(), + successData: {currencyCode: 'EUR', id: 1}, + }); + }); + + it('waits 50s between retries of the instruments endpoint', async () => { + await expectRetryDelays({expectedDelays: [50_000], request: api => api.getInstruments(), successData: []}); + }); + + it('waits 60s between retries of the order-history endpoint', async () => { + await expectRetryDelays({ + expectedDelays: [60_000], + request: api => api.getHistoryOrdersPage(), + successData: emptyHistoryPage, + }); + }); + + it('applies the 60s history delay to paginated nextPath URLs that carry a query string', async () => { + await expectRetryDelays({ + expectedDelays: [60_000], + request: api => api.getHistoryOrdersPage({nextPath: '/api/v0/equity/history/orders?cursor=abc'}), + successData: emptyHistoryPage, + }); + }); + + it('waits 1s between retries of a single-order GET', async () => { + await expectRetryDelays({expectedDelays: [1_000], request: api => api.getOrderById(42), successData: order}); + }); + + it('falls back to linear backoff for non-GET order requests (cancelOrder)', async () => { + await expectRetryDelays({ + expectedDelays: [1_000, 2_000], + request: api => api.cancelOrder(42), + successData: null, + }); + }); + + it('does not retry client errors (HTTP 400) and surfaces a SimplifiedHttpError', async () => { + const api = createAPI(); + let attempts = 0; + api.defaults.adapter = async config => { + attempts++; + throw createHttpError(config, 400); + }; + + await expect(api.getAccountInfo()).rejects.toBeInstanceOf(SimplifiedHttpError); + expect(attempts).toBe(1); + }); + }); +});