Compare commits
10 Commits
1d3688e9ae
...
4c2ed5455f
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2ed5455f | |||
| 019b630d95 | |||
| 4ca7073d77 | |||
| 7b1a20c768 | |||
| 46e7bef4b2 | |||
| 06deefe011 | |||
| 2208524a3f | |||
| e8b05c7870 | |||
| 23e5437402 | |||
| 4f1e745534 |
@@ -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
6
scripts/rebuild.sh
Executable 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
|
||||
@@ -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',
|
||||
|
||||
@@ -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
183
src/backtest-client.test.ts
Normal 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
236
src/backtest-client.ts
Normal 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
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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
15
src/index.ts
15
src/index.ts
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user