Files
backend/src/modules/investments/upcoming-events/upcoming-events.service.ts
T

307 lines
9.4 KiB
TypeScript

import { BadRequestException, Injectable } from '@nestjs/common';
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from '../../../common/decimal';
import { MoneyDto } from '../../../common/money.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
import { decimal } from '../../../integrations/tbank/decimal';
import {
InstrumentMetadataService,
metadataKey,
TbankAccessService,
type TbankDividend,
} from '../../../integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
ForecastService,
type PortfolioPositionDto,
PortfolioService,
} from '../portfolio/portfolio.api';
import {
UpcomingEventDto,
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
const FIRST_BATCH_OFFSET = 0;
const DEFAULT_EVENT_WINDOW_DAYS = 30;
const calendarReadBatchSize = 100;
type Position = PortfolioPositionDto;
@Injectable()
export class UpcomingEventsService {
constructor(
private readonly access: TbankAccessService,
private readonly portfolios: PortfolioService,
private readonly sources: CatalogEnrichmentService,
private readonly forecasts: ForecastService,
private readonly metadata: InstrumentMetadataService,
) {}
async get(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
) {
const window = dateWindow(query);
const connection = await this.access.local(userId, accountId);
const portfolio = await this.portfolios.read(
userId,
accountId,
query.sync === 'observe',
);
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const events: UpcomingEventDto[] = [];
let ready = portfolio.data !== null;
const positions = portfolio.data?.positions.filter(supported) ?? [];
const metadata = await this.metadata.savedMany(positions);
for (const type of ['share', 'bond'] as const) {
const identities = positions
.filter(
(position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'),
)
.map((position) => ({
uid:
position.instrumentUid ||
metadata.get(metadataKey(position))?.data?.uid,
ticker:
position.ticker ||
metadata.get(metadataKey(position))?.data?.ticker ||
'',
}))
.filter((entry): entry is { ticker: string; uid: string } =>
Boolean(entry.uid),
);
const uids = [...new Set(identities.map((entry) => entry.uid))];
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += calendarReadBatchSize
) {
sources.push(
...(await this.sources.readMany(
userId,
type,
uids.slice(offset, offset + calendarReadBatchSize),
query.sync === 'observe',
['detail', type === 'share' ? 'dividends' : 'bond-events'],
{
...window,
tickers: Object.fromEntries(
identities.map((entry) => [entry.uid, entry.ticker]),
),
},
)),
);
}
}
for (const position of positions) {
const generic = metadata.get(metadataKey(position))?.data;
const uid = position.instrumentUid || generic?.uid;
if (!uid) {
ready = false;
sources.push({
input: { uid: '', instrumentType: 'share', kind: 'detail' },
version: null,
payload: undefined,
instrument: null,
price: null,
meta: investmentResourceMeta({
resourceKey: `events/identity/${position.figi ?? ''}`,
dataRevision: '0',
availability: 'missing',
fetchedAt: null,
freshForMs: 0,
work: null,
}),
});
continue;
}
const type = position.type === 'STOCK' ? 'share' : 'bond';
const calendar = sources.find(
(source) =>
source.input.uid === uid &&
source.input.kind ===
(type === 'share' ? 'dividends' : 'bond-events'),
);
const instrument =
sources.find(
(source) =>
source.input.uid === uid && source.input.kind === 'detail',
)?.instrument || generic;
const ticker = position.ticker || instrument?.ticker;
if (!calendar || calendar.meta.availability !== 'complete' || !ticker) {
ready = false;
continue;
}
const identity = {
figi: position.figi || null,
instrumentUid: uid,
ticker,
displayName: instrument?.name?.trim() || position.displayName || ticker,
isin: instrument?.isin || position.isin || null,
logoName: instrument?.brand?.logoName || position.logoName || null,
};
const quantity = decimalFromString(position.quantity);
const projected =
type === 'share'
? (calendar.payload?.dividends ?? [])
.filter(
(event) =>
event.dividendType?.toLowerCase() !== 'cancelled' &&
Boolean(event.recordDate),
)
.map((event) => dividendEvent(identity, quantity, event))
: (calendar.payload?.bondEvents ?? []).map((event) =>
bondEvent(identity, quantity, event),
);
events.push(
...projected
.filter(
(event) => event.date >= window.from && event.date <= window.to,
)
.map((event) => ({
...event,
...(accountId === 'all' && position.account
? { account: position.account }
: {}),
})),
);
}
events.sort(
(a, b) =>
a.date.localeCompare(b.date) ||
(a.account?.id ?? '').localeCompare(b.account?.id ?? '') ||
a.type.localeCompare(b.type) ||
a.ticker.localeCompare(b.ticker),
);
const candidate: undefined | UpcomingEventsDto = ready
? {
accountId,
calculatedAt: new Date().toISOString(),
events,
incomeTotalsByCurrency: incomeTotals(events),
}
: undefined;
return this.forecasts.result({
userId,
revision: connection.revision,
membershipRevision: connection.membershipRevision,
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
portfolio: portfolio.meta,
sources,
candidate,
});
}
getAll(userId: string, query: UpcomingEventsQueryDto = {}) {
return this.get(userId, 'all', query);
}
}
export function dividendEvent(
metadata: Pick<
UpcomingEventDto,
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND)
.toISOString()
.slice(ISO_DATE_START, ISO_DATE_LENGTH),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null,
};
}
export function bondEvent(
metadata: Pick<
UpcomingEventDto,
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
>,
quantity: ReturnType<typeof decimal>,
event: MoexBondEvent,
): UpcomingEventDto {
return {
...metadata,
type: event.type,
date: event.date,
dateKind: 'event',
projectedAmount:
'value' in event
? projectedMoney(quantity, event.value, event.currency)
: null,
};
}
function supported(position: Position): boolean {
return (
!isZero(decimalFromString(position.quantity)) &&
['BOND', 'STOCK'].includes(position.type)
);
}
function dateWindow(query: UpcomingEventsQueryDto) {
const from = query.from ?? new Date().toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
const end = new Date(`${from}T00:00:00.000Z`);
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
if (from > to) {
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
}
return { from, to };
}
function projectedMoney(
quantity: ReturnType<typeof decimal>,
value: string,
currency: string | undefined,
): MoneyDto | null {
const normalizedCurrency = currency?.trim().toUpperCase();
if (!normalizedCurrency) return null;
return {
currency: normalizedCurrency,
value: formatDecimal(multiply(quantity, decimalFromString(value))),
};
}
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimal>>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
continue;
}
const current = totals.get(event.projectedAmount.currency) ?? {
value: 0n,
scale: 0,
};
totals.set(
event.projectedAmount.currency,
add(current, decimalFromString(event.projectedAmount.value)),
);
}
return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}