179 lines
5.1 KiB
TypeScript
179 lines
5.1 KiB
TypeScript
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
|
|
|
import { instrumentPosition } from '../../application/instrument-position';
|
|
import { bondCoupons, bondMetrics } from './bond-income.service';
|
|
import { bondQuote } from './bond-market.service';
|
|
jest.mock('../../../../../prisma/prisma.service', () => ({
|
|
PrismaService: class {},
|
|
}));
|
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
|
const money = (units: string, currency = 'rub') => ({ units, currency });
|
|
const bond = {
|
|
uid: 'uid',
|
|
ticker: 'BOND',
|
|
isin: 'ISIN',
|
|
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
|
nominal: money('500'),
|
|
initialNominal: money('1000'),
|
|
aciValue: money('12'),
|
|
amortizationFlag: true,
|
|
first_1dayCandleDate: time('2024-01-01'),
|
|
};
|
|
describe('bond financial projections', () => {
|
|
beforeEach(() =>
|
|
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
|
|
);
|
|
afterEach(() => jest.useRealTimers());
|
|
it('estimates only future unfixed payouts from exact UID holdings', () => {
|
|
const holding = {
|
|
instrumentUid: 'uid',
|
|
type: 'BOND',
|
|
quantity: '2',
|
|
averagePurchasePrice: null,
|
|
currentValue: { value: '981.25', currency: 'RUB' },
|
|
absoluteReturn: null,
|
|
};
|
|
const position = instrumentPosition(
|
|
{
|
|
positions: [
|
|
holding,
|
|
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
|
{ ...holding, type: 'STOCK', quantity: '999' },
|
|
],
|
|
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
|
isStale: true,
|
|
} as PortfolioDto,
|
|
'uid',
|
|
'BOND',
|
|
);
|
|
const result = bondCoupons(
|
|
[
|
|
{
|
|
couponNumber: '1',
|
|
couponDate: time('2026-10-01'),
|
|
fixDate: time('2026-09-29'),
|
|
payOneBond: money('10'),
|
|
},
|
|
{
|
|
couponNumber: '2',
|
|
couponDate: time('2026-10-01'),
|
|
fixDate: time('2026-09-09'),
|
|
payOneBond: money('10'),
|
|
},
|
|
{
|
|
couponNumber: '3',
|
|
couponDate: time('2026-10-01'),
|
|
payOneBond: money('10'),
|
|
},
|
|
{
|
|
couponNumber: '4',
|
|
couponDate: time('2026-10-01'),
|
|
fixDate: time('2026-09-29'),
|
|
},
|
|
],
|
|
position,
|
|
{ from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' },
|
|
'source-time',
|
|
false,
|
|
);
|
|
expect(result.coupons[0].estimatedAmount).toEqual({
|
|
value: '20',
|
|
currency: 'RUB',
|
|
});
|
|
expect(
|
|
result.coupons.slice(1).every((row) => row.estimatedAmount === null),
|
|
).toBe(true);
|
|
expect(result.coupons[3].perBond).toBeNull();
|
|
expect(result.positionIsStale).toBe(true);
|
|
expect(position.quantity).toBe('2');
|
|
});
|
|
it('preserves amortized nominal, ACI, percent quotes and annualized coupon yield', () => {
|
|
const values = {
|
|
instrument: bond,
|
|
price: { price: { units: '98', nano: 125000000 } },
|
|
candle: { close: { units: '99' } },
|
|
coupons: [
|
|
{
|
|
couponDate: time('2026-10-10'),
|
|
payOneBond: money('30'),
|
|
couponPeriod: 90,
|
|
},
|
|
],
|
|
};
|
|
expect(bondQuote(values, [], 'time')).toMatchObject({
|
|
pricePercent: '98.125',
|
|
cleanPrice: { currency: 'RUB', value: '490.625' },
|
|
dirtyPrice: { currency: 'RUB', value: '502.625' },
|
|
changePercentagePoints: '-0.875',
|
|
});
|
|
expect(bondMetrics(values, [], 'time')).toMatchObject({
|
|
couponDate: '2026-10-10',
|
|
couponYieldPercent: '24.7983',
|
|
creditRating: null,
|
|
});
|
|
});
|
|
it.each(['unknown', 'currency', 'period', 'zero-price'])(
|
|
'does not invent coupon yield with %s inputs',
|
|
(scenario) => {
|
|
const result = bondMetrics(
|
|
{
|
|
instrument: bond,
|
|
price: { price: { units: scenario === 'zero-price' ? '0' : '100' } },
|
|
coupons: [
|
|
{
|
|
couponDate: time('2026-10-10'),
|
|
payOneBond:
|
|
scenario === 'unknown'
|
|
? undefined
|
|
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
|
couponPeriod: scenario === 'period' ? 0 : 90,
|
|
},
|
|
],
|
|
},
|
|
[],
|
|
'time',
|
|
);
|
|
expect(result.couponYieldPercent).toBeNull();
|
|
},
|
|
);
|
|
it('keeps absent and mismatched ACI unknown and never substitutes risk level for a rating', () => {
|
|
expect(
|
|
bondQuote(
|
|
{
|
|
instrument: { ...bond, aciValue: money('1', 'usd') },
|
|
price: { price: { units: '100' } },
|
|
},
|
|
[],
|
|
'time',
|
|
).dirtyPrice,
|
|
).toBeNull();
|
|
expect(
|
|
bondQuote(
|
|
{
|
|
instrument: { ...bond, aciValue: undefined },
|
|
price: { price: { units: '100' } },
|
|
},
|
|
[],
|
|
'time',
|
|
).dirtyPrice,
|
|
).toBeNull();
|
|
expect(
|
|
bondMetrics(
|
|
{
|
|
instrument: {
|
|
...bond,
|
|
riskLevel: 'RISK_LEVEL_LOW',
|
|
floatingCouponFlag: true,
|
|
},
|
|
},
|
|
['yield'],
|
|
'time',
|
|
),
|
|
).toMatchObject({
|
|
creditRating: null,
|
|
couponType: 'COUPON_TYPE_FLOATING',
|
|
couponYieldPercent: null,
|
|
});
|
|
});
|
|
});
|