Remove executor, add buy/sell to Alpaca, move capital allocation to Bot

Replace the Executor logging placeholder with real buy/sell methods on
the Alpaca class that place market orders via createOrder and return the
fill price. Strategies now receive their capital amount directly and
place orders themselves. Bot accepts StrategyAllocation[] to decouple
capital allocation from strategy definition.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-02-04 11:05:21 -07:00
parent e32e30af47
commit 075572a01c
12 changed files with 186 additions and 255 deletions

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' }), 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 }), getLatestQuote: vi.fn().mockResolvedValue({ AskPrice: 50.00, BidPrice: 49.90 }),
getLatestTrades: vi.fn().mockResolvedValue(new Map()), getLatestTrades: vi.fn().mockResolvedValue(new Map()),
createOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }),
...overrides, ...overrides,
}; };
} }
@@ -78,4 +79,34 @@ describe('Alpaca', () => {
await alpaca.getLatestTrades(['TQQQ', 'SPY']); await alpaca.getLatestTrades(['TQQQ', 'SPY']);
expect(client.getLatestTrades).toHaveBeenCalledWith(['TQQQ', 'SPY']); expect(client.getLatestTrades).toHaveBeenCalledWith(['TQQQ', 'SPY']);
}); });
it('buy places a market buy order and returns fill price', async () => {
const client = mockClient();
const alpaca = new Alpaca(false, client);
const price = await alpaca.buy('TQQQ', 5000);
expect(price).toBe(50.25);
expect(client.createOrder).toHaveBeenCalledWith({
symbol: 'TQQQ',
notional: 5000,
side: 'buy',
type: 'market',
time_in_force: 'day',
});
});
it('sell places a market sell order and returns fill price', 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);
expect(client.createOrder).toHaveBeenCalledWith({
symbol: 'TQQQ',
notional: 5000,
side: 'sell',
type: 'market',
time_in_force: 'day',
});
});
}); });

View File

@@ -36,6 +36,15 @@ export interface AlpacaQuote {
BidPrice: number; BidPrice: number;
} }
export interface AlpacaOrder {
id: string;
symbol: string;
filled_avg_price: string;
filled_qty: string;
side: string;
status: string;
}
export interface AlpacaTrade { export interface AlpacaTrade {
p: number; // price p: number; // price
s: number; // size s: number; // size
@@ -49,6 +58,13 @@ export interface AlpacaClient {
getClock(): Promise<AlpacaClock>; getClock(): Promise<AlpacaClock>;
getLatestQuote(symbol: string): Promise<AlpacaQuote>; getLatestQuote(symbol: string): Promise<AlpacaQuote>;
getLatestTrades(symbols: string[]): Promise<Map<string, AlpacaTrade>>; getLatestTrades(symbols: string[]): Promise<Map<string, AlpacaTrade>>;
createOrder(order: {
symbol: string;
notional: number;
side: 'buy' | 'sell';
type: string;
time_in_force: string;
}): Promise<AlpacaOrder>;
} }
export class Alpaca { export class Alpaca {
@@ -98,5 +114,26 @@ export class Alpaca {
public async getLatestTrades(symbols: string[]) { public async getLatestTrades(symbols: string[]) {
return this.alpaca.getLatestTrades(symbols); return this.alpaca.getLatestTrades(symbols);
} }
public async buy(symbol: string, dollarAmount: number): Promise<number> {
const order = await this.alpaca.createOrder({
symbol,
notional: dollarAmount,
side: 'buy',
type: 'market',
time_in_force: 'day',
});
return parseFloat(order.filled_avg_price);
}
public async sell(symbol: string, dollarAmount: number): Promise<number> {
const order = await this.alpaca.createOrder({
symbol,
notional: dollarAmount,
side: 'sell',
type: 'market',
time_in_force: 'day',
});
return parseFloat(order.filled_avg_price);
}
} }

View File

@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Bot } from './bot'; import { Bot, StrategyAllocation } from './bot';
import type { Alpaca } from './alpaca'; import type { Alpaca } from './alpaca';
import type { Strategy, Signal } from './strategy'; import type { Strategy } from './strategy';
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca { function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
return { return {
@@ -17,6 +17,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getLatestBid: vi.fn(), getLatestBid: vi.fn(),
getLatestSpread: vi.fn(), getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(), getLatestTrades: vi.fn(),
buy: vi.fn(),
sell: vi.fn(),
...overrides, ...overrides,
} as unknown as Alpaca; } as unknown as Alpaca;
} }
@@ -24,8 +26,7 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
function mockStrategy(overrides: Partial<Strategy> = {}): Strategy { function mockStrategy(overrides: Partial<Strategy> = {}): Strategy {
return { return {
name: 'test-strategy', name: 'test-strategy',
capitalAllocation: 0.5, execute: vi.fn().mockResolvedValue(undefined),
execute: vi.fn().mockResolvedValue([]),
...overrides, ...overrides,
}; };
} }
@@ -44,83 +45,53 @@ describe('Bot', () => {
describe('constructor', () => { describe('constructor', () => {
it('throws when capital allocations exceed 1.0', () => { it('throws when capital allocations exceed 1.0', () => {
const alpaca = mockAlpaca(); const alpaca = mockAlpaca();
const strategies = [ const allocations: StrategyAllocation[] = [
mockStrategy({ capitalAllocation: 0.6 }), { strategy: mockStrategy(), capitalAllocation: 0.6 },
mockStrategy({ capitalAllocation: 0.5 }), { strategy: mockStrategy(), capitalAllocation: 0.5 },
]; ];
expect(() => new Bot(alpaca, strategies)).toThrow( expect(() => new Bot(alpaca, allocations)).toThrow(
'Capital allocations sum to 1.1, which exceeds 1.0' 'Capital allocations sum to 1.1, which exceeds 1.0'
); );
}); });
it('accepts allocations that sum to exactly 1.0', () => { it('accepts allocations that sum to exactly 1.0', () => {
const alpaca = mockAlpaca(); const alpaca = mockAlpaca();
const strategies = [ const allocations: StrategyAllocation[] = [
mockStrategy({ capitalAllocation: 0.5 }), { strategy: mockStrategy(), capitalAllocation: 0.5 },
mockStrategy({ capitalAllocation: 0.5 }), { strategy: mockStrategy(), capitalAllocation: 0.5 },
]; ];
expect(() => new Bot(alpaca, strategies)).not.toThrow(); expect(() => new Bot(alpaca, allocations)).not.toThrow();
}); });
it('accepts allocations that sum to less than 1.0', () => { it('accepts allocations that sum to less than 1.0', () => {
const alpaca = mockAlpaca(); const alpaca = mockAlpaca();
const strategies = [ const allocations: StrategyAllocation[] = [
mockStrategy({ capitalAllocation: 0.3 }), { strategy: mockStrategy(), capitalAllocation: 0.3 },
mockStrategy({ capitalAllocation: 0.2 }), { strategy: mockStrategy(), capitalAllocation: 0.2 },
]; ];
expect(() => new Bot(alpaca, strategies)).not.toThrow(); expect(() => new Bot(alpaca, allocations)).not.toThrow();
}); });
}); });
describe('runDay', () => { describe('runDay', () => {
it('runs multiple strategies concurrently', async () => { it('passes correct capital amount to each strategy', async () => {
const alpaca = mockAlpaca(); const alpaca = mockAlpaca();
const strategyA = mockStrategy({ const strategyA = mockStrategy({ name: 'A' });
name: 'A', const strategyB = mockStrategy({ name: 'B' });
capitalAllocation: 0.3, const allocations: StrategyAllocation[] = [
execute: vi.fn().mockResolvedValue([ { strategy: strategyA, capitalAllocation: 0.3 },
{ symbol: 'TQQQ', direction: 'buy', allocation: 1.0 }, { strategy: strategyB, capitalAllocation: 0.2 },
]),
});
const strategyB = mockStrategy({
name: 'B',
capitalAllocation: 0.2,
execute: vi.fn().mockResolvedValue([
{ symbol: 'SPY', direction: 'sell', allocation: 0.5 },
]),
});
const bot = new Bot(alpaca, [strategyA, strategyB]);
const promise = bot.runDay();
await vi.advanceTimersByTimeAsync(0);
await promise;
expect(strategyA.execute).toHaveBeenCalledWith(alpaca);
expect(strategyB.execute).toHaveBeenCalledWith(alpaca);
// A: 10000 * 0.3 * 1.0 = 3000
expect(console.log).toHaveBeenCalledWith('[A] BUY TQQQ — $3000.00');
// B: 10000 * 0.2 * 0.5 = 1000
expect(console.log).toHaveBeenCalledWith('[B] SELL SPY — $1000.00');
});
it('passes signals to executor', async () => {
const alpaca = mockAlpaca();
const signals: Signal[] = [
{ symbol: 'TQQQ', direction: 'buy', allocation: 0.5 },
]; ];
const strategy = mockStrategy({
name: 'test',
capitalAllocation: 0.4,
execute: vi.fn().mockResolvedValue(signals),
});
const bot = new Bot(alpaca, [strategy]); const bot = new Bot(alpaca, allocations);
const promise = bot.runDay(); const promise = bot.runDay();
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
await promise; await promise;
// 10000 * 0.4 * 0.5 = 2000 // 10000 * 0.3 = 3000
expect(console.log).toHaveBeenCalledWith('[test] BUY TQQQ — $2000.00'); expect(strategyA.execute).toHaveBeenCalledWith(alpaca, 3000);
// 10000 * 0.2 = 2000
expect(strategyB.execute).toHaveBeenCalledWith(alpaca, 2000);
}); });
it('works with zero strategies', async () => { it('works with zero strategies', async () => {
@@ -131,9 +102,26 @@ describe('Bot', () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
await promise; await promise;
// Only the "waiting for open" log, no strategy logs
expect(console.log).toHaveBeenCalledTimes(1);
expect(console.log).toHaveBeenCalledWith('waiting for open'); expect(console.log).toHaveBeenCalledWith('waiting for open');
}); });
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 strategy = mockStrategy();
const bot = new Bot(alpaca, [{ strategy, capitalAllocation: 1.0 }]);
const promise = bot.runDay();
await vi.advanceTimersByTimeAsync(0);
await promise;
expect(console.log).not.toHaveBeenCalledWith('waiting for open');
expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000);
});
}); });
}); });

View File

@@ -1,23 +1,25 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { Strategy } from "./strategy"; import { Strategy } from "./strategy";
import { Executor } from "./executor";
import { isMarketOpen, waitForNextOpen } from "./trading"; import { isMarketOpen, waitForNextOpen } from "./trading";
export interface StrategyAllocation {
strategy: Strategy;
capitalAllocation: number;
}
export class Bot { export class Bot {
private alpaca: Alpaca; private alpaca: Alpaca;
private strategies: Strategy[]; private allocations: StrategyAllocation[];
private executor: Executor;
constructor(alpaca: Alpaca, strategies: Strategy[]) { constructor(alpaca: Alpaca, allocations: StrategyAllocation[]) {
const totalAllocation = strategies.reduce((sum, s) => sum + s.capitalAllocation, 0); const totalAllocation = allocations.reduce((sum, a) => sum + a.capitalAllocation, 0);
if (totalAllocation > 1.0) { if (totalAllocation > 1.0) {
throw new Error( throw new Error(
`Capital allocations sum to ${totalAllocation}, which exceeds 1.0` `Capital allocations sum to ${totalAllocation}, which exceeds 1.0`
); );
} }
this.alpaca = alpaca; this.alpaca = alpaca;
this.strategies = strategies; this.allocations = allocations;
this.executor = new Executor(alpaca);
} }
async runDay(): Promise<void> { async runDay(): Promise<void> {
@@ -26,14 +28,13 @@ export class Bot {
console.log('waiting for open'); console.log('waiting for open');
await waitForNextOpen(this.alpaca); await waitForNextOpen(this.alpaca);
} }
const account = await this.alpaca.getAccount(); const account = await this.alpaca.getAccount();
const totalCapital = parseFloat(account.cash); 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); await Promise.all(
this.allocations.map(async ({ strategy, capitalAllocation }) => {
const capitalAmount = totalCapital * capitalAllocation;
await strategy.execute(this.alpaca, capitalAmount);
}) })
); );
} }

View File

@@ -1,83 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Executor } from './executor';
import type { Alpaca } from './alpaca';
import type { Strategy, Signal } from './strategy';
function mockAlpaca(): Alpaca {
return {
getAccount: vi.fn(),
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestAsk: vi.fn(),
getLatestBid: vi.fn(),
getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(),
} as unknown as Alpaca;
}
function mockStrategy(overrides: Partial<Strategy> = {}): Strategy {
return {
name: 'test-strategy',
capitalAllocation: 0.5,
execute: vi.fn().mockResolvedValue([]),
...overrides,
};
}
describe('Executor', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('logs a buy signal with correct dollar amount', async () => {
const executor = new Executor(mockAlpaca());
const strategy = mockStrategy({ name: 'momentum', capitalAllocation: 0.5 });
const signals: Signal[] = [{ symbol: 'TQQQ', direction: 'buy', allocation: 0.8 }];
await executor.executeSignals(strategy, signals, 10000);
// 10000 * 0.5 * 0.8 = 4000
expect(console.log).toHaveBeenCalledWith('[momentum] BUY TQQQ — $4000.00');
});
it('logs a sell signal with correct dollar amount', async () => {
const executor = new Executor(mockAlpaca());
const strategy = mockStrategy({ name: 'rebalance', capitalAllocation: 0.3 });
const signals: Signal[] = [{ symbol: 'SPY', direction: 'sell', allocation: 1.0 }];
await executor.executeSignals(strategy, signals, 20000);
// 20000 * 0.3 * 1.0 = 6000
expect(console.log).toHaveBeenCalledWith('[rebalance] SELL SPY — $6000.00');
});
it('ignores signals with 0 allocation', async () => {
const executor = new Executor(mockAlpaca());
const strategy = mockStrategy();
const signals: Signal[] = [{ symbol: 'TQQQ', direction: 'buy', allocation: 0 }];
await executor.executeSignals(strategy, signals, 10000);
expect(console.log).not.toHaveBeenCalled();
});
it('handles multiple signals in one batch', async () => {
const executor = new Executor(mockAlpaca());
const strategy = mockStrategy({ name: 'multi', capitalAllocation: 0.4 });
const signals: Signal[] = [
{ symbol: 'TQQQ', direction: 'buy', allocation: 0.5 },
{ symbol: 'SPY', direction: 'sell', allocation: 0.5 },
];
await executor.executeSignals(strategy, signals, 10000);
// 10000 * 0.4 * 0.5 = 2000 each
expect(console.log).toHaveBeenCalledWith('[multi] BUY TQQQ — $2000.00');
expect(console.log).toHaveBeenCalledWith('[multi] SELL SPY — $2000.00');
});
});

View File

@@ -1,23 +0,0 @@
import { Alpaca } from "./alpaca";
import { Strategy, Signal } from "./strategy";
export class Executor {
private alpaca: Alpaca;
constructor(alpaca: Alpaca) {
this.alpaca = alpaca;
}
async executeSignals(strategy: Strategy, signals: Signal[], totalCapital: number): Promise<void> {
const strategyCapital = totalCapital * strategy.capitalAllocation;
for (const signal of signals) {
if (signal.allocation === 0) continue;
const dollarAmount = strategyCapital * signal.allocation;
console.log(
`[${strategy.name}] ${signal.direction.toUpperCase()} ${signal.symbol}$${dollarAmount.toFixed(2)}`
);
}
}
}

View File

@@ -4,8 +4,8 @@ import { wait } from "./trading";
import { MomentumStrategy } from "./momentum-strategy"; import { MomentumStrategy } from "./momentum-strategy";
const alpaca = new Alpaca(false); const alpaca = new Alpaca(false);
const momentum = new MomentumStrategy(1.0); const momentum = new MomentumStrategy();
const bot = new Bot(alpaca, [momentum]); const bot = new Bot(alpaca, [{ strategy: momentum, capitalAllocation: 1.0 }]);
async function main() { async function main() {
while(true) { while(true) {

View File

@@ -12,6 +12,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getLatestBid: vi.fn(), getLatestBid: vi.fn(),
getLatestSpread: vi.fn(), getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(), getLatestTrades: vi.fn(),
buy: vi.fn(),
sell: vi.fn(),
...overrides, ...overrides,
} as unknown as Alpaca; } as unknown as Alpaca;
} }

View File

@@ -12,6 +12,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getLatestBid: vi.fn(), getLatestBid: vi.fn(),
getLatestSpread: vi.fn(), getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(), getLatestTrades: vi.fn(),
buy: vi.fn().mockResolvedValue(50),
sell: vi.fn().mockResolvedValue(50),
...overrides, ...overrides,
} as unknown as Alpaca; } as unknown as Alpaca;
} }
@@ -37,110 +39,99 @@ describe('MomentumStrategy', () => {
it('buys TQQQ when QQQ goes up', async () => { it('buys TQQQ when QQQ goes up', async () => {
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({
getLatestAsk: vi.fn() getLatestAsk: vi.fn()
// indicator: QQQ before
.mockResolvedValueOnce(100) .mockResolvedValueOnce(100)
// indicator: QQQ after (up) .mockResolvedValueOnce(101),
.mockResolvedValueOnce(101) buy: vi.fn().mockResolvedValue(50),
// entry quote for TQQQ getLatestBid: vi.fn().mockResolvedValue(50.50),
.mockResolvedValueOnce(50),
getLatestBid: vi.fn()
// poll: hit target immediately
.mockResolvedValueOnce(50.50),
}); });
const strategy = new MomentumStrategy(1.0, fastConfig); const strategy = new MomentumStrategy(fastConfig);
const promise = strategy.execute(alpaca); const promise = strategy.execute(alpaca, 5000);
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
const signals = await promise; await promise;
expect(signals[0]).toEqual({ symbol: 'TQQQ', direction: 'buy', allocation: 1.0 }); expect(alpaca.buy).toHaveBeenCalledWith('TQQQ', 5000);
}); });
it('buys SQQQ when QQQ goes down', async () => { it('buys SQQQ when QQQ goes down', async () => {
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({
getLatestAsk: vi.fn() getLatestAsk: vi.fn()
// indicator: QQQ before
.mockResolvedValueOnce(100) .mockResolvedValueOnce(100)
// indicator: QQQ after (down) .mockResolvedValueOnce(99),
.mockResolvedValueOnce(99) buy: vi.fn().mockResolvedValue(30),
// entry quote for SQQQ getLatestBid: vi.fn().mockResolvedValue(30.30),
.mockResolvedValueOnce(30),
getLatestBid: vi.fn()
// poll: hit target immediately
.mockResolvedValueOnce(30.30),
}); });
const strategy = new MomentumStrategy(1.0, fastConfig); const strategy = new MomentumStrategy(fastConfig);
const promise = strategy.execute(alpaca); const promise = strategy.execute(alpaca, 5000);
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
const signals = await promise; await promise;
expect(signals[0]).toEqual({ symbol: 'SQQQ', direction: 'buy', allocation: 1.0 }); expect(alpaca.buy).toHaveBeenCalledWith('SQQQ', 5000);
}); });
it('sells when bid hits 1% target', async () => { it('sells when bid hits 1% target', async () => {
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({
getLatestAsk: vi.fn() getLatestAsk: vi.fn()
// indicator: QQQ samples
.mockResolvedValueOnce(100) .mockResolvedValueOnce(100)
.mockResolvedValueOnce(101) .mockResolvedValueOnce(101),
// entry quote: ask = 50 buy: vi.fn().mockResolvedValue(50),
.mockResolvedValueOnce(50),
getLatestBid: vi.fn() getLatestBid: vi.fn()
// poll 1: not yet (target = 50.50) // poll 1: not yet (target = 50.50)
.mockResolvedValueOnce(50.10) .mockResolvedValueOnce(50.10)
// poll 2: hit target // poll 2: hit target
.mockResolvedValueOnce(50.50), .mockResolvedValueOnce(50.50),
sell: vi.fn().mockResolvedValue(50.50),
}); });
const strategy = new MomentumStrategy(1.0, fastConfig); const strategy = new MomentumStrategy(fastConfig);
const promise = strategy.execute(alpaca); const promise = strategy.execute(alpaca, 5000);
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
const signals = await promise; await promise;
expect(signals[1]).toEqual({ symbol: 'TQQQ', direction: 'sell', allocation: 1.0 });
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target'); expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target');
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
}); });
it('sells on timeout when target not reached', async () => { it('sells on timeout when target not reached', async () => {
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({
getLatestAsk: vi.fn() getLatestAsk: vi.fn()
// indicator: QQQ samples
.mockResolvedValueOnce(100) .mockResolvedValueOnce(100)
.mockResolvedValueOnce(101) .mockResolvedValueOnce(101),
// entry quote: ask = 50 buy: vi.fn().mockResolvedValue(50),
.mockResolvedValueOnce(50), getLatestBid: vi.fn().mockResolvedValue(49.90),
getLatestBid: vi.fn() sell: vi.fn().mockResolvedValue(49.90),
// all polls: never reach target
.mockResolvedValue(49.90),
}); });
const strategy = new MomentumStrategy(1.0, fastConfig); const strategy = new MomentumStrategy(fastConfig);
const promise = strategy.execute(alpaca); const promise = strategy.execute(alpaca, 5000);
await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(2000);
const signals = await promise; await promise;
expect(signals[1]).toEqual({ symbol: 'TQQQ', direction: 'sell', allocation: 1.0 });
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: timeout'); expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: timeout');
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
}); });
it('returns both buy and sell signals', async () => { it('uses actual fill price for target calculation', async () => {
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({
getLatestAsk: vi.fn() getLatestAsk: vi.fn()
.mockResolvedValueOnce(100) .mockResolvedValueOnce(100)
.mockResolvedValueOnce(101) .mockResolvedValueOnce(101),
.mockResolvedValueOnce(50), // fill price is 50, so 1% target = 50.50
buy: vi.fn().mockResolvedValue(50),
getLatestBid: vi.fn() getLatestBid: vi.fn()
// 50.49 is below target
.mockResolvedValueOnce(50.49)
// 50.50 hits target
.mockResolvedValueOnce(50.50), .mockResolvedValueOnce(50.50),
sell: vi.fn().mockResolvedValue(50.50),
}); });
const strategy = new MomentumStrategy(1.0, fastConfig); const strategy = new MomentumStrategy(fastConfig);
const promise = strategy.execute(alpaca); const promise = strategy.execute(alpaca, 5000);
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
const signals = await promise; await promise;
expect(signals).toHaveLength(2); expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target');
expect(signals[0].direction).toBe('buy');
expect(signals[1].direction).toBe('sell');
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { Strategy, Signal } from "./strategy"; import { Strategy } from "./strategy";
import { MomentumIndicator, MomentumIndicatorConfig } from "./momentum-indicator"; import { MomentumIndicator, MomentumIndicatorConfig } from "./momentum-indicator";
import { wait } from "./trading"; import { wait } from "./trading";
@@ -18,23 +18,19 @@ const defaultConfig: MomentumStrategyConfig = {
export class MomentumStrategy implements Strategy { export class MomentumStrategy implements Strategy {
name = 'momentum'; name = 'momentum';
capitalAllocation: number;
private config: MomentumStrategyConfig; private config: MomentumStrategyConfig;
private indicator: MomentumIndicator; private indicator: MomentumIndicator;
constructor(capitalAllocation: number, config: Partial<MomentumStrategyConfig> = {}) { constructor(config: Partial<MomentumStrategyConfig> = {}) {
this.capitalAllocation = capitalAllocation;
this.config = { ...defaultConfig, ...config }; this.config = { ...defaultConfig, ...config };
this.indicator = new MomentumIndicator(this.config.indicatorConfig); this.indicator = new MomentumIndicator(this.config.indicatorConfig);
} }
async execute(alpaca: Alpaca): Promise<Signal[]> { async execute(alpaca: Alpaca, capitalAmount: number): Promise<void> {
const result = await this.indicator.evaluate(alpaca); const result = await this.indicator.evaluate(alpaca);
const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ'; const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ';
const entryPrice = await alpaca.getLatestAsk(symbol); const entryPrice = await alpaca.buy(symbol, capitalAmount);
const buy: Signal = { symbol, direction: 'buy', allocation: 1.0 };
const targetPrice = entryPrice * (1 + this.config.targetGain); const targetPrice = entryPrice * (1 + this.config.targetGain);
const deadline = Date.now() + this.config.holdTime; const deadline = Date.now() + this.config.holdTime;
@@ -53,8 +49,6 @@ export class MomentumStrategy implements Strategy {
console.log(`[${this.name}] exit ${symbol} — reason: ${reason}`); console.log(`[${this.name}] exit ${symbol} — reason: ${reason}`);
const sell: Signal = { symbol, direction: 'sell', allocation: 1.0 }; await alpaca.sell(symbol, capitalAmount);
return [buy, sell];
} }
} }

View File

@@ -1,15 +1,6 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
export type SignalDirection = 'buy' | 'sell';
export interface Signal {
symbol: string;
direction: SignalDirection;
allocation: number; // fraction of this strategy's capital to use (0-1)
}
export interface Strategy { export interface Strategy {
name: string; name: string;
capitalAllocation: number; // fraction of total account capital (0-1) execute(alpaca: Alpaca, capitalAmount: number): Promise<void>;
execute(alpaca: Alpaca): Promise<Signal[]>;
} }

View File

@@ -12,6 +12,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
getLatestBid: vi.fn(), getLatestBid: vi.fn(),
getLatestSpread: vi.fn(), getLatestSpread: vi.fn(),
getLatestTrades: vi.fn(), getLatestTrades: vi.fn(),
buy: vi.fn(),
sell: vi.fn(),
...overrides, ...overrides,
} as unknown as Alpaca; } as unknown as Alpaca;
} }