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:
137
src/bot.test.ts
Normal file
137
src/bot.test.ts
Normal 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
37
src/bot.ts
Normal 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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Alpaca } from "./alpaca";
|
import { Alpaca } from "./alpaca";
|
||||||
import { runDay, wait } from "./trading";
|
import { Bot } from "./bot";
|
||||||
|
import { wait } from "./trading";
|
||||||
|
|
||||||
const alpaca = new Alpaca(false);
|
const alpaca = new Alpaca(false);
|
||||||
|
const bot = new Bot(alpaca, []);
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
while(true) {
|
while(true) {
|
||||||
await runDay(alpaca);
|
await bot.runDay();
|
||||||
await wait(1000 * 60 * 60);//wait an hour before going and getting the next open
|
await wait(1000 * 60 * 60);//wait an hour before going and getting the next open
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
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';
|
import type { Alpaca } from './alpaca';
|
||||||
|
|
||||||
function mockAlpaca(overrides: Partial<Alpaca> = {}): 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));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -24,23 +24,3 @@ export async function waitForNextOpen(alpaca: Alpaca) {
|
|||||||
return wait(new Date(clock.next_open).valueOf() - new Date().valueOf());
|
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());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user