refactor: refactor future event list
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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([
|
||||
@@ -122,7 +104,8 @@ export class MoexClient {
|
||||
}
|
||||
|
||||
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' });
|
||||
const columns = block.columns;
|
||||
if (!columns.every((column) => typeof column === 'string'))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<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>(
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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<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()
|
||||
export class UpcomingEventsService {
|
||||
private readonly crypto: TbankCrypto;
|
||||
private readonly dividendCache = new Map<string, CachedEntry<TbankDividend[]>>();
|
||||
private readonly instrumentCache = new Map<
|
||||
string,
|
||||
CachedEntry<TbankInstrument | null>
|
||||
>();
|
||||
|
||||
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<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(
|
||||
metadata: Pick<
|
||||
UpcomingEventDto,
|
||||
'figi' | 'instrumentUid' | 'ticker' | 'displayName'
|
||||
>,
|
||||
quantity: ReturnType<typeof decimal>,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user