Files
market_screener/tests/bond-scorer.test.ts
T

270 lines
7.6 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('returns structured tier (P0.3)', () => {
const good: BondMetrics = {
ytm: 7.5, // 7.5% vs ~4% risk-free → wide spread
duration: 5,
creditRating: 'A',
creditRatingNumeric: 8,
};
const pass = BondScorer.score(good, DEFAULT_RULES);
assert.equal(pass.tier, 'PASS');
assert.equal(typeof pass.score, 'number');
const junk: BondMetrics = {
ytm: 8,
duration: 5,
creditRating: 'CCC',
creditRatingNumeric: 4, // below investment-grade gate
};
const reject = BondScorer.score(junk, DEFAULT_RULES);
assert.equal(reject.tier, 'REJECT');
assert.equal(reject.score, null);
});
await t.test('handles null/undefined metrics gracefully', () => {
const metrics = {
ytm: null,
duration: 5,
creditRating: null,
creditRatingNumeric: null,
} as unknown as BondMetrics;
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');
});
});