Add momentum strategy with QQQ-based TQQQ/SQQQ trading

Implements MomentumIndicator (samples QQQ direction) and
MomentumStrategy (buys TQQQ on up, SQQQ on down, exits at
+1% target or 1hr timeout). Wired into Bot via index.ts.
Also fixes vitest config to exclude dist/ from test discovery.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-02-01 17:47:20 -07:00
parent 6d63346f4c
commit bf345243fd
6 changed files with 340 additions and 1 deletions

View File

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

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MomentumIndicator } from './momentum-indicator';
import type { Alpaca } from './alpaca';
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
return {
getAccount: vi.fn(),
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestQuote: vi.fn(),
getLatestTrades: vi.fn(),
...overrides,
} as unknown as Alpaca;
}
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
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 }),
});
const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 });
const promise = indicator.evaluate(alpaca);
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result.direction).toBe('up');
expect(result.priceBefore).toBe(100);
expect(result.priceAfter).toBe(101);
});
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 }),
});
const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 });
const promise = indicator.evaluate(alpaca);
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result.direction).toBe('down');
expect(result.priceBefore).toBe(100);
expect(result.priceAfter).toBe(99);
});
it('returns up when prices are equal', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 100, bp: 99 }),
});
const indicator = new MomentumIndicator({ settleDelay: 0, sampleDelay: 100 });
const promise = indicator.evaluate(alpaca);
await vi.advanceTimersByTimeAsync(100);
const result = await promise;
expect(result.direction).toBe('up');
});
it('uses configured symbol', async () => {
const getLatestQuote = vi.fn()
.mockResolvedValueOnce({ ap: 50, bp: 49 })
.mockResolvedValueOnce({ ap: 51, bp: 50 });
const alpaca = mockAlpaca({ getLatestQuote });
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);
});
});

46
src/momentum-indicator.ts Normal file
View File

@@ -0,0 +1,46 @@
import { Alpaca } from "./alpaca";
import { Indicator } from "./indicator";
import { wait } from "./trading";
export interface MomentumResult {
direction: 'up' | 'down';
priceBefore: number;
priceAfter: number;
}
export interface MomentumIndicatorConfig {
symbol: string;
settleDelay: number;
sampleDelay: number;
}
const defaultConfig: MomentumIndicatorConfig = {
symbol: 'QQQ',
settleDelay: 60_000,
sampleDelay: 1_000,
};
export class MomentumIndicator implements Indicator<MomentumResult> {
name = 'momentum-indicator';
private config: MomentumIndicatorConfig;
constructor(config: Partial<MomentumIndicatorConfig> = {}) {
this.config = { ...defaultConfig, ...config };
}
async evaluate(alpaca: Alpaca): Promise<MomentumResult> {
await wait(this.config.settleDelay);
const before = await alpaca.getLatestQuote(this.config.symbol);
const priceBefore = before.ap;
await wait(this.config.sampleDelay);
const after = await alpaca.getLatestQuote(this.config.symbol);
const priceAfter = after.ap;
const direction = priceAfter >= priceBefore ? 'up' : 'down';
return { direction, priceBefore, priceAfter };
}
}

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MomentumStrategy } from './momentum-strategy';
import type { Alpaca } from './alpaca';
function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
return {
getAccount: vi.fn(),
getAssets: vi.fn(),
getAsset: vi.fn(),
getClock: vi.fn(),
getLatestQuote: vi.fn(),
getLatestTrades: vi.fn(),
...overrides,
} as unknown as Alpaca;
}
const fastConfig = {
targetGain: 0.01,
holdTime: 1000,
pollInterval: 100,
indicatorConfig: { settleDelay: 0, sampleDelay: 100 },
};
beforeEach(() => {
vi.useFakeTimers();
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('MomentumStrategy', () => {
it('buys TQQQ when QQQ goes up', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
// indicator: QQQ before
.mockResolvedValueOnce({ ap: 100, bp: 99 })
// indicator: QQQ after (up)
.mockResolvedValueOnce({ ap: 101, bp: 100 })
// entry quote for TQQQ
.mockResolvedValueOnce({ ap: 50, bp: 49 })
// poll: hit target immediately
.mockResolvedValueOnce({ ap: 51, bp: 50.50 }),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
const promise = strategy.execute(alpaca);
await vi.advanceTimersByTimeAsync(1000);
const signals = await promise;
expect(signals[0]).toEqual({ symbol: 'TQQQ', direction: 'buy', allocation: 1.0 });
});
it('buys SQQQ when QQQ goes down', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
// indicator: QQQ before
.mockResolvedValueOnce({ ap: 100, bp: 99 })
// indicator: QQQ after (down)
.mockResolvedValueOnce({ ap: 99, bp: 98 })
// entry quote for SQQQ
.mockResolvedValueOnce({ ap: 30, bp: 29 })
// poll: hit target immediately
.mockResolvedValueOnce({ ap: 31, bp: 30.30 }),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
const promise = strategy.execute(alpaca);
await vi.advanceTimersByTimeAsync(1000);
const signals = await promise;
expect(signals[0]).toEqual({ symbol: 'SQQQ', direction: 'buy', allocation: 1.0 });
});
it('sells when bid hits 1% target', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
// indicator: QQQ samples
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 101, bp: 100 })
// entry quote: ask = 50
.mockResolvedValueOnce({ ap: 50, bp: 49 })
// poll 1: not yet (target = 50.50)
.mockResolvedValueOnce({ ap: 50.20, bp: 50.10 })
// poll 2: hit target
.mockResolvedValueOnce({ ap: 51, bp: 50.50 }),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
const promise = strategy.execute(alpaca);
await vi.advanceTimersByTimeAsync(1000);
const signals = await promise;
expect(signals[1]).toEqual({ symbol: 'TQQQ', direction: 'sell', allocation: 1.0 });
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target');
});
it('sells on timeout when target not reached', async () => {
const alpaca = mockAlpaca({
getLatestQuote: vi.fn()
// indicator: QQQ samples
.mockResolvedValueOnce({ ap: 100, bp: 99 })
.mockResolvedValueOnce({ ap: 101, bp: 100 })
// entry quote: ask = 50
.mockResolvedValueOnce({ ap: 50, bp: 49 })
// all polls: never reach target
.mockResolvedValue({ ap: 50.10, bp: 49.90 }),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
const promise = strategy.execute(alpaca);
await vi.advanceTimersByTimeAsync(2000);
const signals = await promise;
expect(signals[1]).toEqual({ symbol: 'TQQQ', direction: 'sell', allocation: 1.0 });
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: timeout');
});
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 }),
});
const strategy = new MomentumStrategy(1.0, fastConfig);
const promise = strategy.execute(alpaca);
await vi.advanceTimersByTimeAsync(1000);
const signals = await promise;
expect(signals).toHaveLength(2);
expect(signals[0].direction).toBe('buy');
expect(signals[1].direction).toBe('sell');
});
});

61
src/momentum-strategy.ts Normal file
View File

@@ -0,0 +1,61 @@
import { Alpaca } from "./alpaca";
import { Strategy, Signal } from "./strategy";
import { MomentumIndicator, MomentumIndicatorConfig } from "./momentum-indicator";
import { wait } from "./trading";
export interface MomentumStrategyConfig {
targetGain: number;
holdTime: number;
pollInterval: number;
indicatorConfig?: Partial<MomentumIndicatorConfig>;
}
const defaultConfig: MomentumStrategyConfig = {
targetGain: 0.01,
holdTime: 3_600_000,
pollInterval: 5_000,
};
export class MomentumStrategy implements Strategy {
name = 'momentum';
capitalAllocation: number;
private config: MomentumStrategyConfig;
private indicator: MomentumIndicator;
constructor(capitalAllocation: number, config: Partial<MomentumStrategyConfig> = {}) {
this.capitalAllocation = capitalAllocation;
this.config = { ...defaultConfig, ...config };
this.indicator = new MomentumIndicator(this.config.indicatorConfig);
}
async execute(alpaca: Alpaca): Promise<Signal[]> {
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 buy: Signal = { symbol, direction: 'buy', allocation: 1.0 };
const targetPrice = entryPrice * (1 + this.config.targetGain);
const deadline = Date.now() + this.config.holdTime;
let reason: 'target' | 'timeout' = 'timeout';
while (Date.now() < deadline) {
await wait(this.config.pollInterval);
const quote = await alpaca.getLatestQuote(symbol);
if (quote.bp >= targetPrice) {
reason = 'target';
break;
}
}
console.log(`[${this.name}] exit ${symbol} — reason: ${reason}`);
const sell: Signal = { symbol, direction: 'sell', allocation: 1.0 };
return [buy, sell];
}
}