Files
market_screener/tests/PortfolioAdvisor.test.ts
T
2026-06-05 22:44:04 -04:00

109 lines
3.9 KiB
TypeScript

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