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 { 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) {
|
||||
|
||||
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({
|
||||
test: {
|
||||
globals: true,
|
||||
exclude: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user