369 lines
11 KiB
TypeScript
369 lines
11 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { StockScorer } from '../server/domains/screener/scorers/StockScorer.js';
|
|
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
|
|
import type { StockMetrics } from '../server/domains/shared/types/models.model.js';
|
|
|
|
const DEFAULT_RULES = {
|
|
gates: ScoringConfig.base.gates.STOCK,
|
|
weights: ScoringConfig.base.weights.STOCK,
|
|
thresholds: ScoringConfig.base.thresholds.STOCK,
|
|
};
|
|
|
|
test('StockScorer', async (t) => {
|
|
await t.test('rejects stock with high debt-to-equity ratio', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 15,
|
|
pegRatio: 1.0,
|
|
debtToEquity: 2.5, // Exceeds 1.5 gate
|
|
quickRatio: 1.0,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 5,
|
|
fcfYield: 3,
|
|
priceToBook: 1.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('D/E'));
|
|
});
|
|
|
|
await t.test('rejects stock with low quick ratio', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 15,
|
|
pegRatio: 1.0,
|
|
debtToEquity: 1.0,
|
|
quickRatio: 0.5, // Below 0.8 gate
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 5,
|
|
fcfYield: 3,
|
|
priceToBook: 1.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('Quick'));
|
|
});
|
|
|
|
await t.test('rejects stock with high P/E ratio', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 25, // Exceeds 15 gate
|
|
pegRatio: 1.0,
|
|
debtToEquity: 1.0,
|
|
quickRatio: 1.0,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 5,
|
|
fcfYield: 3,
|
|
priceToBook: 1.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('P/E'));
|
|
});
|
|
|
|
await t.test('rejects stock with high PEG ratio', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 15,
|
|
pegRatio: 1.5, // Exceeds 1.0 gate
|
|
debtToEquity: 1.0,
|
|
quickRatio: 1.0,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 5,
|
|
fcfYield: 3,
|
|
priceToBook: 1.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('PEG'));
|
|
});
|
|
|
|
await t.test('scores high-quality stock positively', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 12, // Below gate
|
|
pegRatio: 0.8, // Below gate
|
|
debtToEquity: 0.5,
|
|
quickRatio: 1.2,
|
|
returnOnEquity: 25, // High ROE
|
|
operatingMargin: 20,
|
|
netProfitMargin: 15,
|
|
revenueGrowth: 10,
|
|
fcfYield: 5,
|
|
priceToBook: 2.0,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
// Should have positive score
|
|
assert.ok(result.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('handles null/undefined metrics gracefully', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 15,
|
|
pegRatio: 1.0,
|
|
debtToEquity: null,
|
|
quickRatio: 1.0,
|
|
returnOnEquity: 20,
|
|
operatingMargin: null,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 5,
|
|
fcfYield: 3,
|
|
priceToBook: 1.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
// Should not crash, null values are skipped in gate checks
|
|
assert.ok(result);
|
|
});
|
|
|
|
await t.test('passes all quality gates for strong stock', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 10,
|
|
pegRatio: 0.9,
|
|
debtToEquity: 0.3,
|
|
quickRatio: 1.5,
|
|
returnOnEquity: 30,
|
|
operatingMargin: 25,
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 8,
|
|
fcfYield: 6,
|
|
priceToBook: 3.0,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit?.passedGates);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('includes audit trail of gate checks', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 25,
|
|
pegRatio: 1.0,
|
|
debtToEquity: 1.0,
|
|
quickRatio: 1.0,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 5,
|
|
fcfYield: 3,
|
|
priceToBook: 1.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit);
|
|
if (!result.audit?.passedGates) {
|
|
assert.ok(Array.isArray(result.audit?.failures));
|
|
}
|
|
});
|
|
|
|
await t.test('scores ROE as primary quality factor', () => {
|
|
const metricsLowRoe: StockMetrics = {
|
|
peRatio: 10,
|
|
pegRatio: 0.9,
|
|
debtToEquity: 0.3,
|
|
quickRatio: 1.5,
|
|
returnOnEquity: 5, // Low ROE
|
|
operatingMargin: 25,
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 8,
|
|
fcfYield: 6,
|
|
priceToBook: 3.0,
|
|
} as any;
|
|
|
|
const metricsHighRoe: StockMetrics = {
|
|
peRatio: 10,
|
|
pegRatio: 0.9,
|
|
debtToEquity: 0.3,
|
|
quickRatio: 1.5,
|
|
returnOnEquity: 40, // High ROE
|
|
operatingMargin: 25,
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 8,
|
|
fcfYield: 6,
|
|
priceToBook: 3.0,
|
|
} as any;
|
|
|
|
const resultLow = StockScorer.score(metricsLowRoe, DEFAULT_RULES);
|
|
const resultHigh = StockScorer.score(metricsHighRoe, DEFAULT_RULES);
|
|
|
|
// Both should pass gates, but high ROE should score better
|
|
assert.ok(resultLow.audit?.passedGates);
|
|
assert.ok(resultHigh.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('rejects stock with multiple gate failures', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 25, // High
|
|
pegRatio: 1.5, // High
|
|
debtToEquity: 2.5, // High
|
|
quickRatio: 0.5, // Low
|
|
returnOnEquity: 5,
|
|
operatingMargin: 5,
|
|
netProfitMargin: 2,
|
|
revenueGrowth: -5,
|
|
fcfYield: -1,
|
|
priceToBook: 0.5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
// Should have multiple failures
|
|
if (!result.audit?.passedGates && result.audit?.failures) {
|
|
assert.ok(result.audit.failures.length > 1);
|
|
}
|
|
});
|
|
|
|
await t.test('handles edge case of zero metrics', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 0,
|
|
pegRatio: 0,
|
|
debtToEquity: 0,
|
|
quickRatio: 0,
|
|
returnOnEquity: 0,
|
|
operatingMargin: 0,
|
|
netProfitMargin: 0,
|
|
revenueGrowth: 0,
|
|
fcfYield: 0,
|
|
priceToBook: 0,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
// Zero quick ratio is a real value and fails the liquidity gate;
|
|
// zero P/E, PEG, P/B are impossible values and are treated as missing.
|
|
assert.ok(result);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('Quick'));
|
|
});
|
|
|
|
await t.test('treats zero revenue growth as a real (stagnant) value', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 12,
|
|
pegRatio: 0.8,
|
|
debtToEquity: 0.5,
|
|
quickRatio: 1.2,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 0, // stagnant — must be scored, not skipped
|
|
fcfYield: 5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit?.passedGates);
|
|
// 0% growth is below revMed (5) → scores -1, same as slightly negative growth
|
|
assert.equal(result.audit?.breakdown?.revenue, -1);
|
|
});
|
|
|
|
await t.test('treats zero debt-to-equity as debt-free, not missing', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 12,
|
|
pegRatio: 0.8,
|
|
debtToEquity: 0, // debt-free — should pass the gate, not be skipped
|
|
quickRatio: 1.2,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 15,
|
|
netProfitMargin: 10,
|
|
revenueGrowth: 8,
|
|
fcfYield: 5,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit?.passedGates);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('flags insufficient data instead of plain HOLD', () => {
|
|
const metrics: StockMetrics = { currentPrice: 50 } as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🟡 HOLD (No Data)');
|
|
assert.equal(result.audit?.coverage?.active, 0);
|
|
});
|
|
|
|
await t.test('returns structured tier and numeric score (P0.3)', () => {
|
|
const strong: StockMetrics = {
|
|
peRatio: 12,
|
|
pegRatio: 0.7,
|
|
debtToEquity: 0.3,
|
|
quickRatio: 1.5,
|
|
returnOnEquity: 30,
|
|
operatingMargin: 25,
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 12,
|
|
fcfYield: 6,
|
|
} as any;
|
|
const pass = StockScorer.score(strong, DEFAULT_RULES);
|
|
assert.equal(pass.tier, 'PASS');
|
|
assert.ok(typeof pass.score === 'number' && pass.score >= 4);
|
|
|
|
const gated: StockMetrics = { ...strong, peRatio: 40 } as any;
|
|
const reject = StockScorer.score(gated, DEFAULT_RULES);
|
|
assert.equal(reject.tier, 'REJECT');
|
|
assert.equal(reject.score, null);
|
|
|
|
const noData = StockScorer.score({ currentPrice: 50 } as any, DEFAULT_RULES);
|
|
assert.equal(noData.tier, 'HOLD');
|
|
assert.equal(noData.score, 0);
|
|
});
|
|
|
|
await t.test('reports factor coverage in audit', () => {
|
|
const metrics: StockMetrics = {
|
|
peRatio: 12,
|
|
pegRatio: 0.8,
|
|
quickRatio: 1.2,
|
|
returnOnEquity: 20,
|
|
currentPrice: 50,
|
|
} as any;
|
|
|
|
const result = StockScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit?.coverage);
|
|
assert.ok(result.audit.coverage.active >= 1);
|
|
assert.ok(result.audit.coverage.active <= result.audit.coverage.total);
|
|
});
|
|
|
|
await t.test('scores based on configured thresholds', () => {
|
|
const metricsLowMargin: StockMetrics = {
|
|
peRatio: 10,
|
|
pegRatio: 0.9,
|
|
debtToEquity: 0.3,
|
|
quickRatio: 1.5,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 5, // Below medium threshold
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 8,
|
|
fcfYield: 6,
|
|
priceToBook: 3.0,
|
|
} as any;
|
|
|
|
const metricsHighMargin: StockMetrics = {
|
|
peRatio: 10,
|
|
pegRatio: 0.9,
|
|
debtToEquity: 0.3,
|
|
quickRatio: 1.5,
|
|
returnOnEquity: 20,
|
|
operatingMargin: 25, // Above high threshold
|
|
netProfitMargin: 18,
|
|
revenueGrowth: 8,
|
|
fcfYield: 6,
|
|
priceToBook: 3.0,
|
|
} as any;
|
|
|
|
const resultLow = StockScorer.score(metricsLowMargin, DEFAULT_RULES);
|
|
const resultHigh = StockScorer.score(metricsHighMargin, DEFAULT_RULES);
|
|
|
|
// Both should pass gates
|
|
assert.ok(resultLow.audit?.passedGates);
|
|
assert.ok(resultHigh.audit?.passedGates);
|
|
});
|
|
});
|