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:
@@ -1,9 +1,11 @@
|
|||||||
import { Alpaca } from "./alpaca";
|
import { Alpaca } from "./alpaca";
|
||||||
import { Bot } from "./bot";
|
import { Bot } from "./bot";
|
||||||
import { wait } from "./trading";
|
import { wait } from "./trading";
|
||||||
|
import { MomentumStrategy } from "./momentum-strategy";
|
||||||
|
|
||||||
const alpaca = new Alpaca(false);
|
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() {
|
async function main() {
|
||||||
while(true) {
|
while(true) {
|
||||||
|
|||||||
90
src/momentum-indicator.test.ts
Normal file
90
src/momentum-indicator.test.ts
Normal 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
46
src/momentum-indicator.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
139
src/momentum-strategy.test.ts
Normal file
139
src/momentum-strategy.test.ts
Normal 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
61
src/momentum-strategy.ts
Normal 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
|
exclude: ['dist/**', 'node_modules/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user