diff --git a/src/alpaca.test.ts b/src/alpaca.test.ts index 2b49010..69ab5a5 100644 --- a/src/alpaca.test.ts +++ b/src/alpaca.test.ts @@ -119,6 +119,29 @@ describe('Alpaca', () => { expect(client.getOrder).toHaveBeenCalledTimes(2); }); + it('retries on transient ECONNRESET error', async () => { + const econnreset = new Error('socket hang up'); + (econnreset as { code?: string }).code = 'ECONNRESET'; + const client = mockClient({ + getClock: vi.fn() + .mockRejectedValueOnce(econnreset) + .mockResolvedValueOnce({ is_open: true, next_open: '2025-01-01T14:30:00Z', next_close: '2025-01-01T21:00:00Z' }), + }); + const alpaca = new Alpaca(false, client); + const clock = await alpaca.getClock(); + expect(clock.is_open).toBe(true); + expect(client.getClock).toHaveBeenCalledTimes(2); + }); + + it('does not retry on non-transient errors', async () => { + const client = mockClient({ + getClock: vi.fn().mockRejectedValue(new Error('invalid credentials')), + }); + const alpaca = new Alpaca(false, client); + await expect(alpaca.getClock()).rejects.toThrow('invalid credentials'); + expect(client.getClock).toHaveBeenCalledTimes(1); + }); + it('sell places a market sell order by qty and returns fill', async () => { const client = mockClient({ createOrder: vi.fn().mockResolvedValue({ id: 'order-2', symbol: 'TQQQ', filled_avg_price: '51.00', filled_qty: '10', side: 'sell', status: 'filled' }), diff --git a/src/alpaca.ts b/src/alpaca.ts index 6cb7945..b7fe20b 100644 --- a/src/alpaca.ts +++ b/src/alpaca.ts @@ -76,6 +76,14 @@ export interface AlpacaClient { }): Promise; } +function isTransient(err: unknown): boolean { + if (!(err instanceof Error)) return false; + const msg = err.message; + if (msg.includes('ECONNRESET') || msg.includes('socket hang up') || msg.includes('ETIMEDOUT')) return true; + const status = (err as { response?: { status?: number } }).response?.status; + return status === 429 || status === 502 || status === 503 || status === 504; +} + export class Alpaca { private alpaca: AlpacaClient; constructor(live = false, client?: AlpacaClient) { @@ -90,46 +98,58 @@ export class Alpaca { } } + private async retry(label: string, fn: () => Promise, maxRetries = 3): Promise { + for (let attempt = 1; ; attempt++) { + try { + return await fn(); + } catch (err) { + if (attempt >= maxRetries || !isTransient(err)) throw err; + const delay = attempt * 2000; + logger.info(`${label} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + public async getAccount() { - return this.alpaca.getAccount(); + return this.retry('getAccount', () => this.alpaca.getAccount()); } public async getAssets() { - - return this.alpaca.getAssets({ + return this.retry('getAssets', () => this.alpaca.getAssets({ status : 'active', asset_class : 'us_equity' - }); + })); } public async getAsset(symbol: string) { - return this.alpaca.getAsset(symbol); + return this.retry('getAsset', () => this.alpaca.getAsset(symbol)); } public async getClock() { - const clock = await this.alpaca.getClock(); + const clock = await this.retry('getClock', () => this.alpaca.getClock()); logger.debug(`clock: is_open=${clock.is_open}, next_open=${clock.next_open}`); return clock; } public async getLatestAsk(symbol: string): Promise { - const quote = await this.alpaca.getLatestQuote(symbol); + const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol)); logger.debug(`${symbol} ask: ${quote.AskPrice}`); return quote.AskPrice; } public async getLatestBid(symbol: string): Promise { - const quote = await this.alpaca.getLatestQuote(symbol); + const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol)); logger.debug(`${symbol} bid: ${quote.BidPrice}`); return quote.BidPrice; } public async getLatestSpread(symbol: string): Promise { - const quote = await this.alpaca.getLatestQuote(symbol); + const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol)); return quote.AskPrice - quote.BidPrice; } public async getLatestTrades(symbols: string[]) { - return this.alpaca.getLatestTrades(symbols); + return this.retry('getLatestTrades', () => this.alpaca.getLatestTrades(symbols)); } public async getOrder(id: string): Promise { - return this.alpaca.getOrder(id); + return this.retry('getOrder', () => this.alpaca.getOrder(id)); } private async waitForFill(orderId: string): Promise { @@ -148,13 +168,13 @@ export class Alpaca { public async buy(symbol: string, dollarAmount: number): Promise { logger.info(`buying ${symbol} for $${dollarAmount}`); - const order = await this.alpaca.createOrder({ + const order = await this.retry('createOrder', () => this.alpaca.createOrder({ symbol, notional: dollarAmount, side: 'buy', type: 'market', time_in_force: 'day', - }); + })); const filled = order.status === 'filled' ? order : await this.waitForFill(order.id); logger.info(`bought ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`); return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) }; @@ -162,13 +182,13 @@ export class Alpaca { public async sell(symbol: string, qty: number): Promise { logger.info(`selling ${qty} shares of ${symbol}`); - const order = await this.alpaca.createOrder({ + const order = await this.retry('createOrder', () => this.alpaca.createOrder({ symbol, qty, side: 'sell', type: 'market', time_in_force: 'day', - }); + })); const filled = order.status === 'filled' ? order : await this.waitForFill(order.id); logger.info(`sold ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`); return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) };