280 lines
7.8 KiB
Plaintext
280 lines
7.8 KiB
Plaintext
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);
|
|
// Should handle gracefully (zero is falsy, treated as null)
|
|
assert.ok(result);
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|