feat: add агегку events

This commit is contained in:
2026-08-14 15:46:26 +03:00
parent 326fb67def
commit 34c2b79e27
10 changed files with 635 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
import { MoexClient } from './moex.client';
describe('MoexClient', () => {
it('accepts normal ISS blocks when the response omits cursor blocks', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
dividends: {
columns: ['registryclosedate', 'value', 'currencyid'],
data: [['2026-08-20', '2.5', 'SUR']],
},
}),
} as Response);
await expect(new MoexClient().dividends('SBER')).resolves.toEqual([
{ date: '2026-08-20', value: '2.5', currency: 'RUB' },
]);
fetchMock.mockRestore();
});
it('treats a security absent from MOEX as having no known events', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 404,
} as Response);
await expect(new MoexClient().dividends('UNKNOWN')).resolves.toEqual([]);
fetchMock.mockRestore();
});
});
+156
View File
@@ -0,0 +1,156 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000;
export type MoexDividend = {
date: string;
value: string;
currency: string;
};
export type MoexBondEvent =
| { type: 'coupon'; date: string; value: string; currency: string }
| { type: 'maturity'; date: string; value: string; currency: string }
| { type: 'offer'; date: string };
type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>;
@Injectable()
export class MoexClient {
async dividends(secid: string): Promise<MoexDividend[]> {
const rows = await this.rows(
`securities/${encodeURIComponent(secid)}/dividends.json`,
'dividends',
);
return rows.flatMap((row) => {
const date = text(row.registryclosedate);
const value = numberText(row.value);
const currency = currencyText(row.currencyid);
return date && value && currency ? [{ date, value, currency }] : [];
});
}
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([
this.rows(path, 'coupons'),
this.rows(path, 'offers'),
this.rows(
`securities/${encodeURIComponent(secid)}.json`,
'description',
false,
),
]);
const events: MoexBondEvent[] = [];
for (const row of coupons) {
const date = text(row.coupondate);
const value = numberText(row.value);
const currency = currencyText(row.faceunit);
if (date && value && currency)
events.push({ type: 'coupon', date, value, currency });
}
for (const row of offers) {
const date = text(row.offerdate);
if (date) events.push({ type: 'offer', date });
}
const values = new Map(
description.map((row) => [text(row.name), text(row.value)]),
);
const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency)
events.push({ type: 'maturity', date, value, currency });
return events;
}
private async rows(
path: string,
block: string,
paginated = true,
): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
let start = 0;
for (;;) {
const query = new URLSearchParams({
'iss.meta': 'off',
'iss.only': `${block},${block}.cursor`,
start: String(start),
});
const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) return rows;
const current = parseRows(response[block]);
rows.push(...current);
if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) {
if (current.length < 100) return rows;
start += 100;
continue;
}
const cursor = parseRows(cursorBlock)[0];
const index = integer(cursor?.index);
const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize);
if (
index === null ||
total === null ||
pageSize === null ||
index + pageSize >= total
)
return rows;
start = index + pageSize;
}
}
private async request(url: string): Promise<IssResponse | null> {
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(timeoutMs),
});
if (response.status === 404) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json();
if (!body || typeof body !== 'object')
throw new Error('MOEX malformed response');
return body as IssResponse;
} catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
}
}
}
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block.data))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
const columns = block.columns;
if (!columns.every((column) => typeof column === 'string'))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
return block.data.map((values) => {
if (!Array.isArray(values))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
return Object.fromEntries(
columns.map((column, index) => [column.toLowerCase(), values[index]]),
);
});
}
function text(value: unknown): string | null {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function numberText(value: unknown): string | null {
const result = typeof value === 'number' ? String(value) : text(value);
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
}
function currencyText(value: unknown): string | null {
const result = text(value)?.toUpperCase();
return result === 'SUR' ? 'RUB' : (result ?? null);
}
function integer(value: unknown): number | null {
const result = Number(value);
return Number.isSafeInteger(result) && result >= 0 ? result : null;
}
+6
View File
@@ -3,11 +3,14 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service';
import { BrokerageOperationsController } from './brokerage-operations.controller';
import { BrokerageOperationsService } from './brokerage-operations.service';
import { MoexClient } from './moex.client';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { TbankClient } from './tbank.client';
import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service';
import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service';
@Module({
controllers: [
@@ -15,6 +18,7 @@ import { TbankConnectionService } from './tbank-connection.service';
BrokerageAccountsController,
BrokerageOperationsController,
PortfolioController,
UpcomingEventsController,
],
providers: [
TbankClient,
@@ -22,6 +26,8 @@ import { TbankConnectionService } from './tbank-connection.service';
BrokerageAccountsService,
BrokerageOperationsService,
PortfolioService,
MoexClient,
UpcomingEventsService,
],
})
export class TbankConnectionModule {}
+1
View File
@@ -40,6 +40,7 @@ export type TbankPortfolio = {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
@@ -0,0 +1,14 @@
jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { UpcomingEventsController } from './upcoming-events.controller';
describe('UpcomingEventsController', () => {
it('passes the authenticated user and account to the service', async () => {
const events = { get: jest.fn() };
const controller = new UpcomingEventsController(events as never);
await controller.get({ user: { sub: 'user' } } as never, 'account');
expect(events.get).toHaveBeenCalledWith('user', 'account');
});
});
+26
View File
@@ -0,0 +1,26 @@
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { AccessPayload } from '../auth/auth.types';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator';
import { UpcomingEventsDto } from './upcoming-events.dto';
import { UpcomingEventsService } from './upcoming-events.service';
@ApiTags('brokerage-accounts')
@ApiProblemResponses()
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('brokerage-accounts')
export class UpcomingEventsController {
constructor(private readonly events: UpcomingEventsService) {}
@Get(':accountId/events')
@ApiOkResponse({ type: UpcomingEventsDto })
get(
@Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string,
) {
return this.events.get(request.user.sub, accountId);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { ApiProperty } from '@nestjs/swagger';
export class UpcomingEventDto {
@ApiProperty({ enum: ['coupon', 'dividend', 'maturity', 'offer'] })
type: 'coupon' | 'dividend' | 'maturity' | 'offer';
@ApiProperty({ format: 'date' })
date: string;
@ApiProperty({ enum: ['event', 'registry'] })
dateKind: 'event' | 'registry';
@ApiProperty({ type: String, nullable: true })
figi: string | null;
@ApiProperty({ type: String, nullable: true })
instrumentUid: string | null;
@ApiProperty()
ticker: string;
@ApiProperty()
displayName: string;
@ApiProperty({ type: String, nullable: true })
currency: string | null;
@ApiProperty({ type: String, nullable: true })
amount: string | null;
}
export class UpcomingEventsDto {
@ApiProperty()
accountId: string;
@ApiProperty({ format: 'date-time' })
calculatedAt: string;
@ApiProperty({ type: UpcomingEventDto, isArray: true })
events: UpcomingEventDto[];
}
+139
View File
@@ -0,0 +1,139 @@
import {
BadRequestException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import { UpcomingEventsService } from './upcoming-events.service';
import { TbankCrypto } from './tbank.crypto';
describe('UpcomingEventsService', () => {
const key = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const config = { getOrThrow: jest.fn(() => key) };
const tbank = {
getOpenAccounts: jest.fn(),
getPortfolio: jest.fn(),
getInstrumentByUid: jest.fn(),
getInstrumentByFigi: jest.fn(),
};
const moex = { dividends: jest.fn(), bondEvents: jest.fn() };
const service = new UpcomingEventsService(
prisma as never,
config as never,
tbank as never,
moex as never,
);
const crypto = TbankCrypto.fromBase64(key);
beforeEach(() => {
jest.clearAllMocks();
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: crypto.encrypt('token'),
});
tbank.getOpenAccounts.mockResolvedValue([
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
]);
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
moex.dividends.mockResolvedValue([]);
moex.bondEvents.mockResolvedValue([]);
});
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.getPortfolio.mockResolvedValue({
positions: [
{
figi: 'S',
ticker: 'SBER',
instrumentType: 'share',
quantity: { units: 2 },
},
{
figi: 'B',
ticker: 'BOND',
instrumentType: 'bond',
quantity: { units: 3 },
},
],
});
moex.dividends.mockResolvedValue([
{ date: day(1), value: '1.25', currency: 'RUB' },
]);
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', amount: '7.5', dateKind: 'event' },
{ type: 'dividend', amount: '2.5', dateKind: 'registry' },
{ type: 'offer', amount: null },
],
});
});
it('does not read upstream data for inaccessible accounts', async () => {
tbank.getOpenAccounts.mockResolvedValue([]);
await expect(service.get('user', 'account')).rejects.toEqual(
expect.any(NotFoundException),
);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
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 },
},
],
});
tbank.getInstrumentByFigi.mockRejectedValue(
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
);
moex.dividends.mockResolvedValue([
{ date: today, value: '1', currency: 'RUB' },
]);
await expect(service.get('user', 'account')).resolves.toMatchObject({
events: [{ ticker: 'SBER', amount: '1' }],
});
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
});
it('preserves invalid token and source failures without a partial result', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('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),
);
});
});
+220
View File
@@ -0,0 +1,220 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../config/env.validation';
import { PrismaService } from '../prisma/prisma.service';
import { decimal, formatDecimal, isZero, multiply } from './decimal';
import { MoexBondEvent, MoexClient, MoexDividend } from './moex.client';
import { TbankClient, TbankPortfolio } from './tbank.client';
import { TbankCrypto } from './tbank.crypto';
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto';
const supportedAccounts = new Set([
'ACCOUNT_TYPE_TINKOFF',
'ACCOUNT_TYPE_TINKOFF_IIS',
'ACCOUNT_TYPE_INVEST_BOX',
'ACCOUNT_TYPE_INVEST_FUND',
]);
type Position = NonNullable<TbankPortfolio['positions']>[number];
@Injectable()
export class UpcomingEventsService {
private readonly crypto: TbankCrypto;
private readonly logger = new Logger(UpcomingEventsService.name);
constructor(
private readonly prisma: PrismaService,
config: ConfigService<Environment, true>,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
) {
this.crypto = TbankCrypto.fromBase64(
config.getOrThrow('TOKEN_ENCRYPTION_KEY'),
);
}
async get(userId: string, accountId: string): Promise<UpcomingEventsDto> {
try {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
const token = this.crypto.decrypt(connection.encryptedToken);
const accounts = await this.tbank.getOpenAccounts(token);
if (
!accounts.some(
(account) =>
account.id === accountId && supportedAccounts.has(account.type),
)
)
throw new NotFoundException();
const portfolio = await this.tbank.getPortfolio(token, accountId);
const window = dateWindow();
const events = await Promise.all(
(portfolio.positions ?? [])
.filter((position) => supported(position))
.map((position) => this.positionEvents(token, position)),
);
const projected = events
.flat()
.filter(
(event) => event.date >= window.from && event.date <= window.to,
);
this.logger.log({
event: 'upcoming_events_calculated',
accountId,
from: window.from,
to: window.to,
positionCount: (portfolio.positions ?? []).length,
eventCount: projected.length,
});
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),
),
};
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
throw error;
}
}
private async positionEvents(
token: string,
position: Position,
): Promise<UpcomingEventDto[]> {
const ticker = position.ticker?.trim();
const instrument = ticker
? await this.instrument(token, position).catch((error: unknown) => {
this.logger.warn({
event: 'upcoming_event_metadata_unavailable',
figi: position.figi?.trim() || null,
ticker,
error: error instanceof Error ? error.message : 'UNKNOWN',
});
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,
};
const quantity = decimal(position.quantity ?? {});
this.logger.debug({
event: 'upcoming_event_position_resolved',
figi: metadata.figi,
portfolioTicker: ticker || null,
resolvedTicker,
displayName: metadata.displayName,
instrumentType: position.instrumentType?.toLowerCase() || null,
});
if (position.instrumentType?.toLowerCase() === 'share') {
const dividends = await this.moex.dividends(resolvedTicker);
this.logger.debug({
event: 'upcoming_event_source_result',
ticker: resolvedTicker,
source: 'dividends',
eventCount: dividends.length,
});
return dividends.map((event) => this.dividend(metadata, quantity, event));
}
const events = await this.moex.bondEvents(resolvedTicker);
this.logger.debug({
event: 'upcoming_event_source_result',
ticker: resolvedTicker,
source: 'bondization',
eventCount: events.length,
});
return events.map((event) => this.bond(metadata, quantity, event));
}
private async instrument(token: string, position: Position) {
if (position.instrumentUid)
return this.tbank.getInstrumentByUid(token, position.instrumentUid);
if (position.figi)
return this.tbank.getInstrumentByFigi(token, position.figi);
return null;
}
private dividend(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName'
>,
quantity: ReturnType<typeof decimal>,
event: MoexDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: event.date,
dateKind: 'registry',
currency: event.currency,
amount: amount(quantity, event.value),
};
}
private bond(
metadata: Pick<
UpcomingEventDto,
'figi' | 'instrumentUid' | 'ticker' | 'displayName'
>,
quantity: ReturnType<typeof decimal>,
event: MoexBondEvent,
): UpcomingEventDto {
return {
...metadata,
type: event.type,
date: event.date,
dateKind: 'event',
currency: 'currency' in event ? event.currency : null,
amount: 'value' in event ? amount(quantity, event.value) : null,
};
}
}
function supported(position: Position): boolean {
return (
!isZero(decimal(position.quantity ?? {})) &&
['share', 'bond'].includes(position.instrumentType?.toLowerCase() ?? '')
);
}
function dateWindow() {
const now = new Date();
const from = now.toISOString().slice(0, 10);
const end = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 30),
);
return { from, to: end.toISOString().slice(0, 10) };
}
function amount(quantity: ReturnType<typeof decimal>, value: string): string {
return formatDecimal(multiply(quantity, decimalFromString(value)));
}
function decimalFromString(value: string) {
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}