- MoexHttpClient: infrastructure (axios, rate limiter, circuit breaker) - MoexSecuritiesClient: search and security descriptions - MoexMarketDataClient: share/bond market data and batch queries - MoexCandlesClient: candle data - MoexHistoryClient: share/bond history - MoexDividendsClient: dividend data - Removed @Global() from MoexClientModule - Updated all 7 consumers with explicit DI - All 141 tests passing
500 lines
18 KiB
TypeScript
500 lines
18 KiB
TypeScript
import { CacheService } from '../../cache/cache.service';
|
|
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
|
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
|
|
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
|
|
import { BrokerAccountsService } from './broker-accounts.service';
|
|
import { BrokerEventsService } from './broker-events.service';
|
|
import { BrokerOperationsService } from './broker-operations.service';
|
|
import { BrokerPortfolioService } from './broker-portfolio.service';
|
|
|
|
describe('BrokerEventsService', () => {
|
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
|
const portfolio = { getPositionsWithInstruments: vi.fn() } as unknown as BrokerPortfolioService;
|
|
const moexMarketData = { getBondPositionDataBatch: vi.fn() } as unknown as MoexMarketDataClient;
|
|
const moexDividends = { getDividends: vi.fn() } as unknown as MoexDividendsClient;
|
|
const operations = { getOperations: vi.fn() } as unknown as BrokerOperationsService;
|
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
|
|
|
const acc1 = {
|
|
id: 'acc-1',
|
|
type: 'brokerage' as const,
|
|
name: 'Test Broker',
|
|
status: 'ACCOUNT_STATUS_OPEN',
|
|
openedAt: null,
|
|
accessLevel: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
function mockCachePassthrough() {
|
|
vi.mocked(cache.getOrFetch).mockImplementation(
|
|
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
|
|
data: await fetchFn(),
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
}),
|
|
);
|
|
}
|
|
|
|
it('throws 404 for missing account', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(null);
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
await expect(
|
|
service.getEvents('missing', { from: '2026-06-01', to: '2026-07-01' }),
|
|
).rejects.toThrow(EntityNotFoundException);
|
|
});
|
|
|
|
it('returns empty events for account with no positions', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [],
|
|
instruments: new Map(),
|
|
});
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-01' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-01', to: '2026-07-01' });
|
|
|
|
expect(result.data.items).toEqual([]);
|
|
expect(result.data.summary.eventCount).toBe(0);
|
|
expect(result.data.summary.nearestEventDate).toBeNull();
|
|
expect(result.data.summary.totalEstimatedCashflow).toBe(0);
|
|
expect(result.data.summary.actualCashflow).toBe(0);
|
|
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
|
|
});
|
|
|
|
it('builds dividend events from share positions in date range', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'SBER',
|
|
instrumentUid: 'uid-sber',
|
|
instrumentType: 'share',
|
|
quantity: { units: '10', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
|
|
});
|
|
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
|
{
|
|
secid: 'SBER',
|
|
isin: 'RU000A0JS',
|
|
registryCloseDate: '2026-06-15',
|
|
value: 33.5,
|
|
currencyId: 'RUB',
|
|
},
|
|
{
|
|
secid: 'SBER',
|
|
isin: 'RU000A0JS',
|
|
registryCloseDate: '2026-06-25',
|
|
value: 33.5,
|
|
currencyId: 'RUB',
|
|
},
|
|
{
|
|
secid: 'SBER',
|
|
isin: 'RU000A0JS',
|
|
registryCloseDate: '2026-08-01',
|
|
value: 33.5,
|
|
currencyId: 'RUB',
|
|
},
|
|
]);
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
|
|
|
expect(result.data.items).toHaveLength(1);
|
|
expect(result.data.items[0].type).toBe('dividend');
|
|
expect(result.data.items[0].eventDate).toBe('2026-06-25');
|
|
expect(result.data.items[0].estimatedAmount).toBe(335);
|
|
expect(result.data.items[0].actualAmount).toBeNull();
|
|
expect(result.data.items[0].source).toBe('forecast');
|
|
expect(result.data.items[0].currency).toBe('RUB');
|
|
expect(result.data.items[0].name).toBe('Sberbank');
|
|
});
|
|
|
|
it('builds coupon, maturity, and offer events for bonds', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'SU26248RMFS4',
|
|
instrumentUid: 'uid-bond-1',
|
|
instrumentType: 'bond',
|
|
quantity: { units: '5', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
|
|
});
|
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
|
{
|
|
secid: 'SU26248RMFS4',
|
|
couponValue: 35.4,
|
|
nextCouponDate: '2026-06-25',
|
|
matDate: '2026-06-27',
|
|
offerDate: '2026-06-28',
|
|
faceValue: 1000,
|
|
boardid: 'TQCB',
|
|
shortName: '',
|
|
price: null,
|
|
yieldToMaturity: null,
|
|
duration: null,
|
|
couponPercent: null,
|
|
accruedInt: null,
|
|
bid: null,
|
|
offer: null,
|
|
couponPeriod: null,
|
|
bondType: null,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
|
|
|
expect(result.data.items).toHaveLength(3);
|
|
const coupon = result.data.items.find((e) => e.type === 'coupon')!;
|
|
expect(coupon.estimatedAmount).toBe(177);
|
|
const offer = result.data.items.find((e) => e.type === 'offer')!;
|
|
expect(offer.category).toBe('corporate');
|
|
const maturity = result.data.items.find((e) => e.type === 'maturity')!;
|
|
expect(maturity.estimatedAmount).toBe(5000);
|
|
});
|
|
|
|
it('handles partial failures gracefully', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'OK_SPLIT',
|
|
instrumentUid: 'uid-1',
|
|
instrumentType: 'share',
|
|
quantity: { units: '10', nano: 0 },
|
|
},
|
|
{
|
|
ticker: 'GOOD',
|
|
instrumentUid: 'uid-2',
|
|
instrumentType: 'share',
|
|
quantity: { units: '5', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map([
|
|
['uid-1', { name: 'Failing' }],
|
|
['uid-2', { name: 'Working' }],
|
|
]),
|
|
});
|
|
vi.mocked(moexDividends.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
|
|
vi.mocked(moexDividends.getDividends).mockResolvedValueOnce([
|
|
{ secid: 'GOOD', isin: 'RU', registryCloseDate: '2026-06-25', value: 20, currencyId: 'RUB' },
|
|
]);
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
|
|
|
expect(result.data.items).toHaveLength(1);
|
|
expect(result.data.items[0].ticker).toBe('GOOD');
|
|
});
|
|
|
|
it('includes events without amount when payout is unknown', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'NO_AMT',
|
|
instrumentUid: 'uid-1',
|
|
instrumentType: 'share',
|
|
quantity: { units: '10', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
|
|
});
|
|
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
|
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
|
|
]);
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
|
|
|
expect(result.data.items).toHaveLength(1);
|
|
expect(result.data.items[0].estimatedAmount).toBe(0);
|
|
});
|
|
|
|
it('calculates summary totals correctly', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'SBER',
|
|
instrumentUid: 'uid-1',
|
|
instrumentType: 'share',
|
|
quantity: { units: '10', nano: 0 },
|
|
},
|
|
{
|
|
ticker: 'BOND1',
|
|
instrumentUid: 'uid-2',
|
|
instrumentType: 'bond',
|
|
quantity: { units: '2', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map([
|
|
['uid-1', { name: 'Sber' }],
|
|
['uid-2', { name: 'OFZ' }],
|
|
]),
|
|
});
|
|
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
|
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
|
|
]);
|
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
|
{
|
|
secid: 'BOND1',
|
|
couponValue: 50,
|
|
nextCouponDate: '2026-06-26',
|
|
matDate: '2026-06-27',
|
|
offerDate: null,
|
|
faceValue: 1000,
|
|
boardid: 'TQCB',
|
|
shortName: '',
|
|
price: null,
|
|
yieldToMaturity: null,
|
|
duration: null,
|
|
couponPercent: null,
|
|
accruedInt: null,
|
|
bid: null,
|
|
offer: null,
|
|
couponPeriod: null,
|
|
bondType: null,
|
|
},
|
|
]);
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-10' });
|
|
|
|
expect(result.data.summary.eventCount).toBe(3);
|
|
expect(result.data.summary.nearestEventDate).toBe('2026-06-25');
|
|
expect(result.data.summary.dividendsTotal).toBe(300);
|
|
expect(result.data.summary.couponsTotal).toBe(100);
|
|
expect(result.data.summary.principalRepaymentTotal).toBe(2000);
|
|
expect(result.data.summary.totalEstimatedCashflow).toBe(2400);
|
|
expect(result.data.summary.forecastEstimatedCashflow).toBe(2400);
|
|
expect(result.data.summary.actualCashflow).toBe(0);
|
|
});
|
|
|
|
it('filters events by inclusive date range', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'SBER',
|
|
instrumentUid: 'uid-1',
|
|
instrumentType: 'share',
|
|
quantity: { units: '1', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map([['uid-1', { name: 'Sber' }]]),
|
|
});
|
|
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
|
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-20', value: 10, currencyId: 'RUB' },
|
|
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-29', value: 10, currencyId: 'RUB' },
|
|
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-07-30', value: 10, currencyId: 'RUB' },
|
|
]);
|
|
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', { from: '2026-06-20', to: '2026-07-29' });
|
|
|
|
expect(result.data.items).toHaveLength(2);
|
|
expect(result.data.items[0].eventDate).toBe('2026-06-20');
|
|
expect(result.data.items[1].eventDate).toBe('2026-07-29');
|
|
});
|
|
|
|
it('filters forecast events by selected event types', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [
|
|
{
|
|
ticker: 'SBER',
|
|
instrumentUid: 'uid-share',
|
|
instrumentType: 'share',
|
|
quantity: { units: '10', nano: 0 },
|
|
},
|
|
{
|
|
ticker: 'BOND1',
|
|
instrumentUid: 'uid-bond',
|
|
instrumentType: 'bond',
|
|
quantity: { units: '2', nano: 0 },
|
|
},
|
|
],
|
|
instruments: new Map(),
|
|
});
|
|
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
|
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
|
|
]);
|
|
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
|
{
|
|
secid: 'BOND1',
|
|
couponValue: 50,
|
|
nextCouponDate: '2026-06-26',
|
|
matDate: '2026-06-27',
|
|
offerDate: '2026-06-28',
|
|
faceValue: 1000,
|
|
boardid: 'TQCB',
|
|
shortName: '',
|
|
price: null,
|
|
yieldToMaturity: null,
|
|
duration: null,
|
|
couponPercent: null,
|
|
accruedInt: null,
|
|
bid: null,
|
|
offer: null,
|
|
couponPeriod: null,
|
|
bondType: null,
|
|
},
|
|
]);
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: { accountId: 'acc-1', items: [], nextCursor: null, hasNext: false, asOf: '2026-06-20' },
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', {
|
|
from: '2026-06-20',
|
|
to: '2026-07-10',
|
|
types: 'coupon,maturity',
|
|
});
|
|
|
|
expect(result.data.items.map((event) => event.type)).toEqual(['coupon', 'maturity']);
|
|
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
['acc-1', '2026-06-20', '2026-07-10', 'coupon,maturity'],
|
|
expect.any(Function),
|
|
'tbankPortfolioTtl',
|
|
);
|
|
});
|
|
|
|
it('adds actual past income events from broker operations', async () => {
|
|
vi.mocked(accounts.findById).mockResolvedValue(acc1);
|
|
mockCachePassthrough();
|
|
vi.mocked(portfolio.getPositionsWithInstruments).mockResolvedValue({
|
|
positions: [],
|
|
instruments: new Map(),
|
|
});
|
|
vi.mocked(operations.getOperations).mockResolvedValue({
|
|
data: {
|
|
accountId: 'acc-1',
|
|
items: [
|
|
{
|
|
cursor: 'cur-1',
|
|
accountId: 'acc-1',
|
|
id: 'op-1',
|
|
parentOperationId: null,
|
|
date: '2026-06-18T10:00:00.000Z',
|
|
type: 'OPERATION_TYPE_DIVIDEND',
|
|
category: 'income',
|
|
description: 'Dividend payment',
|
|
name: 'Sberbank',
|
|
state: 'OPERATION_STATE_EXECUTED',
|
|
instrumentUid: 'uid-sber',
|
|
figi: null,
|
|
ticker: 'SBER',
|
|
classCode: 'TQBR',
|
|
instrumentType: 'share',
|
|
payment: { currency: 'RUB', units: '123', nano: 450000000, value: 123.45 },
|
|
price: null,
|
|
commission: null,
|
|
yield: null,
|
|
accruedInt: null,
|
|
quantity: null,
|
|
quantityDone: null,
|
|
},
|
|
],
|
|
nextCursor: null,
|
|
hasNext: false,
|
|
asOf: '2026-06-19T00:00:00.000Z',
|
|
},
|
|
fromCache: false,
|
|
cachedAt: null,
|
|
});
|
|
|
|
const service = new BrokerEventsService(accounts, portfolio, moexMarketData, moexDividends, operations, cache);
|
|
const result = await service.getEvents('acc-1', {
|
|
from: '2026-06-15',
|
|
to: '2026-06-20',
|
|
types: 'dividend',
|
|
});
|
|
|
|
expect(operations.getOperations).toHaveBeenCalledWith('acc-1', {
|
|
from: '2026-06-15T00:00:00.000Z',
|
|
to: '2026-06-20T23:59:59.999Z',
|
|
operationTypes: 'OPERATION_TYPE_DIVIDEND,OPERATION_TYPE_DIV_EXT',
|
|
limit: 100,
|
|
state: 'OPERATION_STATE_EXECUTED',
|
|
});
|
|
expect(result.data.items).toEqual([
|
|
expect.objectContaining({
|
|
id: 'actual-op-1',
|
|
type: 'dividend',
|
|
source: 'actual',
|
|
eventDate: '2026-06-18',
|
|
actualAmount: 123.45,
|
|
estimatedAmount: null,
|
|
estimateMode: null,
|
|
currency: 'RUB',
|
|
}),
|
|
]);
|
|
expect(result.data.summary.actualCashflow).toBe(123.45);
|
|
expect(result.data.summary.actualDividendsTotal).toBe(123.45);
|
|
expect(result.data.summary.forecastEstimatedCashflow).toBe(0);
|
|
});
|
|
});
|