update our alpaca calls to be separate bid and ask

This commit is contained in:
Jon
2026-02-03 13:58:42 -07:00
parent 8872803563
commit e32e30af47
11 changed files with 116 additions and 71 deletions

View File

@@ -7,7 +7,7 @@ function mockClient(overrides: Partial<AlpacaClient> = {}): AlpacaClient {
getAssets: vi.fn().mockResolvedValue([]),
getAsset: vi.fn().mockResolvedValue({ symbol: 'TQQQ', fractionable: true, status: 'active', asset_class: 'us_equity' }),
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({ ap: 50.00, bp: 49.90 }),
getLatestQuote: vi.fn().mockResolvedValue({ AskPrice: 50.00, BidPrice: 49.90 }),
getLatestTrades: vi.fn().mockResolvedValue(new Map()),
...overrides,
};
@@ -41,11 +41,27 @@ describe('Alpaca', () => {
expect(client.getClock).toHaveBeenCalled();
});
it('delegates getLatestQuote to the underlying client', async () => {
it('getLatestAsk returns the ask price', async () => {
const client = mockClient();
const alpaca = new Alpaca(false, client);
const quote = await alpaca.getLatestQuote('TQQQ');
expect(quote.ap).toBe(50.00);
const ask = await alpaca.getLatestAsk('TQQQ');
expect(ask).toBe(50.00);
expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ');
});
it('getLatestBid returns the bid price', async () => {
const client = mockClient();
const alpaca = new Alpaca(false, client);
const bid = await alpaca.getLatestBid('TQQQ');
expect(bid).toBe(49.90);
expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ');
});
it('getLatestSpread returns ask minus bid', async () => {
const client = mockClient();
const alpaca = new Alpaca(false, client);
const spread = await alpaca.getLatestSpread('TQQQ');
expect(spread).toBeCloseTo(0.10);
expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ');
});

View File

@@ -32,8 +32,8 @@ export interface AlpacaClock {
}
export interface AlpacaQuote {
ap: number; // ask price
bp: number; // bid price
AskPrice: number;
BidPrice: number;
}
export interface AlpacaTrade {
@@ -83,8 +83,17 @@ export class Alpaca {
return this.alpaca.getClock();
}
public async getLatestQuote(symbol: string) {
return this.alpaca.getLatestQuote(symbol);
public async getLatestAsk(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol);
return quote.AskPrice;
}
public async getLatestBid(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol);
return quote.BidPrice;
}
public async getLatestSpread(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol);
return quote.AskPrice - quote.BidPrice;
}
public async getLatestTrades(symbols: string[]) {
return this.alpaca.getLatestTrades(symbols);

View File

@@ -13,7 +13,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
next_open: new Date().toISOString(),
next_close: new Date().toISOString(),
}),
getLatestQuote: vi.fn(),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
...overrides,
} as unknown as Alpaca;

View File

@@ -1,7 +1,7 @@
import { Alpaca } from "./alpaca";
import { Strategy } from "./strategy";
import { Executor } from "./executor";
import { waitForNextOpen } from "./trading";
import { isMarketOpen, waitForNextOpen } from "./trading";
export class Bot {
private alpaca: Alpaca;
@@ -21,15 +21,18 @@ export class Bot {
}
async runDay(): Promise<void> {
const open = await isMarketOpen(this.alpaca);
if (!open) {
console.log('waiting for open');
await waitForNextOpen(this.alpaca);
}
const account = await this.alpaca.getAccount();
const totalCapital = parseFloat(account.cash);
await Promise.all(
this.strategies.map(async (strategy) => {
const signals = await strategy.execute(this.alpaca);
await this.executor.executeSignals(strategy, signals, totalCapital);
})
);

View File

@@ -9,7 +9,9 @@ function mockAlpaca(): Alpaca {
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestQuote: vi.fn(),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
} as unknown as Alpaca;
}

View File

@@ -8,7 +8,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestQuote: vi.fn(),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
...overrides,
} as unknown as Alpaca;
@@ -26,9 +28,9 @@ afterEach(() => {
describe('MomentumIndicator', () => {
it('returns up when second quote is higher', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 101, bp: 100 }),
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101),
});
const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 });
@@ -43,9 +45,9 @@ describe('MomentumIndicator', () => {
it('returns down when second quote is lower', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 99, bp: 98 }),
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(99),
});
const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 });
@@ -60,9 +62,9 @@ describe('MomentumIndicator', () => {
it('returns up when prices are equal', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 100, bp: 99 }),
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(100),
});
const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 });
@@ -74,17 +76,17 @@ describe('MomentumIndicator', () => {
});
it('uses configured symbol', async () => {
const getLatestQuote = vi.fn()
.mockResolvedValueOnce({ ap: 50, bp: 49 })
.mockResolvedValueOnce({ ap: 51, bp: 50 });
const alpaca = mockAlpaca({ getLatestQuote });
const getLatestAsk = vi.fn()
.mockResolvedValueOnce(50)
.mockResolvedValueOnce(51);
const alpaca = mockAlpaca({ getLatestAsk });
const indicator = new MomentumIndicator({ symbol: 'SPY', settleDelay: 0, sampleDelay: 100 });
const promise = indicator.evaluate(alpaca);
await vi.advanceTimersByTimeAsync(100);
await promise;
expect(getLatestQuote).toHaveBeenCalledWith('SPY');
expect(getLatestQuote).toHaveBeenCalledTimes(2);
expect(getLatestAsk).toHaveBeenCalledWith('SPY');
expect(getLatestAsk).toHaveBeenCalledTimes(2);
});
});

View File

@@ -31,13 +31,11 @@ export class MomentumIndicator implements Indicator<MomentumResult> {
async evaluate(alpaca: Alpaca): Promise<MomentumResult> {
await wait(this.config.settleDelay);
const before = await alpaca.getLatestQuote(this.config.symbol);
const priceBefore = before.ap;
const priceBefore = await alpaca.getLatestAsk(this.config.symbol);
await wait(this.config.sampleDelay);
const after = await alpaca.getLatestQuote(this.config.symbol);
const priceAfter = after.ap;
const priceAfter = await alpaca.getLatestAsk(this.config.symbol);
const direction = priceAfter >= priceBefore ? 'up' : 'down';

View File

@@ -8,7 +8,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestQuote: vi.fn(),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
...overrides,
} as unknown as Alpaca;
@@ -34,15 +36,16 @@ afterEach(() => {
describe('MomentumStrategy', () => {
it('buys TQQQ when QQQ goes up', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
getLatestAsk: vi.fn()
// indicator: QQQ before
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce(100)
// indicator: QQQ after (up)
.mockResolvedValueOnce({ ap: 101, bp: 100 })
.mockResolvedValueOnce(101)
// entry quote for TQQQ
.mockResolvedValueOnce({ ap: 50, bp: 49 })
.mockResolvedValueOnce(50),
getLatestBid: vi.fn()
// poll: hit target immediately
.mockResolvedValueOnce({ ap: 51, bp: 50.50 }),
.mockResolvedValueOnce(50.50),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
@@ -55,15 +58,16 @@ describe('MomentumStrategy', () => {
it('buys SQQQ when QQQ goes down', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
getLatestAsk: vi.fn()
// indicator: QQQ before
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce(100)
// indicator: QQQ after (down)
.mockResolvedValueOnce({ ap: 99, bp: 98 })
.mockResolvedValueOnce(99)
// entry quote for SQQQ
.mockResolvedValueOnce({ ap: 30, bp: 29 })
.mockResolvedValueOnce(30),
getLatestBid: vi.fn()
// poll: hit target immediately
.mockResolvedValueOnce({ ap: 31, bp: 30.30 }),
.mockResolvedValueOnce(30.30),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
@@ -76,16 +80,17 @@ describe('MomentumStrategy', () => {
it('sells when bid hits 1% target', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
getLatestAsk: vi.fn()
// indicator: QQQ samples
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 101, bp: 100 })
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101)
// entry quote: ask = 50
.mockResolvedValueOnce({ ap: 50, bp: 49 })
.mockResolvedValueOnce(50),
getLatestBid: vi.fn()
// poll 1: not yet (target = 50.50)
.mockResolvedValueOnce({ ap: 50.20, bp: 50.10 })
.mockResolvedValueOnce(50.10)
// poll 2: hit target
.mockResolvedValueOnce({ ap: 51, bp: 50.50 }),
.mockResolvedValueOnce(50.50),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
@@ -99,14 +104,15 @@ describe('MomentumStrategy', () => {
it('sells on timeout when target not reached', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
getLatestAsk: vi.fn()
// indicator: QQQ samples
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 101, bp: 100 })
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101)
// entry quote: ask = 50
.mockResolvedValueOnce({ ap: 50, bp: 49 })
.mockResolvedValueOnce(50),
getLatestBid: vi.fn()
// all polls: never reach target
.mockResolvedValue({ ap: 50.10, bp: 49.90 }),
.mockResolvedValue(49.90),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
@@ -120,11 +126,12 @@ describe('MomentumStrategy', () => {
it('returns both buy and sell signals', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 101, bp: 100 })
.mockResolvedValueOnce({ ap: 50, bp: 49 })
.mockResolvedValueOnce({ ap: 51, bp: 50.50 }),
getLatestAsk: vi.fn()
.mockResolvedValueOnce(100)
.mockResolvedValueOnce(101)
.mockResolvedValueOnce(50),
getLatestBid: vi.fn()
.mockResolvedValueOnce(50.50),
});
const strategy = new MomentumStrategy(1.0, fastConfig);

View File

@@ -32,8 +32,7 @@ export class MomentumStrategy implements Strategy {
const result = await this.indicator.evaluate(alpaca);
const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ';
const entryQuote = await alpaca.getLatestQuote(symbol);
const entryPrice = entryQuote.ap;
const entryPrice = await alpaca.getLatestAsk(symbol);
const buy: Signal = { symbol, direction: 'buy', allocation: 1.0 };
@@ -45,8 +44,8 @@ export class MomentumStrategy implements Strategy {
while (Date.now() < deadline) {
await wait(this.config.pollInterval);
const quote = await alpaca.getLatestQuote(symbol);
if (quote.bp >= targetPrice) {
const bid = await alpaca.getLatestBid(symbol);
if (bid >= targetPrice) {
reason = 'target';
break;
}

View File

@@ -8,7 +8,9 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestQuote: vi.fn(),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
...overrides,
} as unknown as Alpaca;

View File

@@ -19,6 +19,11 @@ export async function accountBalance(alpaca: Alpaca) {
return account.cash;
}
export async function isMarketOpen(alpaca: Alpaca): Promise<boolean> {
const clock = await alpaca.getClock();
return clock.is_open;
}
export async function waitForNextOpen(alpaca: Alpaca) {
const clock = await alpaca.getClock();
return wait(new Date(clock.next_open).valueOf() - new Date().valueOf());