From e0055414d6947e46fb58db1abe96f4ec3ea74f8c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 20 Aug 2026 08:23:42 +0300 Subject: [PATCH] refactor: refactor future event list --- src/tbank/moex.client.spec.ts | 22 +---- src/tbank/moex.client.ts | 21 +---- src/tbank/portfolio.service.spec.ts | 6 +- src/tbank/portfolio.service.ts | 6 +- src/tbank/tbank.client.spec.ts | 21 +++++ src/tbank/tbank.client.ts | 24 +++++ src/tbank/upcoming-events.service.spec.ts | 105 ++++++++++++++++++--- src/tbank/upcoming-events.service.ts | 109 ++++++++++++++++++---- 8 files changed, 245 insertions(+), 69 deletions(-) diff --git a/src/tbank/moex.client.spec.ts b/src/tbank/moex.client.spec.ts index a3563bc..eab1151 100644 --- a/src/tbank/moex.client.spec.ts +++ b/src/tbank/moex.client.spec.ts @@ -1,31 +1,17 @@ import { MoexClient } from './moex.client'; 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({ ok: true, json: () => Promise.resolve({ - dividends: { - columns: ['registryclosedate', 'value', 'currencyid'], - data: [['2026-08-20', '2.5', 'SUR']], - }, + description: { columns: ['name', 'value'], data: [] }, + boards: { columns: ['secid'], data: [] }, }), } 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([]); + await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]); fetchMock.mockRestore(); }); }); diff --git a/src/tbank/moex.client.ts b/src/tbank/moex.client.ts index 5904ed5..afc56e5 100644 --- a/src/tbank/moex.client.ts +++ b/src/tbank/moex.client.ts @@ -3,11 +3,6 @@ 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 } @@ -18,19 +13,6 @@ type IssResponse = Record; @Injectable() export class MoexClient { - async dividends(secid: string): Promise { - 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 { const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const [coupons, offers, description] = await Promise.all([ @@ -122,7 +104,8 @@ export class MoexClient { } function parseRows(block: IssBlock | undefined): Record[] { - 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' }); const columns = block.columns; if (!columns.every((column) => typeof column === 'string')) diff --git a/src/tbank/portfolio.service.spec.ts b/src/tbank/portfolio.service.spec.ts index 568e587..e8e36b2 100644 --- a/src/tbank/portfolio.service.spec.ts +++ b/src/tbank/portfolio.service.spec.ts @@ -88,7 +88,11 @@ describe('PortfolioService', () => { ], }); tbank.getInstrumentByFigi - .mockResolvedValueOnce({ ticker: 'ONE', name: 'Первая позиция', isin: 'RU000A0JX0J2' }) + .mockResolvedValueOnce({ + ticker: 'ONE', + name: 'Первая позиция', + isin: 'RU000A0JX0J2', + }) .mockRejectedValueOnce(new Error('unavailable')); await expect(service.get('user', 'account')).resolves.toMatchObject({ diff --git a/src/tbank/portfolio.service.ts b/src/tbank/portfolio.service.ts index 3e54aa2..943853b 100644 --- a/src/tbank/portfolio.service.ts +++ b/src/tbank/portfolio.service.ts @@ -237,7 +237,11 @@ export class PortfolioService { ) { const lookup = async ( 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); if (cached && Date.now() - cached.storedAt <= metadataForMs) diff --git a/src/tbank/tbank.client.spec.ts b/src/tbank/tbank.client.spec.ts index 8716601..f3c864e 100644 --- a/src/tbank/tbank.client.spec.ts +++ b/src/tbank/tbank.client.spec.ts @@ -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 () => { const client = Object.create(TbankClient.prototype) as TbankClient; const error = new ServiceUnavailableException({ diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index 0074f07..ae078e9 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -45,6 +45,11 @@ export type TbankPortfolio = { }; export type TbankPositions = { money?: TbankMoney[] }; 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 TbankOperationItem = { cursor?: string; @@ -229,6 +234,21 @@ export class TbankClient { ); } + getDividends( + token: string, + instrumentId: string, + from: string, + to: string, + ): Promise { + 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( token: string, client: TbankClientService, @@ -307,3 +327,7 @@ export class TbankClient { }); } } + +function utcSeconds(date: string): number { + return Math.floor(Date.parse(`${date}T00:00:00Z`) / 1000); +} diff --git a/src/tbank/upcoming-events.service.spec.ts b/src/tbank/upcoming-events.service.spec.ts index cd2ef6b..9813d76 100644 --- a/src/tbank/upcoming-events.service.spec.ts +++ b/src/tbank/upcoming-events.service.spec.ts @@ -20,21 +20,23 @@ describe('UpcomingEventsService', () => { getPortfolio: jest.fn(), getInstrumentByUid: 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 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); + let service: UpcomingEventsService; beforeEach(() => { + service = new UpcomingEventsService( + prisma as never, + config as never, + tbank as never, + moex as never, + requestContext as never, + logger as never, + ); jest.clearAllMocks(); prisma.tbankConnection.findUnique.mockResolvedValue({ encryptedToken: crypto.encrypt('token'), @@ -43,8 +45,8 @@ describe('UpcomingEventsService', () => { { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF' }, ]); tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Бумага' }); - moex.dividends.mockResolvedValue([]); moex.bondEvents.mockResolvedValue([]); + tbank.getDividends.mockResolvedValue([]); }); it('projects exact amounts and preserves registry dates and offers', async () => { @@ -75,8 +77,14 @@ describe('UpcomingEventsService', () => { }, ], }); - moex.dividends.mockResolvedValue([ - { date: day(1), value: '1.25', currency: 'RUB' }, + tbank.getDividends.mockResolvedValue([ + { + 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([ { type: 'offer', date: day(2) }, @@ -116,8 +124,14 @@ describe('UpcomingEventsService', () => { tbank.getInstrumentByFigi.mockRejectedValue( new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), ); - moex.dividends.mockResolvedValue([ - { date: today, value: '1', currency: 'RUB' }, + 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 expect(service.get('user', 'account')).resolves.toMatchObject({ @@ -126,6 +140,69 @@ describe('UpcomingEventsService', () => { 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 () => { tbank.getOpenAccounts.mockRejectedValueOnce( new Error('INVALID_TBANK_TOKEN'), diff --git a/src/tbank/upcoming-events.service.ts b/src/tbank/upcoming-events.service.ts index db33aaf..1599458 100644 --- a/src/tbank/upcoming-events.service.ts +++ b/src/tbank/upcoming-events.service.ts @@ -10,8 +10,13 @@ import { RequestContextService } from '../common/logging/request-context.service import { StructuredLogger } from '../common/logging/structured-logger.service'; 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 { MoexBondEvent, MoexClient } from './moex.client'; +import { + TbankClient, + TbankDividend, + TbankInstrument, + TbankPortfolio, +} from './tbank.client'; import { TbankCrypto } from './tbank.crypto'; import { UpcomingEventDto, UpcomingEventsDto } from './upcoming-events.dto'; @@ -23,10 +28,20 @@ const supportedAccounts = new Set([ ]); type Position = NonNullable[number]; +type CachedEntry = { expiresAt: number; promise: Promise }; + +const positionsBatchSize = 5; +const dividendCacheTtlMs = 15 * 60_000; +const instrumentCacheTtlMs = 24 * 60 * 60_000; @Injectable() export class UpcomingEventsService { private readonly crypto: TbankCrypto; + private readonly dividendCache = new Map>(); + private readonly instrumentCache = new Map< + string, + CachedEntry + >(); constructor( private readonly prisma: PrismaService, @@ -60,11 +75,23 @@ export class UpcomingEventsService { 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 supportedPositions = (portfolio.positions ?? []).filter(supported); + const events: UpcomingEventDto[][] = []; + for ( + 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 .flat() .filter( @@ -111,7 +138,7 @@ export class UpcomingEventsService { }; const quantity = decimal(position.quantity ?? {}); 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', { count: dividends.length, }); @@ -125,28 +152,78 @@ export class UpcomingEventsService { } 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); + const instrumentUid = position.instrumentUid?.trim(); + if (instrumentUid) + return this.cached( + 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; } + private async dividends( + token: string, + position: Position, + ): Promise { + 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( + cache: Map>, + key: string, + ttlMs: number, + load: () => Promise, + ): Promise { + 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( metadata: Pick< UpcomingEventDto, 'figi' | 'instrumentUid' | 'ticker' | 'displayName' >, quantity: ReturnType, - event: MoexDividend, + event: TbankDividend, ): UpcomingEventDto { return { ...metadata, type: 'dividend', - date: event.date, + date: new Date(Number(event.recordDate?.seconds) * 1000) + .toISOString() + .slice(0, 10), dateKind: 'registry', - currency: event.currency, - amount: amount(quantity, event.value), + currency: event.dividendNet?.currency ?? null, + amount: event.dividendNet + ? formatDecimal(multiply(quantity, decimal(event.dividendNet))) + : null, }; }