Refactor for TDD workflow with Vitest and ESLint
- Move API keys from hardcoded values to .env via dotenv - Extract business logic into src/trading.ts with dependency injection - Add typed AlpacaClient interface, replace `any` on Alpaca class - Add Vitest test suites for trading logic and Alpaca wrapper (14 tests) - Set up ESLint with @typescript-eslint for linting - Fix getAsset to pass symbol string directly to SDK - Fix typo (alpacha -> alpaca), remove unused ws/node-fetch deps - Update .gitignore for node_modules and .env Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
65
src/alpaca.test.ts
Normal file
65
src/alpaca.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { Alpaca, AlpacaClient } from './alpaca';
|
||||
|
||||
function mockClient(overrides: Partial<AlpacaClient> = {}): AlpacaClient {
|
||||
return {
|
||||
getAccount: vi.fn().mockResolvedValue({ cash: '10000.00' }),
|
||||
getAssets: vi.fn().mockResolvedValue([]),
|
||||
getAsset: vi.fn().mockResolvedValue({ symbol: 'TQQQ', fractionable: true, status: 'active', asset_class: 'us_equity' }),
|
||||
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({ ap: 50.00, bp: 49.90 }),
|
||||
getLatestTrades: vi.fn().mockResolvedValue(new Map()),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Alpaca', () => {
|
||||
it('throws when live is true', () => {
|
||||
expect(() => new Alpaca(true)).toThrow('not doing live yet');
|
||||
});
|
||||
|
||||
it('delegates getAccount to the underlying client', async () => {
|
||||
const client = mockClient();
|
||||
const alpaca = new Alpaca(false, client);
|
||||
const account = await alpaca.getAccount();
|
||||
expect(account.cash).toBe('10000.00');
|
||||
expect(client.getAccount).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates getAsset with the symbol string', async () => {
|
||||
const client = mockClient();
|
||||
const alpaca = new Alpaca(false, client);
|
||||
await alpaca.getAsset('TQQQ');
|
||||
expect(client.getAsset).toHaveBeenCalledWith('TQQQ');
|
||||
});
|
||||
|
||||
it('delegates getClock to the underlying client', async () => {
|
||||
const client = mockClient();
|
||||
const alpaca = new Alpaca(false, client);
|
||||
const clock = await alpaca.getClock();
|
||||
expect(clock.is_open).toBe(false);
|
||||
expect(client.getClock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('delegates getLatestQuote to the underlying client', async () => {
|
||||
const client = mockClient();
|
||||
const alpaca = new Alpaca(false, client);
|
||||
const quote = await alpaca.getLatestQuote('TQQQ');
|
||||
expect(quote.ap).toBe(50.00);
|
||||
expect(client.getLatestQuote).toHaveBeenCalledWith('TQQQ');
|
||||
});
|
||||
|
||||
it('delegates getAssets with active us_equity filter', async () => {
|
||||
const client = mockClient();
|
||||
const alpaca = new Alpaca(false, client);
|
||||
await alpaca.getAssets();
|
||||
expect(client.getAssets).toHaveBeenCalledWith({ status: 'active', asset_class: 'us_equity' });
|
||||
});
|
||||
|
||||
it('delegates getLatestTrades to the underlying client', async () => {
|
||||
const client = mockClient();
|
||||
const alpaca = new Alpaca(false, client);
|
||||
await alpaca.getLatestTrades(['TQQQ', 'SPY']);
|
||||
expect(client.getLatestTrades).toHaveBeenCalledWith(['TQQQ', 'SPY']);
|
||||
});
|
||||
});
|
||||
169
src/alpaca.ts
169
src/alpaca.ts
@@ -1,130 +1,93 @@
|
||||
import ws from 'ws';
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const AlpacaJS = require('@alpacahq/alpaca-trade-api')
|
||||
|
||||
if (!process.env.ALPACA_KEY_ID || !process.env.ALPACA_SECRET_KEY) {
|
||||
throw new Error('Missing ALPACA_KEY_ID or ALPACA_SECRET_KEY in environment');
|
||||
}
|
||||
|
||||
const paper_credentials = {
|
||||
key : 'PKCSK7Z6WGNF6QDEKEG3',
|
||||
secret : '89JxFVaJ14BVa1xVzDL6gIZETsFHYl1cnseEVJog'
|
||||
}
|
||||
const live_livecredentials = {
|
||||
key : '', //not yet
|
||||
secret : ''
|
||||
keyId : process.env.ALPACA_KEY_ID,
|
||||
secretKey : process.env.ALPACA_SECRET_KEY,
|
||||
paper : true
|
||||
}
|
||||
|
||||
export interface AlpacaAccount {
|
||||
cash: string;
|
||||
}
|
||||
|
||||
export interface AlpacaAsset {
|
||||
symbol: string;
|
||||
fractionable: boolean;
|
||||
status: string;
|
||||
asset_class: string;
|
||||
}
|
||||
|
||||
export interface AlpacaClock {
|
||||
is_open: boolean;
|
||||
next_open: string;
|
||||
next_close: string;
|
||||
}
|
||||
|
||||
export interface AlpacaQuote {
|
||||
ap: number; // ask price
|
||||
bp: number; // bid price
|
||||
}
|
||||
|
||||
export interface AlpacaTrade {
|
||||
p: number; // price
|
||||
s: number; // size
|
||||
t: string; // timestamp
|
||||
}
|
||||
|
||||
export interface AlpacaClient {
|
||||
getAccount(): Promise<AlpacaAccount>;
|
||||
getAssets(params: { status: string; asset_class: string }): Promise<AlpacaAsset[]>;
|
||||
getAsset(symbol: string): Promise<AlpacaAsset>;
|
||||
getClock(): Promise<AlpacaClock>;
|
||||
getLatestQuote(symbol: string): Promise<AlpacaQuote>;
|
||||
getLatestTrades(symbols: string[]): Promise<Map<string, AlpacaTrade>>;
|
||||
}
|
||||
|
||||
export class Alpaca {
|
||||
private credentials: {key: string, secret: string};
|
||||
private api_url : string;
|
||||
constructor(live = false) {
|
||||
if (live) {
|
||||
this.credentials = live_livecredentials;
|
||||
this.api_url = ''//
|
||||
private alpaca: AlpacaClient;
|
||||
constructor(live = false, client?: AlpacaClient) {
|
||||
if (client) {
|
||||
this.alpaca = client;
|
||||
}
|
||||
else if (live) {
|
||||
throw new Error("not doing live yet");
|
||||
}
|
||||
else {
|
||||
this.credentials = paper_credentials;
|
||||
this.api_url = 'https://paper-api.alpaca.markets';
|
||||
this.alpaca = new AlpacaJS(paper_credentials);
|
||||
}
|
||||
}
|
||||
|
||||
private get(url: string) {
|
||||
//probably options
|
||||
return fetch(this.api_url + url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'APCA-API-KEY-ID': this.credentials.key,
|
||||
'APCA-API-SECRET-KEY': this.credentials.secret
|
||||
}
|
||||
});
|
||||
}
|
||||
private data(url: string) {
|
||||
return fetch( url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'APCA-API-KEY-ID': this.credentials.key,
|
||||
'APCA-API-SECRET-KEY': this.credentials.secret
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccount() {
|
||||
const response = await this.get('/v2/account');
|
||||
return await response.json();
|
||||
return this.alpaca.getAccount();
|
||||
}
|
||||
public async getPositions() {
|
||||
const response = await this.get('/v2/positions');
|
||||
return await response.json();
|
||||
}
|
||||
public async getOrders() {
|
||||
const response = await this.get('/v2/orders');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getAssets() {
|
||||
const response = await this.get('/v2/assets');
|
||||
return await response.json();
|
||||
|
||||
return this.alpaca.getAssets({
|
||||
status : 'active',
|
||||
asset_class : 'us_equity'
|
||||
});
|
||||
}
|
||||
public async getAsset(symbol: string) {
|
||||
const response = await this.get('/v2/assets/' + symbol);
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getWatchlists() {
|
||||
const response = await this.get('/v2/watchlists');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getCalendar() {
|
||||
const response = await this.get('/v2/calendar');
|
||||
return await response.json();
|
||||
return this.alpaca.getAsset(symbol);
|
||||
}
|
||||
|
||||
public async getClock() {
|
||||
const response = await this.get('/v2/clock');
|
||||
return await response.json();
|
||||
return this.alpaca.getClock();
|
||||
}
|
||||
|
||||
public async getAccountConfigurations() {
|
||||
const response = await this.get('/v2/account/configurations');
|
||||
return await response.json();
|
||||
public async getLatestQuote(symbol: string) {
|
||||
return this.alpaca.getLatestQuote(symbol);
|
||||
}
|
||||
public async getAccountActivities() {
|
||||
const response = await this.get('/v2/account/activities');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getPortfolioHistory() {
|
||||
const response = await this.get('/v2/account/portfolio/history');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getOrdersByPath() {
|
||||
const response = await this.get('/v2/orders');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getOrderByPath() {
|
||||
const response = await this.get('/v2/orders');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getOrderByClientOrderId() {
|
||||
const response = await this.get('/v2/orders');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
public async getOrderByID() {
|
||||
const response = await this.get('/v2/orders');
|
||||
return await response.json();
|
||||
}
|
||||
public async getLatestQuote(symbol: string) {{
|
||||
const response = await this.data(`https://data.alpaca.markets/v2/stocks/${symbol}/quotes/latest`);
|
||||
return response.json();
|
||||
|
||||
}}
|
||||
public async getLatestTrades(symbols: string[]) {
|
||||
console.log(symbols.length)
|
||||
console.log(symbols.join(','))
|
||||
const response = await this.data('https://data.alpaca.markets/v2/stocks/trades/latest?symbols=' + symbols.join(','));
|
||||
return response.json();
|
||||
return this.alpaca.getLatestTrades(symbols);
|
||||
}
|
||||
|
||||
}
|
||||
52
src/index.ts
52
src/index.ts
@@ -1,61 +1,15 @@
|
||||
import { Alpaca } from "./alpaca";
|
||||
import { runDay, wait } from "./trading";
|
||||
|
||||
const alpaca = new Alpaca(false);
|
||||
|
||||
async function printAsset(symbol: string) {
|
||||
const asset = await alpaca.getAsset(symbol);
|
||||
if (asset && asset.fractionable)
|
||||
console.log(symbol + ' is fractional')
|
||||
else
|
||||
console.log(symbol + ' is not fractional')
|
||||
}
|
||||
async function accountBalance() {
|
||||
const account = await alpaca.getAccount();
|
||||
return account.cash;
|
||||
|
||||
}
|
||||
async function waitForNextOpen() {
|
||||
const clock = await alpaca.getClock();
|
||||
return wait(new Date(clock.next_open).valueOf() - new Date().valueOf());
|
||||
}
|
||||
function wait(ms :number) {
|
||||
return new Promise((resolve) => {
|
||||
//console.log('would wait: ' + ms + 'ms');
|
||||
//resolve('')
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
}
|
||||
async function runDay() {
|
||||
console.log('waiting for open');
|
||||
await waitForNextOpen();
|
||||
|
||||
await wait(1000); //wait a miniute
|
||||
|
||||
const q = await alpaca.getLatestQuote('TQQQ');
|
||||
await wait(1000);
|
||||
|
||||
const q2 = await alpaca.getLatestQuote('TQQQ');
|
||||
|
||||
if (q2.ap - q.ap > 0) {
|
||||
//up day
|
||||
console.log('up day: ', new Date())
|
||||
}
|
||||
else {
|
||||
//down day
|
||||
console.log('down day', new Date());
|
||||
}
|
||||
}
|
||||
async function main() {
|
||||
while(true) {
|
||||
await runDay();
|
||||
await runDay(alpaca);
|
||||
await wait(1000 * 60 * 60);//wait an hour before going and getting the next open
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
//run main
|
||||
main().then(
|
||||
() => {
|
||||
@@ -65,4 +19,4 @@ main().then(
|
||||
(e) => console.log('Error: ', e)
|
||||
).finally(
|
||||
() => process.exit(0)
|
||||
);
|
||||
);
|
||||
|
||||
125
src/trading.test.ts
Normal file
125
src/trading.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { printAsset, accountBalance, waitForNextOpen, runDay } from './trading';
|
||||
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('printAsset', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('logs fractional when asset is fractionable', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getAsset: vi.fn().mockResolvedValue({ symbol: 'TQQQ', fractionable: true, status: 'active', asset_class: 'us_equity' }),
|
||||
});
|
||||
|
||||
await printAsset(alpaca, 'TQQQ');
|
||||
expect(console.log).toHaveBeenCalledWith('TQQQ is fractional');
|
||||
});
|
||||
|
||||
it('logs not fractional when asset is not fractionable', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getAsset: vi.fn().mockResolvedValue({ symbol: 'BRK.A', fractionable: false, status: 'active', asset_class: 'us_equity' }),
|
||||
});
|
||||
|
||||
await printAsset(alpaca, 'BRK.A');
|
||||
expect(console.log).toHaveBeenCalledWith('BRK.A is not fractional');
|
||||
});
|
||||
});
|
||||
|
||||
describe('accountBalance', () => {
|
||||
it('returns the cash value from the account', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getAccount: vi.fn().mockResolvedValue({ cash: '10000.00' }),
|
||||
});
|
||||
|
||||
const result = await accountBalance(alpaca);
|
||||
expect(result).toBe('10000.00');
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForNextOpen', () => {
|
||||
it('calls getClock and waits until next_open', async () => {
|
||||
const futureDate = new Date(Date.now() + 60000).toISOString();
|
||||
const alpaca = mockAlpaca({
|
||||
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: futureDate, next_close: futureDate }),
|
||||
});
|
||||
|
||||
const promise = waitForNextOpen(alpaca);
|
||||
await vi.advanceTimersByTimeAsync(60000);
|
||||
await promise;
|
||||
|
||||
expect(alpaca.getClock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runDay', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('logs up day when second quote ask price is higher', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: new Date().toISOString(), next_close: new Date().toISOString() }),
|
||||
getLatestQuote: vi.fn()
|
||||
.mockResolvedValueOnce({ ap: 50.00, bp: 49.90 })
|
||||
.mockResolvedValueOnce({ ap: 50.50, bp: 50.40 }),
|
||||
});
|
||||
|
||||
const promise = runDay(alpaca);
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
await promise;
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith('up day: ', expect.any(Date));
|
||||
});
|
||||
|
||||
it('logs down day when second quote ask price is lower', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: new Date().toISOString(), next_close: new Date().toISOString() }),
|
||||
getLatestQuote: vi.fn()
|
||||
.mockResolvedValueOnce({ ap: 50.00, bp: 49.90 })
|
||||
.mockResolvedValueOnce({ ap: 49.50, bp: 49.40 }),
|
||||
});
|
||||
|
||||
const promise = runDay(alpaca);
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
await promise;
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith('down day', expect.any(Date));
|
||||
});
|
||||
|
||||
it('logs down day when prices are equal', async () => {
|
||||
const alpaca = mockAlpaca({
|
||||
getClock: vi.fn().mockResolvedValue({ is_open: false, next_open: new Date().toISOString(), next_close: new Date().toISOString() }),
|
||||
getLatestQuote: vi.fn()
|
||||
.mockResolvedValueOnce({ ap: 50.00, bp: 49.90 })
|
||||
.mockResolvedValueOnce({ ap: 50.00, bp: 49.90 }),
|
||||
});
|
||||
|
||||
const promise = runDay(alpaca);
|
||||
await vi.advanceTimersByTimeAsync(61000);
|
||||
await promise;
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith('down day', expect.any(Date));
|
||||
});
|
||||
});
|
||||
46
src/trading.ts
Normal file
46
src/trading.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Alpaca } from "./alpaca";
|
||||
|
||||
export function wait(ms: number) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
export async function printAsset(alpaca: Alpaca, symbol: string) {
|
||||
const asset = await alpaca.getAsset(symbol);
|
||||
if (asset && asset.fractionable)
|
||||
console.log(symbol + ' is fractional')
|
||||
else
|
||||
console.log(symbol + ' is not fractional')
|
||||
}
|
||||
|
||||
export async function accountBalance(alpaca: Alpaca) {
|
||||
const account = await alpaca.getAccount();
|
||||
return account.cash;
|
||||
}
|
||||
|
||||
export async function waitForNextOpen(alpaca: Alpaca) {
|
||||
const clock = await alpaca.getClock();
|
||||
return wait(new Date(clock.next_open).valueOf() - new Date().valueOf());
|
||||
}
|
||||
|
||||
export async function runDay(alpaca: Alpaca) {
|
||||
console.log('waiting for open');
|
||||
await waitForNextOpen(alpaca);
|
||||
|
||||
await wait(60000); //wait a minute
|
||||
|
||||
const q = await alpaca.getLatestQuote('TQQQ');
|
||||
await wait(1000);
|
||||
|
||||
const q2 = await alpaca.getLatestQuote('TQQQ');
|
||||
|
||||
if (q2.ap - q.ap > 0) {
|
||||
//up day
|
||||
console.log('up day: ', new Date())
|
||||
}
|
||||
else {
|
||||
//down day
|
||||
console.log('down day', new Date());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user