Add backtesting entry point for offline strategy testing

Introduces a BacktestClient that replays historical 1-min bars against the
existing strategy code, plus a CLI entry point (npm run backtest) that fetches
bars from Alpaca and runs the bot over a date range. Makes wait() pluggable
so the backtest resolves delays instantly while advancing simulated time.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-02-10 00:59:22 -07:00
parent 1d3688e9ae
commit 4f1e745534
6 changed files with 593 additions and 6 deletions

View File

@@ -16,7 +16,8 @@
"dev": "nodemon --exec ts-node src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/"
"lint": "eslint src/",
"backtest": "ts-node src/backtest.ts"
},
"dependencies": {
"@alpacahq/alpaca-trade-api": "^3.1.3",

160
src/backtest-client.test.ts Normal file
View File

@@ -0,0 +1,160 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { BacktestClient, Bar } from './backtest-client';
function makeBars(symbol: string, prices: number[], startTime: number): Bar[] {
return prices.map((price, i) => ({
Timestamp: new Date(startTime + i * 60_000).toISOString(),
OpenPrice: price,
HighPrice: price + 0.1,
LowPrice: price - 0.1,
ClosePrice: price,
Volume: 1000,
}));
}
describe('BacktestClient', () => {
const startTime = new Date('2025-01-06T09:30:00-05:00').getTime();
let bars: Map<string, Bar[]>;
let client: BacktestClient;
beforeEach(() => {
bars = new Map();
bars.set('QQQ', makeBars('QQQ', [500, 501, 502, 503, 504], startTime));
bars.set('TQQQ', makeBars('TQQQ', [80, 81, 82, 83, 84], startTime));
bars.set('SQQQ', makeBars('SQQQ', [10, 9.9, 9.8, 9.7, 9.6], startTime));
client = new BacktestClient(bars, 100_000, startTime);
});
describe('getSimulatedTime / advanceTime', () => {
it('returns initial time', () => {
expect(client.getSimulatedTime()).toBe(startTime);
});
it('advances time', () => {
client.advanceTime(60_000);
expect(client.getSimulatedTime()).toBe(startTime + 60_000);
});
});
describe('getLatestQuote', () => {
it('returns quote derived from bar at cursor', async () => {
const quote = await client.getLatestQuote('QQQ');
expect(quote.AskPrice).toBe(500);
expect(quote.BidPrice).toBeCloseTo(500 * 0.999, 2);
});
it('advances cursor when time moves forward', async () => {
client.advanceTime(120_000); // 2 minutes forward
const quote = await client.getLatestQuote('QQQ');
expect(quote.AskPrice).toBe(502);
});
it('throws for unknown symbol', async () => {
await expect(client.getLatestQuote('AAPL')).rejects.toThrow('No bar data for AAPL');
});
});
describe('getClock', () => {
it('always returns is_open true', async () => {
const clock = await client.getClock();
expect(clock.is_open).toBe(true);
});
});
describe('createOrder - buy', () => {
it('fills at close price and reduces cash', async () => {
const order = await client.createOrder({
symbol: 'TQQQ',
notional: 8000,
side: 'buy',
type: 'market',
time_in_force: 'day',
});
expect(order.status).toBe('filled');
expect(parseFloat(order.filled_avg_price)).toBe(80);
expect(parseFloat(order.filled_qty)).toBe(100);
const account = await client.getAccount();
expect(parseFloat(account.cash)).toBe(92_000);
});
it('records fill in trade log', async () => {
await client.createOrder({
symbol: 'TQQQ',
notional: 8000,
side: 'buy',
type: 'market',
time_in_force: 'day',
});
expect(client.fills).toHaveLength(1);
expect(client.fills[0].symbol).toBe('TQQQ');
expect(client.fills[0].side).toBe('buy');
});
});
describe('createOrder - sell', () => {
it('fills at close price and increases cash', async () => {
await client.createOrder({ symbol: 'TQQQ', notional: 8000, side: 'buy', type: 'market', time_in_force: 'day' });
client.advanceTime(120_000); // price goes to 82
await client.createOrder({ symbol: 'TQQQ', notional: 8200, side: 'sell', type: 'market', time_in_force: 'day' });
const account = await client.getAccount();
expect(parseFloat(account.cash)).toBeCloseTo(100_200, 0);
});
});
describe('getTotalValue', () => {
it('equals cash when no positions', () => {
expect(client.getTotalValue()).toBe(100_000);
});
it('includes position mark-to-market', async () => {
await client.createOrder({ symbol: 'TQQQ', notional: 8000, side: 'buy', type: 'market', time_in_force: 'day' });
// cash = 92000, position = 100 shares @ 80 = 8000 mtm
expect(client.getTotalValue()).toBeCloseTo(100_000, 0);
client.advanceTime(120_000); // TQQQ now 82
// cash = 92000, position = 100 shares @ 82 = 8200 mtm
expect(client.getTotalValue()).toBeCloseTo(100_200, 0);
});
});
describe('resetCursorsForDay', () => {
it('repositions cursors to the given day start', async () => {
client.advanceTime(240_000); // move to end
const quote1 = await client.getLatestQuote('QQQ');
expect(quote1.AskPrice).toBe(504);
// Reset to beginning
client.resetCursorsForDay(startTime);
const quote2 = await client.getLatestQuote('QQQ');
expect(quote2.AskPrice).toBe(500);
});
});
describe('getLatestTrades', () => {
it('returns trades for requested symbols', async () => {
const trades = await client.getLatestTrades(['QQQ', 'TQQQ']);
expect(trades.size).toBe(2);
expect(trades.get('QQQ')?.p).toBe(500);
expect(trades.get('TQQQ')?.p).toBe(80);
});
});
describe('getAccount', () => {
it('returns cash as string with 2 decimal places', async () => {
const account = await client.getAccount();
expect(account.cash).toBe('100000.00');
});
});
describe('getAsset', () => {
it('returns asset info', async () => {
const asset = await client.getAsset('TQQQ');
expect(asset.symbol).toBe('TQQQ');
expect(asset.fractionable).toBe(true);
});
});
});

221
src/backtest-client.ts Normal file
View File

@@ -0,0 +1,221 @@
import {
AlpacaClient,
AlpacaAccount,
AlpacaAsset,
AlpacaClock,
AlpacaQuote,
AlpacaOrder,
AlpacaTrade,
} from "./alpaca";
export interface Bar {
Timestamp: string;
OpenPrice: number;
HighPrice: number;
LowPrice: number;
ClosePrice: number;
Volume: number;
}
export interface Fill {
time: string;
symbol: string;
side: 'buy' | 'sell';
price: number;
notional: number;
qty: number;
}
interface Position {
qty: number;
avgCost: number;
}
export class BacktestClient implements AlpacaClient {
private bars: Map<string, Bar[]>;
private cursors: Map<string, number> = new Map();
private simulatedTime: number;
private cash: number;
private positions: Map<string, Position> = new Map();
private _fills: Fill[] = [];
private nextOrderId = 1;
constructor(bars: Map<string, Bar[]>, capital: number, startTime: number) {
this.bars = bars;
this.cash = capital;
this.simulatedTime = startTime;
for (const symbol of bars.keys()) {
this.cursors.set(symbol, 0);
}
}
get fills(): readonly Fill[] {
return this._fills;
}
getSimulatedTime(): number {
return this.simulatedTime;
}
advanceTime(ms: number): void {
this.simulatedTime += ms;
this.syncCursors();
}
resetCursorsForDay(dayStart: number): void {
this.simulatedTime = dayStart;
for (const [symbol, bars] of this.bars.entries()) {
let idx = 0;
for (let i = 0; i < bars.length; i++) {
if (new Date(bars[i].Timestamp).getTime() <= dayStart) {
idx = i;
} else {
break;
}
}
this.cursors.set(symbol, idx);
}
}
private syncCursors(): void {
for (const [symbol, bars] of this.bars.entries()) {
const cursor = this.cursors.get(symbol) ?? 0;
let idx = cursor;
while (idx + 1 < bars.length && new Date(bars[idx + 1].Timestamp).getTime() <= this.simulatedTime) {
idx++;
}
this.cursors.set(symbol, idx);
}
}
private getBarAtCursor(symbol: string): Bar | undefined {
const bars = this.bars.get(symbol);
if (!bars || bars.length === 0) return undefined;
const cursor = this.cursors.get(symbol) ?? 0;
return bars[cursor];
}
async getAccount(): Promise<AlpacaAccount> {
return { cash: this.cash.toFixed(2) };
}
async getAssets(params: { status: string; asset_class: string }): Promise<AlpacaAsset[]> {
return [...this.bars.keys()].map((symbol) => ({
symbol,
fractionable: true,
status: params.status,
asset_class: params.asset_class,
}));
}
async getAsset(symbol: string): Promise<AlpacaAsset> {
return {
symbol,
fractionable: true,
status: "active",
asset_class: "us_equity",
};
}
async getClock(): Promise<AlpacaClock> {
const t = new Date(this.simulatedTime);
const nextClose = new Date(t);
nextClose.setHours(16, 0, 0, 0);
return {
is_open: true,
next_open: t.toISOString(),
next_close: nextClose.toISOString(),
};
}
async getLatestQuote(symbol: string): Promise<AlpacaQuote> {
const bar = this.getBarAtCursor(symbol);
if (!bar) {
throw new Error(`No bar data for ${symbol}`);
}
return {
AskPrice: bar.ClosePrice,
BidPrice: bar.ClosePrice * 0.999,
};
}
async getLatestTrades(symbols: string[]): Promise<Map<string, AlpacaTrade>> {
const result = new Map<string, AlpacaTrade>();
for (const symbol of symbols) {
const bar = this.getBarAtCursor(symbol);
if (bar) {
result.set(symbol, {
p: bar.ClosePrice,
s: bar.Volume,
t: bar.Timestamp,
});
}
}
return result;
}
async createOrder(order: {
symbol: string;
notional: number;
side: "buy" | "sell";
type: string;
time_in_force: string;
}): Promise<AlpacaOrder> {
const bar = this.getBarAtCursor(order.symbol);
if (!bar) {
throw new Error(`No bar data for ${order.symbol}`);
}
const price = bar.ClosePrice;
const qty = order.notional / price;
const id = String(this.nextOrderId++);
if (order.side === "buy") {
this.cash -= order.notional;
const pos = this.positions.get(order.symbol) ?? { qty: 0, avgCost: 0 };
const totalCost = pos.avgCost * pos.qty + price * qty;
pos.qty += qty;
pos.avgCost = pos.qty > 0 ? totalCost / pos.qty : 0;
this.positions.set(order.symbol, pos);
} else {
this.cash += order.notional;
const pos = this.positions.get(order.symbol);
if (pos) {
pos.qty -= qty;
if (pos.qty <= 0.0001) {
this.positions.delete(order.symbol);
}
}
}
this._fills.push({
time: new Date(this.simulatedTime).toISOString(),
symbol: order.symbol,
side: order.side,
price,
notional: order.notional,
qty,
});
return {
id,
symbol: order.symbol,
filled_avg_price: price.toFixed(4),
filled_qty: qty.toFixed(6),
side: order.side,
status: "filled",
};
}
getTotalValue(): number {
let value = this.cash;
for (const [symbol, pos] of this.positions.entries()) {
const bar = this.getBarAtCursor(symbol);
if (bar) {
value += pos.qty * bar.ClosePrice;
}
}
return value;
}
}

169
src/backtest.ts Normal file
View File

@@ -0,0 +1,169 @@
import dotenv from 'dotenv';
dotenv.config();
import { Alpaca } from './alpaca';
import { Bot } from './bot';
import { MomentumStrategy } from './momentum-strategy';
import { BacktestClient, Bar } from './backtest-client';
import { setWaitFn, resetWaitFn } from './trading';
import { logger } from './logger';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const AlpacaJS = require('@alpacahq/alpaca-trade-api');
interface Args {
start: string;
end: string;
capital: number;
}
function parseArgs(): Args {
const args = process.argv.slice(2);
let start = '';
let end = '';
let capital = 100_000;
for (let i = 0; i < args.length; i++) {
if (args[i] === '--start' && args[i + 1]) start = args[++i];
else if (args[i] === '--end' && args[i + 1]) end = args[++i];
else if (args[i] === '--capital' && args[i + 1]) capital = parseFloat(args[++i]);
}
if (!start || !end) {
console.error('Usage: npm run backtest -- --start YYYY-MM-DD --end YYYY-MM-DD [--capital N]');
process.exit(1);
}
return { start, end, capital };
}
async function fetchBars(symbols: string[], start: string, end: string): Promise<Map<string, Bar[]>> {
const client = new AlpacaJS({
keyId: process.env.ALPACA_KEY_ID,
secretKey: process.env.ALPACA_SECRET_KEY,
paper: true,
});
const allBars = new Map<string, Bar[]>();
for (const symbol of symbols) {
logger.info(`fetching bars for ${symbol} from ${start} to ${end}`);
const bars: Bar[] = [];
const resp = client.getBarsV2(symbol, {
start,
end,
timeframe: '1Min',
limit: 10000,
feed: 'sip',
});
for await (const bar of resp) {
bars.push({
Timestamp: bar.Timestamp,
OpenPrice: bar.OpenPrice,
HighPrice: bar.HighPrice,
LowPrice: bar.LowPrice,
ClosePrice: bar.ClosePrice,
Volume: bar.Volume,
});
}
bars.sort((a, b) => new Date(a.Timestamp).getTime() - new Date(b.Timestamp).getTime());
allBars.set(symbol, bars);
logger.info(`${symbol}: ${bars.length} bars loaded`);
}
return allBars;
}
function getTradingDays(bars: Map<string, Bar[]>): Date[] {
const daySet = new Set<string>();
for (const symbolBars of bars.values()) {
for (const bar of symbolBars) {
const d = new Date(bar.Timestamp);
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
daySet.add(dateStr);
}
}
return [...daySet].sort().map((d) => {
const date = new Date(d + 'T09:30:00-05:00');
return date;
});
}
async function main() {
const { start, end, capital } = parseArgs();
const symbols = ['QQQ', 'TQQQ', 'SQQQ'];
const bars = await fetchBars(symbols, start, end);
const startTime = new Date(start + 'T09:30:00-05:00').getTime();
const backtestClient = new BacktestClient(bars, capital, startTime);
const alpaca = new Alpaca(false, backtestClient);
setWaitFn(async (ms: number) => {
backtestClient.advanceTime(ms);
});
const originalDateNow = Date.now;
Date.now = () => backtestClient.getSimulatedTime();
try {
const tradingDays = getTradingDays(bars);
logger.info(`backtest: ${tradingDays.length} trading days from ${start} to ${end}`);
const momentum = new MomentumStrategy();
const bot = new Bot(alpaca, [{ strategy: momentum, capitalAllocation: 1.0 }]);
for (const day of tradingDays) {
const dayStr = day.toISOString().split('T')[0];
logger.info(`--- day: ${dayStr} ---`);
backtestClient.resetCursorsForDay(day.getTime());
await bot.runDay();
}
printResults(backtestClient, capital);
} finally {
Date.now = originalDateNow;
resetWaitFn();
}
}
function printResults(client: BacktestClient, startingCapital: number) {
const fills = client.fills;
const finalValue = client.getTotalValue();
const pnl = finalValue - startingCapital;
const pnlPct = (pnl / startingCapital) * 100;
console.log('\n=== BACKTEST RESULTS ===\n');
console.log(`Starting capital: $${startingCapital.toFixed(2)}`);
console.log(`Final value: $${finalValue.toFixed(2)}`);
console.log(`P&L: $${pnl.toFixed(2)} (${pnlPct.toFixed(2)}%)`);
console.log(`Total fills: ${fills.length}`);
let wins = 0;
let losses = 0;
for (let i = 0; i < fills.length - 1; i += 2) {
const buy = fills[i];
const sell = fills[i + 1];
if (buy && sell && buy.side === 'buy' && sell.side === 'sell') {
if (sell.price > buy.price) wins++;
else losses++;
}
}
if (wins + losses > 0) {
console.log(`Win/Loss: ${wins}W / ${losses}L (${((wins / (wins + losses)) * 100).toFixed(1)}%)`);
}
console.log('\n--- Trade Log ---');
for (const fill of fills) {
console.log(` ${fill.time} ${fill.side.toUpperCase().padEnd(4)} ${fill.symbol.padEnd(5)} ${fill.qty.toFixed(4)} @ $${fill.price.toFixed(4)} ($${fill.notional.toFixed(2)})`);
}
console.log();
}
main().catch((e) => {
console.error('Backtest error:', e);
process.exit(1);
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { printAsset, accountBalance, waitForNextOpen } from './trading';
import { printAsset, accountBalance, waitForNextOpen, wait, setWaitFn, resetWaitFn } from './trading';
import type { Alpaca } from './alpaca';
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
@@ -25,6 +25,7 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
resetWaitFn();
});
describe('printAsset', () => {
@@ -82,3 +83,26 @@ describe('waitForNextOpen', () => {
});
});
describe('setWaitFn / resetWaitFn', () => {
it('uses custom wait function when set', async () => {
const calls: number[] = [];
setWaitFn(async (ms) => { calls.push(ms); });
await wait(5000);
await wait(3000);
expect(calls).toEqual([5000, 3000]);
});
it('restores default wait after resetWaitFn', async () => {
setWaitFn(async () => {});
resetWaitFn();
// After reset, wait should use real setTimeout again
const promise = wait(1000);
await vi.advanceTimersByTimeAsync(1000);
await promise;
// If we got here without hanging, default wait is restored
});
});

View File

@@ -1,10 +1,22 @@
import { Alpaca } from "./alpaca";
import { logger } from "./logger";
export function wait(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
type WaitFn = (ms: number) => Promise<void>;
const defaultWait: WaitFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
let currentWait: WaitFn = defaultWait;
export function wait(ms: number): Promise<void> {
return currentWait(ms);
}
export function setWaitFn(fn: WaitFn): void {
currentWait = fn;
}
export function resetWaitFn(): void {
currentWait = defaultWait;
}
export async function printAsset(alpaca: Alpaca, symbol: string) {