Files
backend/src/modules/investments/upcoming-events/upcoming-events.service.ts
T
ksv741 2d14081d27
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s
refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 13:18:06 +03:00

309 lines
9.5 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';
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
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: EventsErrorCode.InvalidPeriod });
}
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) }));
}