From 4ca7073d773e1602a7a6c0449e2acc846f288d9b Mon Sep 17 00:00:00 2001 From: Jon Date: Wed, 11 Feb 2026 12:37:12 -0700 Subject: [PATCH] Sell by qty instead of notional, poll for buy fill The buy order can return before filling, giving null price/qty. Now buy polls getOrder until filled. Sell takes the actual qty from the buy fill instead of the original dollar amount, which avoids the "insufficient qty" error when the price moves between buy and sell. Co-Authored-By: Claude Opus 4.6 --- src/alpaca.test.ts | 31 ++++++++++++++++++------ src/alpaca.ts | 45 ++++++++++++++++++++++++++++------- src/backtest-client.ts | 25 +++++++++++++++---- src/bot.test.ts | 1 + src/momentum-strategy.test.ts | 31 ++++++++++++------------ src/momentum-strategy.ts | 8 +++---- src/trading.test.ts | 1 + 7 files changed, 102 insertions(+), 40 deletions(-) diff --git a/src/alpaca.test.ts b/src/alpaca.test.ts index ce566dc..2b49010 100644 --- a/src/alpaca.test.ts +++ b/src/alpaca.test.ts @@ -9,6 +9,7 @@ function mockClient(overrides: Partial = {}): AlpacaClient { getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: '2025-01-01T14:30:00Z', next_close: '2025-01-01T21:00:00Z' }), getLatestQuote: vi.fn().mockResolvedValue({ AskPrice: 50.00, BidPrice: 49.90 }), getLatestTrades: vi.fn().mockResolvedValue(new Map()), + getOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }), createOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }), ...overrides, }; @@ -89,11 +90,12 @@ describe('Alpaca', () => { expect(client.getLatestTrades).toHaveBeenCalledWith(['TQQQ', 'SPY']); }); - it('buy places a market buy order and returns fill price', async () => { + it('buy places a market buy order and returns fill', async () => { const client = mockClient(); const alpaca = new Alpaca(false, client); - const price = await alpaca.buy('TQQQ', 5000); - expect(price).toBe(50.25); + const fill = await alpaca.buy('TQQQ', 5000); + expect(fill.price).toBe(50.25); + expect(fill.qty).toBe(10); expect(client.createOrder).toHaveBeenCalledWith({ symbol: 'TQQQ', notional: 5000, @@ -103,16 +105,31 @@ describe('Alpaca', () => { }); }); - it('sell places a market sell order and returns fill price', async () => { + it('buy polls getOrder when createOrder returns unfilled', async () => { + const client = mockClient({ + createOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: null, filled_qty: '0', side: 'buy', status: 'accepted' }), + getOrder: vi.fn() + .mockResolvedValueOnce({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: null, filled_qty: '0', side: 'buy', status: 'partially_filled' }) + .mockResolvedValueOnce({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }), + }); + const alpaca = new Alpaca(false, client); + const fill = await alpaca.buy('TQQQ', 5000); + expect(fill.price).toBe(50.25); + expect(fill.qty).toBe(10); + expect(client.getOrder).toHaveBeenCalledTimes(2); + }); + + 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' }), }); const alpaca = new Alpaca(false, client); - const price = await alpaca.sell('TQQQ', 5000); - expect(price).toBe(51.00); + const fill = await alpaca.sell('TQQQ', 10); + expect(fill.price).toBe(51.00); + expect(fill.qty).toBe(10); expect(client.createOrder).toHaveBeenCalledWith({ symbol: 'TQQQ', - notional: 5000, + qty: 10, side: 'sell', type: 'market', time_in_force: 'day', diff --git a/src/alpaca.ts b/src/alpaca.ts index 23248cf..6cb7945 100644 --- a/src/alpaca.ts +++ b/src/alpaca.ts @@ -53,6 +53,11 @@ export interface AlpacaTrade { t: string; // timestamp } +export interface OrderFill { + price: number; + qty: number; +} + export interface AlpacaClient { getAccount(): Promise; getAssets(params: { status: string; asset_class: string }): Promise; @@ -60,9 +65,11 @@ export interface AlpacaClient { getClock(): Promise; getLatestQuote(symbol: string): Promise; getLatestTrades(symbols: string[]): Promise>; + getOrder(id: string): Promise; createOrder(order: { symbol: string; - notional: number; + notional?: number; + qty?: number; side: 'buy' | 'sell'; type: string; time_in_force: string; @@ -121,7 +128,25 @@ export class Alpaca { return this.alpaca.getLatestTrades(symbols); } - public async buy(symbol: string, dollarAmount: number): Promise { + public async getOrder(id: string): Promise { + return this.alpaca.getOrder(id); + } + + private async waitForFill(orderId: string): Promise { + const maxAttempts = 30; + for (let i = 0; i < maxAttempts; i++) { + const order = await this.alpaca.getOrder(orderId); + if (order.status === 'filled') return order; + if (order.status === 'canceled' || order.status === 'expired' || order.status === 'rejected') { + throw new Error(`Order ${orderId} ${order.status}`); + } + logger.debug(`order ${orderId} status: ${order.status}, waiting...`); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + throw new Error(`Order ${orderId} not filled after ${maxAttempts}s`); + } + + public async buy(symbol: string, dollarAmount: number): Promise { logger.info(`buying ${symbol} for $${dollarAmount}`); const order = await this.alpaca.createOrder({ symbol, @@ -130,20 +155,22 @@ export class Alpaca { type: 'market', time_in_force: 'day', }); - logger.info(`bought ${symbol} — filled at ${order.filled_avg_price}, qty ${order.filled_qty}, order ${order.id}`); - return parseFloat(order.filled_avg_price); + 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) }; } - public async sell(symbol: string, dollarAmount: number): Promise { - logger.info(`selling ${symbol} for $${dollarAmount}`); + public async sell(symbol: string, qty: number): Promise { + logger.info(`selling ${qty} shares of ${symbol}`); const order = await this.alpaca.createOrder({ symbol, - notional: dollarAmount, + qty, side: 'sell', type: 'market', time_in_force: 'day', }); - logger.info(`sold ${symbol} — filled at ${order.filled_avg_price}, qty ${order.filled_qty}, order ${order.id}`); - return parseFloat(order.filled_avg_price); + 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) }; } } \ No newline at end of file diff --git a/src/backtest-client.ts b/src/backtest-client.ts index 60c5418..0fe9e2c 100644 --- a/src/backtest-client.ts +++ b/src/backtest-client.ts @@ -155,9 +155,23 @@ export class BacktestClient implements AlpacaClient { return result; } + async getOrder(id: string): Promise { + const fill = this._fills.find((_, i) => String(i + 1) === id); + if (!fill) throw new Error(`Order ${id} not found`); + return { + id, + symbol: fill.symbol, + filled_avg_price: fill.price.toFixed(4), + filled_qty: fill.qty.toFixed(6), + side: fill.side, + status: "filled", + }; + } + async createOrder(order: { symbol: string; - notional: number; + notional?: number; + qty?: number; side: "buy" | "sell"; type: string; time_in_force: string; @@ -168,18 +182,19 @@ export class BacktestClient implements AlpacaClient { } const price = bar.ClosePrice; - const qty = order.notional / price; + const qty = order.qty ?? (order.notional! / price); + const notional = order.notional ?? (order.qty! * price); const id = String(this.nextOrderId++); if (order.side === "buy") { - this.cash -= order.notional; + this.cash -= notional; const pos = this.positions.get(order.symbol) ?? { qty: 0, avgCost: 0 }; const totalCost = pos.avgCost * pos.qty + price * qty; pos.qty += qty; pos.avgCost = pos.qty > 0 ? totalCost / pos.qty : 0; this.positions.set(order.symbol, pos); } else { - this.cash += order.notional; + this.cash += notional; const pos = this.positions.get(order.symbol); if (pos) { pos.qty -= qty; @@ -194,7 +209,7 @@ export class BacktestClient implements AlpacaClient { symbol: order.symbol, side: order.side, price, - notional: order.notional, + notional, qty, }); diff --git a/src/bot.test.ts b/src/bot.test.ts index f45691b..b86d82a 100644 --- a/src/bot.test.ts +++ b/src/bot.test.ts @@ -19,6 +19,7 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { getLatestTrades: vi.fn(), buy: vi.fn(), sell: vi.fn(), + getOrder: vi.fn(), ...overrides, } as unknown as Alpaca; } diff --git a/src/momentum-strategy.test.ts b/src/momentum-strategy.test.ts index 69aed17..d96d4ef 100644 --- a/src/momentum-strategy.test.ts +++ b/src/momentum-strategy.test.ts @@ -16,8 +16,9 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { getLatestBid: vi.fn(), getLatestSpread: vi.fn(), getLatestTrades: vi.fn(), - buy: vi.fn().mockResolvedValue(50), - sell: vi.fn().mockResolvedValue(50), + buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), + sell: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), + getOrder: vi.fn(), ...overrides, } as unknown as Alpaca; } @@ -46,7 +47,7 @@ describe('MomentumStrategy', () => { getLatestAsk: vi.fn() .mockResolvedValueOnce(100) .mockResolvedValueOnce(101), - buy: vi.fn().mockResolvedValue(50), + buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), getLatestBid: vi.fn().mockResolvedValue(50.50), }); @@ -63,7 +64,7 @@ describe('MomentumStrategy', () => { getLatestAsk: vi.fn() .mockResolvedValueOnce(100) .mockResolvedValueOnce(99), - buy: vi.fn().mockResolvedValue(30), + buy: vi.fn().mockResolvedValue({ price: 30, qty: 166.67 }), getLatestBid: vi.fn().mockResolvedValue(30.30), }); @@ -80,13 +81,13 @@ describe('MomentumStrategy', () => { getLatestAsk: vi.fn() .mockResolvedValueOnce(100) .mockResolvedValueOnce(101), - buy: vi.fn().mockResolvedValue(50), + buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), getLatestBid: vi.fn() // poll 1: not yet (target = 50.50) .mockResolvedValueOnce(50.10) // poll 2: hit target .mockResolvedValueOnce(50.50), - sell: vi.fn().mockResolvedValue(50.50), + sell: vi.fn().mockResolvedValue({ price: 50.50, qty: 100 }), }); const strategy = new MomentumStrategy(fastConfig); @@ -95,7 +96,7 @@ describe('MomentumStrategy', () => { await promise; expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: target'); - expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000); + expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 100); }); it('sells on timeout when target not reached', async () => { @@ -103,9 +104,9 @@ describe('MomentumStrategy', () => { getLatestAsk: vi.fn() .mockResolvedValueOnce(100) .mockResolvedValueOnce(101), - buy: vi.fn().mockResolvedValue(50), + buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), getLatestBid: vi.fn().mockResolvedValue(49.90), - sell: vi.fn().mockResolvedValue(49.90), + sell: vi.fn().mockResolvedValue({ price: 49.90, qty: 100 }), }); const strategy = new MomentumStrategy(fastConfig); @@ -114,7 +115,7 @@ describe('MomentumStrategy', () => { await promise; expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: timeout'); - expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000); + expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 100); }); it('caps hold time to 2 min before market close', async () => { @@ -126,14 +127,14 @@ describe('MomentumStrategy', () => { getLatestAsk: vi.fn() .mockResolvedValueOnce(100) .mockResolvedValueOnce(101), - buy: vi.fn().mockResolvedValue(50), + buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), getClock: vi.fn().mockResolvedValue({ is_open: true, next_open: new Date().toISOString(), next_close: closeTime, }), getLatestBid: vi.fn().mockResolvedValue(49.90), - sell: vi.fn().mockResolvedValue(49.90), + sell: vi.fn().mockResolvedValue({ price: 49.90, qty: 100 }), }); const strategy = new MomentumStrategy(fastConfig); @@ -143,7 +144,7 @@ describe('MomentumStrategy', () => { await vi.advanceTimersByTimeAsync(500); await promise; - expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000); + expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 100); // With close only 500ms away, safeClose = close - 120s is already past, // so the loop body should never run (no bid checks) expect(alpaca.getLatestBid).not.toHaveBeenCalled(); @@ -155,13 +156,13 @@ describe('MomentumStrategy', () => { .mockResolvedValueOnce(100) .mockResolvedValueOnce(101), // fill price is 50, so 1% target = 50.50 - buy: vi.fn().mockResolvedValue(50), + buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }), getLatestBid: vi.fn() // 50.49 is below target .mockResolvedValueOnce(50.49) // 50.50 hits target .mockResolvedValueOnce(50.50), - sell: vi.fn().mockResolvedValue(50.50), + sell: vi.fn().mockResolvedValue({ price: 50.50, qty: 100 }), }); const strategy = new MomentumStrategy(fastConfig); diff --git a/src/momentum-strategy.ts b/src/momentum-strategy.ts index e3af955..fc763f2 100644 --- a/src/momentum-strategy.ts +++ b/src/momentum-strategy.ts @@ -33,10 +33,10 @@ export class MomentumStrategy implements Strategy { logger.debug(`[${this.name}] indicator result: ${JSON.stringify(result)}`); const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ'; - const entryPrice = await alpaca.buy(symbol, capitalAmount); - logger.info(`[${this.name}] entered ${symbol} at price ${entryPrice}`); + const fill = await alpaca.buy(symbol, capitalAmount); + logger.info(`[${this.name}] entered ${symbol} at price ${fill.price}, qty ${fill.qty}`); - const targetPrice = entryPrice * (1 + this.config.targetGain); + const targetPrice = fill.price * (1 + this.config.targetGain); let deadline = Date.now() + this.config.holdTime; const clock = await alpaca.getClock(); @@ -62,6 +62,6 @@ export class MomentumStrategy implements Strategy { logger.info(`[${this.name}] exit ${symbol} — reason: ${reason}`); - await alpaca.sell(symbol, capitalAmount); + await alpaca.sell(symbol, fill.qty); } } diff --git a/src/trading.test.ts b/src/trading.test.ts index 2f086ca..8ebbe76 100644 --- a/src/trading.test.ts +++ b/src/trading.test.ts @@ -14,6 +14,7 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { getLatestTrades: vi.fn(), buy: vi.fn(), sell: vi.fn(), + getOrder: vi.fn(), ...overrides, } as unknown as Alpaca; }