248 lines
6.9 KiB
TypeScript
248 lines
6.9 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { BondScorer } from '../server/domains/screener/scorers/BondScorer.js';
|
|
import { ScoringConfig } from '../server/domains/shared/scoring/ScoringConfig.js';
|
|
import type { BondMetrics } from '../server/domains/shared/types/models.model.js';
|
|
|
|
const DEFAULT_RULES = {
|
|
gates: ScoringConfig.base.gates.BOND,
|
|
weights: ScoringConfig.base.weights.BOND,
|
|
thresholds: ScoringConfig.base.thresholds.BOND,
|
|
};
|
|
|
|
test('BondScorer', async (t) => {
|
|
await t.test('rejects bond with low credit rating', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 5,
|
|
creditRating: 'BB', // Below BBB (gate is BBB = 7)
|
|
creditRatingNumeric: 5,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
assert.ok(result.scoreSummary.includes('Credit'));
|
|
});
|
|
|
|
await t.test('accepts bond with BBB credit rating', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 5,
|
|
creditRating: 'BBB',
|
|
creditRatingNumeric: 7,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('accepts bond with A credit rating', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 5,
|
|
creditRating: 'A',
|
|
creditRatingNumeric: 9,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
});
|
|
|
|
await t.test('scores high-yield bond positively', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 7.5, // High yield
|
|
duration: 5,
|
|
creditRating: 'A',
|
|
creditRatingNumeric: 9,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
assert.ok(result.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('penalizes long-duration bond', () => {
|
|
const metricsShort: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 3, // Short
|
|
creditRating: 'A',
|
|
creditRatingNumeric: 9,
|
|
};
|
|
|
|
const metricsLong: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 10, // Long
|
|
creditRating: 'A',
|
|
creditRatingNumeric: 9,
|
|
};
|
|
|
|
const resultShort = BondScorer.score(metricsShort, DEFAULT_RULES);
|
|
const resultLong = BondScorer.score(metricsLong, DEFAULT_RULES);
|
|
|
|
// Both should pass gates
|
|
assert.ok(resultShort.audit?.passedGates);
|
|
assert.ok(resultLong.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('handles null/undefined metrics gracefully', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: null,
|
|
duration: 5,
|
|
creditRating: null,
|
|
creditRatingNumeric: null,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
// Should not crash
|
|
assert.ok(result);
|
|
});
|
|
|
|
await t.test('includes audit trail of gate checks', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 5,
|
|
creditRating: 'BB',
|
|
creditRatingNumeric: 5,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit);
|
|
if (!result.audit?.passedGates) {
|
|
assert.ok(Array.isArray(result.audit?.failures));
|
|
}
|
|
});
|
|
|
|
await t.test('accepts high-quality bond (AAA rated)', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 4.0, // Lower yield (less risk)
|
|
duration: 7,
|
|
creditRating: 'AAA',
|
|
creditRatingNumeric: 10,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.ok(result.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('scores spread relative to risk-free rate', () => {
|
|
const metricsWideSpread: BondMetrics = {
|
|
ytm: 7.5, // Wide spread from ~4.5% risk-free
|
|
duration: 5,
|
|
creditRating: 'BBB',
|
|
creditRatingNumeric: 7,
|
|
};
|
|
|
|
const metricsTightSpread: BondMetrics = {
|
|
ytm: 4.8, // Tight spread
|
|
duration: 5,
|
|
creditRating: 'BBB',
|
|
creditRatingNumeric: 7,
|
|
};
|
|
|
|
const resultWide = BondScorer.score(metricsWideSpread, DEFAULT_RULES);
|
|
const resultTight = BondScorer.score(metricsTightSpread, DEFAULT_RULES);
|
|
|
|
// Both should pass gates
|
|
assert.ok(resultWide.audit?.passedGates);
|
|
assert.ok(resultTight.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('rejects bond with very long duration', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 20, // Much longer than default gate
|
|
creditRating: 'A',
|
|
creditRatingNumeric: 9,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
// May fail duration gate if threshold is enforced
|
|
assert.ok(result);
|
|
});
|
|
|
|
await t.test('handles extreme yield scenarios', () => {
|
|
const metricsVeryHigh: BondMetrics = {
|
|
ytm: 15.0, // Very high (distressed)
|
|
duration: 5,
|
|
creditRating: 'B',
|
|
creditRatingNumeric: 4,
|
|
};
|
|
|
|
const metricsVeryLow: BondMetrics = {
|
|
ytm: 2.0, // Very low (low risk)
|
|
duration: 5,
|
|
creditRating: 'AAA',
|
|
creditRatingNumeric: 10,
|
|
};
|
|
|
|
const resultHigh = BondScorer.score(metricsVeryHigh, DEFAULT_RULES);
|
|
const resultLow = BondScorer.score(metricsVeryLow, DEFAULT_RULES);
|
|
|
|
// High yield bond likely fails credit gate
|
|
assert.ok(resultHigh);
|
|
// Low yield AAA bond should pass
|
|
assert.ok(resultLow.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('scores based on credit rating thresholds', () => {
|
|
const metricsJustAboveGate: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 5,
|
|
creditRating: 'BBB',
|
|
creditRatingNumeric: 7, // Exactly at gate
|
|
};
|
|
|
|
const metricsWellAboveGate: BondMetrics = {
|
|
ytm: 5.5,
|
|
duration: 5,
|
|
creditRating: 'AA',
|
|
creditRatingNumeric: 9, // Well above gate
|
|
};
|
|
|
|
const resultAt = BondScorer.score(metricsJustAboveGate, DEFAULT_RULES);
|
|
const resultAbove = BondScorer.score(metricsWellAboveGate, DEFAULT_RULES);
|
|
|
|
// Both should pass
|
|
assert.ok(resultAt.audit?.passedGates);
|
|
assert.ok(resultAbove.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('handles negative YTM (unlikely but possible)', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: -0.5, // Negative yield (Swiss bonds in past)
|
|
duration: 2,
|
|
creditRating: 'AAA',
|
|
creditRatingNumeric: 10,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
// Should handle gracefully
|
|
assert.ok(result);
|
|
});
|
|
|
|
await t.test('investment-grade bond scores well', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 5.0,
|
|
duration: 5,
|
|
creditRating: 'A',
|
|
creditRatingNumeric: 8,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.notEqual(result.label, '🔴 REJECT');
|
|
assert.ok(result.audit?.passedGates);
|
|
});
|
|
|
|
await t.test('speculative-grade bond rejected', () => {
|
|
const metrics: BondMetrics = {
|
|
ytm: 8.5,
|
|
duration: 5,
|
|
creditRating: 'CCC',
|
|
creditRatingNumeric: 3,
|
|
};
|
|
|
|
const result = BondScorer.score(metrics, DEFAULT_RULES);
|
|
assert.equal(result.label, '🔴 REJECT');
|
|
});
|
|
});
|