diff --git a/package.json b/package.json index cc97dca..ae5bdc6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/backtest-client.test.ts b/src/backtest-client.test.ts new file mode 100644 index 0000000..379e81b --- /dev/null +++ b/src/backtest-client.test.ts @@ -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; + 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); + }); + }); +}); diff --git a/src/backtest-client.ts b/src/backtest-client.ts new file mode 100644 index 0000000..60c5418 --- /dev/null +++ b/src/backtest-client.ts @@ -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; + private cursors: Map = new Map(); + private simulatedTime: number; + private cash: number; + private positions: Map = new Map(); + private _fills: Fill[] = []; + private nextOrderId = 1; + + constructor(bars: Map, 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 { + return { cash: this.cash.toFixed(2) }; + } + + async getAssets(params: { status: string; asset_class: string }): Promise { + return [...this.bars.keys()].map((symbol) => ({ + symbol, + fractionable: true, + status: params.status, + asset_class: params.asset_class, + })); + } + + async getAsset(symbol: string): Promise { + return { + symbol, + fractionable: true, + status: "active", + asset_class: "us_equity", + }; + } + + async getClock(): Promise { + 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 { + 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> { + const result = new Map(); + 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 { + 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; + } +} diff --git a/src/backtest.ts b/src/backtest.ts new file mode 100644 index 0000000..fa3e616 --- /dev/null +++ b/src/backtest.ts @@ -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> { + const client = new AlpacaJS({ + keyId: process.env.ALPACA_KEY_ID, + secretKey: process.env.ALPACA_SECRET_KEY, + paper: true, + }); + + const allBars = new Map(); + + 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): Date[] { + const daySet = new Set(); + 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); +}); diff --git a/src/trading.test.ts b/src/trading.test.ts index 6589d4c..04e0715 100644 --- a/src/trading.test.ts +++ b/src/trading.test.ts @@ -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 { @@ -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 + }); +}); + diff --git a/src/trading.ts b/src/trading.ts index 7455265..88ed033 100644 --- a/src/trading.ts +++ b/src/trading.ts @@ -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; + +const defaultWait: WaitFn = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +let currentWait: WaitFn = defaultWait; + +export function wait(ms: number): Promise { + 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) {