Files
fit/src/backtest.ts
Jon 4f1e745534 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>
2026-02-10 00:59:22 -07:00

170 lines
5.4 KiB
TypeScript

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