Files
backend/src/modules/investments/instruments/stocks/application/stock-income.service.ts
T

174 lines
5.7 KiB
TypeScript

import { Injectable, NotFoundException } from '@nestjs/common';
import {
decimalFromString,
formatDecimal,
multiply,
} from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/decimal';
import {
TbankAccessService,
type TbankDividend,
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
PortfolioService,
} from '../../../portfolio/portfolio.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position';
import {
StockDividendDto,
StockDividendsDto,
StockDividendsResourceDto,
StockPositionDto,
} from '../presentation/stock.dto';
const INCOME_FORECAST_YEARS = 1;
const ZERO_QUANTITY = 0n;
const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5;
@Injectable()
export class StockIncomeService {
constructor(
private readonly portfolios: PortfolioService,
private readonly sources: CatalogEnrichmentService,
private readonly access: TbankAccessService,
private readonly forecasts: ForecastService,
) {}
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, 'STOCK') : null,
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
};
}
async dividends(
userId: string,
uid: string,
observe = false,
): Promise<StockDividendsResourceDto> {
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,
'share',
[uid],
observe,
['detail', 'dividends'],
{ from, to },
);
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
throw new NotFoundException();
}
const events = sources[1].payload?.dividends;
const position = portfolio.data ?
instrumentPosition(portfolio.data, uid, 'STOCK') :
null;
const projected = events ?
stockDividends(
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: `stock/dividends/${JSON.stringify([uid, from, to])}`,
portfolio: portfolio.meta,
sources,
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
return result.data || !projected ?
result :
{
data: stockDividends(
events ?? [],
null,
{ from, to, today },
projected.observedAt,
true,
),
meta: { ...result.meta, availability: 'partial' },
};
}
}
export function stockDividends(
events: TbankDividend[],
position: null | StockPositionDto,
window: { from: string; to: string; today: string },
observedAt: string,
positionUnavailable: boolean,
): StockDividendsDto {
const { from, to, today } = window;
const quantity =
position &&
position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
decimalFromString(position.quantity) :
null;
const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => {
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase();
const perShare =
currency && event.dividendNet ?
{ currency, value: formatDecimal(decimal(event.dividendNet)) } :
null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate ?
eventDate >= today ?
'upcoming' :
'past' :
'undated';
const estimatedAmount =
quantity &&
perShare &&
recordDate &&
recordDate >= today &&
timing === 'upcoming' ?
{
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
} :
null;
return {
perShare,
recordDate,
lastBuyDate,
paymentDate,
timing,
estimatedAmount,
};
})
.sort((a, b) => (b.recordDate ?? '').localeCompare(a.recordDate ?? ''));
return {
dividends,
from,
to,
observedAt: observedAt,
positionAt: quantity ? (position?.observedAt ?? null) : null,
estimateUnavailable: positionUnavailable,
positionIsStale: position?.isStale ?? false,
};
}