Compare commits

...

10 Commits

Author SHA1 Message Date
Jon
4c2ed5455f Increase fill poll timeout to 2min, always sell after buy
waitForFill now polls 60 times at 2s intervals (2 min total) instead
of 30x1s, and uses retry on the getOrder calls. The strategy wraps
the hold loop in try/finally so if anything fails after a buy, we
still sell the position instead of leaving it orphaned.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 10:46:20 -07:00
Jon
019b630d95 Add retry logic for transient API errors
Wraps all Alpaca API calls with automatic retries (up to 3 attempts
with increasing backoff) for transient errors like ECONNRESET, socket
hang up, ETIMEDOUT, and 429/502/503/504 responses. Non-transient
errors like 422 or auth failures are thrown immediately.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 22:55:55 -07:00
Jon
4ca7073d77 Sell by qty instead of notional, poll for buy fill
The buy order can return before filling, giving null price/qty. Now
buy polls getOrder until filled. Sell takes the actual qty from the
buy fill instead of the original dollar amount, which avoids the
"insufficient qty" error when the price moves between buy and sell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 12:37:12 -07:00
Jon
7b1a20c768 Poll for market open instead of single long setTimeout
macOS sleep suspends the Docker VM, causing long setTimeout calls to
never fire. Replace the one-shot wait with a polling loop that checks
getClock every 30s (capped to time-until-open), so the bot resumes
promptly after the host wakes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:02:49 -07:00
Jon
46e7bef4b2 Add docker build step to rebuild script
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:58:42 -07:00
Jon
06deefe011 Add tests for backtest-client getAssets and createOrder unknown symbol
Covers the two remaining uncovered lines: getAssets returning all
symbols from bar data, and createOrder throwing on unknown symbols.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:56:00 -07:00
Jon
2208524a3f add a script to rebuild the docker container and run it 2026-02-10 19:54:08 -07:00
Jon
e8b05c7870 Move error handling inside main loop to survive bad cycles
A failed trading cycle (e.g. sell rejected after close) previously
propagated to the top-level .catch() and process.exit(0), killing
the container permanently. Now errors are caught per-iteration so
the bot sleeps and retries on the next cycle.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:49:23 -07:00
Jon
23e5437402 Cap strategy hold time to 2 min before market close
Prevents sell orders from being rejected after market close by fetching
the market clock after entry and capping the hold deadline to next_close
minus a 2-minute buffer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 15:37:19 -07:00
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
13 changed files with 904 additions and 100 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",

6
scripts/rebuild.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
docker build -t fit .
docker stop fit
docker rm fit
docker run --name fit --restart=unless-stopped -d fit

View File

@@ -9,6 +9,7 @@ function mockClient(overrides: Partial<AlpacaClient> = {}): AlpacaClient {
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: '2025-01-01T14:30:00Z', next_close: '2025-01-01T21:00:00Z' }),
getLatestQuote: vi.fn().mockResolvedValue({ AskPrice: 50.00, BidPrice: 49.90 }),
getLatestTrades: vi.fn().mockResolvedValue(new Map()),
getOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }),
createOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }),
...overrides,
};
@@ -89,11 +90,12 @@ describe('Alpaca', () => {
expect(client.getLatestTrades).toHaveBeenCalledWith(['TQQQ', 'SPY']);
});
it('buy places a market buy order and returns fill price', async () => {
it('buy places a market buy order and returns fill', async () => {
const client = mockClient();
const alpaca = new Alpaca(false, client);
const price = await alpaca.buy('TQQQ', 5000);
expect(price).toBe(50.25);
const fill = await alpaca.buy('TQQQ', 5000);
expect(fill.price).toBe(50.25);
expect(fill.qty).toBe(10);
expect(client.createOrder).toHaveBeenCalledWith({
symbol: 'TQQQ',
notional: 5000,
@@ -103,16 +105,54 @@ describe('Alpaca', () => {
});
});
it('sell places a market sell order and returns fill price', async () => {
it('buy polls getOrder when createOrder returns unfilled', async () => {
const client = mockClient({
createOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: null, filled_qty: '0', side: 'buy', status: 'accepted' }),
getOrder: vi.fn()
.mockResolvedValueOnce({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: null, filled_qty: '0', side: 'buy', status: 'partially_filled' })
.mockResolvedValueOnce({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }),
});
const alpaca = new Alpaca(false, client);
const fill = await alpaca.buy('TQQQ', 5000);
expect(fill.price).toBe(50.25);
expect(fill.qty).toBe(10);
expect(client.getOrder).toHaveBeenCalledTimes(2);
});
it('retries on transient ECONNRESET error', async () => {
const econnreset = new Error('socket hang up');
(econnreset as { code?: string }).code = 'ECONNRESET';
const client = mockClient({
getClock: vi.fn()
.mockRejectedValueOnce(econnreset)
.mockResolvedValueOnce({ is_open: true, next_open: '2025-01-01T14:30:00Z', next_close: '2025-01-01T21:00:00Z' }),
});
const alpaca = new Alpaca(false, client);
const clock = await alpaca.getClock();
expect(clock.is_open).toBe(true);
expect(client.getClock).toHaveBeenCalledTimes(2);
});
it('does not retry on non-transient errors', async () => {
const client = mockClient({
getClock: vi.fn().mockRejectedValue(new Error('invalid credentials')),
});
const alpaca = new Alpaca(false, client);
await expect(alpaca.getClock()).rejects.toThrow('invalid credentials');
expect(client.getClock).toHaveBeenCalledTimes(1);
});
it('sell places a market sell order by qty and returns fill', async () => {
const client = mockClient({
createOrder: vi.fn().mockResolvedValue({ id: 'order-2', symbol: 'TQQQ', filled_avg_price: '51.00', filled_qty: '10', side: 'sell', status: 'filled' }),
});
const alpaca = new Alpaca(false, client);
const price = await alpaca.sell('TQQQ', 5000);
expect(price).toBe(51.00);
const fill = await alpaca.sell('TQQQ', 10);
expect(fill.price).toBe(51.00);
expect(fill.qty).toBe(10);
expect(client.createOrder).toHaveBeenCalledWith({
symbol: 'TQQQ',
notional: 5000,
qty: 10,
side: 'sell',
type: 'market',
time_in_force: 'day',

View File

@@ -53,6 +53,11 @@ export interface AlpacaTrade {
t: string; // timestamp
}
export interface OrderFill {
price: number;
qty: number;
}
export interface AlpacaClient {
getAccount(): Promise<AlpacaAccount>;
getAssets(params: { status: string; asset_class: string }): Promise<AlpacaAsset[]>;
@@ -60,15 +65,25 @@ export interface AlpacaClient {
getClock(): Promise<AlpacaClock>;
getLatestQuote(symbol: string): Promise<AlpacaQuote>;
getLatestTrades(symbols: string[]): Promise<Map<string, AlpacaTrade>>;
getOrder(id: string): Promise<AlpacaOrder>;
createOrder(order: {
symbol: string;
notional: number;
notional?: number;
qty?: number;
side: 'buy' | 'sell';
type: string;
time_in_force: string;
}): Promise<AlpacaOrder>;
}
function isTransient(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message;
if (msg.includes('ECONNRESET') || msg.includes('socket hang up') || msg.includes('ETIMEDOUT')) return true;
const status = (err as { response?: { status?: number } }).response?.status;
return status === 429 || status === 502 || status === 503 || status === 504;
}
export class Alpaca {
private alpaca: AlpacaClient;
constructor(live = false, client?: AlpacaClient) {
@@ -83,67 +98,100 @@ export class Alpaca {
}
}
private async retry<T>(label: string, fn: () => Promise<T>, maxRetries = 3): Promise<T> {
for (let attempt = 1; ; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt >= maxRetries || !isTransient(err)) throw err;
const delay = attempt * 2000;
logger.info(`${label} failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms`);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
public async getAccount() {
return this.alpaca.getAccount();
return this.retry('getAccount', () => this.alpaca.getAccount());
}
public async getAssets() {
return this.alpaca.getAssets({
return this.retry('getAssets', () => this.alpaca.getAssets({
status : 'active',
asset_class : 'us_equity'
});
}));
}
public async getAsset(symbol: string) {
return this.alpaca.getAsset(symbol);
return this.retry('getAsset', () => this.alpaca.getAsset(symbol));
}
public async getClock() {
const clock = await this.alpaca.getClock();
const clock = await this.retry('getClock', () => this.alpaca.getClock());
logger.debug(`clock: is_open=${clock.is_open}, next_open=${clock.next_open}`);
return clock;
}
public async getLatestAsk(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol);
const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol));
logger.debug(`${symbol} ask: ${quote.AskPrice}`);
return quote.AskPrice;
}
public async getLatestBid(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol);
const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol));
logger.debug(`${symbol} bid: ${quote.BidPrice}`);
return quote.BidPrice;
}
public async getLatestSpread(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol);
const quote = await this.retry('getLatestQuote', () => this.alpaca.getLatestQuote(symbol));
return quote.AskPrice - quote.BidPrice;
}
public async getLatestTrades(symbols: string[]) {
return this.alpaca.getLatestTrades(symbols);
return this.retry('getLatestTrades', () => this.alpaca.getLatestTrades(symbols));
}
public async buy(symbol: string, dollarAmount: number): Promise<number> {
public async getOrder(id: string): Promise<AlpacaOrder> {
return this.retry('getOrder', () => this.alpaca.getOrder(id));
}
private async waitForFill(orderId: string): Promise<AlpacaOrder> {
const maxAttempts = 60;
const pollInterval = 2000;
for (let i = 0; i < maxAttempts; i++) {
const order = await this.retry('getOrder', () => this.alpaca.getOrder(orderId));
if (order.status === 'filled') return order;
if (order.status === 'canceled' || order.status === 'expired' || order.status === 'rejected') {
throw new Error(`Order ${orderId} ${order.status}`);
}
logger.debug(`order ${orderId} status: ${order.status}, waiting...`);
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
throw new Error(`Order ${orderId} not filled after ${maxAttempts * pollInterval / 1000}s`);
}
public async buy(symbol: string, dollarAmount: number): Promise<OrderFill> {
logger.info(`buying ${symbol} for $${dollarAmount}`);
const order = await this.alpaca.createOrder({
const order = await this.retry('createOrder', () => this.alpaca.createOrder({
symbol,
notional: dollarAmount,
side: 'buy',
type: 'market',
time_in_force: 'day',
});
logger.info(`bought ${symbol} — filled at ${order.filled_avg_price}, qty ${order.filled_qty}, order ${order.id}`);
return parseFloat(order.filled_avg_price);
}));
const filled = order.status === 'filled' ? order : await this.waitForFill(order.id);
logger.info(`bought ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`);
return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) };
}
public async sell(symbol: string, dollarAmount: number): Promise<number> {
logger.info(`selling ${symbol} for $${dollarAmount}`);
const order = await this.alpaca.createOrder({
public async sell(symbol: string, qty: number): Promise<OrderFill> {
logger.info(`selling ${qty} shares of ${symbol}`);
const order = await this.retry('createOrder', () => this.alpaca.createOrder({
symbol,
notional: dollarAmount,
qty,
side: 'sell',
type: 'market',
time_in_force: 'day',
});
logger.info(`sold ${symbol} — filled at ${order.filled_avg_price}, qty ${order.filled_qty}, order ${order.id}`);
return parseFloat(order.filled_avg_price);
}));
const filled = order.status === 'filled' ? order : await this.waitForFill(order.id);
logger.info(`sold ${symbol} — filled at ${filled.filled_avg_price}, qty ${filled.filled_qty}, order ${filled.id}`);
return { price: parseFloat(filled.filled_avg_price), qty: parseFloat(filled.filled_qty) };
}
}

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

@@ -0,0 +1,183 @@
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('getAssets', () => {
it('returns all symbols from bar data', async () => {
const assets = await client.getAssets({ status: 'active', asset_class: 'us_equity' });
const symbols = assets.map(a => a.symbol);
expect(symbols).toEqual(['QQQ', 'TQQQ', 'SQQQ']);
expect(assets[0].status).toBe('active');
expect(assets[0].asset_class).toBe('us_equity');
expect(assets[0].fractionable).toBe(true);
});
});
describe('getAsset', () => {
it('returns asset info', async () => {
const asset = await client.getAsset('TQQQ');
expect(asset.symbol).toBe('TQQQ');
expect(asset.fractionable).toBe(true);
});
});
describe('createOrder - unknown symbol', () => {
it('throws when no bar data exists', async () => {
await expect(client.createOrder({
symbol: 'AAPL',
notional: 1000,
side: 'buy',
type: 'market',
time_in_force: 'day',
})).rejects.toThrow('No bar data for AAPL');
});
});
});

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

@@ -0,0 +1,236 @@
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 getOrder(id: string): Promise<AlpacaOrder> {
const fill = this._fills.find((_, i) => String(i + 1) === id);
if (!fill) throw new Error(`Order ${id} not found`);
return {
id,
symbol: fill.symbol,
filled_avg_price: fill.price.toFixed(4),
filled_qty: fill.qty.toFixed(6),
side: fill.side,
status: "filled",
};
}
async createOrder(order: {
symbol: string;
notional?: number;
qty?: 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.qty ?? (order.notional! / price);
const notional = order.notional ?? (order.qty! * price);
const id = String(this.nextOrderId++);
if (order.side === "buy") {
this.cash -= 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 += 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,
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

@@ -9,9 +9,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn().mockResolvedValue({
is_open: false,
is_open: true,
next_open: new Date().toISOString(),
next_close: new Date().toISOString(),
next_close: new Date(Date.now() + 86_400_000).toISOString(),
}),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
@@ -19,6 +19,7 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getLatestTrades: vi.fn(),
buy: vi.fn(),
sell: vi.fn(),
getOrder: vi.fn(),
...overrides,
} as unknown as Alpaca;
}
@@ -103,17 +104,11 @@ describe('Bot', () => {
await vi.advanceTimersByTimeAsync(0);
await promise;
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'market is open, running strategies');
});
it('skips waiting when market is already open', async () => {
const alpaca = mockAlpaca({
getClock: vi.fn().mockResolvedValue({
is_open: true,
next_open: new Date().toISOString(),
next_close: new Date().toISOString(),
}),
});
const alpaca = mockAlpaca();
const strategy = mockStrategy();
const bot = new Bot(alpaca, [{ strategy, capitalAllocation: 1.0 }]);
@@ -124,5 +119,24 @@ describe('Bot', () => {
expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000);
});
it('waits for market open when closed', async () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const alpaca = mockAlpaca({
getClock: vi.fn()
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValue({ is_open: true, next_open: futureDate, next_close: new Date(Date.now() + 86_400_000).toISOString() }),
});
const strategy = mockStrategy();
const bot = new Bot(alpaca, [{ strategy, capitalAllocation: 1.0 }]);
const promise = bot.runDay();
await vi.advanceTimersByTimeAsync(120000);
await promise;
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000);
});
});
});

View File

@@ -11,20 +11,17 @@ logger.info('bot initialized');
async function main() {
while(true) {
try {
logger.info('starting trading cycle');
await bot.runDay();
logger.info('trading cycle complete, sleeping 1h');
} catch (e) {
logger.error('trading cycle failed: ', e);
logger.info('sleeping 1h before retrying');
}
await wait(1000 * 60 * 60);//wait an hour before going and getting the next open
}
}
//run main
main().then(
() => {
logger.info("done")
}
).catch(
(e) => logger.error('Error: ', e)
).finally(
() => process.exit(0)
);
main();

View File

@@ -7,13 +7,18 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getAccount: vi.fn(),
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getClock: vi.fn().mockResolvedValue({
is_open: true,
next_open: new Date().toISOString(),
next_close: new Date(Date.now() + 86_400_000).toISOString(),
}),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
buy: vi.fn().mockResolvedValue(50),
sell: vi.fn().mockResolvedValue(50),
buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
sell: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
getOrder: vi.fn(),
...overrides,
} as unknown as Alpaca;
}
@@ -42,7 +47,7 @@ describe('MomentumStrategy', () => {
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101),
buy: vi.fn().mockResolvedValue(50),
buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
getLatestBid: vi.fn().mockResolvedValue(50.50),
});
@@ -59,7 +64,7 @@ describe('MomentumStrategy', () => {
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(99),
buy: vi.fn().mockResolvedValue(30),
buy: vi.fn().mockResolvedValue({ price: 30, qty: 166.67 }),
getLatestBid: vi.fn().mockResolvedValue(30.30),
});
@@ -76,13 +81,13 @@ describe('MomentumStrategy', () => {
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101),
buy: vi.fn().mockResolvedValue(50),
buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
getLatestBid: vi.fn()
// poll 1: not yet (target = 50.50)
.mockResolvedValueOnce(50.10)
// poll 2: hit target
.mockResolvedValueOnce(50.50),
sell: vi.fn().mockResolvedValue(50.50),
sell: vi.fn().mockResolvedValue({ price: 50.50, qty: 100 }),
});
const strategy = new MomentumStrategy(fastConfig);
@@ -91,7 +96,7 @@ describe('MomentumStrategy', () => {
await promise;
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: target');
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 100);
});
it('sells on timeout when target not reached', async () => {
@@ -99,9 +104,9 @@ describe('MomentumStrategy', () => {
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101),
buy: vi.fn().mockResolvedValue(50),
buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
getLatestBid: vi.fn().mockResolvedValue(49.90),
sell: vi.fn().mockResolvedValue(49.90),
sell: vi.fn().mockResolvedValue({ price: 49.90, qty: 100 }),
});
const strategy = new MomentumStrategy(fastConfig);
@@ -110,7 +115,39 @@ describe('MomentumStrategy', () => {
await promise;
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: timeout');
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 100);
});
it('caps hold time to 2 min before market close', async () => {
// Market closes in 500ms, but holdTime is 1000ms
// So the strategy should sell after ~380ms (500 - 120 = 380ms safe close)
// rather than waiting for the full 1000ms holdTime
const closeTime = new Date(Date.now() + 500).toISOString();
const alpaca = mockAlpaca({
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101),
buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
getClock: vi.fn().mockResolvedValue({
is_open: true,
next_open: new Date().toISOString(),
next_close: closeTime,
}),
getLatestBid: vi.fn().mockResolvedValue(49.90),
sell: vi.fn().mockResolvedValue({ price: 49.90, qty: 100 }),
});
const strategy = new MomentumStrategy(fastConfig);
const promise = strategy.execute(alpaca, 5000);
// Advance enough for the capped deadline (close - 120_000 is in the past,
// so the loop should exit immediately without any polls)
await vi.advanceTimersByTimeAsync(500);
await promise;
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 100);
// With close only 500ms away, safeClose = close - 120s is already past,
// so the loop body should never run (no bid checks)
expect(alpaca.getLatestBid).not.toHaveBeenCalled();
});
it('uses actual fill price for target calculation', async () => {
@@ -119,13 +156,13 @@ describe('MomentumStrategy', () => {
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101),
// fill price is 50, so 1% target = 50.50
buy: vi.fn().mockResolvedValue(50),
buy: vi.fn().mockResolvedValue({ price: 50, qty: 100 }),
getLatestBid: vi.fn()
// 50.49 is below target
.mockResolvedValueOnce(50.49)
// 50.50 hits target
.mockResolvedValueOnce(50.50),
sell: vi.fn().mockResolvedValue(50.50),
sell: vi.fn().mockResolvedValue({ price: 50.50, qty: 100 }),
});
const strategy = new MomentumStrategy(fastConfig);

View File

@@ -33,11 +33,19 @@ export class MomentumStrategy implements Strategy {
logger.debug(`[${this.name}] indicator result: ${JSON.stringify(result)}`);
const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ';
const entryPrice = await alpaca.buy(symbol, capitalAmount);
logger.info(`[${this.name}] entered ${symbol} at price ${entryPrice}`);
const fill = await alpaca.buy(symbol, capitalAmount);
logger.info(`[${this.name}] entered ${symbol} at price ${fill.price}, qty ${fill.qty}`);
const targetPrice = entryPrice * (1 + this.config.targetGain);
const deadline = Date.now() + this.config.holdTime;
try {
const targetPrice = fill.price * (1 + this.config.targetGain);
let deadline = Date.now() + this.config.holdTime;
const clock = await alpaca.getClock();
const safeClose = new Date(clock.next_close).getTime() - 120_000;
if (safeClose < deadline) {
logger.info(`[${this.name}] capping hold time to 2 min before market close`);
deadline = safeClose;
}
logger.debug(`[${this.name}] monitoring ${symbol} for target price ${targetPrice} or timeout at ${new Date(deadline).toISOString()}`);
let reason: 'target' | 'timeout' = 'timeout';
@@ -54,7 +62,9 @@ export class MomentumStrategy implements Strategy {
}
logger.info(`[${this.name}] exit ${symbol} — reason: ${reason}`);
await alpaca.sell(symbol, capitalAmount);
} finally {
logger.info(`[${this.name}] selling ${fill.qty} shares of ${symbol}`);
await alpaca.sell(symbol, fill.qty);
}
}
}

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 {
@@ -14,6 +14,7 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getLatestTrades: vi.fn(),
buy: vi.fn(),
sell: vi.fn(),
getOrder: vi.fn(),
...overrides,
} as unknown as Alpaca;
}
@@ -25,6 +26,7 @@ beforeEach(() => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
resetWaitFn();
});
describe('printAsset', () => {
@@ -68,17 +70,59 @@ describe('waitForNextOpen', () => {
vi.spyOn(console, 'debug').mockImplementation(() => {});
});
it('calls getClock and waits until next_open', async () => {
it('polls until market opens', async () => {
const futureDate = new Date(Date.now() + 60000).toISOString();
const alpaca = mockAlpaca({
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: futureDate, next_close: futureDate }),
getClock: vi.fn()
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValueOnce({ is_open: false, next_open: futureDate, next_close: futureDate })
.mockResolvedValueOnce({ is_open: true, next_open: futureDate, next_close: futureDate }),
});
const promise = waitForNextOpen(alpaca);
await vi.advanceTimersByTimeAsync(60000);
const promise = waitForNextOpen(alpaca, 100);
await vi.advanceTimersByTimeAsync(300);
await promise;
expect(alpaca.getClock).toHaveBeenCalled();
expect(alpaca.getClock).toHaveBeenCalledTimes(3);
});
it('sleeps at most pollInterval even when open is far away', async () => {
const farFuture = new Date(Date.now() + 86_400_000).toISOString();
const alpaca = mockAlpaca({
getClock: vi.fn()
.mockResolvedValueOnce({ is_open: false, next_open: farFuture, next_close: farFuture })
.mockResolvedValueOnce({ is_open: true, next_open: farFuture, next_close: farFuture }),
});
const promise = waitForNextOpen(alpaca, 200);
await vi.advanceTimersByTimeAsync(300);
await promise;
// Should have polled twice: first sleep capped to 200ms, then saw is_open
expect(alpaca.getClock).toHaveBeenCalledTimes(2);
});
});
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) {
@@ -25,10 +37,17 @@ export async function isMarketOpen(alpaca: Alpaca): Promise<boolean> {
return clock.is_open;
}
export async function waitForNextOpen(alpaca: Alpaca) {
export async function waitForNextOpen(alpaca: Alpaca, pollInterval = 30_000) {
while (true) {
const clock = await alpaca.getClock();
const ms = new Date(clock.next_open).valueOf() - new Date().valueOf();
logger.info(`market closed, next open: ${clock.next_open} (waiting ${ms}ms)`);
return wait(ms);
if (clock.is_open) {
logger.info('market is now open');
return;
}
const ms = new Date(clock.next_open).valueOf() - Date.now();
const sleepMs = Math.min(Math.max(ms, 0), pollInterval);
logger.info(`market closed, next open: ${clock.next_open} (sleeping ${sleepMs}ms)`);
await wait(sleepMs);
}
}