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; }