diff --git a/src/index.ts b/src/index.ts index a31e502..1b60b25 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) { diff --git a/src/momentum-indicator.test.ts b/src/momentum-indicator.test.ts new file mode 100644 index 0000000..86542fd --- /dev/null +++ b/src/momentum-indicator.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/momentum-indicator.ts b/src/momentum-indicator.ts new file mode 100644 index 0000000..dd24a15 --- /dev/null +++ b/src/momentum-indicator.ts @@ -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 { + name = 'momentum-indicator'; + private config: MomentumIndicatorConfig; + + constructor(config: Partial = {}) { + this.config = { ...defaultConfig, ...config }; + } + + async evaluate(alpaca: Alpaca): Promise { + 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 }; + } +} diff --git a/src/momentum-strategy.test.ts b/src/momentum-strategy.test.ts new file mode 100644 index 0000000..75794c6 --- /dev/null +++ b/src/momentum-strategy.test.ts @@ -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 { + 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'); + }); +}); diff --git a/src/momentum-strategy.ts b/src/momentum-strategy.ts new file mode 100644 index 0000000..d48d071 --- /dev/null +++ b/src/momentum-strategy.ts @@ -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; +} + +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 = {}) { + this.capitalAllocation = capitalAllocation; + this.config = { ...defaultConfig, ...config }; + this.indicator = new MomentumIndicator(this.config.indicatorConfig); + } + + async execute(alpaca: Alpaca): Promise { + 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]; + } +} diff --git a/vitest.config.ts b/vitest.config.ts index 7888b7a..5834ec5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, + exclude: ['dist/**', 'node_modules/**'], }, });