64 lines
2.1 KiB
TypeScript
64 lines
2.1 KiB
TypeScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { BondScorer } from '../server/scorers/BondScorer';
|
|
import type { MarketContext } from '../server/types';
|
|
|
|
// ytm is stored as a percentage value (e.g. 6.5 = 6.5%), matching how DataMapper outputs it.
|
|
// BondScorer._sanitize divides by 100 to convert to decimal before spread calculation.
|
|
|
|
const rules = {
|
|
gates: { minCreditRating: 7 },
|
|
weights: { yieldSpread: 3, duration: 2 },
|
|
thresholds: { minSpread: 1.0, maxDuration: 10 },
|
|
};
|
|
// BondScorer only uses riskFreeRate from context; cast the partial fixture to satisfy the type.
|
|
const ctx = { riskFreeRate: 4.5 } as MarketContext;
|
|
|
|
test('rejects bond below investment-grade floor', () => {
|
|
const result = BondScorer.score(
|
|
{ ytm: 8.0, duration: 5, creditRating: 'BB', creditRatingNumeric: 6 },
|
|
rules,
|
|
ctx,
|
|
);
|
|
assert.equal(result.label, '🔴 Avoid');
|
|
assert(result.scoreSummary.includes('Gate failed'));
|
|
});
|
|
|
|
test('attractive for wide spread and short duration', () => {
|
|
// ytm=6.5%, riskFree=4.5% → spreadPct=(0.065-0.045)*100=2.0% >= minSpread 1.0%
|
|
const result = BondScorer.score(
|
|
{ ytm: 6.5, duration: 4, creditRating: 'AA', creditRatingNumeric: 9 },
|
|
rules,
|
|
ctx,
|
|
);
|
|
assert.equal(result.label, '🟢 Attractive');
|
|
});
|
|
|
|
test('spread calculation: ytm% → decimal, subtract riskFreeRate/100, back to %', () => {
|
|
const result = BondScorer.score(
|
|
{ ytm: 6.5, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
|
rules,
|
|
ctx,
|
|
);
|
|
assert.equal(result.audit.breakdown!.spread, rules.weights.yieldSpread);
|
|
});
|
|
|
|
test('fails spread when yield barely above risk-free', () => {
|
|
// ytm=4.7%, riskFree=4.5% → spreadPct=0.2% < minSpread 1.0%
|
|
const result = BondScorer.score(
|
|
{ ytm: 4.7, duration: 5, creditRating: 'AAA', creditRatingNumeric: 10 },
|
|
rules,
|
|
ctx,
|
|
);
|
|
assert.equal(result.audit.breakdown!.spread, -2);
|
|
});
|
|
|
|
test('penalises long duration', () => {
|
|
const result = BondScorer.score(
|
|
{ ytm: 6.5, duration: 15, creditRating: 'AA', creditRatingNumeric: 9 },
|
|
rules,
|
|
ctx,
|
|
);
|
|
assert.equal(result.audit.breakdown!.duration, -1);
|
|
});
|