271 lines
7.6 KiB
TypeScript
271 lines
7.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { EtfScorer } from '../server/domains/screener/scorers/EtfScorer.js';
|
|
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
|
|
import type { EtfMetrics } from '../server/domains/shared/types/models.model.js';
|
|
|
|
const DEFAULT_RULES = {
|
|
gates: ScoringConfig.base.gates.ETF,
|
|
weights: ScoringConfig.base.weights.ETF,
|
|
thresholds: ScoringConfig.base.thresholds.ETF,
|
|
};
|
|
|
|
test('EtfScorer', async (t) => {
|
|
await t.test('rejects ETF with high expense ratio', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.8, // Exceeds 0.2% gate
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('Expense ratio'));
|
|
});
|
|
|
|
await t.test('accepts ETF with low expense ratio', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.05, // Well below 0.2% gate
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('rejects ETF with zero expense ratio (data issue)', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0, // Zero treated as missing/invalid
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
// Zero is treated as null/missing data
|
|
assert.ok(result);
|
|
});
|
|
|
|
await t.test('rejects ETF with low 5-year return', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 5, // Below 8% gate
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('5-year return'));
|
|
});
|
|
|
|
await t.test('accepts ETF with strong 5-year return', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 12, // Above 8% gate
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('rejects ETF with insufficient volume', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 500000, // Below 1M gate
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('Volume'));
|
|
});
|
|
|
|
await t.test('accepts ETF with strong volume', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 10000000, // Well above 1M gate
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('scores high-quality ETF positively', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.05, // Low
|
|
yield: 3.0, // Decent
|
|
volume: 20000000, // High
|
|
fiveYearReturn: 15, // Strong
|
|
totalAssets: 50e9, // Large
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
assert.ok(result.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('handles null/undefined metrics gracefully', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: null,
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: null,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
// Should not crash, null values are skipped
|
|
assert.ok(result);
|
|
});
|
|
|
|
await t.test('includes audit trail of gate checks', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.8,
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit);
|
|
if (!result.audit?.passedGates) {
|
|
assert.ok(Array.isArray(result.audit?.failures));
|
|
}
|
|
});
|
|
|
|
await t.test('scores yield as secondary factor', () => {
|
|
const metricsLowYield: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 0.5, // Low yield
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const metricsHighYield: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 4.0, // High yield
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const resultLow = EtfScorer.score(metricsLowYield, DEFAULT_RULES);
|
|
const resultHigh = EtfScorer.score(metricsHighYield, DEFAULT_RULES);
|
|
|
|
// Both should pass gates
|
|
assert.ok(resultLow.audit?.passedGates);
|
|
assert.ok(resultHigh.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('rejects ETF with multiple gate failures', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 1.0, // High
|
|
yield: 0.5,
|
|
volume: 100000, // Low
|
|
fiveYearReturn: 3, // Low
|
|
totalAssets: 100e6, // Small
|
|
};
|
|
|
|
const result = EtfScorer.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('calculates score based on thresholds', () => {
|
|
const metricsLowReturn: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 8.5, // Just above gate
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const metricsHighReturn: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 15, // Well above gate
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const resultLow = EtfScorer.score(metricsLowReturn, DEFAULT_RULES);
|
|
const resultHigh = EtfScorer.score(metricsHighReturn, DEFAULT_RULES);
|
|
|
|
// Both should pass
|
|
assert.ok(resultLow.audit?.passedGates);
|
|
assert.ok(resultHigh.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('penalizes low-volume ETF', () => {
|
|
const metricsLowVolume: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 2000000, // Low but above gate
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const metricsHighVolume: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 50000000, // High volume
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const resultLow = EtfScorer.score(metricsLowVolume, DEFAULT_RULES);
|
|
const resultHigh = EtfScorer.score(metricsHighVolume, DEFAULT_RULES);
|
|
|
|
// Both pass gates, but high volume should score higher
|
|
assert.ok(resultLow.audit?.passedGates);
|
|
assert.ok(resultHigh.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('handles extremely high expense ratio', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 5.0, // 5%!
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: 10,
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('handles negative 5-year return', () => {
|
|
const metrics: EtfMetrics = {
|
|
expenseRatio: 0.1,
|
|
yield: 2.5,
|
|
volume: 5000000,
|
|
fiveYearReturn: -5, // Negative return
|
|
totalAssets: 5e9,
|
|
};
|
|
|
|
const result = EtfScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
});
|
|
});
|