feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s

Store account membership, portfolio, operations, forecasts and market sources in SQLite.
Coalesce scoped refreshes, fence connection revisions, preserve complete financial
snapshots and stabilize operation period state.

BREAKING CHANGE: investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
This commit is contained in:
2026-09-26 21:29:15 +03:00
parent 0862d56c4e
commit d6e6c4ace5
116 changed files with 18321 additions and 5409 deletions
@@ -0,0 +1 @@
export { UpcomingEventsService } from './upcoming-events.service';
@@ -4,7 +4,7 @@ import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import {
UpcomingEventsDto,
UpcomingEventsResourceDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
import { UpcomingEventsService } from './upcoming-events.service';
@@ -18,7 +18,7 @@ export class UpcomingEventsController {
constructor(private readonly events: UpcomingEventsService) {}
@Get('all/events')
@ApiOkResponse({ type: UpcomingEventsDto })
@ApiOkResponse({ type: UpcomingEventsResourceDto })
all(
@Req() request: Request & { user: AccessPayload },
@Query() query: UpcomingEventsQueryDto,
@@ -27,7 +27,7 @@ export class UpcomingEventsController {
}
@Get(':accountId/events')
@ApiOkResponse({ type: UpcomingEventsDto })
@ApiOkResponse({ type: UpcomingEventsResourceDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
@@ -1,9 +1,13 @@
import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../investment-resource.dto';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { AccountReferenceDto } from '../account-reference.dto';
export class UpcomingEventsQueryDto {
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
@@ -72,3 +76,10 @@ export class UpcomingEventsDto {
@ApiProperty({ type: MoneyDto, isArray: true })
declare incomeTotalsByCurrency: MoneyDto[];
}
export class UpcomingEventsResourceDto {
@ApiProperty({ type: UpcomingEventsDto, nullable: true })
declare data: UpcomingEventsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -1,13 +1,15 @@
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service';
@Module({
imports: [AuthModule, TbankModule, MoexModule],
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
controllers: [UpcomingEventsController],
providers: [UpcomingEventsService],
exports: [UpcomingEventsService],
})
export class UpcomingEventsModule {}
@@ -1,434 +1,54 @@
import {
BadRequestException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../../integrations/tbank/tbank-access.service', () => ({
TbankAccessService: class TbankAccessService {},
import { dividendEvent, bondEvent } from './upcoming-events.service';
import { decimalFromString } from '../../../common/decimal';
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import type { TbankAccountContext } from '../../../integrations/tbank/tbank-access.service';
import { UpcomingEventsService } from './upcoming-events.service';
describe('UpcomingEventsService', () => {
const access = { withAccount: jest.fn() };
const tbank = {
getPortfolio: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
getDividends: jest.fn(),
};
const moex = { bondEvents: jest.fn() };
const requestContext = { set: jest.fn() };
const logger = { info: jest.fn(), event: jest.fn() };
let service: UpcomingEventsService;
beforeEach(() => {
service = new UpcomingEventsService(
access as never,
tbank as never,
moex as never,
requestContext as never,
logger as never,
);
jest.clearAllMocks();
access.withAccount.mockImplementation(
(
_userId: string,
_accountId: string,
handler: (context: TbankAccountContext) => Promise<unknown>,
) =>
handler({
token: 'token',
generation: 'generation-1',
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
}),
);
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
moex.bondEvents.mockResolvedValue([]);
tbank.getDividends.mockResolvedValue([]);
});
it('includes both selected boundaries beyond the default window and isolates dividend caches by period', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SHARE',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
{ type: 'maturity', date: '2099-03-01', value: '1000', currency: 'RUB' },
{ type: 'offer', date: '2099-01-31' },
]);
tbank.getDividends.mockImplementation(
(_token: string, _id: string, from: string) =>
Promise.resolve([
{
dividendNet: { units: 5, currency: 'RUB' },
recordDate: {
seconds: String(Date.parse(`${from}T00:00:00Z`) / 1000),
},
},
]),
);
const february = { from: '2099-02-01', to: '2099-02-28' };
const first = await service.get('user', 'account', february);
expect(first.events.map(({ type, date }) => ({ type, date }))).toEqual([
{ type: 'coupon', date: february.from },
{ type: 'dividend', date: february.from },
{ type: 'offer', date: february.to },
]);
expect(first.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '16' },
]);
const march = await service.get('user', 'account', {
from: '2099-03-01',
to: '2099-03-01',
});
expect(march.events.map((event) => event.type)).toEqual([
'dividend',
'maturity',
]);
expect(march.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '10' },
]);
await service.get('user', 'account', february);
expect(tbank.getDividends).toHaveBeenCalledTimes(2);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
1,
'token',
'S',
february.from,
february.to,
);
expect(tbank.getDividends).toHaveBeenNthCalledWith(
2,
'token',
'S',
'2099-03-01',
'2099-03-01',
);
});
it('defaults the end to 30 days after the selected start', async () => {
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: '2099-01-01' },
{ type: 'offer', date: '2099-01-31' },
{ type: 'offer', date: '2099-02-01' },
]);
const result = await service.get('user', 'account', { from: '2099-01-01' });
expect(result.events.map((event) => event.date)).toEqual([
'2099-01-01',
'2099-01-31',
]);
});
it('projects exact amounts and preserves registry dates and offers', async () => {
const now = new Date();
const day = (offset: number) =>
new Date(
Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate() + offset,
),
)
.toISOString()
.slice(0, 10);
tbank.getInstrumentByFigi.mockResolvedValue({
name: 'Бумага',
isin: ' RU000A0JX0J2 ',
brand: { logoName: ' instrument-logo.png ' },
});
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 250000000, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${day(1)}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
moex.bondEvents.mockResolvedValue([
{ type: 'offer', date: day(2) },
{ type: 'coupon', date: day(1), value: '2.5', currency: 'RUB' },
{ type: 'maturity', date: day(31), value: '1000', currency: 'RUB' },
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
type: 'coupon',
projectedAmount: { currency: 'RUB', value: '7.5' },
dateKind: 'event',
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2.5' },
dateKind: 'registry',
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
{
type: 'offer',
projectedAmount: null,
isin: 'RU000A0JX0J2',
logoName: 'instrument-logo.png',
},
],
const metadata = {
figi: 'figi',
instrumentUid: 'uid',
ticker: 'TEST',
displayName: 'Synthetic',
isin: null,
logoName: null,
};
describe('forecast financial projections', () => {
it('preserves registry dates, source currency and exact signed fractional quantities', () => {
expect(
dividendEvent(metadata, decimalFromString('-2.5'), {
recordDate: { seconds: String(Date.parse('2026-10-13Z') / 1000) },
dividendNet: { currency: 'usd', units: '10', nano: 1 },
}),
).toMatchObject({
date: '2026-10-13',
dateKind: 'registry',
projectedAmount: { currency: 'USD', value: '-25.0000000025' },
});
});
it.each([undefined, ' '])(
'keeps events without optional metadata when it is %s',
async (metadata) => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
instrumentUid: 'bond-uid',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
tbank.getInstrumentByUid.mockResolvedValue({
name: 'Bond',
isin: metadata,
brand: { logoName: metadata },
});
moex.bondEvents.mockResolvedValue([{ type: 'offer', date: today }]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'offer', isin: null, logoName: null }],
});
},
);
it('does not read upstream data for inaccessible accounts', async () => {
access.withAccount.mockRejectedValueOnce(new NotFoundException());
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
it('does not invent an amount for offers or missing currency', () => {
expect(
bondEvent(metadata, decimalFromString('3'), {
type: 'offer',
date: '2026-10-01',
}).projectedAmount,
).toBeNull();
expect(
dividendEvent(metadata, decimalFromString('3'), {
recordDate: { seconds: '1790812800' },
dividendNet: { units: '10' },
}).projectedAmount,
).toBeNull();
});
it('uses the portfolio ticker without an unnecessary instrument lookup', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
it('preserves redemption as a non-income event with its exact amount', () => {
expect(
bondEvent(metadata, decimalFromString('2.5'), {
type: 'maturity',
date: '2026-10-01',
currency: 'rub',
value: '500.000000001',
}),
).toMatchObject({
type: 'maturity',
projectedAmount: { currency: 'RUB', value: '1250.0000000025' },
});
tbank.getInstrumentByFigi.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
ticker: 'SBER',
projectedAmount: { currency: 'RUB', value: '1' },
isin: null,
logoName: null,
},
],
});
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
});
it('skips cancelled dividends', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Cancelled',
},
{
dividendNet: { units: 2, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [
{
type: 'dividend',
projectedAmount: { currency: 'RUB', value: '2' },
},
],
});
});
it('does not invent projected money when the source omits currency', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0 },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ type: 'dividend', projectedAmount: null }],
});
});
it('adds exact source-currency income totals without changing the events list', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 1 },
},
],
});
moex.bondEvents.mockResolvedValue([
{ type: 'coupon', date: today, value: '1.25', currency: 'RUB' },
{ type: 'maturity', date: today, value: '100', currency: 'RUB' },
{ type: 'offer', date: today },
]);
const result = await service.get('user', 'account');
expect(result.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '1.25' },
]);
expect(result.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: 'maturity' }),
expect.objectContaining({ type: 'offer' }),
]),
);
});
it('reuses upstream results within the cache TTL across requests', async () => {
const today = new Date().toISOString().slice(0, 10);
tbank.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 1 },
},
],
});
tbank.getDividends.mockResolvedValue([
{
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
recordDate: {
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
},
dividendType: 'Regular Cash',
},
]);
await service.get('user', 'account');
await service.get('user', 'account');
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
expect(tbank.getDividends).toHaveBeenCalledTimes(1);
});
it('preserves invalid token and source failures without a partial result', async () => {
access.withAccount.mockRejectedValueOnce(
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(BadRequestException),
);
tbank.getPortfolio.mockRejectedValueOnce(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
});
});
@@ -1,5 +1,5 @@
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
import { BadRequestException, Injectable } from '@nestjs/common';
import { MILLISECONDS_PER_SECOND } from '../../../common/time';
import {
add,
decimalFromString,
@@ -7,285 +7,250 @@ import {
isZero,
multiply,
} from '../../../common/decimal';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
import { MoneyDto } from '../../../common/money.dto';
import { MoexBondEvent, MoexClient } from '../../../integrations/moex/moex.api';
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import { decimal } from '../../../integrations/tbank/decimal';
import {
TbankAccessService,
TbankClient,
TbankDividend,
TbankInstrument,
TbankPortfolio,
InstrumentMetadataService,
metadataKey,
type TbankDividend,
} from '../../../integrations/tbank/tbank.api';
import {
PortfolioService,
ForecastService,
type PortfolioPositionDto,
} from '../portfolio/portfolio.api';
import { CatalogEnrichmentService } from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
UpcomingEventDto,
UpcomingEventsDto,
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
const DEFAULT_EVENT_WINDOW_DAYS = 30;
type Position = NonNullable<TbankPortfolio['positions']>[number];
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
const positionsBatchSize = 5;
const dividendCacheTtlMs = 900_000;
const instrumentCacheTtlMs = 86_400_000;
const calendarReadBatchSize = 100;
type Position = PortfolioPositionDto;
@Injectable()
export class UpcomingEventsService {
private readonly dividendCache = new Map<
string,
CachedEntry<TbankDividend[]>
>();
private readonly instrumentCache = new Map<
string,
CachedEntry<TbankInstrument | null>
>();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
private readonly portfolios: PortfolioService,
private readonly sources: CatalogEnrichmentService,
private readonly forecasts: ForecastService,
private readonly metadata: InstrumentMetadataService,
) {}
async get(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
) {
const window = dateWindow(query);
this.requestContext.set({ userId, brokerageAccountId: accountId });
return this.access.withAccount(userId, accountId, ({ token }) =>
this.forAccount(token, accountId, window),
);
}
async getAll(
userId: string,
query: UpcomingEventsQueryDto = {},
): Promise<UpcomingEventsDto> {
const window = dateWindow(query);
return this.access.withSupportedAccounts(
const connection = await this.access.local(userId, accountId);
const portfolio = await this.portfolios.read(
userId,
async ({ token, accounts }) => {
const events: (UpcomingEventDto & {
account: { id: string; name: string };
})[] = [];
for (const account of accounts) {
const result = await this.forAccount(token, account.id, window);
events.push(
...result.events.map((event) => ({
...event,
account: { id: account.id, name: account.name },
})),
);
}
events.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.account.id.localeCompare(right.account.id),
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 { uid: string; ticker: string } =>
Boolean(entry.uid),
);
return {
accountId: 'all',
const uids = [...new Set(identities.map((entry) => entry.uid))];
for (
let offset = 0;
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: UpcomingEventsDto | undefined = ready
? {
accountId,
calculatedAt: new Date().toISOString(),
events,
incomeTotalsByCurrency: incomeTotals(events),
};
},
);
}
private async forAccount(
token: string,
accountId: string,
window: { from: string; to: string },
): Promise<UpcomingEventsDto> {
const portfolio = await this.tbank.getPortfolio(token, accountId);
const supportedPositions = (portfolio.positions ?? []).filter(supported);
const events: UpcomingEventDto[][] = [];
for (
let offset = 0;
offset < supportedPositions.length;
offset += positionsBatchSize
) {
const batch = supportedPositions.slice(
offset,
offset + positionsBatchSize,
);
events.push(
...(await Promise.all(
batch.map((position) => this.positionEvents(token, position, window)),
)),
);
}
const projected = events
.flat()
.filter((event) => event.date >= window.from && event.date <= window.to);
this.logger.info('upcoming-events.calculated', {
count: projected.length,
}
: 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,
});
return {
accountId,
calculatedAt: new Date().toISOString(),
events: projected.sort(
(left, right) =>
left.date.localeCompare(right.date) ||
left.type.localeCompare(right.type) ||
left.ticker.localeCompare(right.ticker),
),
incomeTotalsByCurrency: incomeTotals(projected),
};
}
private async positionEvents(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim();
const instrument = ticker
? await this.instrument(token, position).catch(() => {
this.logger.event('warn', 'upcoming-events.metadata-unavailable');
return null;
})
: await this.instrument(token, position);
const resolvedTicker = ticker || instrument?.ticker?.trim();
if (!resolvedTicker) return [];
const metadata = {
figi: position.figi?.trim() || null,
instrumentUid: position.instrumentUid?.trim() || null,
ticker: resolvedTicker,
displayName: instrument?.name?.trim() || resolvedTicker,
isin: instrument?.isin?.trim() || null,
logoName: instrument?.brand?.logoName?.trim() || null,
};
const quantity = decimal(position.quantity ?? {});
if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.dividends(token, position, window);
this.logger.event('debug', 'upcoming-events.source-result', {
count: dividends.length,
});
return dividends.map((event) => this.dividend(metadata, quantity, event));
}
const events = await this.moex.bondEvents(resolvedTicker);
this.logger.event('debug', 'upcoming-events.source-result', {
count: events.length,
});
return events.map((event) => this.bond(metadata, quantity, event));
getAll(userId: string, query: UpcomingEventsQueryDto = {}) {
return this.get(userId, 'all', query);
}
}
private async instrument(token: string, position: Position) {
const instrumentUid = position.instrumentUid?.trim();
if (instrumentUid)
return this.cached(
this.instrumentCache,
instrumentUid,
instrumentCacheTtlMs,
() => this.tbank.getInstrumentByUid(token, instrumentUid),
);
const figi = position.figi?.trim();
if (figi)
return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () =>
this.tbank.getInstrumentByFigi(token, figi),
);
return null;
}
export function dividendEvent(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND)
.toISOString()
.slice(0, 10),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null,
};
}
private async dividends(
token: string,
position: Position,
window: { from: string; to: string },
): Promise<TbankDividend[]> {
const instrumentId = position.instrumentUid ?? position.figi;
if (!instrumentId) return [];
const dividends = await this.cached(
this.dividendCache,
JSON.stringify([instrumentId, window.from, window.to]),
dividendCacheTtlMs,
() =>
this.tbank.getDividends(token, instrumentId, window.from, window.to),
);
return dividends.filter(
(event) =>
event.dividendType !== 'Cancelled' && Boolean(event.recordDate),
);
}
private async cached<T>(
cache: Map<string, CachedEntry<T>>,
key: string,
ttlMs: number,
load: () => Promise<T>,
): Promise<T> {
const now = Date.now();
const hit = cache.get(key);
if (hit && hit.expiresAt > now) return hit.promise;
const promise = load().catch((error: unknown) => {
cache.delete(key);
throw error;
});
cache.set(key, { expiresAt: now + ttlMs, promise });
return promise;
}
private dividend(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(
Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND,
)
.toISOString()
.slice(0, 10),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
export function bondEvent(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
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,
};
}
private bond(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName'
>,
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(decimal(position.quantity ?? {})) &&
['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '')
!isZero(decimalFromString(position.quantity)) &&
['STOCK', 'BOND'].includes(position.type)
);
}