Files
backend/src/modules/investments/instruments/bonds/application/bond-income.service.ts
T
ksv741 2d14081d27
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s
refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 13:18:06 +03:00

264 lines
8.2 KiB
TypeScript

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,
} 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<BondMetricsResourceDto> {
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, observe = false) {
const saved = await this.portfolios.read(userId, 'all', observe);
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<BondCouponsResourceDto> {
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', observe);
const sources = await this.sources.readMany(
userId,
'bond',
[uid],
observe,
['detail', 'coupons'],
{ from, to },
);
if (sources[0].meta.availability === '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,
});
return result.data || !projected
? result
: {
data: bondCoupons(
events ?? [],
null,
{ from, to, today },
projected.observedAt,
true,
),
meta: { ...result.meta, availability: 'partial' },
};
}
}
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,
};
}