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 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-01-30 14:09:30 -07:00
parent d0d3d7254a
commit ecdffab950
5 changed files with 179 additions and 73 deletions

137
src/bot.test.ts Normal file
View File

@@ -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> = {}): 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> = {}): 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');
});
});
});

37
src/bot.ts Normal file
View File

@@ -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<void> {
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);
})
);
}
}

View File

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

View File

@@ -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> = {}): 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));
});
});

View File

@@ -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());
}
}