174 lines
5.7 KiB
TypeScript
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,
|
|
};
|
|
}
|