269 lines
8.5 KiB
TypeScript
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.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,
|
|
};
|
|
}
|