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>
This commit is contained in:
@@ -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,31 @@ 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('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,9 +65,11 @@ 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;
|
||||
@@ -121,7 +128,25 @@ export class Alpaca {
|
||||
return this.alpaca.getLatestTrades(symbols);
|
||||
}
|
||||
|
||||
public async buy(symbol: string, dollarAmount: number): Promise<number> {
|
||||
public async getOrder(id: string): Promise<AlpacaOrder> {
|
||||
return this.alpaca.getOrder(id);
|
||||
}
|
||||
|
||||
private async waitForFill(orderId: string): Promise<AlpacaOrder> {
|
||||
const maxAttempts = 30;
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const order = await 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, 1000));
|
||||
}
|
||||
throw new Error(`Order ${orderId} not filled after ${maxAttempts}s`);
|
||||
}
|
||||
|
||||
public async buy(symbol: string, dollarAmount: number): Promise<OrderFill> {
|
||||
logger.info(`buying ${symbol} for $${dollarAmount}`);
|
||||
const order = await this.alpaca.createOrder({
|
||||
symbol,
|
||||
@@ -130,20 +155,22 @@ export class Alpaca {
|
||||
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}`);
|
||||
public async sell(symbol: string, qty: number): Promise<OrderFill> {
|
||||
logger.info(`selling ${qty} shares of ${symbol}`);
|
||||
const order = await 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) };
|
||||
}
|
||||
}
|
||||
@@ -155,9 +155,23 @@ export class BacktestClient implements AlpacaClient {
|
||||
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;
|
||||
notional?: number;
|
||||
qty?: number;
|
||||
side: "buy" | "sell";
|
||||
type: string;
|
||||
time_in_force: string;
|
||||
@@ -168,18 +182,19 @@ export class BacktestClient implements AlpacaClient {
|
||||
}
|
||||
|
||||
const price = bar.ClosePrice;
|
||||
const qty = order.notional / price;
|
||||
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 -= order.notional;
|
||||
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 += order.notional;
|
||||
this.cash += notional;
|
||||
const pos = this.positions.get(order.symbol);
|
||||
if (pos) {
|
||||
pos.qty -= qty;
|
||||
@@ -194,7 +209,7 @@ export class BacktestClient implements AlpacaClient {
|
||||
symbol: order.symbol,
|
||||
side: order.side,
|
||||
price,
|
||||
notional: order.notional,
|
||||
notional,
|
||||
qty,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||
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;
|
||||
}
|
||||
@@ -46,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),
|
||||
});
|
||||
|
||||
@@ -63,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),
|
||||
});
|
||||
|
||||
@@ -80,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);
|
||||
@@ -95,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 () => {
|
||||
@@ -103,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);
|
||||
@@ -114,7 +115,7 @@ 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 () => {
|
||||
@@ -126,14 +127,14 @@ describe('MomentumStrategy', () => {
|
||||
getLatestAsk: vi.fn()
|
||||
.mockResolvedValueOnce(100)
|
||||
.mockResolvedValueOnce(101),
|
||||
buy: vi.fn().mockResolvedValue(50),
|
||||
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(49.90),
|
||||
sell: vi.fn().mockResolvedValue({ price: 49.90, qty: 100 }),
|
||||
});
|
||||
|
||||
const strategy = new MomentumStrategy(fastConfig);
|
||||
@@ -143,7 +144,7 @@ describe('MomentumStrategy', () => {
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
await promise;
|
||||
|
||||
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
|
||||
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();
|
||||
@@ -155,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,10 +33,10 @@ 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 targetPrice = fill.price * (1 + this.config.targetGain);
|
||||
let deadline = Date.now() + this.config.holdTime;
|
||||
|
||||
const clock = await alpaca.getClock();
|
||||
@@ -62,6 +62,6 @@ export class MomentumStrategy implements Strategy {
|
||||
|
||||
logger.info(`[${this.name}] exit ${symbol} — reason: ${reason}`);
|
||||
|
||||
await alpaca.sell(symbol, capitalAmount);
|
||||
await alpaca.sell(symbol, fill.qty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user