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' }),
|
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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -99,4 +115,25 @@ export class Alpaca {
|
|||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
106
src/bot.test.ts
106
src/bot.test.ts
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
23
src/bot.ts
23
src/bot.ts
@@ -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> {
|
||||||
@@ -30,10 +32,9 @@ export class Bot {
|
|||||||
const totalCapital = parseFloat(account.cash);
|
const totalCapital = parseFloat(account.cash);
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.strategies.map(async (strategy) => {
|
this.allocations.map(async ({ strategy, capitalAllocation }) => {
|
||||||
const signals = await strategy.execute(this.alpaca);
|
const capitalAmount = totalCapital * capitalAllocation;
|
||||||
|
await strategy.execute(this.alpaca, capitalAmount);
|
||||||
await this.executor.executeSignals(strategy, signals, totalCapital);
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]>;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user