import { test } from 'node:test'; import assert from 'node:assert/strict'; import { PortfolioAdvisor } from '../server/services/PortfolioAdvisor'; import { SIGNAL } from '../server/config/constants'; import type { PortfolioHolding } from '../server/types'; import type { YahooFinanceClient } from '../server/clients/YahooFinanceClient'; // _cryptoPrices is the only method that uses the client; all other private // methods under test are pure calculations that never touch it. 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). const holding = (costBasis: number, shares: number): PortfolioHolding => ({ ticker: 'TEST', source: 'Test', type: 'stock', costBasis, shares, }); test('_position: computes gain/loss correctly', () => { 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); 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); assert.equal(action, '🟢 Hold & Add'); }); test('_advice: Avoid + loss → Sell (Cut Loss)', () => { 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); assert.equal(action, '🔴 Sell (Take Profits)'); }); test('_advice: Speculation + >20% gain → Reduce Position', () => { 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); assert.equal(action, '⚪ No price data'); }); test('_cryptoAdvice: >100% gain → Consider taking profits', () => { const { action } = advisor.cryptoAdvice(holding(10000, 1), 25000); assert.equal(action, '🟠 Consider taking profits'); }); // ── Result map dot-notation normalisation (BRK.B / BRK-B) ─────────────────── test('advise: BRK-B screener result matches BRK.B holding', async () => { const mockResult = { asset: { ticker: 'BRK-B', currentPrice: 500 }, signal: SIGNAL.STRONG_BUY, inflated: { label: '🟢 BUY (High Conviction)' }, fundamental: { label: '🟢 BUY (High Conviction)' }, }; const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] }; const holding: PortfolioHolding = { ticker: 'BRK.B', shares: 1, costBasis: 400, type: 'stock', source: 'Robinhood', }; const advice = await advisor.advise([holding], screenedResults); // Should match and return a real signal, not "Not screened" assert.equal(advice[0].signal, SIGNAL.STRONG_BUY); }); test('advise: BRK.B screener result matches BRK-B holding', async () => { const mockResult = { asset: { ticker: 'BRK.B', currentPrice: 500 }, signal: SIGNAL.STRONG_BUY, inflated: { label: '🟢 BUY (High Conviction)' }, fundamental: { label: '🟢 BUY (High Conviction)' }, }; const screenedResults = { STOCK: [mockResult], ETF: [], BOND: [] }; const holding: PortfolioHolding = { ticker: 'BRK-B', shares: 1, costBasis: 400, type: 'stock', source: 'Robinhood', }; const advice = await advisor.advise([holding], screenedResults); assert.equal(advice[0].signal, SIGNAL.STRONG_BUY); });