diff --git a/src/bot.test.ts b/src/bot.test.ts new file mode 100644 index 0000000..c811b00 --- /dev/null +++ b/src/bot.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { Bot } from './bot'; +import type { Alpaca } from './alpaca'; +import type { Strategy, Signal } from './strategy'; + +function mockAlpaca(overrides: Partial = {}): Alpaca { + return { + getAccount: vi.fn().mockResolvedValue({ cash: '10000.00' }), + getAssets: vi.fn(), + getAsset: vi.fn(), + getClock: vi.fn().mockResolvedValue({ + is_open: false, + next_open: new Date().toISOString(), + next_close: new Date().toISOString(), + }), + getLatestQuote: vi.fn(), + getLatestTrades: vi.fn(), + ...overrides, + } as unknown as Alpaca; +} + +function mockStrategy(overrides: Partial = {}): Strategy { + return { + name: 'test-strategy', + capitalAllocation: 0.5, + execute: vi.fn().mockResolvedValue([]), + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('Bot', () => { + describe('constructor', () => { + it('throws when capital allocations exceed 1.0', () => { + const alpaca = mockAlpaca(); + const strategies = [ + mockStrategy({ capitalAllocation: 0.6 }), + mockStrategy({ capitalAllocation: 0.5 }), + ]; + expect(() => new Bot(alpaca, strategies)).toThrow( + 'Capital allocations sum to 1.1, which exceeds 1.0' + ); + }); + + it('accepts allocations that sum to exactly 1.0', () => { + const alpaca = mockAlpaca(); + const strategies = [ + mockStrategy({ capitalAllocation: 0.5 }), + mockStrategy({ capitalAllocation: 0.5 }), + ]; + expect(() => new Bot(alpaca, strategies)).not.toThrow(); + }); + + it('accepts allocations that sum to less than 1.0', () => { + const alpaca = mockAlpaca(); + const strategies = [ + mockStrategy({ capitalAllocation: 0.3 }), + mockStrategy({ capitalAllocation: 0.2 }), + ]; + expect(() => new Bot(alpaca, strategies)).not.toThrow(); + }); + }); + + describe('runDay', () => { + it('runs multiple strategies concurrently', async () => { + const alpaca = mockAlpaca(); + const strategyA = mockStrategy({ + name: 'A', + capitalAllocation: 0.3, + execute: vi.fn().mockResolvedValue([ + { symbol: 'TQQQ', direction: 'buy', allocation: 1.0 }, + ]), + }); + const strategyB = mockStrategy({ + name: 'B', + capitalAllocation: 0.2, + execute: vi.fn().mockResolvedValue([ + { symbol: 'SPY', direction: 'sell', allocation: 0.5 }, + ]), + }); + + const bot = new Bot(alpaca, [strategyA, strategyB]); + const promise = bot.runDay(); + await vi.advanceTimersByTimeAsync(0); + await promise; + + expect(strategyA.execute).toHaveBeenCalledWith(alpaca); + expect(strategyB.execute).toHaveBeenCalledWith(alpaca); + // A: 10000 * 0.3 * 1.0 = 3000 + expect(console.log).toHaveBeenCalledWith('[A] BUY TQQQ — $3000.00'); + // B: 10000 * 0.2 * 0.5 = 1000 + expect(console.log).toHaveBeenCalledWith('[B] SELL SPY — $1000.00'); + }); + + it('passes signals to executor', async () => { + const alpaca = mockAlpaca(); + const signals: Signal[] = [ + { symbol: 'TQQQ', direction: 'buy', allocation: 0.5 }, + ]; + const strategy = mockStrategy({ + name: 'test', + capitalAllocation: 0.4, + execute: vi.fn().mockResolvedValue(signals), + }); + + const bot = new Bot(alpaca, [strategy]); + const promise = bot.runDay(); + await vi.advanceTimersByTimeAsync(0); + await promise; + + // 10000 * 0.4 * 0.5 = 2000 + expect(console.log).toHaveBeenCalledWith('[test] BUY TQQQ — $2000.00'); + }); + + it('works with zero strategies', async () => { + const alpaca = mockAlpaca(); + const bot = new Bot(alpaca, []); + + const promise = bot.runDay(); + await vi.advanceTimersByTimeAsync(0); + await promise; + + // Only the "waiting for open" log, no strategy logs + expect(console.log).toHaveBeenCalledTimes(1); + expect(console.log).toHaveBeenCalledWith('waiting for open'); + }); + }); +}); diff --git a/src/bot.ts b/src/bot.ts new file mode 100644 index 0000000..bf4587d --- /dev/null +++ b/src/bot.ts @@ -0,0 +1,37 @@ +import { Alpaca } from "./alpaca"; +import { Strategy } from "./strategy"; +import { Executor } from "./executor"; +import { waitForNextOpen } from "./trading"; + +export class Bot { + private alpaca: Alpaca; + private strategies: Strategy[]; + private executor: Executor; + + constructor(alpaca: Alpaca, strategies: Strategy[]) { + const totalAllocation = strategies.reduce((sum, s) => sum + s.capitalAllocation, 0); + if (totalAllocation > 1.0) { + throw new Error( + `Capital allocations sum to ${totalAllocation}, which exceeds 1.0` + ); + } + this.alpaca = alpaca; + this.strategies = strategies; + this.executor = new Executor(alpaca); + } + + async runDay(): Promise { + 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/index.ts b/src/index.ts index 776a507..a31e502 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ import { Alpaca } from "./alpaca"; -import { runDay, wait } from "./trading"; +import { Bot } from "./bot"; +import { wait } from "./trading"; const alpaca = new Alpaca(false); +const bot = new Bot(alpaca, []); async function main() { while(true) { - await runDay(alpaca); + await bot.runDay(); await wait(1000 * 60 * 60);//wait an hour before going and getting the next open } } diff --git a/src/trading.test.ts b/src/trading.test.ts index 9dba83d..b085a55 100644 --- a/src/trading.test.ts +++ b/src/trading.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { printAsset, accountBalance, waitForNextOpen, runDay } from './trading'; +import { printAsset, accountBalance, waitForNextOpen } from './trading'; import type { Alpaca } from './alpaca'; function mockAlpaca(overrides: Partial = {}): Alpaca { @@ -73,53 +73,3 @@ describe('waitForNextOpen', () => { }); }); -describe('runDay', () => { - beforeEach(() => { - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - it('logs up day when second quote ask price is higher', async () => { - const alpaca = mockAlpaca({ - getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: new Date().toISOString(), next_close: new Date().toISOString() }), - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 50.00, bp: 49.90 }) - .mockResolvedValueOnce({ ap: 50.50, bp: 50.40 }), - }); - - const promise = runDay(alpaca); - await vi.advanceTimersByTimeAsync(61000); - await promise; - - expect(console.log).toHaveBeenCalledWith('up day: ', expect.any(Date)); - }); - - it('logs down day when second quote ask price is lower', async () => { - const alpaca = mockAlpaca({ - getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: new Date().toISOString(), next_close: new Date().toISOString() }), - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 50.00, bp: 49.90 }) - .mockResolvedValueOnce({ ap: 49.50, bp: 49.40 }), - }); - - const promise = runDay(alpaca); - await vi.advanceTimersByTimeAsync(61000); - await promise; - - expect(console.log).toHaveBeenCalledWith('down day', expect.any(Date)); - }); - - it('logs down day when prices are equal', async () => { - const alpaca = mockAlpaca({ - getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: new Date().toISOString(), next_close: new Date().toISOString() }), - getLatestQuote: vi.fn() - .mockResolvedValueOnce({ ap: 50.00, bp: 49.90 }) - .mockResolvedValueOnce({ ap: 50.00, bp: 49.90 }), - }); - - const promise = runDay(alpaca); - await vi.advanceTimersByTimeAsync(61000); - await promise; - - expect(console.log).toHaveBeenCalledWith('down day', expect.any(Date)); - }); -}); diff --git a/src/trading.ts b/src/trading.ts index 5bb8048..8d90cf3 100644 --- a/src/trading.ts +++ b/src/trading.ts @@ -24,23 +24,3 @@ export async function waitForNextOpen(alpaca: Alpaca) { return wait(new Date(clock.next_open).valueOf() - new Date().valueOf()); } -export async function runDay(alpaca: Alpaca) { - console.log('waiting for open'); - await waitForNextOpen(alpaca); - - await wait(60000); //wait a minute - - const q = await alpaca.getLatestQuote('TQQQ'); - await wait(1000); - - const q2 = await alpaca.getLatestQuote('TQQQ'); - - if (q2.ap - q.ap > 0) { - //up day - console.log('up day: ', new Date()) - } - else { - //down day - console.log('down day', new Date()); - } -}