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:
@@ -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()),
|
||||
createOrder: vi.fn().mockResolvedValue({ id: 'order-1', symbol: 'TQQQ', filled_avg_price: '50.25', filled_qty: '10', side: 'buy', status: 'filled' }),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -78,4 +79,34 @@ describe('Alpaca', () => {
|
||||
await alpaca.getLatestTrades(['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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,15 @@ export interface AlpacaQuote {
|
||||
BidPrice: number;
|
||||
}
|
||||
|
||||
export interface AlpacaOrder {
|
||||
id: string;
|
||||
symbol: string;
|
||||
filled_avg_price: string;
|
||||
filled_qty: string;
|
||||
side: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AlpacaTrade {
|
||||
p: number; // price
|
||||
s: number; // size
|
||||
@@ -49,6 +58,13 @@ export interface AlpacaClient {
|
||||
getClock(): Promise<AlpacaClock>;
|
||||
getLatestQuote(symbol: string): Promise<AlpacaQuote>;
|
||||
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 {
|
||||
@@ -99,4 +115,25 @@ export class Alpaca {
|
||||
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);
|
||||
}
|
||||
}
|
||||
106
src/bot.test.ts
106
src/bot.test.ts
@@ -1,7 +1,7 @@
|
||||
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 { Strategy, Signal } from './strategy';
|
||||
import type { Strategy } from './strategy';
|
||||
|
||||
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||
return {
|
||||
@@ -17,6 +17,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||
getLatestBid: vi.fn(),
|
||||
getLatestSpread: vi.fn(),
|
||||
getLatestTrades: vi.fn(),
|
||||
buy: vi.fn(),
|
||||
sell: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as Alpaca;
|
||||
}
|
||||
@@ -24,8 +26,7 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||
function mockStrategy(overrides: Partial<Strategy> = {}): Strategy {
|
||||
return {
|
||||
name: 'test-strategy',
|
||||
capitalAllocation: 0.5,
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
execute: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -44,83 +45,53 @@ describe('Bot', () => {
|
||||
describe('constructor', () => {
|
||||
it('throws when capital allocations exceed 1.0', () => {
|
||||
const alpaca = mockAlpaca();
|
||||
const strategies = [
|
||||
mockStrategy({ capitalAllocation: 0.6 }),
|
||||
mockStrategy({ capitalAllocation: 0.5 }),
|
||||
const allocations: StrategyAllocation[] = [
|
||||
{ strategy: mockStrategy(), capitalAllocation: 0.6 },
|
||||
{ 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'
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts allocations that sum to exactly 1.0', () => {
|
||||
const alpaca = mockAlpaca();
|
||||
const strategies = [
|
||||
mockStrategy({ capitalAllocation: 0.5 }),
|
||||
mockStrategy({ capitalAllocation: 0.5 }),
|
||||
const allocations: StrategyAllocation[] = [
|
||||
{ strategy: 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', () => {
|
||||
const alpaca = mockAlpaca();
|
||||
const strategies = [
|
||||
mockStrategy({ capitalAllocation: 0.3 }),
|
||||
mockStrategy({ capitalAllocation: 0.2 }),
|
||||
const allocations: StrategyAllocation[] = [
|
||||
{ strategy: mockStrategy(), capitalAllocation: 0.3 },
|
||||
{ strategy: mockStrategy(), capitalAllocation: 0.2 },
|
||||
];
|
||||
expect(() => new Bot(alpaca, strategies)).not.toThrow();
|
||||
expect(() => new Bot(alpaca, allocations)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDay', () => {
|
||||
it('runs multiple strategies concurrently', async () => {
|
||||
it('passes correct capital amount to each strategy', async () => {
|
||||
const alpaca = mockAlpaca();
|
||||
const strategyA = mockStrategy({
|
||||
name: 'A',
|
||||
capitalAllocation: 0.3,
|
||||
execute: vi.fn().mockResolvedValue([
|
||||
{ symbol: 'TQQQ', direction: 'buy', allocation: 1.0 },
|
||||
]),
|
||||
});
|
||||
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 strategyA = mockStrategy({ name: 'A' });
|
||||
const strategyB = mockStrategy({ name: 'B' });
|
||||
const allocations: StrategyAllocation[] = [
|
||||
{ strategy: strategyA, capitalAllocation: 0.3 },
|
||||
{ strategy: strategyB, capitalAllocation: 0.2 },
|
||||
];
|
||||
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();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await promise;
|
||||
|
||||
// 10000 * 0.4 * 0.5 = 2000
|
||||
expect(console.log).toHaveBeenCalledWith('[test] BUY TQQQ — $2000.00');
|
||||
// 10000 * 0.3 = 3000
|
||||
expect(strategyA.execute).toHaveBeenCalledWith(alpaca, 3000);
|
||||
// 10000 * 0.2 = 2000
|
||||
expect(strategyB.execute).toHaveBeenCalledWith(alpaca, 2000);
|
||||
});
|
||||
|
||||
it('works with zero strategies', async () => {
|
||||
@@ -131,9 +102,26 @@ describe('Bot', () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await promise;
|
||||
|
||||
// Only the "waiting for open" log, no strategy logs
|
||||
expect(console.log).toHaveBeenCalledTimes(1);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
23
src/bot.ts
23
src/bot.ts
@@ -1,23 +1,25 @@
|
||||
import { Alpaca } from "./alpaca";
|
||||
import { Strategy } from "./strategy";
|
||||
import { Executor } from "./executor";
|
||||
import { isMarketOpen, waitForNextOpen } from "./trading";
|
||||
|
||||
export interface StrategyAllocation {
|
||||
strategy: Strategy;
|
||||
capitalAllocation: number;
|
||||
}
|
||||
|
||||
export class Bot {
|
||||
private alpaca: Alpaca;
|
||||
private strategies: Strategy[];
|
||||
private executor: Executor;
|
||||
private allocations: StrategyAllocation[];
|
||||
|
||||
constructor(alpaca: Alpaca, strategies: Strategy[]) {
|
||||
const totalAllocation = strategies.reduce((sum, s) => sum + s.capitalAllocation, 0);
|
||||
constructor(alpaca: Alpaca, allocations: StrategyAllocation[]) {
|
||||
const totalAllocation = allocations.reduce((sum, a) => sum + a.capitalAllocation, 0);
|
||||
if (totalAllocation > 1.0) {
|
||||
throw new Error(
|
||||
`Capital allocations sum to ${totalAllocation}, which exceeds 1.0`
|
||||
);
|
||||
}
|
||||
this.alpaca = alpaca;
|
||||
this.strategies = strategies;
|
||||
this.executor = new Executor(alpaca);
|
||||
this.allocations = allocations;
|
||||
}
|
||||
|
||||
async runDay(): Promise<void> {
|
||||
@@ -30,10 +32,9 @@ export class Bot {
|
||||
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);
|
||||
this.allocations.map(async ({ strategy, capitalAllocation }) => {
|
||||
const capitalAmount = totalCapital * capitalAllocation;
|
||||
await strategy.execute(this.alpaca, capitalAmount);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { wait } from "./trading";
|
||||
import { MomentumStrategy } from "./momentum-strategy";
|
||||
|
||||
const alpaca = new Alpaca(false);
|
||||
const momentum = new MomentumStrategy(1.0);
|
||||
const bot = new Bot(alpaca, [momentum]);
|
||||
const momentum = new MomentumStrategy();
|
||||
const bot = new Bot(alpaca, [{ strategy: momentum, capitalAllocation: 1.0 }]);
|
||||
|
||||
async function main() {
|
||||
while(true) {
|
||||
|
||||
@@ -12,6 +12,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||
getLatestBid: vi.fn(),
|
||||
getLatestSpread: vi.fn(),
|
||||
getLatestTrades: vi.fn(),
|
||||
buy: vi.fn(),
|
||||
sell: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as Alpaca;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ 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),
|
||||
...overrides,
|
||||
} as unknown as Alpaca;
|
||||
}
|
||||
@@ -37,110 +39,99 @@ describe('MomentumStrategy', () => {
|
||||
it('buys TQQQ when QQQ goes up', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getLatestAsk: vi.fn()
|
||||
// indicator: QQQ before
|
||||
.mockResolvedValueOnce(100)
|
||||
// indicator: QQQ after (up)
|
||||
.mockResolvedValueOnce(101)
|
||||
// entry quote for TQQQ
|
||||
.mockResolvedValueOnce(50),
|
||||
getLatestBid: vi.fn()
|
||||
// poll: hit target immediately
|
||||
.mockResolvedValueOnce(50.50),
|
||||
.mockResolvedValueOnce(101),
|
||||
buy: vi.fn().mockResolvedValue(50),
|
||||
getLatestBid: vi.fn().mockResolvedValue(50.50),
|
||||
});
|
||||
|
||||
const strategy = new MomentumStrategy(1.0, fastConfig);
|
||||
const promise = strategy.execute(alpaca);
|
||||
const strategy = new MomentumStrategy(fastConfig);
|
||||
const promise = strategy.execute(alpaca, 5000);
|
||||
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 () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getLatestAsk: vi.fn()
|
||||
// indicator: QQQ before
|
||||
.mockResolvedValueOnce(100)
|
||||
// indicator: QQQ after (down)
|
||||
.mockResolvedValueOnce(99)
|
||||
// entry quote for SQQQ
|
||||
.mockResolvedValueOnce(30),
|
||||
getLatestBid: vi.fn()
|
||||
// poll: hit target immediately
|
||||
.mockResolvedValueOnce(30.30),
|
||||
.mockResolvedValueOnce(99),
|
||||
buy: vi.fn().mockResolvedValue(30),
|
||||
getLatestBid: vi.fn().mockResolvedValue(30.30),
|
||||
});
|
||||
|
||||
const strategy = new MomentumStrategy(1.0, fastConfig);
|
||||
const promise = strategy.execute(alpaca);
|
||||
const strategy = new MomentumStrategy(fastConfig);
|
||||
const promise = strategy.execute(alpaca, 5000);
|
||||
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 () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getLatestAsk: vi.fn()
|
||||
// indicator: QQQ samples
|
||||
.mockResolvedValueOnce(100)
|
||||
.mockResolvedValueOnce(101)
|
||||
// entry quote: ask = 50
|
||||
.mockResolvedValueOnce(50),
|
||||
.mockResolvedValueOnce(101),
|
||||
buy: vi.fn().mockResolvedValue(50),
|
||||
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),
|
||||
});
|
||||
|
||||
const strategy = new MomentumStrategy(1.0, fastConfig);
|
||||
const promise = strategy.execute(alpaca);
|
||||
const strategy = new MomentumStrategy(fastConfig);
|
||||
const promise = strategy.execute(alpaca, 5000);
|
||||
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(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
|
||||
});
|
||||
|
||||
it('sells on timeout when target not reached', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getLatestAsk: vi.fn()
|
||||
// indicator: QQQ samples
|
||||
.mockResolvedValueOnce(100)
|
||||
.mockResolvedValueOnce(101)
|
||||
// entry quote: ask = 50
|
||||
.mockResolvedValueOnce(50),
|
||||
getLatestBid: vi.fn()
|
||||
// all polls: never reach target
|
||||
.mockResolvedValue(49.90),
|
||||
.mockResolvedValueOnce(101),
|
||||
buy: vi.fn().mockResolvedValue(50),
|
||||
getLatestBid: vi.fn().mockResolvedValue(49.90),
|
||||
sell: vi.fn().mockResolvedValue(49.90),
|
||||
});
|
||||
|
||||
const strategy = new MomentumStrategy(1.0, fastConfig);
|
||||
const promise = strategy.execute(alpaca);
|
||||
const strategy = new MomentumStrategy(fastConfig);
|
||||
const promise = strategy.execute(alpaca, 5000);
|
||||
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(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({
|
||||
getLatestAsk: vi.fn()
|
||||
.mockResolvedValueOnce(100)
|
||||
.mockResolvedValueOnce(101)
|
||||
.mockResolvedValueOnce(50),
|
||||
.mockResolvedValueOnce(101),
|
||||
// fill price is 50, so 1% target = 50.50
|
||||
buy: vi.fn().mockResolvedValue(50),
|
||||
getLatestBid: vi.fn()
|
||||
// 50.49 is below target
|
||||
.mockResolvedValueOnce(50.49)
|
||||
// 50.50 hits target
|
||||
.mockResolvedValueOnce(50.50),
|
||||
sell: vi.fn().mockResolvedValue(50.50),
|
||||
});
|
||||
|
||||
const strategy = new MomentumStrategy(1.0, fastConfig);
|
||||
const promise = strategy.execute(alpaca);
|
||||
const strategy = new MomentumStrategy(fastConfig);
|
||||
const promise = strategy.execute(alpaca, 5000);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
const signals = await promise;
|
||||
await promise;
|
||||
|
||||
expect(signals).toHaveLength(2);
|
||||
expect(signals[0].direction).toBe('buy');
|
||||
expect(signals[1].direction).toBe('sell');
|
||||
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alpaca } from "./alpaca";
|
||||
import { Strategy, Signal } from "./strategy";
|
||||
import { Strategy } from "./strategy";
|
||||
import { MomentumIndicator, MomentumIndicatorConfig } from "./momentum-indicator";
|
||||
import { wait } from "./trading";
|
||||
|
||||
@@ -18,23 +18,19 @@ const defaultConfig: MomentumStrategyConfig = {
|
||||
|
||||
export class MomentumStrategy implements Strategy {
|
||||
name = 'momentum';
|
||||
capitalAllocation: number;
|
||||
private config: MomentumStrategyConfig;
|
||||
private indicator: MomentumIndicator;
|
||||
|
||||
constructor(capitalAllocation: number, config: Partial<MomentumStrategyConfig> = {}) {
|
||||
this.capitalAllocation = capitalAllocation;
|
||||
constructor(config: Partial<MomentumStrategyConfig> = {}) {
|
||||
this.config = { ...defaultConfig, ...config };
|
||||
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 symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ';
|
||||
|
||||
const entryPrice = await alpaca.getLatestAsk(symbol);
|
||||
|
||||
const buy: Signal = { symbol, direction: 'buy', allocation: 1.0 };
|
||||
const entryPrice = await alpaca.buy(symbol, capitalAmount);
|
||||
|
||||
const targetPrice = entryPrice * (1 + this.config.targetGain);
|
||||
const deadline = Date.now() + this.config.holdTime;
|
||||
@@ -53,8 +49,6 @@ export class MomentumStrategy implements Strategy {
|
||||
|
||||
console.log(`[${this.name}] exit ${symbol} — reason: ${reason}`);
|
||||
|
||||
const sell: Signal = { symbol, direction: 'sell', allocation: 1.0 };
|
||||
|
||||
return [buy, sell];
|
||||
await alpaca.sell(symbol, capitalAmount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
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 {
|
||||
name: string;
|
||||
capitalAllocation: number; // fraction of total account capital (0-1)
|
||||
execute(alpaca: Alpaca): Promise<Signal[]>;
|
||||
execute(alpaca: Alpaca, capitalAmount: number): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
|
||||
getLatestBid: vi.fn(),
|
||||
getLatestSpread: vi.fn(),
|
||||
getLatestTrades: vi.fn(),
|
||||
buy: vi.fn(),
|
||||
sell: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as Alpaca;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user