feat: add агегку events
This commit is contained in:
@@ -38,6 +38,7 @@ export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
|
||||
problemResponse(400, 'Invalid request'),
|
||||
problemResponse(401, 'Authentication required'),
|
||||
problemResponse(404, 'Resource not found'),
|
||||
problemResponse(503, 'Upstream service unavailable'),
|
||||
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 { 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 {}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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