Files
backend/src/modules/investments/instruments/bonds/application/bond-income.service.ts
T
ksv741 0057925888
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
feat: add hourly portfolio sync history
2026-10-09 09:04:41 +03:00

269 lines
8.5 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,
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<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) {
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<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');
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,
});
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, 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,
};
}