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
@@ -38,6 +38,7 @@ export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
problemResponse(400, 'Invalid request'), problemResponse(400, 'Invalid request'),
problemResponse(401, 'Authentication required'), problemResponse(401, 'Authentication required'),
problemResponse(404, 'Resource not found'), problemResponse(404, 'Resource not found'),
problemResponse(503, 'Upstream service unavailable'),
problemResponse(500, 'Internal server error'), problemResponse(500, 'Internal server error'),
); );
} }
+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 { BrokerageAccountsService } from './brokerage-accounts.service';
import { BrokerageOperationsController } from './brokerage-operations.controller'; import { BrokerageOperationsController } from './brokerage-operations.controller';
import { BrokerageOperationsService } from './brokerage-operations.service'; import { BrokerageOperationsService } from './brokerage-operations.service';
import { MoexClient } from './moex.client';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service'; import { TbankConnectionService } from './tbank-connection.service';
import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service';
@Module({ @Module({
controllers: [ controllers: [
@@ -15,6 +18,7 @@ import { TbankConnectionService } from './tbank-connection.service';
BrokerageAccountsController, BrokerageAccountsController,
BrokerageOperationsController, BrokerageOperationsController,
PortfolioController, PortfolioController,
UpcomingEventsController,
], ],
providers: [ providers: [
TbankClient, TbankClient,
@@ -22,6 +26,8 @@ import { TbankConnectionService } from './tbank-connection.service';
BrokerageAccountsService, BrokerageAccountsService,
BrokerageOperationsService, BrokerageOperationsService,
PortfolioService, PortfolioService,
MoexClient,
UpcomingEventsService,
], ],
}) })
export class TbankConnectionModule {} export class TbankConnectionModule {}
+1
View File
@@ -40,6 +40,7 @@ export type TbankPortfolio = {
figi?: string; figi?: string;
instrumentUid?: string; instrumentUid?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal; quantity?: TbankDecimal;
currentPrice?: TbankMoney; currentPrice?: TbankMoney;
averagePositionPrice?: 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,
};
}