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:
Jon
2026-01-30 13:49:31 -07:00
parent 892305e349
commit 5e06c06987
12 changed files with 3781 additions and 262 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
dist/
node_modules/
.env

29
CLAUDE.md Normal file
View File

@@ -0,0 +1,29 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
A TypeScript trading bot that monitors the TQQQ ETF via the Alpaca paper trading API. It waits for market open, samples two price quotes one second apart, and logs whether the price moved up or down. It runs in a continuous hourly loop.
## Commands
- **Build:** `npm run build` (compiles TypeScript from `src/` to `dist/`)
- **Start:** `npm start` (runs compiled `dist/index.js`)
- **Dev:** `npm run dev` (runs with ts-node + nodemon for auto-reload)
- **Docker:** Standard `docker build` / `docker run` using the included Dockerfile (Node 22 base)
There are no tests or linting configured.
## Architecture
Two source files with a simple layered design:
- `src/index.ts` — Entry point. Contains the main loop (`main()``runDay()`) that waits for market open via the Alpaca clock API, fetches two consecutive TQQQ quotes, compares them, and logs the direction. Utility functions for account balance, asset info, and delays live here too.
- `src/alpaca.ts` — Wrapper class around `@alpacahq/alpaca-trade-api`. Exposes methods for account info, asset lookup, market clock, and price quotes. Uses CommonJS `require` for the Alpaca SDK (not ES import).
## Build Configuration
- **TypeScript target:** ES2016, CommonJS modules, strict mode enabled
- **Source/output dirs:** `src/``dist/`
- `esModuleInterop` is on — mixed import styles are allowed

20
eslint.config.js Normal file
View File

@@ -0,0 +1,20 @@
const tseslint = require('@typescript-eslint/eslint-plugin');
const tsparser = require('@typescript-eslint/parser');
module.exports = [
{
files: ['src/**/*.ts'],
languageOptions: {
parser: tsparser,
ecmaVersion: 'latest',
sourceType: 'module',
},
plugins: {
'@typescript-eslint': tseslint,
},
rules: {
...tseslint.configs.recommended.rules,
'no-console': 'off',
},
},
];

3507
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,24 @@
{
"devDependencies": {
"@types/node": "^22.13.1",
"@types/ws": "^8.5.14",
"@typescript-eslint/eslint-plugin": "^8.54.0",
"@typescript-eslint/parser": "^8.54.0",
"eslint": "^9.39.2",
"nodemon": "^3.1.9",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
"typescript": "^5.7.3",
"vitest": "^4.0.18"
},
"scripts": {
"build": "tsc --build tsconfig.json",
"start": "node dist/index.js",
"dev": "nodemon --exec ts-node src/index.ts"
"dev": "nodemon --exec ts-node src/index.ts",
"test": "vitest run",
"test:watch": "vitest",
"lint": "eslint src/"
},
"dependencies": {
"node-fetch": "^3.3.2",
"ws": "^8.18.0"
"@alpacahq/alpaca-trade-api": "^3.1.3",
"dotenv": "^17.2.3"
}
}

65
src/alpaca.test.ts Normal file
View 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']);
});
});

View File

@@ -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);
}
}

View File

@@ -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(
() => {

125
src/trading.test.ts Normal file
View 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
View 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());
}
}

View File

@@ -107,5 +107,6 @@
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
},
"include": ["src"]
}

7
vitest.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
},
});