import { Injectable, NotFoundException } from '@nestjs/common'; import { decimalFromString, divide, formatDecimal, multiply, } from '../../../../../common/decimal'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol'; import { TbankAccessService, type TbankBond, type TbankCoupon, tbankSourceNeedsRefresh, } from '../../../../../integrations/tbank/tbank.api'; import { ForecastService, PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService, type CatalogPayload, } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; import { money } from '../infrastructure/bond-money.mapper'; import { BondCouponsDto, BondCouponsResourceDto, BondMetricsDto, BondMetricsResourceDto, BondPositionDto, } from '../presentation/bond.dto'; import { bondQuote } from './bond-market.service'; const INCOME_FORECAST_YEARS = 1; const ZERO_QUANTITY = 0n; const MIN_COUPON_PERIOD_EXCLUSIVE = 0; const ZERO_AMOUNT = 0n; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; const COUPON_YIELD_DECIMAL_PLACES = 4; @Injectable() export class BondIncomeService { constructor( private readonly portfolios: PortfolioService, private readonly sources: CatalogEnrichmentService, private readonly access: TbankAccessService, private readonly forecasts: ForecastService, ) {} async metrics( userId: string, uid: string, observe = false, ): Promise { const result = await this.sources.section( userId, 'bond', uid, 'metrics', observe, ); return { data: result.values.instrument ? bondMetrics(result.values, result.unavailable, result.observedAt) : null, meta: result.meta, }; } async readPosition(userId: string, uid: string) { const saved = await this.portfolios.read(userId, 'all'); return { data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, }; } async coupons( userId: string, uid: string, observe = false, ): Promise { const connection = await this.access.local(userId); const today = moscowDay(new Date().toISOString()); const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const portfolio = await this.portfolios.read(userId, 'all'); const sources = await this.sources.readMany( userId, 'bond', [uid], observe, ['detail', 'coupons'], { from, to }, ); if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) { throw new NotFoundException(); } const events = sources[1].payload?.coupons; const position = portfolio.data ? instrumentPosition(portfolio.data, uid, 'BOND') : null; const projected = events ? bondCoupons( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, ) : undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, membershipRevision: connection.membershipRevision, scopeKey: `bond/coupons/${JSON.stringify([uid, from, to])}`, portfolio: portfolio.meta, sources, candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); if (result.data?.positionAt) { result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now())); if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale'; } return result.data || !projected ? result : { data: bondCoupons( events ?? [], null, { from, to, today }, projected.observedAt, true, ), meta: { ...result.meta, completeness: 'incomplete' }, }; } } export function bondCoupons( events: TbankCoupon[], position: BondPositionDto | null, window: { from: string; to: string; today: string }, observedAt: string, positionUnavailable: boolean, ): BondCouponsDto { const { from, to, today } = window; const quantity = position?.accounts.length && position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? decimalFromString(position.quantity) : null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const perBond = money(event.payOneBond); const timing = !paymentDate ? ('undated' as const) : paymentDate >= today ? ('upcoming' as const) : ('past' as const); return { number: event.couponNumber ?? null, paymentDate, recordDate, perBond, couponType: event.couponType ?? null, periodDays: event.couponPeriod ?? null, timing, estimatedAmount: quantity && perBond && timing === 'upcoming' && recordDate && recordDate >= today ? { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), } : null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); return { coupons, from, to, observedAt: observedAt, positionAt: quantity ? (position?.observedAt ?? null) : null, estimateUnavailable: positionUnavailable, positionIsStale: position?.isStale ?? false, }; } export function bondMetrics( values: CatalogPayload, unavailable: string[], observedAt: string, ): BondMetricsDto { const instrument = values.instrument as TbankBond; const events = values.coupons; const quote = bondQuote(values, unavailable, observedAt); const effectiveYield = values.effectiveYield ?? null; const creditRating = values.creditRating ?? null; const today = moscowDay(new Date().toISOString()); const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const next = events ?.filter((event) => { const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH); return date && date >= today && date <= to; }) .sort( (a, b) => Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds), )[0]; const coupon = money(next?.payOneBond); const period = next?.couponPeriod; const couponPeriodDays = period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null; const clean = quote?.cleanPrice; const couponYieldPercent = coupon && couponPeriodDays && clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= ZERO_AMOUNT && decimalFromString(clean.value).value > ZERO_AMOUNT ? formatDecimal( divide( multiply( decimalFromString(coupon.value), decimalFromString('36500'), ), multiply( decimalFromString(String(couponPeriodDays)), decimalFromString(clean.value), ), COUPON_YIELD_DECIMAL_PLACES, ), ) : null; const couponType = next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified) ? next.couponType : instrument.floatingCouponFlag === true ? TbankCouponType.Floating : null; return { creditRating, coupon, couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, couponType, couponPeriodDays, couponYieldPercent, couponYieldPriceAt: couponYieldPercent !== null ? (quote?.priceAt ?? null) : null, effectiveYield, unavailable, observedAt, }; }