From ecdffab950b220e85dd5f0d1c41b633f8cc23e30 Mon Sep 17 00:00:00 2001 From: Jon Date: Fri, 30 Jan 2026 14:09:30 -0700 Subject: [PATCH] Add bot orchestrator, wire up index.ts, remove old runDay Bot validates strategy capital allocations, waits for market open, runs all strategies concurrently, and passes signals to the executor. The main loop in index.ts now delegates to Bot.runDay(). Co-Authored-By: Claude Opus 4.5 --- src/bot.test.ts | 137 ++++++++++++++++++++++++++++++++++++++++++++ src/bot.ts | 37 ++++++++++++ src/index.ts | 6 +- src/trading.test.ts | 52 +---------------- src/trading.ts | 20 ------- 5 files changed, 179 insertions(+), 73 deletions(-) create mode 100644 src/bot.test.ts create mode 100644 src/bot.ts 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()); - } -}