refactor: refactor future event list

This commit is contained in:
2026-08-20 08:23:42 +03:00
parent 5aa9bd395a
commit e0055414d6
8 changed files with 245 additions and 69 deletions
+4 -18
View File
@@ -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();
}); });
}); });
+2 -19
View File
@@ -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'))
+5 -1
View File
@@ -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({
+5 -1
View File
@@ -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)
+21
View File
@@ -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({
+24
View File
@@ -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);
}
+91 -14
View File
@@ -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'),
+93 -16
View File
@@ -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,
}; };
} }