diff --git a/src/alpaca.test.ts b/src/alpaca.test.ts index 114c78a..86f44c2 100644 --- a/src/alpaca.test.ts +++ b/src/alpaca.test.ts @@ -7,7 +7,7 @@ function mockClient(overrides: Partial = {}): AlpacaClient { getAssets: vi.fn().mockResolvedValue([]), getAsset: vi.fn().mockResolvedValue({ symbol: 'TQQQ', fractionable: true, status: 'active', asset_class: 'us_equity' }), 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({ ap: 50.00, bp: 49.90 }), + getLatestQuote: vi.fn().mockResolvedValue({ AskPrice: 50.00, BidPrice: 49.90 }), getLatestTrades: vi.fn().mockResolvedValue(new Map()), ...overrides, }; @@ -41,11 +41,27 @@ describe('Alpaca', () => { expect(client.getClock).toHaveBeenCalled(); }); - it('delegates getLatestQuote to the underlying client', async () => { + it('getLatestAsk returns the ask price', async () => { const client = mockClient(); const alpaca = new Alpaca(false, client); - const quote = await alpaca.getLatestQuote('TQQQ'); - expect(quote.ap).toBe(50.00); + const ask = await alpaca.getLatestAsk('TQQQ'); + expect(ask).toBe(50.00); + expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ'); + }); + + it('getLatestBid returns the bid price', async () => { + const client = mockClient(); + const alpaca = new Alpaca(false, client); + const bid = await alpaca.getLatestBid('TQQQ'); + expect(bid).toBe(49.90); + expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ'); + }); + + it('getLatestSpread returns ask minus bid', async () => { + const client = mockClient(); + const alpaca = new Alpaca(false, client); + const spread = await alpaca.getLatestSpread('TQQQ'); + expect(spread).toBeCloseTo(0.10); expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ'); }); diff --git a/src/alpaca.ts b/src/alpaca.ts index 1250cc5..c5b16d5 100644 --- a/src/alpaca.ts +++ b/src/alpaca.ts @@ -32,8 +32,8 @@ export interface AlpacaClock { } export interface AlpacaQuote { - ap: number; // ask price - bp: number; // bid price + AskPrice: number; + BidPrice: number; } export interface AlpacaTrade { @@ -83,8 +83,17 @@ export class Alpaca { return this.alpaca.getClock(); } - public async getLatestQuote(symbol: string) { - return this.alpaca.getLatestQuote(symbol); + public async getLatestAsk(symbol: string): Promise { + const quote = await this.alpaca.getLatestQuote(symbol); + return quote.AskPrice; + } + public async getLatestBid(symbol: string): Promise { + const quote = await this.alpaca.getLatestQuote(symbol); + return quote.BidPrice; + } + public async getLatestSpread(symbol: string): Promise { + const quote = await this.alpaca.getLatestQuote(symbol); + return quote.AskPrice - quote.BidPrice; } public async getLatestTrades(symbols: string[]) { return this.alpaca.getLatestTrades(symbols); diff --git a/src/bot.test.ts b/src/bot.test.ts index c811b00..30d9d44 100644 --- a/src/bot.test.ts +++ b/src/bot.test.ts @@ -13,7 +13,9 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { next_open: new Date().toISOString(), next_close: new Date().toISOString(), }), - getLatestQuote: vi.fn(), + getLatestAsk: vi.fn(), + getLatestBid: vi.fn(), + getLatestSpread: vi.fn(), getLatestTrades: vi.fn(), ...overrides, } as unknown as Alpaca; diff --git a/src/bot.ts b/src/bot.ts index bf4587d..1484fe0 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,7 +1,7 @@ import { Alpaca } from "./alpaca"; import { Strategy } from "./strategy"; import { Executor } from "./executor"; -import { waitForNextOpen } from "./trading"; +import { isMarketOpen, waitForNextOpen } from "./trading"; export class Bot { private alpaca: Alpaca; @@ -21,15 +21,18 @@ export class Bot { } async runDay(): Promise { - console.log('waiting for open'); - await waitForNextOpen(this.alpaca); - - const account = await this.alpaca.getAccount(); - const totalCapital = parseFloat(account.cash); - + const open = await isMarketOpen(this.alpaca); + if (!open) { + console.log('waiting for open'); + await waitForNextOpen(this.alpaca); + } + const account = await this.alpaca.getAccount(); + const totalCapital = parseFloat(account.cash); + await Promise.all( this.strategies.map(async (strategy) => { const signals = await strategy.execute(this.alpaca); + await this.executor.executeSignals(strategy, signals, totalCapital); }) ); diff --git a/src/executor.test.ts b/src/executor.test.ts index 5c852c6..b60558e 100644 --- a/src/executor.test.ts +++ b/src/executor.test.ts @@ -9,7 +9,9 @@ function mockAlpaca(): Alpaca { getAssets: vi.fn(), getAsset: vi.fn(), getClock: vi.fn(), - getLatestQuote: vi.fn(), + getLatestAsk: vi.fn(), + getLatestBid: vi.fn(), + getLatestSpread: vi.fn(), getLatestTrades: vi.fn(), } as unknown as Alpaca; } diff --git a/src/momentum-indicator.test.ts b/src/momentum-indicator.test.ts index 86542fd..25b736d 100644 --- a/src/momentum-indicator.test.ts +++ b/src/momentum-indicator.test.ts @@ -8,7 +8,9 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { getAssets: vi.fn(), getAsset: vi.fn(), getClock: vi.fn(), - getLatestQuote: vi.fn(), + getLatestAsk: vi.fn(), + getLatestBid: vi.fn(), + getLatestSpread: vi.fn(), getLatestTrades: vi.fn(), ...overrides, } as unknown as Alpaca; @@ -26,9 +28,9 @@ afterEach(() => { describe('MomentumIndicator', () => { it('returns up when second quote is higher', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 100, bp: 99 }) - .mockResolvedValueOnce({ ap: 101, bp: 100 }), + getLatestAsk: vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(101), }); const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 }); @@ -43,9 +45,9 @@ describe('MomentumIndicator', () => { it('returns down when second quote is lower', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 100, bp: 99 }) - .mockResolvedValueOnce({ ap: 99, bp: 98 }), + getLatestAsk: vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(99), }); const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 }); @@ -60,9 +62,9 @@ describe('MomentumIndicator', () => { it('returns up when prices are equal', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 100, bp: 99 }) - .mockResolvedValueOnce({ ap: 100, bp: 99 }), + getLatestAsk: vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(100), }); const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 }); @@ -74,17 +76,17 @@ describe('MomentumIndicator', () => { }); it('uses configured symbol', async () => { - const getLatestQuote = vi.fn() - .mockResolvedValueOnce({ ap: 50, bp: 49 }) - .mockResolvedValueOnce({ ap: 51, bp: 50 }); - const alpaca = mockAlpaca({ getLatestQuote }); + const getLatestAsk = vi.fn() + .mockResolvedValueOnce(50) + .mockResolvedValueOnce(51); + const alpaca = mockAlpaca({ getLatestAsk }); const indicator = new MomentumIndicator({ symbol: 'SPY', settleDelay: 0, sampleDelay: 100 }); const promise = indicator.evaluate(alpaca); await vi.advanceTimersByTimeAsync(100); await promise; - expect(getLatestQuote).toHaveBeenCalledWith('SPY'); - expect(getLatestQuote).toHaveBeenCalledTimes(2); + expect(getLatestAsk).toHaveBeenCalledWith('SPY'); + expect(getLatestAsk).toHaveBeenCalledTimes(2); }); }); diff --git a/src/momentum-indicator.ts b/src/momentum-indicator.ts index dd24a15..80f9f24 100644 --- a/src/momentum-indicator.ts +++ b/src/momentum-indicator.ts @@ -31,14 +31,12 @@ export class MomentumIndicator implements Indicator { async evaluate(alpaca: Alpaca): Promise { await wait(this.config.settleDelay); - const before = await alpaca.getLatestQuote(this.config.symbol); - const priceBefore = before.ap; - + const priceBefore = await alpaca.getLatestAsk(this.config.symbol); + await wait(this.config.sampleDelay); - const after = await alpaca.getLatestQuote(this.config.symbol); - const priceAfter = after.ap; - + const priceAfter = await alpaca.getLatestAsk(this.config.symbol); + const direction = priceAfter >= priceBefore ? 'up' : 'down'; return { direction, priceBefore, priceAfter }; diff --git a/src/momentum-strategy.test.ts b/src/momentum-strategy.test.ts index 75794c6..7c60b31 100644 --- a/src/momentum-strategy.test.ts +++ b/src/momentum-strategy.test.ts @@ -8,7 +8,9 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { getAssets: vi.fn(), getAsset: vi.fn(), getClock: vi.fn(), - getLatestQuote: vi.fn(), + getLatestAsk: vi.fn(), + getLatestBid: vi.fn(), + getLatestSpread: vi.fn(), getLatestTrades: vi.fn(), ...overrides, } as unknown as Alpaca; @@ -34,15 +36,16 @@ afterEach(() => { describe('MomentumStrategy', () => { it('buys TQQQ when QQQ goes up', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() + getLatestAsk: vi.fn() // indicator: QQQ before - .mockResolvedValueOnce({ ap: 100, bp: 99 }) + .mockResolvedValueOnce(100) // indicator: QQQ after (up) - .mockResolvedValueOnce({ ap: 101, bp: 100 }) + .mockResolvedValueOnce(101) // entry quote for TQQQ - .mockResolvedValueOnce({ ap: 50, bp: 49 }) + .mockResolvedValueOnce(50), + getLatestBid: vi.fn() // poll: hit target immediately - .mockResolvedValueOnce({ ap: 51, bp: 50.50 }), + .mockResolvedValueOnce(50.50), }); const strategy = new MomentumStrategy(1.0, fastConfig); @@ -55,15 +58,16 @@ describe('MomentumStrategy', () => { it('buys SQQQ when QQQ goes down', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() + getLatestAsk: vi.fn() // indicator: QQQ before - .mockResolvedValueOnce({ ap: 100, bp: 99 }) + .mockResolvedValueOnce(100) // indicator: QQQ after (down) - .mockResolvedValueOnce({ ap: 99, bp: 98 }) + .mockResolvedValueOnce(99) // entry quote for SQQQ - .mockResolvedValueOnce({ ap: 30, bp: 29 }) + .mockResolvedValueOnce(30), + getLatestBid: vi.fn() // poll: hit target immediately - .mockResolvedValueOnce({ ap: 31, bp: 30.30 }), + .mockResolvedValueOnce(30.30), }); const strategy = new MomentumStrategy(1.0, fastConfig); @@ -76,16 +80,17 @@ describe('MomentumStrategy', () => { it('sells when bid hits 1% target', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() + getLatestAsk: vi.fn() // indicator: QQQ samples - .mockResolvedValueOnce({ ap: 100, bp: 99 }) - .mockResolvedValueOnce({ ap: 101, bp: 100 }) + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(101) // entry quote: ask = 50 - .mockResolvedValueOnce({ ap: 50, bp: 49 }) + .mockResolvedValueOnce(50), + getLatestBid: vi.fn() // poll 1: not yet (target = 50.50) - .mockResolvedValueOnce({ ap: 50.20, bp: 50.10 }) + .mockResolvedValueOnce(50.10) // poll 2: hit target - .mockResolvedValueOnce({ ap: 51, bp: 50.50 }), + .mockResolvedValueOnce(50.50), }); const strategy = new MomentumStrategy(1.0, fastConfig); @@ -99,14 +104,15 @@ describe('MomentumStrategy', () => { it('sells on timeout when target not reached', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() + getLatestAsk: vi.fn() // indicator: QQQ samples - .mockResolvedValueOnce({ ap: 100, bp: 99 }) - .mockResolvedValueOnce({ ap: 101, bp: 100 }) + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(101) // entry quote: ask = 50 - .mockResolvedValueOnce({ ap: 50, bp: 49 }) + .mockResolvedValueOnce(50), + getLatestBid: vi.fn() // all polls: never reach target - .mockResolvedValue({ ap: 50.10, bp: 49.90 }), + .mockResolvedValue(49.90), }); const strategy = new MomentumStrategy(1.0, fastConfig); @@ -120,11 +126,12 @@ describe('MomentumStrategy', () => { it('returns both buy and sell signals', async () => { const alpaca = mockAlpaca({ - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 100, bp: 99 }) - .mockResolvedValueOnce({ ap: 101, bp: 100 }) - .mockResolvedValueOnce({ ap: 50, bp: 49 }) - .mockResolvedValueOnce({ ap: 51, bp: 50.50 }), + getLatestAsk: vi.fn() + .mockResolvedValueOnce(100) + .mockResolvedValueOnce(101) + .mockResolvedValueOnce(50), + getLatestBid: vi.fn() + .mockResolvedValueOnce(50.50), }); const strategy = new MomentumStrategy(1.0, fastConfig); diff --git a/src/momentum-strategy.ts b/src/momentum-strategy.ts index d48d071..532e318 100644 --- a/src/momentum-strategy.ts +++ b/src/momentum-strategy.ts @@ -32,8 +32,7 @@ export class MomentumStrategy implements Strategy { const result = await this.indicator.evaluate(alpaca); const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ'; - const entryQuote = await alpaca.getLatestQuote(symbol); - const entryPrice = entryQuote.ap; + const entryPrice = await alpaca.getLatestAsk(symbol); const buy: Signal = { symbol, direction: 'buy', allocation: 1.0 }; @@ -45,8 +44,8 @@ export class MomentumStrategy implements Strategy { while (Date.now() < deadline) { await wait(this.config.pollInterval); - const quote = await alpaca.getLatestQuote(symbol); - if (quote.bp >= targetPrice) { + const bid = await alpaca.getLatestBid(symbol); + if (bid >= targetPrice) { reason = 'target'; break; } diff --git a/src/trading.test.ts b/src/trading.test.ts index b085a55..75eeaff 100644 --- a/src/trading.test.ts +++ b/src/trading.test.ts @@ -8,7 +8,9 @@ function mockAlpaca(overrides: Partial = {}): Alpaca { getAssets: vi.fn(), getAsset: vi.fn(), getClock: vi.fn(), - getLatestQuote: vi.fn(), + getLatestAsk: vi.fn(), + getLatestBid: vi.fn(), + getLatestSpread: vi.fn(), getLatestTrades: vi.fn(), ...overrides, } as unknown as Alpaca; diff --git a/src/trading.ts b/src/trading.ts index 8d90cf3..01757e4 100644 --- a/src/trading.ts +++ b/src/trading.ts @@ -19,6 +19,11 @@ export async function accountBalance(alpaca: Alpaca) { return account.cash; } +export async function isMarketOpen(alpaca: Alpaca): Promise { + const clock = await alpaca.getClock(); + return clock.is_open; +} + export async function waitForNextOpen(alpaca: Alpaca) { const clock = await alpaca.getClock(); return wait(new Date(clock.next_open).valueOf() - new Date().valueOf());