feat: add агегку events
This commit is contained in:
@@ -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'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user