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,
};
}