125 lines
3.3 KiB
TypeScript
125 lines
3.3 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { StockScorer } from '../server/scorers/StockScorer';
|
|
import type { StockMetrics } from '../server/types';
|
|
|
|
const baseRules = {
|
|
gates: { maxDebtToEquity: 3.0, minQuickRatio: 0.5, maxPERatio: 20, maxPegGate: 1.5 },
|
|
weights: { margin: 2, opMargin: 2, roe: 3, peg: 2, revenue: 2, fcf: 2 },
|
|
thresholds: {
|
|
marginHigh: 20,
|
|
marginMed: 10,
|
|
opMarginHigh: 20,
|
|
opMarginMed: 10,
|
|
roeHigh: 20,
|
|
roeMed: 10,
|
|
pegHigh: 1.0,
|
|
pegMed: 1.5,
|
|
revHigh: 15,
|
|
revMed: 5,
|
|
fcfHigh: 5,
|
|
fcfMed: 2,
|
|
},
|
|
};
|
|
|
|
// Minimal fixture — tests exercise specific fields; unused metrics are null.
|
|
const nullMetrics: Omit<
|
|
StockMetrics,
|
|
| 'sector'
|
|
| 'capCategory'
|
|
| 'growthCategory'
|
|
| 'currentPrice'
|
|
| 'peRatio'
|
|
| 'pegRatio'
|
|
| 'debtToEquity'
|
|
| 'quickRatio'
|
|
| 'returnOnEquity'
|
|
| 'operatingMargin'
|
|
| 'netProfitMargin'
|
|
| 'revenueGrowth'
|
|
| 'fcfYield'
|
|
> = {
|
|
priceToBook: null,
|
|
grossMargin: null,
|
|
earningsGrowth: null,
|
|
pFFO: null,
|
|
dividendYield: null,
|
|
beta: null,
|
|
week52High: null,
|
|
week52Low: null,
|
|
week52Change: null,
|
|
week52FromHigh: null,
|
|
week52FromLow: null,
|
|
marketCap: null,
|
|
analystRating: null,
|
|
analystTargetPrice: null,
|
|
analystUpside: null,
|
|
numberOfAnalysts: null,
|
|
dcfIntrinsicValue: null,
|
|
dcfMarginOfSafety: null,
|
|
};
|
|
|
|
const pass: StockMetrics = {
|
|
...nullMetrics,
|
|
sector: 'GENERAL',
|
|
capCategory: 'Large Cap',
|
|
growthCategory: 'Growth',
|
|
currentPrice: 150,
|
|
peRatio: 15,
|
|
pegRatio: 1.2,
|
|
debtToEquity: 1.0,
|
|
quickRatio: 1.0,
|
|
returnOnEquity: 22,
|
|
operatingMargin: 25,
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 16,
|
|
fcfYield: 6,
|
|
};
|
|
|
|
test('rejects on high D/E', () => {
|
|
const result = StockScorer.score({ ...pass, debtToEquity: 4.0 }, baseRules);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert(result.scoreSummary.includes('D/E'));
|
|
});
|
|
|
|
test('rejects on high P/E', () => {
|
|
const result = StockScorer.score({ ...pass, peRatio: 25 }, baseRules);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert(result.scoreSummary.includes('P/E'));
|
|
});
|
|
|
|
test('rejects on high PEG', () => {
|
|
const result = StockScorer.score({ ...pass, pegRatio: 2.0 }, baseRules);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
test('skips gate when metric is null (missing data)', () => {
|
|
const result = StockScorer.score({ ...pass, pegRatio: null, peRatio: null }, baseRules);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
test('high-conviction BUY on strong metrics', () => {
|
|
const result = StockScorer.score(pass, baseRules);
|
|
assert.equal(result.label, '🟢 BUY (High Conviction)');
|
|
});
|
|
|
|
test('audit breakdown contains scored factors', () => {
|
|
const result = StockScorer.score(pass, baseRules);
|
|
assert(result.audit.passedGates);
|
|
assert(result.audit.breakdown!.roe != null);
|
|
assert(result.audit.breakdown!.margin != null);
|
|
});
|
|
|
|
test('beta > 1.5 surfaces as risk flag', () => {
|
|
const result = StockScorer.score({ ...pass, beta: 2.0 }, baseRules);
|
|
assert(result.audit.riskFlags?.some((f) => f.includes('High volatility')));
|
|
});
|
|
|
|
test('near 52-week high surfaces as risk flag', () => {
|
|
const result = StockScorer.score(
|
|
{ ...pass, week52High: 200, week52Low: 100, currentPrice: 195 },
|
|
baseRules,
|
|
);
|
|
assert(result.audit.riskFlags?.some((f) => f.includes('52-week high')));
|
|
});
|