Add granular logging across all source files

Introduce logger infrastructure and add structured logging to every layer:
alpaca.ts (buy/sell/quote/clock), momentum-indicator.ts (evaluate flow),
momentum-strategy.ts (poll loop), index.ts (startup/cycle), and trading.ts
(market timing). Replace raw console.log calls with leveled logger. Update
all tests with appropriate console spies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jon
2026-02-09 17:00:15 -07:00
parent 075572a01c
commit 188a1dc8b0
13 changed files with 91 additions and 19 deletions

View File

@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Alpaca, AlpacaClient } from './alpaca'; import { Alpaca, AlpacaClient } from './alpaca';
function mockClient(overrides: Partial<AlpacaClient> = {}): AlpacaClient { function mockClient(overrides: Partial<AlpacaClient> = {}): AlpacaClient {
@@ -14,6 +14,15 @@ function mockClient(overrides: Partial<AlpacaClient> = {}): AlpacaClient {
}; };
} }
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'debug').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('Alpaca', () => { describe('Alpaca', () => {
it('throws when live is true', () => { it('throws when live is true', () => {
expect(() => new Alpaca(true)).toThrow('not doing live yet'); expect(() => new Alpaca(true)).toThrow('not doing live yet');

View File

@@ -1,6 +1,8 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
dotenv.config(); dotenv.config();
import { logger } from './logger';
// eslint-disable-next-line @typescript-eslint/no-require-imports // eslint-disable-next-line @typescript-eslint/no-require-imports
const AlpacaJS = require('@alpacahq/alpaca-trade-api') const AlpacaJS = require('@alpacahq/alpaca-trade-api')
@@ -96,15 +98,19 @@ export class Alpaca {
} }
public async getClock() { public async getClock() {
return this.alpaca.getClock(); const clock = await this.alpaca.getClock();
logger.debug(`clock: is_open=${clock.is_open}, next_open=${clock.next_open}`);
return clock;
} }
public async getLatestAsk(symbol: string): Promise<number> { public async getLatestAsk(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol); const quote = await this.alpaca.getLatestQuote(symbol);
logger.debug(`${symbol} ask: ${quote.AskPrice}`);
return quote.AskPrice; return quote.AskPrice;
} }
public async getLatestBid(symbol: string): Promise<number> { public async getLatestBid(symbol: string): Promise<number> {
const quote = await this.alpaca.getLatestQuote(symbol); const quote = await this.alpaca.getLatestQuote(symbol);
logger.debug(`${symbol} bid: ${quote.BidPrice}`);
return quote.BidPrice; return quote.BidPrice;
} }
public async getLatestSpread(symbol: string): Promise<number> { public async getLatestSpread(symbol: string): Promise<number> {
@@ -116,6 +122,7 @@ export class Alpaca {
} }
public async buy(symbol: string, dollarAmount: number): Promise<number> { public async buy(symbol: string, dollarAmount: number): Promise<number> {
logger.info(`buying ${symbol} for $${dollarAmount}`);
const order = await this.alpaca.createOrder({ const order = await this.alpaca.createOrder({
symbol, symbol,
notional: dollarAmount, notional: dollarAmount,
@@ -123,10 +130,12 @@ export class Alpaca {
type: 'market', type: 'market',
time_in_force: 'day', time_in_force: 'day',
}); });
logger.info(`bought ${symbol} — filled at ${order.filled_avg_price}, qty ${order.filled_qty}, order ${order.id}`);
return parseFloat(order.filled_avg_price); return parseFloat(order.filled_avg_price);
} }
public async sell(symbol: string, dollarAmount: number): Promise<number> { public async sell(symbol: string, dollarAmount: number): Promise<number> {
logger.info(`selling ${symbol} for $${dollarAmount}`);
const order = await this.alpaca.createOrder({ const order = await this.alpaca.createOrder({
symbol, symbol,
notional: dollarAmount, notional: dollarAmount,
@@ -134,6 +143,7 @@ export class Alpaca {
type: 'market', type: 'market',
time_in_force: 'day', time_in_force: 'day',
}); });
logger.info(`sold ${symbol} — filled at ${order.filled_avg_price}, qty ${order.filled_qty}, order ${order.id}`);
return parseFloat(order.filled_avg_price); return parseFloat(order.filled_avg_price);
} }
} }

View File

@@ -34,6 +34,7 @@ function mockStrategy(overrides: Partial<Strategy> = {}): Strategy {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'debug').mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {
@@ -102,7 +103,7 @@ describe('Bot', () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
await promise; await promise;
expect(console.log).toHaveBeenCalledWith('waiting for open'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
}); });
it('skips waiting when market is already open', async () => { it('skips waiting when market is already open', async () => {
@@ -120,7 +121,7 @@ describe('Bot', () => {
await vi.advanceTimersByTimeAsync(0); await vi.advanceTimersByTimeAsync(0);
await promise; await promise;
expect(console.log).not.toHaveBeenCalledWith('waiting for open'); expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining('[INFO]'), 'waiting for open');
expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000); expect(strategy.execute).toHaveBeenCalledWith(alpaca, 10000);
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { logger } from "./logger";
import { Strategy } from "./strategy"; import { Strategy } from "./strategy";
import { isMarketOpen, waitForNextOpen } from "./trading"; import { isMarketOpen, waitForNextOpen } from "./trading";
@@ -25,11 +26,14 @@ export class Bot {
async runDay(): Promise<void> { async runDay(): Promise<void> {
const open = await isMarketOpen(this.alpaca); const open = await isMarketOpen(this.alpaca);
if (!open) { if (!open) {
console.log('waiting for open'); logger.info('waiting for open');
await waitForNextOpen(this.alpaca); await waitForNextOpen(this.alpaca);
} }
logger.info('market is open, running strategies');
const account = await this.alpaca.getAccount(); const account = await this.alpaca.getAccount();
logger.debug(`got account: ${JSON.stringify(account)}`);
const totalCapital = parseFloat(account.cash); const totalCapital = parseFloat(account.cash);
logger.debug(`total capital: ${totalCapital}`);
await Promise.all( await Promise.all(
this.allocations.map(async ({ strategy, capitalAllocation }) => { this.allocations.map(async ({ strategy, capitalAllocation }) => {

View File

@@ -1,15 +1,19 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { Bot } from "./bot"; import { Bot } from "./bot";
import { logger } from "./logger";
import { wait } from "./trading"; import { wait } from "./trading";
import { MomentumStrategy } from "./momentum-strategy"; import { MomentumStrategy } from "./momentum-strategy";
const alpaca = new Alpaca(false); const alpaca = new Alpaca(false);
const momentum = new MomentumStrategy(); const momentum = new MomentumStrategy();
const bot = new Bot(alpaca, [{ strategy: momentum, capitalAllocation: 1.0 }]); const bot = new Bot(alpaca, [{ strategy: momentum, capitalAllocation: 1.0 }]);
logger.info('bot initialized');
async function main() { async function main() {
while(true) { while(true) {
logger.info('starting trading cycle');
await bot.runDay(); await bot.runDay();
logger.info('trading cycle complete, sleeping 1h');
await wait(1000 * 60 * 60);//wait an hour before going and getting the next open await wait(1000 * 60 * 60);//wait an hour before going and getting the next open
} }
} }
@@ -17,10 +21,10 @@ async function main() {
//run main //run main
main().then( main().then(
() => { () => {
console.log("done") logger.info("done")
} }
).catch( ).catch(
(e) => console.log('Error: ', e) (e) => logger.error('Error: ', e)
).finally( ).finally(
() => process.exit(0) () => process.exit(0)
); );

24
src/logger.ts Normal file
View File

@@ -0,0 +1,24 @@
const levels = { debug: 0, info: 1, warn: 2, error: 3 } as const;
type Level = keyof typeof levels;
const currentLevel: Level =
(process.env.LOG_LEVEL as Level) in levels
? (process.env.LOG_LEVEL as Level)
: 'info';
function log(level: Level, ...args: unknown[]) {
if (levels[level] < levels[currentLevel]) return;
const tag = `[${new Date().toISOString()}] [${level.toUpperCase()}]`;
const fn = level === 'debug' ? console.debug
: level === 'warn' ? console.warn
: level === 'error' ? console.error
: console.log;
fn(tag, ...args);
}
export const logger = {
debug: (...args: unknown[]) => log('debug', ...args),
info: (...args: unknown[]) => log('info', ...args),
warn: (...args: unknown[]) => log('warn', ...args),
error: (...args: unknown[]) => log('error', ...args),
};

View File

@@ -20,6 +20,7 @@ function mockAlpaca(overrides: Partial<Alpaca> = {}): Alpaca {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.spyOn(console, 'debug').mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {

View File

@@ -1,5 +1,6 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { Indicator } from "./indicator"; import { Indicator } from "./indicator";
import { logger } from "./logger";
import { wait } from "./trading"; import { wait } from "./trading";
export interface MomentumResult { export interface MomentumResult {
@@ -29,15 +30,17 @@ export class MomentumIndicator implements Indicator<MomentumResult> {
} }
async evaluate(alpaca: Alpaca): Promise<MomentumResult> { async evaluate(alpaca: Alpaca): Promise<MomentumResult> {
logger.debug(`waiting ${this.config.settleDelay}ms for market to settle`);
await wait(this.config.settleDelay); await wait(this.config.settleDelay);
const priceBefore = await alpaca.getLatestAsk(this.config.symbol); const priceBefore = await alpaca.getLatestAsk(this.config.symbol);
logger.debug(`${this.config.symbol} priceBefore: ${priceBefore}`);
await wait(this.config.sampleDelay); await wait(this.config.sampleDelay);
const priceAfter = await alpaca.getLatestAsk(this.config.symbol); const priceAfter = await alpaca.getLatestAsk(this.config.symbol);
const direction = priceAfter >= priceBefore ? 'up' : 'down'; const direction = priceAfter >= priceBefore ? 'up' : 'down';
logger.debug(`${this.config.symbol} priceAfter: ${priceAfter} → direction: ${direction}`);
return { direction, priceBefore, priceAfter }; return { direction, priceBefore, priceAfter };
} }

View File

@@ -28,6 +28,7 @@ const fastConfig = {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'debug').mockImplementation(() => {});
}); });
afterEach(() => { afterEach(() => {
@@ -89,7 +90,7 @@ describe('MomentumStrategy', () => {
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
await promise; await promise;
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: target');
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000); expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
}); });
@@ -108,7 +109,7 @@ describe('MomentumStrategy', () => {
await vi.advanceTimersByTimeAsync(2000); await vi.advanceTimersByTimeAsync(2000);
await promise; await promise;
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: timeout'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: timeout');
expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000); expect(alpaca.sell).toHaveBeenCalledWith('TQQQ', 5000);
}); });
@@ -132,6 +133,6 @@ describe('MomentumStrategy', () => {
await vi.advanceTimersByTimeAsync(1000); await vi.advanceTimersByTimeAsync(1000);
await promise; await promise;
expect(console.log).toHaveBeenCalledWith('[momentum] exit TQQQ — reason: target'); expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[INFO]'), '[momentum] exit TQQQ — reason: target');
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { logger } from "./logger";
import { Strategy } from "./strategy"; import { Strategy } from "./strategy";
import { MomentumIndicator, MomentumIndicatorConfig } from "./momentum-indicator"; import { MomentumIndicator, MomentumIndicatorConfig } from "./momentum-indicator";
import { wait } from "./trading"; import { wait } from "./trading";
@@ -27,27 +28,32 @@ export class MomentumStrategy implements Strategy {
} }
async execute(alpaca: Alpaca, capitalAmount: number): Promise<void> { async execute(alpaca: Alpaca, capitalAmount: number): Promise<void> {
logger.debug(`[${this.name}] executing with capital amount: ${capitalAmount}`);
const result = await this.indicator.evaluate(alpaca); const result = await this.indicator.evaluate(alpaca);
logger.debug(`[${this.name}] indicator result: ${JSON.stringify(result)}`);
const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ'; const symbol = result.direction === 'up' ? 'TQQQ' : 'SQQQ';
const entryPrice = await alpaca.buy(symbol, capitalAmount); const entryPrice = await alpaca.buy(symbol, capitalAmount);
logger.info(`[${this.name}] entered ${symbol} at price ${entryPrice}`);
const targetPrice = entryPrice * (1 + this.config.targetGain); const targetPrice = entryPrice * (1 + this.config.targetGain);
const deadline = Date.now() + this.config.holdTime; const deadline = Date.now() + this.config.holdTime;
logger.debug(`[${this.name}] monitoring ${symbol} for target price ${targetPrice} or timeout at ${new Date(deadline).toISOString()}`);
let reason: 'target' | 'timeout' = 'timeout'; let reason: 'target' | 'timeout' = 'timeout';
while (Date.now() < deadline) { while (Date.now() < deadline) {
await wait(this.config.pollInterval); await wait(this.config.pollInterval);
const bid = await alpaca.getLatestBid(symbol); const bid = await alpaca.getLatestBid(symbol);
logger.debug(`${symbol} bid: ${bid} / target: ${targetPrice}`);
if (bid >= targetPrice) { if (bid >= targetPrice) {
reason = 'target'; reason = 'target';
break; break;
} }
} }
console.log(`[${this.name}] exit ${symbol} — reason: ${reason}`); logger.info(`[${this.name}] exit ${symbol} — reason: ${reason}`);
await alpaca.sell(symbol, capitalAmount); await alpaca.sell(symbol, capitalAmount);
} }

View File

@@ -29,7 +29,7 @@ afterEach(() => {
describe('printAsset', () => { describe('printAsset', () => {
beforeEach(() => { beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {}); vi.spyOn(console, 'debug').mockImplementation(() => {});
}); });
it('logs fractional when asset is fractionable', async () => { it('logs fractional when asset is fractionable', async () => {
@@ -38,7 +38,7 @@ describe('printAsset', () => {
}); });
await printAsset(alpaca, 'TQQQ'); await printAsset(alpaca, 'TQQQ');
expect(console.log).toHaveBeenCalledWith('TQQQ is fractional'); expect(console.debug).toHaveBeenCalledWith(expect.stringContaining('[DEBUG]'), 'TQQQ is fractional');
}); });
it('logs not fractional when asset is not fractionable', async () => { it('logs not fractional when asset is not fractionable', async () => {
@@ -47,7 +47,7 @@ describe('printAsset', () => {
}); });
await printAsset(alpaca, 'BRK.A'); await printAsset(alpaca, 'BRK.A');
expect(console.log).toHaveBeenCalledWith('BRK.A is not fractional'); expect(console.debug).toHaveBeenCalledWith(expect.stringContaining('[DEBUG]'), 'BRK.A is not fractional');
}); });
}); });
@@ -63,6 +63,11 @@ describe('accountBalance', () => {
}); });
describe('waitForNextOpen', () => { describe('waitForNextOpen', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {});
vi.spyOn(console, 'debug').mockImplementation(() => {});
});
it('calls getClock and waits until next_open', async () => { it('calls getClock and waits until next_open', async () => {
const futureDate = new Date(Date.now() + 60000).toISOString(); const futureDate = new Date(Date.now() + 60000).toISOString();
const alpaca = mockAlpaca({ const alpaca = mockAlpaca({

View File

@@ -1,4 +1,5 @@
import { Alpaca } from "./alpaca"; import { Alpaca } from "./alpaca";
import { logger } from "./logger";
export function wait(ms: number) { export function wait(ms: number) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -9,9 +10,9 @@ export function wait(ms: number) {
export async function printAsset(alpaca: Alpaca, symbol: string) { export async function printAsset(alpaca: Alpaca, symbol: string) {
const asset = await alpaca.getAsset(symbol); const asset = await alpaca.getAsset(symbol);
if (asset && asset.fractionable) if (asset && asset.fractionable)
console.log(symbol + ' is fractional') logger.debug(symbol + ' is fractional')
else else
console.log(symbol + ' is not fractional') logger.debug(symbol + ' is not fractional')
} }
export async function accountBalance(alpaca: Alpaca) { export async function accountBalance(alpaca: Alpaca) {
@@ -26,6 +27,8 @@ export async function isMarketOpen(alpaca: Alpaca): Promise<boolean> {
export async function waitForNextOpen(alpaca: Alpaca) { export async function waitForNextOpen(alpaca: Alpaca) {
const clock = await alpaca.getClock(); const clock = await alpaca.getClock();
return wait(new Date(clock.next_open).valueOf() - new Date().valueOf()); const ms = new Date(clock.next_open).valueOf() - new Date().valueOf();
logger.info(`market closed, next open: ${clock.next_open} (waiting ${ms}ms)`);
return wait(ms);
} }

View File

@@ -4,5 +4,6 @@ export default defineConfig({
test: { test: {
globals: true, globals: true,
exclude: ['dist/**', 'node_modules/**'], exclude: ['dist/**', 'node_modules/**'],
env: { LOG_LEVEL: 'debug' },
}, },
}); });