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> = []; 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, 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, 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, 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>(); 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) })); }