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:
@@ -16,7 +16,8 @@
|
|||||||
"dev": "nodemon --exec ts-node src/index.ts",
|
"dev": "nodemon --exec ts-node src/index.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/",
|
||||||
|
"backtest": "ts-node src/backtest.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alpacahq/alpaca-trade-api": "^3.1.3",
|
"@alpacahq/alpaca-trade-api": "^3.1.3",
|
||||||
|
|||||||
160
src/backtest-client.test.ts
Normal file
160
src/backtest-client.test.ts
Normal 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
221
src/backtest-client.ts
Normal 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
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);
|
||||||
|
});
|
||||||
@@ -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 } from './trading';
|
import { printAsset, accountBalance, waitForNextOpen, wait, setWaitFn, resetWaitFn } from './trading';
|
||||||
import type { Alpaca } from './alpaca';
|
import type { Alpaca } from './alpaca';
|
||||||
|
|
||||||
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||||
@@ -25,6 +25,7 @@ beforeEach(() => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
resetWaitFn();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('printAsset', () => {
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
import { Alpaca } from "./alpaca";
|
import { Alpaca } from "./alpaca";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
||||||
export function wait(ms: number) {
|
type WaitFn = (ms: number) => Promise<void>;
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(resolve, ms);
|
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) {
|
export async function printAsset(alpaca: Alpaca, symbol: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user