diff --git a/CLAUDE.md b/CLAUDE.md index 31a5332..85a310a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -657,6 +657,14 @@ Both `market-calls.json` and `portfolio.json` use `writeFileSync` with no concur Update `StockScorer.test.js` to cover the three new scoring factors: analyst consensus scoring (including the `numberOfAnalysts < 3` guard), DCF margin of safety scoring (positive/negative/null cases), and the new 52W risk flags. +#### 8l — Anthropic prompt caching for LLMAnalyst + +`LLMAnalyst.analyze()` sends a large system prompt on every `/api/analyze` call. Enabling Anthropic prompt caching would cache the static system prompt across calls, reducing latency and token costs significantly. + +Target: add `cache_control: { type: 'ephemeral' }` to the system prompt message block in `AnthropicClient.complete()` (or in `LLMAnalyst.analyze()` if the system prompt is built there). Use the `anthropic-beta: prompt-caching-2024-07-31` header. The cache has a 5-minute TTL and applies to the longest common prefix of consecutive requests — ideal for the static analysis instructions that never change between calls. + +See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching + --- ## Adding a New Asset Type diff --git a/server/clients/SimpleFINClient.ts b/server/clients/SimpleFINClient.ts index fc257a4..1af904d 100644 --- a/server/clients/SimpleFINClient.ts +++ b/server/clients/SimpleFINClient.ts @@ -24,7 +24,7 @@ export class SimpleFINClient { return; } if (process.env.SIMPLEFIN_SETUP_TOKEN) { - this.accessUrl = await this._claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN); + this.accessUrl = await this.claimAccessUrl(process.env.SIMPLEFIN_SETUP_TOKEN); if (this.onAccessUrlClaimed) await this.onAccessUrlClaimed(this.accessUrl); return; } @@ -36,7 +36,7 @@ export class SimpleFINClient { async getAccounts(options: GetAccountsOptions = {}): Promise { if (!this.accessUrl) await this.init(); - const startDate = options.startDate ?? this._daysAgo(30); + const startDate = options.startDate ?? this.daysAgo(30); const endDate = options.endDate ?? Math.floor(Date.now() / 1000); const parsed = new URL(this.accessUrl!); @@ -59,13 +59,13 @@ export class SimpleFINClient { data.errors.forEach((e) => this.logger.warn(` ⚠ SimpleFIN: ${e}`)); } - return this._normalise(data as { accounts: unknown[]; errors: string[] }); + return this.normalise(data as { accounts: unknown[]; errors: string[] }); } - private async _claimAccessUrl(setupToken: string): Promise { + private async claimAccessUrl(setupToken: string): Promise { const claimUrl = Buffer.from(setupToken.trim(), 'base64').toString('utf8').trim(); this.logger.write(`\n🔑 Claiming SimpleFIN access URL...\n → ${claimUrl}\n`); - const accessUrl = await this._post(claimUrl); + const accessUrl = await this.post(claimUrl); if (!accessUrl || !accessUrl.startsWith('http')) { throw new Error( `Unexpected response from SimpleFIN: "${accessUrl}"\nSetup tokens are one-time use — if already claimed, generate a new one at https://beta-bridge.simplefin.org`, @@ -75,7 +75,7 @@ export class SimpleFINClient { return accessUrl.trim(); } - private _post(url: string): Promise { + private post(url: string): Promise { return new Promise((resolve, reject) => { const parsed = new URL(url); const lib = parsed.protocol === 'https:' ? https : http; @@ -101,7 +101,7 @@ export class SimpleFINClient { }); } - private _normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData { + private normalise(data: { accounts: unknown[]; errors: string[] }): SimpleFINData { const accounts = (data.accounts ?? []).map((acc: any) => ({ id: acc.id, name: acc.name, @@ -109,19 +109,19 @@ export class SimpleFINClient { balance: parseFloat(acc.balance) ?? 0, balanceDate: new Date(acc['balance-date'] * 1000).toISOString().slice(0, 10), org: acc.org?.name ?? 'Unknown', - type: this._classifyAccount(acc.name), + type: this.classifyAccount(acc.name), transactions: (acc.transactions ?? []).map((tx: any) => ({ id: tx.id, date: new Date(tx.posted * 1000).toISOString().slice(0, 10), amount: parseFloat(tx.amount) ?? 0, description: tx.description ?? '', - category: this._categorise(tx.description ?? ''), + category: this.categorise(tx.description ?? ''), })), })); return { accounts, errors: data.errors ?? [] }; } - private _classifyAccount(name: string): string { + private classifyAccount(name: string): string { const n = name.toLowerCase(); if (n.includes('checking') || n.includes('current')) return 'CHECKING'; if (n.includes('saving')) return 'SAVINGS'; @@ -132,7 +132,7 @@ export class SimpleFINClient { return 'OTHER'; } - private _categorise(description: string): string { + private categorise(description: string): string { const d = description.toLowerCase(); if (d.match(/amazon|walmart|target|costco|grocery|whole foods|trader joe/)) return 'Shopping'; if (d.match(/uber eats|doordash|grubhub|postmates|instacart/)) return 'Delivery'; @@ -147,7 +147,7 @@ export class SimpleFINClient { return 'Other'; } - private _daysAgo(n: number): number { + private daysAgo(n: number): number { return Math.floor((Date.now() - n * 24 * 60 * 60 * 1000) / 1000); } } diff --git a/server/models/Stock.ts b/server/models/Stock.ts index 3f1438b..fa120a2 100644 --- a/server/models/Stock.ts +++ b/server/models/Stock.ts @@ -9,12 +9,12 @@ export class Stock extends Asset { constructor(data: StockData) { super(data); - this.sector = this._mapToStandardSector(data); + this.sector = this.mapToStandardSector(data); this.metrics = { sector: this.sector, - capCategory: this._classifyMarketCap(data.marketCap ?? null), - growthCategory: this._classifyGrowth( + capCategory: this.classifyMarketCap(data.marketCap ?? null), + growthCategory: this.classifyGrowth( data.revenueGrowth ?? null, data.earningsGrowth ?? null, data.dividendYield ?? null, @@ -52,7 +52,7 @@ export class Stock extends Asset { // ── Market cap tier classification ────────────────────────────────────── // Thresholds follow MSCI/Russell institutional convention. - _classifyMarketCap(marketCap: number | null): CapCategory { + classifyMarketCap(marketCap: number | null): CapCategory { if (marketCap == null) return CAP_CATEGORY.LARGE; // safe default if (marketCap >= 200e9) return CAP_CATEGORY.MEGA; if (marketCap >= 10e9) return CAP_CATEGORY.LARGE; @@ -64,7 +64,7 @@ export class Stock extends Asset { // ── Growth / style classification ─────────────────────────────────────── // revenueGrowth and earningsGrowth are in percentage form (e.g. 15 = 15%). // dividendYield is also in percentage form (e.g. 3.5 = 3.5%). - _classifyGrowth( + classifyGrowth( revenueGrowth: number | null, earningsGrowth: number | null, dividendYield: number | null, @@ -81,7 +81,7 @@ export class Stock extends Asset { return GROWTH_CATEGORY.STABLE; } - _mapToStandardSector(data: StockData): Sector { + mapToStandardSector(data: StockData): Sector { const profile = data.assetProfile ?? {}; const industry = (profile.industry || '').toLowerCase(); const sector = (profile.sector || '').toLowerCase(); diff --git a/server/repositories/MarketCallRepository.ts b/server/repositories/MarketCallRepository.ts index 0a98bcc..fbf69c3 100644 --- a/server/repositories/MarketCallRepository.ts +++ b/server/repositories/MarketCallRepository.ts @@ -3,29 +3,35 @@ import { randomUUID } from 'crypto'; import type { MarketCall, CreateCallInput, StoreData } from '../types'; export class MarketCallRepository { - private static readonly STORE_PATH = './market-calls.json'; + private static readonly DEFAULT_PATH = './market-calls.json'; - private _load(): StoreData { - if (!existsSync(MarketCallRepository.STORE_PATH)) return { calls: [] }; + private readonly storePath: string; + + constructor(storePath?: string) { + this.storePath = storePath ?? MarketCallRepository.DEFAULT_PATH; + } + + private load(): StoreData { + if (!existsSync(this.storePath)) return { calls: [] }; try { - return JSON.parse(readFileSync(MarketCallRepository.STORE_PATH, 'utf8')) as StoreData; + return JSON.parse(readFileSync(this.storePath, 'utf8')) as StoreData; } catch { return { calls: [] }; } } - private _save(data: StoreData): void { - writeFileSync(MarketCallRepository.STORE_PATH, JSON.stringify(data, null, 2), 'utf8'); + private save(data: StoreData): void { + writeFileSync(this.storePath, JSON.stringify(data, null, 2), 'utf8'); } list(): (MarketCall & { createdAt: string })[] { - return this._load().calls.sort( + return this.load().calls.sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ); } get(id: string): (MarketCall & { createdAt: string }) | null { - return this._load().calls.find((c) => c.id === id) ?? null; + return this.load().calls.find((c) => c.id === id) ?? null; } create({ @@ -36,7 +42,7 @@ export class MarketCallRepository { tickers, snapshot, }: CreateCallInput): MarketCall & { createdAt: string } { - const data = this._load(); + const data = this.load(); const call = { id: randomUUID(), title, @@ -48,16 +54,16 @@ export class MarketCallRepository { createdAt: new Date().toISOString(), }; data.calls.push(call); - this._save(data); + this.save(data); return call; } delete(id: string): boolean { - const data = this._load(); + const data = this.load(); const before = data.calls.length; data.calls = data.calls.filter((c) => c.id !== id); if (data.calls.length === before) return false; - this._save(data); + this.save(data); return true; } } diff --git a/server/scorers/BondScorer.ts b/server/scorers/BondScorer.ts index 1422acb..61a3447 100644 --- a/server/scorers/BondScorer.ts +++ b/server/scorers/BondScorer.ts @@ -11,7 +11,7 @@ export class BondScorer { context?: MarketContext | null, ): ScoreResult { const { gates, weights, thresholds } = rules; - const metrics = BondScorer._sanitize(m); + const metrics = BondScorer.sanitize(m); const riskFreeRate = (context?.riskFreeRate ?? 4.0) / 100; if (metrics.creditRatingNumeric < gates.minCreditRating) { @@ -37,7 +37,7 @@ export class BondScorer { }; } - private static _sanitize(m: BondMetrics): SanitizedBondMetrics { + private static sanitize(m: BondMetrics): SanitizedBondMetrics { const pct = (v: unknown): number => parseFloat(typeof v === 'string' ? v.replace('%', '') : String(v)) / 100 || 0; return { diff --git a/server/scorers/StockScorer.ts b/server/scorers/StockScorer.ts index 65a2b8c..eca6b94 100644 --- a/server/scorers/StockScorer.ts +++ b/server/scorers/StockScorer.ts @@ -22,7 +22,7 @@ export class StockScorer { }, ): ScoreResult { const { gates, weights, thresholds } = rules; - const m = StockScorer._sanitize(metrics); + const m = StockScorer.sanitize(metrics); const failures = [ m.debtToEquity != null && @@ -208,20 +208,20 @@ export class StockScorer { ].filter(Boolean) as string[]; return { - label: StockScorer._label(totalScore), + label: StockScorer.label(totalScore), scoreSummary: `Score: ${totalScore}`, audit: { passedGates: true, breakdown, riskFlags: riskFlags.length ? riskFlags : null }, }; } - private static _label(score: number): string { + private static label(score: number): string { if (score >= 8) return '🟢 BUY (High Conviction)'; if (score >= 4) return '🟢 BUY (Speculative)'; if (score >= 0) return '🟡 HOLD'; return '🔴 REJECT'; } - private static _sanitize(m: StockMetrics): SanitizedMetrics { + private static sanitize(m: StockMetrics): SanitizedMetrics { const w52 = m.week52High != null && m.week52High > 0 && m.week52Low != null && m.currentPrice > 0 ? (m.currentPrice - m.week52Low) / (m.week52High - m.week52Low) diff --git a/server/services/CatalystAnalyst.ts b/server/services/CatalystAnalyst.ts index 976c171..c2679c4 100644 --- a/server/services/CatalystAnalyst.ts +++ b/server/services/CatalystAnalyst.ts @@ -21,7 +21,7 @@ export class CatalystAnalyst { async run(): Promise { this.logger.write('🔍 Fetching market news...'); - const rawStories = await this._fetchNews(); + const rawStories = await this.fetchNews(); if (!rawStories.length) { this.logger.write(' ⚠ all news queries failed — check network or Yahoo rate limit\n'); @@ -67,7 +67,7 @@ export class CatalystAnalyst { })); } - private async _fetchNews(): Promise { + private async fetchNews(): Promise { const seen = new Map(); let successCount = 0; for (const query of CatalystAnalyst.NEWS_QUERIES) { diff --git a/server/services/MarketRegime.ts b/server/services/MarketRegime.ts index 956c285..0799e9f 100644 --- a/server/services/MarketRegime.ts +++ b/server/services/MarketRegime.ts @@ -20,13 +20,13 @@ export class MarketRegime { } getInflatedOverrides(type: AssetType, sector?: string): InflatedOverrides { - if (type === ASSET_TYPE.STOCK) return this._stock(sector); - if (type === ASSET_TYPE.ETF) return this._etf(); - if (type === ASSET_TYPE.BOND) return this._bond(); + if (type === ASSET_TYPE.STOCK) return this.stock(sector); + if (type === ASSET_TYPE.ETF) return this.etf(); + if (type === ASSET_TYPE.BOND) return this.bond(); return { gates: {}, thresholds: {} }; } - private _stock(sector?: string): InflatedOverrides { + private stock(sector?: string): InflatedOverrides { if (sector === SECTOR.REIT) { return { gates: {}, @@ -55,11 +55,11 @@ export class MarketRegime { }; } - private _etf(): InflatedOverrides { + private etf(): InflatedOverrides { return { gates: { maxExpenseRatio: 0.75 }, thresholds: { minYield: 0.5 } }; } - private _bond(): InflatedOverrides { + private bond(): InflatedOverrides { const spreadMultiplier = this.rateRegime === REGIME.HIGH ? 0.9 : 0.8; return { gates: {}, diff --git a/server/services/PortfolioAdvisor.ts b/server/services/PortfolioAdvisor.ts index f11a7ee..0ab4ec7 100644 --- a/server/services/PortfolioAdvisor.ts +++ b/server/services/PortfolioAdvisor.ts @@ -25,7 +25,7 @@ export class PortfolioAdvisor { resultMap[t.replace(/\./g, '-')] = r; } - const cryptoPrices = await this._cryptoPrices(holdings.filter((h) => h.type === 'crypto')); + const cryptoPrices = await this.cryptoPrices(holdings.filter((h) => h.type === 'crypto')); return holdings.map((holding) => { const type = (holding.type ?? 'stock').toLowerCase(); @@ -36,35 +36,35 @@ export class PortfolioAdvisor { : (resultMap[holding.ticker.toUpperCase()]?.asset?.currentPrice ?? null); return type === 'crypto' - ? this._row(holding, price, source, '—', '—', '—', this._cryptoAdvice(holding, price)) - : this._stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]); + ? this.row(holding, price, source, '—', '—', '—', this.cryptoAdvice(holding, price)) + : this.stockRow(holding, price, source, resultMap[holding.ticker.toUpperCase()]); }); } - private _stockRow( + private stockRow( holding: PortfolioHolding, price: number | null, source: string, result: AssetResult | undefined, ): AdviceRow { if (!result) { - return this._row(holding, price, source, '—', '—', '—', { + return this.row(holding, price, source, '—', '—', '—', { action: '⚪ Not screened', reason: 'No screener data available — Yahoo Finance may not support this ticker.', }); } - return this._row( + return this.row( holding, price, source, result.signal, result.inflated.label, result.fundamental.label, - this._advice(result.signal, holding, price), + this.advice(result.signal, holding, price), ); } - private _row( + private row( holding: PortfolioHolding, currentPrice: number | null, source: string, @@ -73,7 +73,7 @@ export class PortfolioAdvisor { fundamental: string, { action, reason }: AdviceOutput, ): AdviceRow { - const { marketValue, totalCost, gainLossPct } = this._position(holding, currentPrice); + const { marketValue, totalCost, gainLossPct } = this.position(holding, currentPrice); return { ticker: holding.ticker, type: holding.type ?? 'stock', @@ -92,7 +92,7 @@ export class PortfolioAdvisor { }; } - private _position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc { + private position(holding: PortfolioHolding, currentPrice: number | null): PositionCalc { return { totalCost: (holding.costBasis * holding.shares).toFixed(2), marketValue: currentPrice != null ? (currentPrice * holding.shares).toFixed(2) : null, @@ -103,8 +103,8 @@ export class PortfolioAdvisor { }; } - private _cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput { - const { gainLossPct } = this._position(holding, price); + private cryptoAdvice(holding: PortfolioHolding, price: number | null): AdviceOutput { + const { gainLossPct } = this.position(holding, price); const g = parseFloat(gainLossPct ?? 'NaN'); if (gainLossPct == null) return { @@ -127,8 +127,8 @@ export class PortfolioAdvisor { }; } - private _advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput { - const { gainLossPct } = this._position(holding, price); + private advice(signal: Signal, holding: PortfolioHolding, price: number | null): AdviceOutput { + const { gainLossPct } = this.position(holding, price); const gain = parseFloat(gainLossPct ?? '0'); switch (signal) { case SIGNAL.STRONG_BUY: @@ -164,9 +164,7 @@ export class PortfolioAdvisor { } } - private async _cryptoPrices( - holdings: PortfolioHolding[], - ): Promise> { + private async cryptoPrices(holdings: PortfolioHolding[]): Promise> { const prices: Record = {}; for (const h of holdings) { try { diff --git a/server/services/ScreenerEngine.ts b/server/services/ScreenerEngine.ts index 3ff2338..f32b768 100644 --- a/server/services/ScreenerEngine.ts +++ b/server/services/ScreenerEngine.ts @@ -44,24 +44,24 @@ export class ScreenerEngine { } async screenTickers(tickers: string[]): Promise { - return this._screenInternal(tickers, false); + return this.screenInternal(tickers, false); } async screenWithProgress(tickers: string[]): Promise { - return this._screenInternal(tickers, true); + return this.screenInternal(tickers, true); } - private async _screenInternal(tickers: string[], showProgress: boolean): Promise { - const marketContext = await this._fetchMarketContext(showProgress); - const results = this._initializeResults(); + private async screenInternal(tickers: string[], showProgress: boolean): Promise { + const marketContext = await this.fetchMarketContext(showProgress); + const results = this.initializeResults(); const chunks = chunkArray(tickers, ScreenerEngine.BATCH_SIZE); let processed = 0; for (const chunk of chunks) { - await this._processBatch(chunk, marketContext, results); + await this.processBatch(chunk, marketContext, results); processed += chunk.length; - this._logProgress(showProgress, processed, tickers.length); - await this._rateLimitDelay(); + this.logProgress(showProgress, processed, tickers.length); + await this.rateLimitDelay(); } if (showProgress) { @@ -71,7 +71,7 @@ export class ScreenerEngine { return { ...results, marketContext }; } - private async _fetchMarketContext(showProgress: boolean): Promise { + private async fetchMarketContext(showProgress: boolean): Promise { if (showProgress) { this.logger.write('⏳ Fetching market context...'); } @@ -82,30 +82,30 @@ export class ScreenerEngine { return context; } - private _initializeResults(): Omit { + private initializeResults(): Omit { return { STOCK: [], ETF: [], BOND: [], ERROR: [] }; } - private async _processBatch( + private async processBatch( tickers: string[], marketContext: MarketContext, results: Omit, ): Promise { - const batch = await Promise.all(tickers.map((t) => this._fetch(t))); - batch.forEach((data) => this._process(data, marketContext, results)); + const batch = await Promise.all(tickers.map((t) => this.fetch(t))); + batch.forEach((data) => this.process(data, marketContext, results)); } - private _logProgress(showProgress: boolean, processed: number, total: number): void { + private logProgress(showProgress: boolean, processed: number, total: number): void { if (showProgress) { this.logger.write(`\r⏳ Screening tickers... ${processed}/${total}`); } } - private async _rateLimitDelay(): Promise { + private async rateLimitDelay(): Promise { await new Promise((r) => setTimeout(r, ScreenerEngine.BATCH_DELAY_MS)); } - private async _fetch(ticker: string): Promise { + private async fetch(ticker: string): Promise { try { const summary = await this.client.fetchSummary(ticker); if (!summary?.price) throw new Error('Empty response from Yahoo'); @@ -115,7 +115,7 @@ export class ScreenerEngine { } } - private _process( + private process( data: MappedData | ErrorResult, marketContext: MarketContext, results: Omit, @@ -127,15 +127,15 @@ export class ScreenerEngine { } try { - const asset = this._buildAsset(data as MappedData); - const fundamental = this._score(asset, marketContext, SCORE_MODE.FUNDAMENTAL); - const inflated = this._score(asset, marketContext, SCORE_MODE.INFLATED); + const asset = this.buildAsset(data as MappedData); + const fundamental = this.score(asset, marketContext, SCORE_MODE.FUNDAMENTAL); + const inflated = this.score(asset, marketContext, SCORE_MODE.INFLATED); (results[asset.type as AssetType] as unknown[]).push({ asset, fundamental, inflated, - signal: this._signal(fundamental.label, inflated.label), + signal: this.signal(fundamental.label, inflated.label), }); } catch (err) { results.ERROR.push({ @@ -147,7 +147,7 @@ export class ScreenerEngine { // Typed scorer dispatch — instanceof narrows the asset so each scorer receives // its exact metrics type. No `as never` or unsafe casts required. - private _score( + private score( asset: Stock | Etf | Bond, marketContext: MarketContext, mode: string, @@ -165,7 +165,7 @@ export class ScreenerEngine { throw new Error('No scorer for unknown asset type'); } - private _buildAsset(data: Record): Stock | Etf | Bond { + private buildAsset(data: Record): Stock | Etf | Bond { switch (((data.type as string) || ASSET_TYPE.STOCK).toUpperCase()) { case ASSET_TYPE.BOND: return new Bond(data as BondData); @@ -176,7 +176,7 @@ export class ScreenerEngine { } } - private _signal(fundamentalLabel: string, inflatedLabel: string): Signal { + private signal(fundamentalLabel: string, inflatedLabel: string): Signal { const green = (l: string) => l.startsWith('🟢'); const yellow = (l: string) => l.startsWith('🟡'); if (green(fundamentalLabel)) return SIGNAL.STRONG_BUY; diff --git a/tests/MarketCallRepository.test.ts b/tests/MarketCallRepository.test.ts new file mode 100644 index 0000000..f2902b3 --- /dev/null +++ b/tests/MarketCallRepository.test.ts @@ -0,0 +1,166 @@ +/** + * Unit tests for MarketCallRepository + * Each test gets its own temp file so tests are fully isolated. + */ + +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { MarketCallRepository } from '../server/repositories/MarketCallRepository'; + +// ── Temp-file helpers ───────────────────────────────────────────────────────── + +const tmpDirs: string[] = []; + +function tempRepo(): MarketCallRepository { + const dir = mkdtempSync(join(tmpdir(), 'mkt-calls-test-')); + const path = join(dir, 'calls.json'); + tmpDirs.push(dir); + return new MarketCallRepository(path); +} + +after(() => { + for (const dir of tmpDirs) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const CALL_INPUT = { + title: 'Rate pivot play', + quarter: 'Q3 2025', + thesis: 'Fed cuts expected — rotate into duration and growth.', + tickers: ['TLT', 'QQQ'], +}; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +test('list() returns empty array when file does not exist', () => { + const repo = tempRepo(); + assert.deepEqual(repo.list(), []); +}); + +test('create() returns call with id, createdAt, and correct fields', () => { + const repo = tempRepo(); + const call = repo.create(CALL_INPUT); + assert.ok(call.id, 'id should be set'); + assert.ok(call.createdAt, 'createdAt should be set'); + assert.equal(call.title, CALL_INPUT.title); + assert.equal(call.quarter, CALL_INPUT.quarter); + assert.equal(call.thesis, CALL_INPUT.thesis); + assert.deepEqual(call.tickers, CALL_INPUT.tickers); +}); + +test('create() persists to disk — list() returns the created call', () => { + const repo = tempRepo(); + repo.create(CALL_INPUT); + assert.equal(repo.list().length, 1); + assert.equal(repo.list()[0].title, CALL_INPUT.title); +}); + +test('list() returns calls newest-first', () => { + // Write two calls directly with distinct timestamps to guarantee stable ordering. + const dir = mkdtempSync(join(tmpdir(), 'mkt-order-')); + tmpDirs.push(dir); + const path = join(dir, 'calls.json'); + + const older = { + id: 'old-id', + title: 'First', + quarter: 'Q1', + date: '2025-01-01', + thesis: 'A', + tickers: [], + snapshot: {}, + createdAt: '2025-01-01T00:00:00.000Z', + }; + const newer = { + id: 'new-id', + title: 'Second', + quarter: 'Q1', + date: '2025-01-02', + thesis: 'B', + tickers: [], + snapshot: {}, + createdAt: '2025-01-02T00:00:00.000Z', + }; + writeFileSync(path, JSON.stringify({ calls: [older, newer] }), 'utf8'); + + const repo = new MarketCallRepository(path); + const list = repo.list(); + assert.equal(list[0].id, 'new-id', 'newer call should be first'); + assert.equal(list[1].id, 'old-id', 'older call should be second'); +}); + +test('get() returns the call by id', () => { + const repo = tempRepo(); + const call = repo.create(CALL_INPUT); + const found = repo.get(call.id); + assert.ok(found, 'should find by id'); + assert.equal(found!.id, call.id); +}); + +test('get() returns null for unknown id', () => { + const repo = tempRepo(); + assert.equal(repo.get('nonexistent-id'), null); +}); + +test('delete() removes the call and returns true', () => { + const repo = tempRepo(); + const call = repo.create(CALL_INPUT); + const ok = repo.delete(call.id); + assert.equal(ok, true); + assert.equal(repo.list().length, 0); + assert.equal(repo.get(call.id), null); +}); + +test('delete() returns false for unknown id', () => { + const repo = tempRepo(); + assert.equal(repo.delete('no-such-id'), false); +}); + +test('delete() only removes the targeted call, leaves others intact', () => { + const repo = tempRepo(); + const a = repo.create({ ...CALL_INPUT, title: 'Keep me' }); + const b = repo.create({ ...CALL_INPUT, title: 'Delete me' }); + repo.delete(b.id); + const list = repo.list(); + assert.equal(list.length, 1); + assert.equal(list[0].id, a.id); +}); + +test('create() stores snapshot when provided', () => { + const repo = tempRepo(); + const snapshot = { TLT: { price: 95.5, signal: '✅ Strong Buy' } }; + const call = repo.create({ ...CALL_INPUT, snapshot } as any); + const found = repo.get(call.id)!; + assert.deepEqual(found.snapshot, snapshot); +}); + +test('create() sets default date when not provided', () => { + const repo = tempRepo(); + const call = repo.create(CALL_INPUT); + assert.match(call.date, /^\d{4}-\d{2}-\d{2}$/); +}); + +test('create() uses provided date', () => { + const repo = tempRepo(); + const call = repo.create({ ...CALL_INPUT, date: '2025-03-15' }); + assert.equal(call.date, '2025-03-15'); +}); + +test('concurrent writes: two rapid creates do not lose data', async () => { + const repo = tempRepo(); + // Both writes happen synchronously (writeFileSync), so the second + // always sees the first. This test documents the behaviour. + const a = repo.create({ ...CALL_INPUT, title: 'A' }); + const b = repo.create({ ...CALL_INPUT, title: 'B' }); + const list = repo.list(); + assert.equal(list.length, 2, 'both calls should be persisted'); + const ids = new Set(list.map((c) => c.id)); + assert.ok(ids.has(a.id), 'call A should be present'); + assert.ok(ids.has(b.id), 'call B should be present'); +}); diff --git a/tests/PortfolioAdvisor.test.ts b/tests/PortfolioAdvisor.test.ts index 362e8bf..7cc3436 100644 --- a/tests/PortfolioAdvisor.test.ts +++ b/tests/PortfolioAdvisor.test.ts @@ -12,7 +12,7 @@ const stubClient = {} as unknown as YahooFinanceClient; // Cast to any to access private methods — tests exercise internal behaviour directly. const advisor = new PortfolioAdvisor(stubClient) as any; -// Minimal holding shape used by _position and _advice (only costBasis/shares matter). +// Minimal holding shape used by position and advice (only costBasis/shares matter). const holding = (costBasis: number, shares: number): PortfolioHolding => ({ ticker: 'TEST', source: 'Test', @@ -22,45 +22,45 @@ const holding = (costBasis: number, shares: number): PortfolioHolding => ({ }); test('_position: computes gain/loss correctly', () => { - const pos = advisor._position(holding(100, 10), 150); + const pos = advisor.position(holding(100, 10), 150); assert.equal(pos.gainLossPct, '50.0'); assert.equal(pos.marketValue, '1500.00'); assert.equal(pos.totalCost, '1000.00'); }); test('_position: returns null gainLoss when price unavailable', () => { - const pos = advisor._position(holding(100, 10), null); + const pos = advisor.position(holding(100, 10), null); assert.equal(pos.gainLossPct, null); assert.equal(pos.marketValue, null); }); test('_advice: Strong Buy → Hold & Add', () => { - const { action } = advisor._advice(SIGNAL.STRONG_BUY, holding(100, 10), 150); + const { action } = advisor.advice(SIGNAL.STRONG_BUY, holding(100, 10), 150); assert.equal(action, '🟢 Hold & Add'); }); test('_advice: Avoid + loss → Sell (Cut Loss)', () => { - const { action } = advisor._advice(SIGNAL.AVOID, holding(150, 10), 100); + const { action } = advisor.advice(SIGNAL.AVOID, holding(150, 10), 100); assert.equal(action, '🔴 Sell (Cut Loss)'); }); test('_advice: Avoid + profit → Sell (Take Profits)', () => { - const { action } = advisor._advice(SIGNAL.AVOID, holding(100, 10), 150); + const { action } = advisor.advice(SIGNAL.AVOID, holding(100, 10), 150); assert.equal(action, '🔴 Sell (Take Profits)'); }); test('_advice: Speculation + >20% gain → Reduce Position', () => { - const { action } = advisor._advice(SIGNAL.SPECULATION, holding(100, 10), 125); + const { action } = advisor.advice(SIGNAL.SPECULATION, holding(100, 10), 125); assert.equal(action, '🟠 Reduce Position'); }); test('_cryptoAdvice: no price → No price data', () => { - const { action } = advisor._cryptoAdvice(holding(100, 1), null); + const { action } = advisor.cryptoAdvice(holding(100, 1), null); assert.equal(action, '⚪ No price data'); }); test('_cryptoAdvice: >100% gain → Consider taking profits', () => { - const { action } = advisor._cryptoAdvice(holding(10000, 1), 25000); + const { action } = advisor.cryptoAdvice(holding(10000, 1), 25000); assert.equal(action, '🟠 Consider taking profits'); }); diff --git a/tests/calls.controller.test.ts b/tests/calls.controller.test.ts new file mode 100644 index 0000000..197c167 --- /dev/null +++ b/tests/calls.controller.test.ts @@ -0,0 +1,214 @@ +/** + * Integration tests for CallsController + * Uses Fastify inject() with an in-memory MarketCallRepository stub. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { CallsController } from '../server/controllers/calls.controller'; +import type { ScreenerEngine } from '../server/services/ScreenerEngine'; +import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient'; +import type { MarketCall, ScreenerResult, MarketContext, CreateCallInput } from '../server/types'; + +// ── Stubs ──────────────────────────────────────────────────────────────────── + +const MARKET_CTX: MarketContext = { + sp500Price: 5000, + riskFreeRate: 4.5, + vixLevel: 18, + rateRegime: 'NORMAL', + volatilityRegime: 'NORMAL', + benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 }, +}; + +const EMPTY_RESULT: ScreenerResult = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: MARKET_CTX, +}; + +const stubEngine = { + screenTickers: async () => EMPTY_RESULT, +} as unknown as ScreenerEngine; + +const stubYahoo = { + fetchCalendarEvents: async () => null, +} as unknown as YahooFinanceClient; + +// In-memory MarketCallRepository stub +function makeRepoStub() { + const calls: (MarketCall & { createdAt: string })[] = []; + return { + list: () => + [...calls].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()), + get: (id: string) => calls.find((c) => c.id === id) ?? null, + create: ({ + title, + quarter, + date, + thesis, + tickers, + snapshot, + }: CreateCallInput & { snapshot: any }) => { + const call = { + id: `call-${calls.length + 1}`, + title, + quarter, + date: date ?? new Date().toISOString().slice(0, 10), + thesis, + tickers, + snapshot, + createdAt: new Date().toISOString(), + }; + calls.push(call); + return call; + }, + delete: (id: string) => { + const idx = calls.findIndex((c) => c.id === id); + if (idx === -1) return false; + calls.splice(idx, 1); + return true; + }, + }; +} + +// ── App factory ────────────────────────────────────────────────────────────── + +async function buildTestApp() { + const app = Fastify({ logger: false }); + await app.register(cors, { origin: '*' }); + new CallsController(makeRepoStub() as any, stubEngine, stubYahoo).register(app); + await app.ready(); + return app; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test('GET /api/calls → 200 with empty calls list', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'GET', url: '/api/calls' }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { calls: [] }); +}); + +test('POST /api/calls → 201 and returns the created call', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/calls', + payload: { + title: 'Q3 rate pivot play', + quarter: 'Q3 2025', + thesis: 'Fed cuts incoming — rotate into duration and growth.', + tickers: ['TLT', 'QQQ'], + }, + }); + assert.equal(res.statusCode, 201); + const body = res.json(); + assert.equal(body.title, 'Q3 rate pivot play'); + assert.deepEqual(body.tickers, ['TLT', 'QQQ']); + assert.ok(body.id); + assert.ok(body.createdAt); +}); + +test('POST /api/calls → created call appears in GET /api/calls', async () => { + const app = await buildTestApp(); + await app.inject({ + method: 'POST', + url: '/api/calls', + payload: { + title: 'AI semiconductor cycle', + quarter: 'Q4 2025', + thesis: 'Capex cycle benefits chip designers more than hyperscalers.', + tickers: ['NVDA', 'AMD'], + }, + }); + const listRes = await app.inject({ method: 'GET', url: '/api/calls' }); + assert.equal(listRes.json().calls.length, 1); + assert.equal(listRes.json().calls[0].title, 'AI semiconductor cycle'); +}); + +test('POST /api/calls with missing required fields → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/calls', + payload: { title: 'incomplete' }, // missing quarter, thesis, tickers + }); + assert.equal(res.statusCode, 400); +}); + +test('POST /api/calls with thesis too short → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/calls', + payload: { title: 'Test', quarter: 'Q1', thesis: 'short', tickers: ['AAPL'] }, + }); + assert.equal(res.statusCode, 400); +}); + +test('DELETE /api/calls/:id on non-existent id → 404', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'DELETE', url: '/api/calls/nonexistent' }); + assert.equal(res.statusCode, 404); +}); + +test('DELETE /api/calls/:id removes the call', async () => { + const app = await buildTestApp(); + const created = await app.inject({ + method: 'POST', + url: '/api/calls', + payload: { + title: 'Call to delete', + quarter: 'Q1 2025', + thesis: 'This call will be deleted in the test.', + tickers: ['SPY'], + }, + }); + const { id } = created.json(); + + const del = await app.inject({ method: 'DELETE', url: `/api/calls/${id}` }); + assert.equal(del.statusCode, 200); + assert.deepEqual(del.json(), { ok: true }); + + const list = await app.inject({ method: 'GET', url: '/api/calls' }); + assert.equal(list.json().calls.length, 0); +}); + +test('GET /api/calls/:id on non-existent id → 404', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'GET', url: '/api/calls/no-such-id' }); + assert.equal(res.statusCode, 404); +}); + +test('GET /api/calls/:id returns call with current snapshot shape', async () => { + const app = await buildTestApp(); + const created = await app.inject({ + method: 'POST', + url: '/api/calls', + payload: { + title: 'Rate trade', + quarter: 'Q2 2025', + thesis: 'Long duration bonds when yield curve inverts.', + tickers: ['TLT'], + }, + }); + const { id } = created.json(); + const res = await app.inject({ method: 'GET', url: `/api/calls/${id}` }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.equal(body.id, id); + assert.ok('current' in body, 'response should include current snapshot'); +}); + +test('GET /api/calls/calendar with no calls → 200 empty events', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'GET', url: '/api/calls/calendar' }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { events: [] }); +}); diff --git a/tests/finance.controller.test.ts b/tests/finance.controller.test.ts new file mode 100644 index 0000000..bfcdf98 --- /dev/null +++ b/tests/finance.controller.test.ts @@ -0,0 +1,177 @@ +/** + * Integration tests for FinanceController + * Uses Fastify inject() with stub engine, advisor, and in-memory portfolio repo. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { FinanceController } from '../server/controllers/finance.controller'; +import type { ScreenerEngine } from '../server/services/ScreenerEngine'; +import type { PortfolioAdvisor } from '../server/services/PortfolioAdvisor'; +import type { PortfolioHolding, MarketContext, ScreenerResult } from '../server/types'; + +// ── Stubs ──────────────────────────────────────────────────────────────────── + +const MARKET_CTX: MarketContext = { + sp500Price: 5000, + riskFreeRate: 4.5, + vixLevel: 18, + rateRegime: 'NORMAL', + volatilityRegime: 'NORMAL', + benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 }, +}; + +const EMPTY_RESULT: ScreenerResult = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: MARKET_CTX, +}; + +const stubEngine = { + screenTickers: async () => EMPTY_RESULT, + getMarketContext: async () => MARKET_CTX, +} as unknown as ScreenerEngine; + +const stubAdvisor = { + advise: async () => [], +} as unknown as PortfolioAdvisor; + +// In-memory PortfolioRepository stub +function makePortfolioRepo(seed: PortfolioHolding[] = []) { + const holdings: PortfolioHolding[] = [...seed]; + return { + exists: () => true, + read: () => ({ holdings: [...holdings] }), + upsert: (entry: PortfolioHolding) => { + const idx = holdings.findIndex((h) => h.ticker === entry.ticker); + if (idx >= 0) holdings[idx] = entry; + else holdings.push(entry); + return entry; + }, + remove: (ticker: string) => { + const idx = holdings.findIndex((h) => h.ticker === ticker); + if (idx === -1) return false; + holdings.splice(idx, 1); + return true; + }, + }; +} + +function makeEmptyRepo() { + return { + exists: () => false, + read: () => ({ holdings: [] }), + upsert: () => {}, + remove: () => false, + }; +} + +// ── App factory ────────────────────────────────────────────────────────────── + +async function buildTestApp(repo = makePortfolioRepo()) { + const app = Fastify({ logger: false }); + await app.register(cors, { origin: '*' }); + new FinanceController(stubEngine, repo as any, stubAdvisor).register(app); + await app.ready(); + return app; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test('GET /api/finance/portfolio → 200 with advice and marketContext keys', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.ok(Array.isArray(body.advice), 'advice should be array'); + assert.ok(body.marketContext, 'marketContext should be present'); +}); + +test('GET /api/finance/portfolio with no portfolio.json → 404', async () => { + const app = await buildTestApp(makeEmptyRepo() as any); + const res = await app.inject({ method: 'GET', url: '/api/finance/portfolio' }); + assert.equal(res.statusCode, 404); +}); + +test('GET /api/finance/market-context → 200 with benchmark fields', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'GET', url: '/api/finance/market-context' }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.ok(typeof body.riskFreeRate === 'number'); + assert.ok(typeof body.sp500Price === 'number'); + assert.ok(body.benchmarks); +}); + +test('POST /api/finance/holdings → 201 and returns the holding', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/finance/holdings', + payload: { ticker: 'AAPL', shares: 10, costBasis: 150, type: 'stock', source: 'Robinhood' }, + }); + assert.equal(res.statusCode, 201); + const body = res.json(); + assert.equal(body.ticker, 'AAPL'); + assert.equal(body.shares, 10); +}); + +test('POST /api/finance/holdings with missing shares → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/finance/holdings', + payload: { ticker: 'AAPL' }, + }); + assert.equal(res.statusCode, 400); +}); + +test('POST /api/finance/holdings with missing ticker → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/finance/holdings', + payload: { shares: 5 }, + }); + assert.equal(res.statusCode, 400); +}); + +test('POST /api/finance/holdings with zero shares → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/finance/holdings', + payload: { ticker: 'AAPL', shares: 0 }, + }); + assert.equal(res.statusCode, 400); +}); + +test('POST /api/finance/holdings with invalid type → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/finance/holdings', + payload: { ticker: 'AAPL', shares: 5, type: 'options' }, + }); + assert.equal(res.statusCode, 400); +}); + +test('DELETE /api/finance/holdings/:ticker removes existing holding → 200', async () => { + const repo = makePortfolioRepo([ + { ticker: 'MSFT', shares: 5, costBasis: 300, type: 'stock', source: 'Manual' }, + ]); + const app = await buildTestApp(repo); + const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/MSFT' }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { ok: true }); +}); + +test('DELETE /api/finance/holdings/:ticker on missing ticker → 404', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'DELETE', url: '/api/finance/holdings/NOTHERE' }); + assert.equal(res.statusCode, 404); +}); diff --git a/tests/screener.controller.test.ts b/tests/screener.controller.test.ts new file mode 100644 index 0000000..67fc3ae --- /dev/null +++ b/tests/screener.controller.test.ts @@ -0,0 +1,118 @@ +/** + * Integration tests for ScreenerController + /health + * Uses Fastify inject() — no real Yahoo calls, no live server. + */ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import Fastify from 'fastify'; +import cors from '@fastify/cors'; +import { ScreenerController } from '../server/controllers/screener.controller'; +import type { ScreenerEngine } from '../server/services/ScreenerEngine'; +import type { ScreenerResult, MarketContext } from '../server/types'; + +// ── Fixture data ──────────────────────────────────────────────────────────── + +const MARKET_CTX: MarketContext = { + sp500Price: 5000, + riskFreeRate: 4.5, + vixLevel: 18, + rateRegime: 'NORMAL', + volatilityRegime: 'NORMAL', + benchmarks: { marketPE: 22, techPE: 30, reitYield: 3.5, igSpread: 1.0 }, +}; + +const EMPTY_RESULT: ScreenerResult = { + STOCK: [], + ETF: [], + BOND: [], + ERROR: [], + marketContext: MARKET_CTX, +}; + +// ── Stub ──────────────────────────────────────────────────────────────────── + +const stubEngine = { + screenTickers: async (_tickers: string[]) => EMPTY_RESULT, + getMarketContext: async () => MARKET_CTX, +} as unknown as ScreenerEngine; + +// ── App factory ───────────────────────────────────────────────────────────── + +async function buildTestApp() { + const app = Fastify({ logger: false }); + await app.register(cors, { origin: '*' }); + new ScreenerController(stubEngine).register(app); + app.get('/health', async () => ({ status: 'ok' })); + await app.ready(); + return app; +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +test('GET /health → 200 { status: ok }', async () => { + const app = await buildTestApp(); + const res = await app.inject({ method: 'GET', url: '/health' }); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.json(), { status: 'ok' }); +}); + +test('POST /api/screen → 200 with STOCK/ETF/BOND/ERROR/marketContext keys', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: { tickers: ['AAPL'] }, + }); + assert.equal(res.statusCode, 200); + const body = res.json(); + assert.ok(Array.isArray(body.STOCK), 'STOCK should be array'); + assert.ok(Array.isArray(body.ETF), 'ETF should be array'); + assert.ok(Array.isArray(body.BOND), 'BOND should be array'); + assert.ok(Array.isArray(body.ERROR), 'ERROR should be array'); + assert.ok(body.marketContext, 'marketContext should be present'); +}); + +test('POST /api/screen → marketContext has expected shape', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: { tickers: ['MSFT'] }, + }); + const { marketContext } = res.json(); + assert.ok(typeof marketContext.riskFreeRate === 'number'); + assert.ok(typeof marketContext.sp500Price === 'number'); + assert.ok(typeof marketContext.vixLevel === 'number'); + assert.ok(marketContext.benchmarks); +}); + +test('POST /api/screen with missing tickers → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: {}, + }); + assert.equal(res.statusCode, 400); +}); + +test('POST /api/screen with empty tickers array → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: { tickers: [] }, + }); + assert.equal(res.statusCode, 400); +}); + +test('POST /api/screen with too many tickers (>50) → 400', async () => { + const app = await buildTestApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/screen', + payload: { tickers: Array.from({ length: 51 }, (_, i) => `T${i}`) }, + }); + assert.equal(res.statusCode, 400); +});