refactor: refactor future event list
This commit is contained in:
@@ -1,31 +1,17 @@
|
|||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
|
|
||||||
describe('MoexClient', () => {
|
describe('MoexClient', () => {
|
||||||
it('accepts normal ISS blocks when the response omits cursor blocks', async () => {
|
it('treats a successful response without the requested block as no events', async () => {
|
||||||
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: () =>
|
json: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
dividends: {
|
description: { columns: ['name', 'value'], data: [] },
|
||||||
columns: ['registryclosedate', 'value', 'currencyid'],
|
boards: { columns: ['secid'], data: [] },
|
||||||
data: [['2026-08-20', '2.5', 'SUR']],
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
} as Response);
|
} as Response);
|
||||||
|
|
||||||
await expect(new MoexClient().dividends('SBER')).resolves.toEqual([
|
await expect(new MoexClient().bondEvents('BOND')).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();
|
fetchMock.mockRestore();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
|||||||
const baseUrl = 'https://iss.moex.com/iss';
|
const baseUrl = 'https://iss.moex.com/iss';
|
||||||
const timeoutMs = 10_000;
|
const timeoutMs = 10_000;
|
||||||
|
|
||||||
export type MoexDividend = {
|
|
||||||
date: string;
|
|
||||||
value: string;
|
|
||||||
currency: string;
|
|
||||||
};
|
|
||||||
export type MoexBondEvent =
|
export type MoexBondEvent =
|
||||||
| { type: 'coupon'; date: string; value: string; currency: string }
|
| { type: 'coupon'; date: string; value: string; currency: string }
|
||||||
| { type: 'maturity'; date: string; value: string; currency: string }
|
| { type: 'maturity'; date: string; value: string; currency: string }
|
||||||
@@ -18,19 +13,6 @@ type IssResponse = Record<string, IssBlock | undefined>;
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MoexClient {
|
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[]> {
|
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
|
||||||
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
|
||||||
const [coupons, offers, description] = await Promise.all([
|
const [coupons, offers, description] = await Promise.all([
|
||||||
@@ -122,7 +104,8 @@ export class MoexClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||||
if (!Array.isArray(block?.columns) || !Array.isArray(block.data))
|
if (!block) return [];
|
||||||
|
if (!Array.isArray(block.columns) || !Array.isArray(block.data))
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||||
const columns = block.columns;
|
const columns = block.columns;
|
||||||
if (!columns.every((column) => typeof column === 'string'))
|
if (!columns.every((column) => typeof column === 'string'))
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ describe('PortfolioService', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
tbank.getInstrumentByFigi
|
tbank.getInstrumentByFigi
|
||||||
.mockResolvedValueOnce({ ticker: 'ONE', name: 'Первая позиция', isin: 'RU000A0JX0J2' })
|
.mockResolvedValueOnce({
|
||||||
|
ticker: 'ONE',
|
||||||
|
name: 'Первая позиция',
|
||||||
|
isin: 'RU000A0JX0J2',
|
||||||
|
})
|
||||||
.mockRejectedValueOnce(new Error('unavailable'));
|
.mockRejectedValueOnce(new Error('unavailable'));
|
||||||
|
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||||
|
|||||||
@@ -237,7 +237,11 @@ export class PortfolioService {
|
|||||||
) {
|
) {
|
||||||
const lookup = async (
|
const lookup = async (
|
||||||
key: string,
|
key: string,
|
||||||
fetch: () => Promise<{ ticker?: string; name?: string; isin?: string } | null>,
|
fetch: () => Promise<{
|
||||||
|
ticker?: string;
|
||||||
|
name?: string;
|
||||||
|
isin?: string;
|
||||||
|
} | null>,
|
||||||
) => {
|
) => {
|
||||||
const cached = this.metadata.get(key);
|
const cached = this.metadata.get(key);
|
||||||
if (cached && Date.now() - cached.storedAt <= metadataForMs)
|
if (cached && Date.now() - cached.storedAt <= metadataForMs)
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ describe('TbankClient operations', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes a GetDividends request to InstrumentsService', async () => {
|
||||||
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
|
const call = jest.fn().mockResolvedValue({
|
||||||
|
dividends: [{ dividendType: 'Regular Cash' }],
|
||||||
|
});
|
||||||
|
(client as unknown as { call: jest.Mock }).call = call;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
client.getDividends('token', 'uid', '2026-08-19', '2026-09-18'),
|
||||||
|
).resolves.toEqual([{ dividendType: 'Regular Cash' }]);
|
||||||
|
expect(call).toHaveBeenCalledWith('token', undefined, 'getDividends', {
|
||||||
|
instrumentId: 'uid',
|
||||||
|
from: {
|
||||||
|
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('propagates the normalized upstream error', async () => {
|
it('propagates the normalized upstream error', async () => {
|
||||||
const client = Object.create(TbankClient.prototype) as TbankClient;
|
const client = Object.create(TbankClient.prototype) as TbankClient;
|
||||||
const error = new ServiceUnavailableException({
|
const error = new ServiceUnavailableException({
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ export type TbankPortfolio = {
|
|||||||
};
|
};
|
||||||
export type TbankPositions = { money?: TbankMoney[] };
|
export type TbankPositions = { money?: TbankMoney[] };
|
||||||
export type TbankInstrument = { ticker?: string; name?: string; isin?: string };
|
export type TbankInstrument = { ticker?: string; name?: string; isin?: string };
|
||||||
|
export type TbankDividend = {
|
||||||
|
dividendNet?: TbankMoney;
|
||||||
|
recordDate?: TbankTimestamp;
|
||||||
|
dividendType?: string;
|
||||||
|
};
|
||||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||||
export type TbankOperationItem = {
|
export type TbankOperationItem = {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
@@ -229,6 +234,21 @@ export class TbankClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDividends(
|
||||||
|
token: string,
|
||||||
|
instrumentId: string,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): Promise<TbankDividend[]> {
|
||||||
|
return this.call(token, this.instruments, 'getDividends', {
|
||||||
|
instrumentId,
|
||||||
|
from: { seconds: String(utcSeconds(from)) },
|
||||||
|
to: { seconds: String(utcSeconds(to)) },
|
||||||
|
}).then(
|
||||||
|
(response: { dividends?: TbankDividend[] }) => response.dividends ?? [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async call<Response>(
|
private async call<Response>(
|
||||||
token: string,
|
token: string,
|
||||||
client: TbankClientService,
|
client: TbankClientService,
|
||||||
@@ -307,3 +327,7 @@ export class TbankClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function utcSeconds(date: string): number {
|
||||||
|
return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,21 +20,23 @@ describe('UpcomingEventsService', () => {
|
|||||||
getPortfolio: jest.fn(),
|
getPortfolio: jest.fn(),
|
||||||
getInstrumentByUid: jest.fn(),
|
getInstrumentByUid: jest.fn(),
|
||||||
getInstrumentByFigi: jest.fn(),
|
getInstrumentByFigi: jest.fn(),
|
||||||
|
getDividends: jest.fn(),
|
||||||
};
|
};
|
||||||
const moex = { dividends: jest.fn(), bondEvents: jest.fn() };
|
const moex = { bondEvents: jest.fn() };
|
||||||
const requestContext = { set: jest.fn() };
|
const requestContext = { set: jest.fn() };
|
||||||
const logger = { info: jest.fn(), event: jest.fn() };
|
const logger = { info: jest.fn(), event: jest.fn() };
|
||||||
const service = new UpcomingEventsService(
|
|
||||||
prisma as never,
|
|
||||||
config as never,
|
|
||||||
tbank as never,
|
|
||||||
moex as never,
|
|
||||||
requestContext as never,
|
|
||||||
logger as never,
|
|
||||||
);
|
|
||||||
const crypto = TbankCrypto.fromBase64(key);
|
const crypto = TbankCrypto.fromBase64(key);
|
||||||
|
let service: UpcomingEventsService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
service = new UpcomingEventsService(
|
||||||
|
prisma as never,
|
||||||
|
config as never,
|
||||||
|
tbank as never,
|
||||||
|
moex as never,
|
||||||
|
requestContext as never,
|
||||||
|
logger as never,
|
||||||
|
);
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
prisma.tbankConnection.findUnique.mockResolvedValue({
|
||||||
encryptedToken: crypto.encrypt('token'),
|
encryptedToken: crypto.encrypt('token'),
|
||||||
@@ -43,8 +45,8 @@ describe('UpcomingEventsService', () => {
|
|||||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
|
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' },
|
||||||
]);
|
]);
|
||||||
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
|
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' });
|
||||||
moex.dividends.mockResolvedValue([]);
|
|
||||||
moex.bondEvents.mockResolvedValue([]);
|
moex.bondEvents.mockResolvedValue([]);
|
||||||
|
tbank.getDividends.mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('projects exact amounts and preserves registry dates and offers', async () => {
|
it('projects exact amounts and preserves registry dates and offers', async () => {
|
||||||
@@ -75,8 +77,14 @@ describe('UpcomingEventsService', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
moex.dividends.mockResolvedValue([
|
tbank.getDividends.mockResolvedValue([
|
||||||
{ date: day(1), value: '1.25', currency: 'RUB' },
|
{
|
||||||
|
dividendNet: { units: 1, nano: 250000000, currency: 'RUB' },
|
||||||
|
recordDate: {
|
||||||
|
seconds: String(Math.floor(Date.parse(`${day(1)}T00:00:00Z`) / 1000)),
|
||||||
|
},
|
||||||
|
dividendType: 'Regular Cash',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
moex.bondEvents.mockResolvedValue([
|
moex.bondEvents.mockResolvedValue([
|
||||||
{ type: 'offer', date: day(2) },
|
{ type: 'offer', date: day(2) },
|
||||||
@@ -116,8 +124,14 @@ describe('UpcomingEventsService', () => {
|
|||||||
tbank.getInstrumentByFigi.mockRejectedValue(
|
tbank.getInstrumentByFigi.mockRejectedValue(
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
||||||
);
|
);
|
||||||
moex.dividends.mockResolvedValue([
|
tbank.getDividends.mockResolvedValue([
|
||||||
{ date: today, value: '1', currency: 'RUB' },
|
{
|
||||||
|
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
|
||||||
|
recordDate: {
|
||||||
|
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||||
|
},
|
||||||
|
dividendType: 'Regular Cash',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||||
@@ -126,6 +140,69 @@ describe('UpcomingEventsService', () => {
|
|||||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
|
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'S');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('skips cancelled dividends', async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'S',
|
||||||
|
ticker: 'SBER',
|
||||||
|
instrumentType: 'share',
|
||||||
|
quantity: { units: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
tbank.getDividends.mockResolvedValue([
|
||||||
|
{
|
||||||
|
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
|
||||||
|
recordDate: {
|
||||||
|
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||||
|
},
|
||||||
|
dividendType: 'Cancelled',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dividendNet: { units: 2, nano: 0, currency: 'RUB' },
|
||||||
|
recordDate: {
|
||||||
|
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||||
|
},
|
||||||
|
dividendType: 'Regular Cash',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
||||||
|
events: [{ type: 'dividend', amount: '2' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reuses upstream results within the cache TTL across requests', async () => {
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
|
positions: [
|
||||||
|
{
|
||||||
|
figi: 'S',
|
||||||
|
ticker: 'SBER',
|
||||||
|
instrumentType: 'share',
|
||||||
|
quantity: { units: 1 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
tbank.getDividends.mockResolvedValue([
|
||||||
|
{
|
||||||
|
dividendNet: { units: 1, nano: 0, currency: 'RUB' },
|
||||||
|
recordDate: {
|
||||||
|
seconds: String(Math.floor(Date.parse(`${today}T00:00:00Z`) / 1000)),
|
||||||
|
},
|
||||||
|
dividendType: 'Regular Cash',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await service.get('user', 'account');
|
||||||
|
await service.get('user', 'account');
|
||||||
|
|
||||||
|
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tbank.getDividends).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves invalid token and source failures without a partial result', async () => {
|
it('preserves invalid token and source failures without a partial result', async () => {
|
||||||
tbank.getOpenAccounts.mockRejectedValueOnce(
|
tbank.getOpenAccounts.mockRejectedValueOnce(
|
||||||
new Error('INVALID_TBANK_TOKEN'),
|
new Error('INVALID_TBANK_TOKEN'),
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ import { RequestContextService } from '../common/logging/request-context.service
|
|||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { decimal, formatDecimal, isZero, multiply } from './decimal';
|
import { decimal, formatDecimal, isZero, multiply } from './decimal';
|
||||||
import { MoexBondEvent, MoexClient, MoexDividend } from './moex.client';
|
import { MoexBondEvent, MoexClient } from './moex.client';
|
||||||
import { TbankClient, TbankPortfolio } from './tbank.client';
|
import {
|
||||||
|
TbankClient,
|
||||||
|
TbankDividend,
|
||||||
|
TbankInstrument,
|
||||||
|
TbankPortfolio,
|
||||||
|
} from './tbank.client';
|
||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto';
|
import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto';
|
||||||
|
|
||||||
@@ -23,10 +28,20 @@ const supportedAccounts = new Set([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
||||||
|
type CachedEntry<T> = { expiresAt: number; promise: Promise<T> };
|
||||||
|
|
||||||
|
const positionsBatchSize = 5;
|
||||||
|
const dividendCacheTtlMs = 15 * 60_000;
|
||||||
|
const instrumentCacheTtlMs = 24 * 60 * 60_000;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UpcomingEventsService {
|
export class UpcomingEventsService {
|
||||||
private readonly crypto: TbankCrypto;
|
private readonly crypto: TbankCrypto;
|
||||||
|
private readonly dividendCache = new Map<string, CachedEntry<TbankDividend[]>>();
|
||||||
|
private readonly instrumentCache = new Map<
|
||||||
|
string,
|
||||||
|
CachedEntry<TbankInstrument | null>
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
@@ -60,11 +75,23 @@ export class UpcomingEventsService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
const portfolio = await this.tbank.getPortfolio(token, accountId);
|
const portfolio = await this.tbank.getPortfolio(token, accountId);
|
||||||
const window = dateWindow();
|
const window = dateWindow();
|
||||||
const events = await Promise.all(
|
const supportedPositions = (portfolio.positions ?? []).filter(supported);
|
||||||
(portfolio.positions ?? [])
|
const events: UpcomingEventDto[][] = [];
|
||||||
.filter((position) => supported(position))
|
for (
|
||||||
.map((position) => this.positionEvents(token, position)),
|
let offset = 0;
|
||||||
);
|
offset < supportedPositions.length;
|
||||||
|
offset += positionsBatchSize
|
||||||
|
) {
|
||||||
|
const batch = supportedPositions.slice(
|
||||||
|
offset,
|
||||||
|
offset + positionsBatchSize,
|
||||||
|
);
|
||||||
|
events.push(
|
||||||
|
...(await Promise.all(
|
||||||
|
batch.map((position) => this.positionEvents(token, position)),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
}
|
||||||
const projected = events
|
const projected = events
|
||||||
.flat()
|
.flat()
|
||||||
.filter(
|
.filter(
|
||||||
@@ -111,7 +138,7 @@ export class UpcomingEventsService {
|
|||||||
};
|
};
|
||||||
const quantity = decimal(position.quantity ?? {});
|
const quantity = decimal(position.quantity ?? {});
|
||||||
if (position.instrumentType?.toLowerCase() === 'share') {
|
if (position.instrumentType?.toLowerCase() === 'share') {
|
||||||
const dividends = await this.moex.dividends(resolvedTicker);
|
const dividends = await this.dividends(token, position);
|
||||||
this.logger.event('debug', 'upcoming-events.source-result', {
|
this.logger.event('debug', 'upcoming-events.source-result', {
|
||||||
count: dividends.length,
|
count: dividends.length,
|
||||||
});
|
});
|
||||||
@@ -125,28 +152,78 @@ export class UpcomingEventsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async instrument(token: string, position: Position) {
|
private async instrument(token: string, position: Position) {
|
||||||
if (position.instrumentUid)
|
const instrumentUid = position.instrumentUid?.trim();
|
||||||
return this.tbank.getInstrumentByUid(token, position.instrumentUid);
|
if (instrumentUid)
|
||||||
if (position.figi)
|
return this.cached(
|
||||||
return this.tbank.getInstrumentByFigi(token, position.figi);
|
this.instrumentCache,
|
||||||
|
instrumentUid,
|
||||||
|
instrumentCacheTtlMs,
|
||||||
|
() => this.tbank.getInstrumentByUid(token, instrumentUid),
|
||||||
|
);
|
||||||
|
const figi = position.figi?.trim();
|
||||||
|
if (figi)
|
||||||
|
return this.cached(this.instrumentCache, figi, instrumentCacheTtlMs, () =>
|
||||||
|
this.tbank.getInstrumentByFigi(token, figi),
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async dividends(
|
||||||
|
token: string,
|
||||||
|
position: Position,
|
||||||
|
): Promise<TbankDividend[]> {
|
||||||
|
const instrumentId = position.instrumentUid ?? position.figi;
|
||||||
|
if (!instrumentId) return [];
|
||||||
|
const window = dateWindow();
|
||||||
|
const dividends = await this.cached(
|
||||||
|
this.dividendCache,
|
||||||
|
instrumentId,
|
||||||
|
dividendCacheTtlMs,
|
||||||
|
() =>
|
||||||
|
this.tbank.getDividends(token, instrumentId, window.from, window.to),
|
||||||
|
);
|
||||||
|
return dividends.filter(
|
||||||
|
(event) =>
|
||||||
|
event.dividendType !== 'Cancelled' && Boolean(event.recordDate),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async cached<T>(
|
||||||
|
cache: Map<string, CachedEntry<T>>,
|
||||||
|
key: string,
|
||||||
|
ttlMs: number,
|
||||||
|
load: () => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const now = Date.now();
|
||||||
|
const hit = cache.get(key);
|
||||||
|
if (hit && hit.expiresAt > now) return hit.promise;
|
||||||
|
const promise = load().catch((error: unknown) => {
|
||||||
|
cache.delete(key);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
cache.set(key, { expiresAt: now + ttlMs, promise });
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
private dividend(
|
private dividend(
|
||||||
metadata: Pick<
|
metadata: Pick<
|
||||||
UpcomingEventDto,
|
UpcomingEventDto,
|
||||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName'
|
'figi' | 'instrumentUid' | 'ticker' | 'displayName'
|
||||||
>,
|
>,
|
||||||
quantity: ReturnType<typeof decimal>,
|
quantity: ReturnType<typeof decimal>,
|
||||||
event: MoexDividend,
|
event: TbankDividend,
|
||||||
): UpcomingEventDto {
|
): UpcomingEventDto {
|
||||||
return {
|
return {
|
||||||
...metadata,
|
...metadata,
|
||||||
type: 'dividend',
|
type: 'dividend',
|
||||||
date: event.date,
|
date: new Date(Number(event.recordDate?.seconds) * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10),
|
||||||
dateKind: 'registry',
|
dateKind: 'registry',
|
||||||
currency: event.currency,
|
currency: event.dividendNet?.currency ?? null,
|
||||||
amount: amount(quantity, event.value),
|
amount: event.dividendNet
|
||||||
|
? formatDecimal(multiply(quantity, decimal(event.dividendNet)))
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user