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, }); }); });