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:
169
src/backtest.ts
Normal file
169
src/backtest.ts
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user