307 lines
9.4 KiB
TypeScript
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) }));
|
|
}
|