codex/backend-architecture-improvements #47
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { BondsController } from './bonds.controller';
|
||||
import { BondsService } from './bonds.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [BondsController],
|
||||
providers: [BondsService],
|
||||
exports: [BondsService],
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { BondsService } from './bonds.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
|
||||
describe('BondsService', () => {
|
||||
let service: BondsService;
|
||||
let moexClient: Pick<MoexClientService, 'getBondData' | 'getBondMarketData'>;
|
||||
let moexMarketData: Pick<MoexMarketDataClient, 'getBondData' | 'getBondMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
moexMarketData = {
|
||||
getBondData: vi.fn(),
|
||||
getBondMarketData: vi.fn(),
|
||||
};
|
||||
@ -25,7 +26,8 @@ describe('BondsService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BondsService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: MoexHistoryClient, useValue: { getBondHistory: vi.fn() } },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -34,7 +36,7 @@ describe('BondsService', () => {
|
||||
});
|
||||
|
||||
it('returns normalized SU26238RMFS5 bond spec and market data without live MOEX dependency', async () => {
|
||||
vi.mocked(moexClient.getBondData).mockResolvedValue({
|
||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
boardid: 'TQCB',
|
||||
shortName: 'ОФЗ 26238',
|
||||
@ -57,7 +59,7 @@ describe('BondsService', () => {
|
||||
bondSubType: 'fixed',
|
||||
listLevel: 1,
|
||||
});
|
||||
vi.mocked(moexClient.getBondMarketData).mockResolvedValue({
|
||||
vi.mocked(moexMarketData.getBondMarketData).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
bid: 72.9,
|
||||
offer: 73.1,
|
||||
@ -92,8 +94,8 @@ describe('BondsService', () => {
|
||||
expect.any(Function),
|
||||
'marketDataTtl',
|
||||
);
|
||||
expect(moexClient.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexClient.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexMarketData.getBondData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(moexMarketData.getBondMarketData).toHaveBeenCalledWith('SU26238RMFS5');
|
||||
expect(result).toMatchObject({
|
||||
data: {
|
||||
secid: 'SU26238RMFS5',
|
||||
@ -136,10 +138,10 @@ describe('BondsService', () => {
|
||||
});
|
||||
|
||||
it('throws EntityNotFoundException when bond data is missing', async () => {
|
||||
vi.mocked(moexClient.getBondData).mockResolvedValue(null);
|
||||
vi.mocked(moexMarketData.getBondData).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getBond('UNKNOWN')).rejects.toBeInstanceOf(EntityNotFoundException);
|
||||
expect(cache.getOrFetch).toHaveBeenCalledTimes(1);
|
||||
expect(moexClient.getBondMarketData).not.toHaveBeenCalled();
|
||||
expect(moexMarketData.getBondMarketData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
@ -7,7 +8,8 @@ import { EntityNotFoundException } from '../../common/exceptions/entity-not-foun
|
||||
@Injectable()
|
||||
export class BondsService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexHistory: MoexHistoryClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -19,7 +21,7 @@ export class BondsService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'bond',
|
||||
[secid],
|
||||
() => this.moexClient.getBondData(secid),
|
||||
() => this.moexMarketData.getBondData(secid),
|
||||
'securityTtl',
|
||||
);
|
||||
|
||||
@ -30,7 +32,7 @@ export class BondsService {
|
||||
const { data: mkt } = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['bonds', secid],
|
||||
() => this.moexClient.getBondMarketData(secid),
|
||||
() => this.moexMarketData.getBondMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
@ -85,7 +87,7 @@ export class BondsService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['bonds', secid],
|
||||
() => this.moexClient.getBondMarketData(secid),
|
||||
() => this.moexMarketData.getBondMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
@ -119,7 +121,7 @@ export class BondsService {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'history',
|
||||
['bonds', secid, from, till],
|
||||
() => this.moexClient.getBondHistory(secid, from, till),
|
||||
() => this.moexHistory.getBondHistory(secid, from, till),
|
||||
'historyTtl',
|
||||
);
|
||||
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { CandlesController } from './candles.controller';
|
||||
import { CandlesService } from './candles.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [CandlesController],
|
||||
providers: [CandlesService],
|
||||
exports: [CandlesService],
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { CandlesService } from './candles.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { CandleInterval } from './dto/candles-query.dto';
|
||||
|
||||
describe('CandlesService', () => {
|
||||
let service: CandlesService;
|
||||
let moexClient: Pick<MoexClientService, 'getCandles'>;
|
||||
let moexCandles: Pick<MoexCandlesClient, 'getCandles'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
moexCandles = {
|
||||
getCandles: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
@ -24,7 +24,7 @@ describe('CandlesService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CandlesService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: MoexCandlesClient, useValue: moexCandles },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -33,7 +33,7 @@ describe('CandlesService', () => {
|
||||
});
|
||||
|
||||
it('uses MOEX interval 24 for daily share candles and maps output envelope', async () => {
|
||||
vi.mocked(moexClient.getCandles).mockResolvedValue([
|
||||
vi.mocked(moexCandles.getCandles).mockResolvedValue([
|
||||
{
|
||||
open: 320,
|
||||
high: 325,
|
||||
@ -60,7 +60,7 @@ describe('CandlesService', () => {
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||||
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'shares',
|
||||
'SBER',
|
||||
@ -87,7 +87,7 @@ describe('CandlesService', () => {
|
||||
});
|
||||
|
||||
it('uses MOEX interval 60 for hourly bond candles without live MOEX dependency', async () => {
|
||||
vi.mocked(moexClient.getCandles).mockResolvedValue([]);
|
||||
vi.mocked(moexCandles.getCandles).mockResolvedValue([]);
|
||||
|
||||
await service.getCandles(
|
||||
'bonds',
|
||||
@ -103,7 +103,7 @@ describe('CandlesService', () => {
|
||||
expect.any(Function),
|
||||
'candlesTtl',
|
||||
);
|
||||
expect(moexClient.getCandles).toHaveBeenCalledWith(
|
||||
expect(moexCandles.getCandles).toHaveBeenCalledWith(
|
||||
'stock',
|
||||
'bonds',
|
||||
'SU26238RMFS5',
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexCandlesClient } from '../moex-client/moex-candles.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { CandleInterval } from './dto/candles-query.dto';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
@ -7,7 +7,7 @@ import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
@Injectable()
|
||||
export class CandlesService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexCandles: MoexCandlesClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -26,7 +26,7 @@ export class CandlesService {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'candles',
|
||||
[market, secid, String(moexInterval), from, till],
|
||||
() => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
|
||||
() => this.moexCandles.getCandles('stock', market, secid, moexInterval, from, till),
|
||||
'candlesTtl',
|
||||
);
|
||||
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
import 'reflect-metadata';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexCandlesClient } from './moex-candles.client';
|
||||
|
||||
describe('MoexCandlesClient', () => {
|
||||
let client: MoexCandlesClient;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
let extractTable: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
request = vi.fn();
|
||||
extractTable = vi.fn();
|
||||
client = new MoexCandlesClient({ request, extractTable } as unknown as MoexHttpClient);
|
||||
});
|
||||
|
||||
it('возвращает свечи для заданного инструмента', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValue([
|
||||
{ open: '320', close: '322', high: '323', low: '319', value: '100000', volume: '3000', begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
|
||||
]);
|
||||
|
||||
const result = await client.getCandles('stock', 'shares', 'SBER', 60, '2025-01-10', '2025-01-11');
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER/candles', {
|
||||
interval: '60', from: '2025-01-10', till: '2025-01-11',
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ open: 320, close: 322, high: 323, low: 319, value: 100000, volume: 3000, begin: '2025-01-10 10:00:00', end: '2025-01-10 10:59:59' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
36
apps/backend/src/modules/moex-client/moex-candles.client.ts
Normal file
36
apps/backend/src/modules/moex-client/moex-candles.client.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexCandle } from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexCandlesClient {
|
||||
constructor(private readonly http: MoexHttpClient) {}
|
||||
|
||||
async getCandles(
|
||||
engine: 'stock',
|
||||
market: 'shares' | 'bonds',
|
||||
secid: string,
|
||||
interval: 1 | 10 | 60 | 24,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<MoexCandle[]> {
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
||||
{
|
||||
interval: String(interval),
|
||||
from,
|
||||
till,
|
||||
},
|
||||
);
|
||||
return this.http.extractTable(data, 'candles').map((c) => ({
|
||||
open: parseFloat(c.open as string),
|
||||
close: parseFloat(c.close as string),
|
||||
high: parseFloat(c.high as string),
|
||||
low: parseFloat(c.low as string),
|
||||
value: parseFloat(c.value as string),
|
||||
volume: parseInt(c.volume as string, 10),
|
||||
begin: c.begin as string,
|
||||
end: c.end as string,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,26 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||
import { MoexCandlesClient } from './moex-candles.client';
|
||||
import { MoexHistoryClient } from './moex-history.client';
|
||||
import { MoexDividendsClient } from './moex-dividends.client';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [MoexClientService],
|
||||
exports: [MoexClientService],
|
||||
providers: [
|
||||
MoexHttpClient,
|
||||
MoexSecuritiesClient,
|
||||
MoexMarketDataClient,
|
||||
MoexCandlesClient,
|
||||
MoexHistoryClient,
|
||||
MoexDividendsClient,
|
||||
],
|
||||
exports: [
|
||||
MoexSecuritiesClient,
|
||||
MoexMarketDataClient,
|
||||
MoexCandlesClient,
|
||||
MoexHistoryClient,
|
||||
MoexDividendsClient,
|
||||
],
|
||||
})
|
||||
export class MoexClientModule {}
|
||||
|
||||
@ -1,32 +1,35 @@
|
||||
import 'reflect-metadata';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
import { MoexClientModule } from './moex-client.module';
|
||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||
import configuration from '../../config/configuration';
|
||||
|
||||
describe.skipIf(process.env.MOEX_LIVE_TESTS !== '1')(
|
||||
'MoexClientService live MOEX integration',
|
||||
'MoexClient live MOEX integration',
|
||||
() => {
|
||||
let service: MoexClientService;
|
||||
let moexSecurities: MoexSecuritiesClient;
|
||||
let moexMarketData: MoexMarketDataClient;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] })],
|
||||
providers: [MoexClientService],
|
||||
imports: [ConfigModule.forRoot({ load: [configuration] }), MoexClientModule],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MoexClientService>(MoexClientService);
|
||||
moexSecurities = module.get<MoexSecuritiesClient>(MoexSecuritiesClient);
|
||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||
});
|
||||
|
||||
it('возвращает результаты поиска для SBER из live MOEX', async () => {
|
||||
const results = await service.searchSecurities('SBER');
|
||||
const results = await moexSecurities.searchSecurities('SBER');
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].secid).toBeDefined();
|
||||
}, 15000);
|
||||
|
||||
it('возвращает рыночные данные SBER из live MOEX', async () => {
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
const data = await moexMarketData.getShareMarketData('SBER');
|
||||
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.secid).toBe('SBER');
|
||||
|
||||
@ -1,186 +0,0 @@
|
||||
import 'reflect-metadata';
|
||||
import axios from 'axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MoexClientService } from './moex-client.service';
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MoexClientService', () => {
|
||||
let service: MoexClientService;
|
||||
let getMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
getMock = vi.fn();
|
||||
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
|
||||
|
||||
service = new MoexClientService({
|
||||
get: vi.fn((key: string, fallback?: unknown) => {
|
||||
const values: Record<string, unknown> = {
|
||||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
||||
'app.moex.circuitBreakerThreshold': 5,
|
||||
'app.moex.circuitBreakerResetSeconds': 30,
|
||||
'app.moex.rateLimit': 10,
|
||||
};
|
||||
return values[key] ?? fallback;
|
||||
}),
|
||||
} as unknown as ConfigService);
|
||||
});
|
||||
|
||||
it('создаётся с настроенным MOEX client', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(axios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'https://iss.moex.test/iss',
|
||||
timeout: 10000,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('нормализует результаты поиска из ISS table format', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
securities: {
|
||||
columns: [
|
||||
'secid',
|
||||
'isin',
|
||||
'name',
|
||||
'shortName',
|
||||
'latName',
|
||||
'listLevel',
|
||||
'issuesize',
|
||||
'facevalue',
|
||||
'faceunit',
|
||||
'issuedate',
|
||||
'typename',
|
||||
'group',
|
||||
'type',
|
||||
'isqualifiedinvestors',
|
||||
'morningsession',
|
||||
'eveningsession',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'SBER',
|
||||
'RU0009029540',
|
||||
'Сбербанк России ПАО ао',
|
||||
'Сбербанк',
|
||||
'Sberbank',
|
||||
'1',
|
||||
'21586948000',
|
||||
'3',
|
||||
'SUR',
|
||||
'2007-07-20',
|
||||
'Акция обыкновенная',
|
||||
'stock_shares',
|
||||
'common_share',
|
||||
'0',
|
||||
'1',
|
||||
'1',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.searchSecurities('SBER');
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/securities.json', {
|
||||
params: { q: 'SBER', 'iss.meta': 'off' },
|
||||
});
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк',
|
||||
latName: 'Sberbank',
|
||||
listLevel: 1,
|
||||
issueSize: 21586948000,
|
||||
faceValue: 3,
|
||||
faceUnit: 'SUR',
|
||||
issueDate: '2007-07-20',
|
||||
typeName: 'Акция обыкновенная',
|
||||
group: 'stock_shares',
|
||||
type: 'common_share',
|
||||
isQualifiedInvestors: false,
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('нормализует market data акции без live MOEX запроса', async () => {
|
||||
getMock.mockResolvedValueOnce({
|
||||
data: {
|
||||
securities: {
|
||||
columns: ['SECID', 'BOARDID', 'SHORTNAME', 'PREVPRICE'],
|
||||
data: [['SBER', 'TQBR', 'Сбербанк', '320.10']],
|
||||
},
|
||||
marketdata: {
|
||||
columns: [
|
||||
'SECID',
|
||||
'BOARDID',
|
||||
'BID',
|
||||
'OFFER',
|
||||
'OPEN',
|
||||
'LOW',
|
||||
'HIGH',
|
||||
'LAST',
|
||||
'LASTCHANGE',
|
||||
'LASTCHANGEPRCNT',
|
||||
'VOLTODAY',
|
||||
'VALTODAY',
|
||||
'WAPRICE',
|
||||
'NUMTRADES',
|
||||
'ISSUECAPITALIZATION',
|
||||
'TRADINGSTATUS',
|
||||
'UPDATETIME',
|
||||
],
|
||||
data: [
|
||||
[
|
||||
'SBER',
|
||||
'TQBR',
|
||||
'321',
|
||||
'322',
|
||||
'320',
|
||||
'319',
|
||||
'323',
|
||||
'322.35',
|
||||
'1.15',
|
||||
'0.36',
|
||||
'1925163',
|
||||
'620184479',
|
||||
'321.9',
|
||||
'12345',
|
||||
'6958336818320',
|
||||
'T',
|
||||
'10:30:00',
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data = await service.getShareMarketData('SBER');
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER.json', {
|
||||
params: { boards: 'TQBR', 'iss.meta': 'off' },
|
||||
});
|
||||
expect(data).toMatchObject({
|
||||
secid: 'SBER',
|
||||
boardid: 'TQBR',
|
||||
shortName: 'Сбербанк',
|
||||
last: 322.35,
|
||||
lastChange: 1.15,
|
||||
lastChangePrcnt: 0.36,
|
||||
volume: 1925163,
|
||||
value: 620184479,
|
||||
issueCapitalization: 6958336818320,
|
||||
tradingStatus: 'T',
|
||||
updateTime: '10:30:00',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,423 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import PQueue from 'p-queue';
|
||||
import {
|
||||
MoexSecurityDescription,
|
||||
MoexShareMarketData,
|
||||
MoexBondData,
|
||||
MoexBondMarketData,
|
||||
MoexBondPositionData,
|
||||
MoexDividend,
|
||||
MoexCandle,
|
||||
MoexHistoryEntry,
|
||||
MoexBondHistoryEntry,
|
||||
} from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexClientService {
|
||||
private readonly logger = new Logger(MoexClientService.name);
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly queue: PQueue;
|
||||
private circuitOpen = false;
|
||||
private circuitErrorCount = 0;
|
||||
private readonly threshold: number;
|
||||
private readonly resetMs: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
||||
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
||||
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
||||
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 10000,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
this.queue = new PQueue({
|
||||
interval: 1000,
|
||||
intervalCap: rateLimit,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
if (this.circuitOpen) {
|
||||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
||||
}
|
||||
|
||||
return this.queue.add(async () => {
|
||||
try {
|
||||
const jsonPath = path + '.json';
|
||||
const response = await this.client.get(jsonPath, {
|
||||
params: { ...params, 'iss.meta': 'off' },
|
||||
});
|
||||
this.circuitErrorCount = 0;
|
||||
return response.data as T;
|
||||
} catch (error) {
|
||||
this.circuitErrorCount++;
|
||||
if (this.circuitErrorCount >= this.threshold) {
|
||||
this.circuitOpen = true;
|
||||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
||||
setTimeout(() => {
|
||||
this.circuitOpen = false;
|
||||
this.circuitErrorCount = 0;
|
||||
this.logger.log('Circuit breaker reset');
|
||||
}, this.resetMs);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
private extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
||||
const table = data[name] as Record<string, unknown> | undefined;
|
||||
if (!table || !table.columns || !table.data) return [];
|
||||
const columns = table.columns as string[];
|
||||
const rows = table.data as unknown[][];
|
||||
return rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
||||
const data = await this.request<Record<string, unknown>>('/securities', {
|
||||
q: query,
|
||||
});
|
||||
return this.extractTable(data, 'securities').map((s) => ({
|
||||
secid: s.secid as string,
|
||||
isin: s.isin as string,
|
||||
name: s.name as string,
|
||||
shortName: s.shortName as string,
|
||||
latName: (s.latName as string) || null,
|
||||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
||||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
||||
faceValue: parseFloat(s.facevalue as string) || 0,
|
||||
faceUnit: (s.faceunit as string) || '',
|
||||
issueDate: (s.issuedate as string) || '',
|
||||
typeName: (s.typename as string) || '',
|
||||
group: (s.group as string) || '',
|
||||
type: (s.type as string) || '',
|
||||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
||||
morningSession: (s.morningsession as string) === '1',
|
||||
eveningSession: (s.eveningsession as string) === '1',
|
||||
}));
|
||||
}
|
||||
|
||||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}`);
|
||||
const rows = this.extractTable(data, 'description');
|
||||
if (rows.length === 0) return null;
|
||||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
||||
return {
|
||||
secid,
|
||||
isin: (map.get('ISIN') as string) || '',
|
||||
name: (map.get('NAME') as string) || '',
|
||||
shortName: (map.get('SHORTNAME') as string) || '',
|
||||
latName: (map.get('LATNAME') as string) || null,
|
||||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
||||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
||||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
||||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
||||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
||||
typeName: (map.get('TYPENAME') as string) || '',
|
||||
group: (map.get('GROUP') as string) || '',
|
||||
type: (map.get('TYPE') as string) || '',
|
||||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
||||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
||||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
||||
};
|
||||
}
|
||||
|
||||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.extractTable(data, 'securities');
|
||||
const share = rows.find((r) => r.BOARDID === boardId);
|
||||
if (!share) return null;
|
||||
|
||||
const mktRows = this.extractTable(data, 'marketdata');
|
||||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (share?.SHORTNAME as string) || '',
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((share.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
async getShareMarketDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQBR',
|
||||
): Promise<MoexShareMarketData[]> {
|
||||
const params: Record<string, string> = { boards: boardId };
|
||||
if (secids.length > 0) {
|
||||
params.securities = secids.join(',');
|
||||
}
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities`,
|
||||
params,
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
||||
const filteredSecurities = secidSet
|
||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
||||
: securities;
|
||||
|
||||
return filteredSecurities.map((sec) => {
|
||||
const secid = sec.SECID as string;
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (sec?.SHORTNAME as string) || '',
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((sec?.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getBondPositionDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQCB',
|
||||
): Promise<MoexBondPositionData[]> {
|
||||
const params: Record<string, string> = { boards: boardId };
|
||||
if (secids.length > 0) {
|
||||
params.securities = secids.join(',');
|
||||
}
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities`,
|
||||
params,
|
||||
);
|
||||
const securities = this.extractTable(data, 'securities');
|
||||
const marketdata = this.extractTable(data, 'marketdata');
|
||||
|
||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
||||
const filteredSecurities = secidSet
|
||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
||||
: securities;
|
||||
|
||||
return filteredSecurities.map((bond) => {
|
||||
const secid = bond.SECID as string;
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: (bond.BOARDID as string) || boardId,
|
||||
shortName: (bond?.SHORTNAME as string) || '',
|
||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
couponPercent:
|
||||
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
||||
matDate: (bond?.MATDATE as string) || null,
|
||||
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
||||
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
||||
bondType: (bond?.BONDTYPE as string) || null,
|
||||
offerDate: (bond?.OFFERDATE as string) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.extractTable(data, 'securities');
|
||||
const bond =
|
||||
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
||||
rows.find((r) => r.PREVWAPRICE != null) ||
|
||||
rows[0];
|
||||
if (!bond) return null;
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (bond.SHORTNAME as string) || '',
|
||||
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
||||
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
||||
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
||||
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
||||
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
||||
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
||||
matDate: (bond.MATDATE as string) || '',
|
||||
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
||||
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
||||
isin: (bond.ISIN as string) || '',
|
||||
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
offerDate: (bond.OFFERDATE as string) || null,
|
||||
buybackDate: (bond.BUYBACKDATE as string) || null,
|
||||
bondType: (bond.BONDTYPE as string) || '',
|
||||
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
||||
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const mktRows = this.extractTable(data, 'marketdata');
|
||||
const mkt =
|
||||
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
|
||||
mktRows.find((r) => r.LAST != null) ||
|
||||
mktRows.find((r) => r.SECID === secid);
|
||||
if (!mkt) return null;
|
||||
|
||||
return {
|
||||
secid,
|
||||
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
||||
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
||||
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
||||
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
||||
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
||||
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
||||
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
||||
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
||||
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt.UPDATETIME as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
async getDividends(secid: string): Promise<MoexDividend[]> {
|
||||
const data = await this.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
||||
return this.extractTable(data, 'dividends').map((d) => ({
|
||||
secid: d.secid as string,
|
||||
isin: d.isin as string,
|
||||
registryCloseDate: d.registryclosedate as string,
|
||||
value: parseFloat(d.value as string),
|
||||
currencyId: (d.currencyid as string) || 'RUB',
|
||||
}));
|
||||
}
|
||||
|
||||
async getCandles(
|
||||
engine: 'stock',
|
||||
market: 'shares' | 'bonds',
|
||||
secid: string,
|
||||
interval: 1 | 10 | 60 | 24,
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<MoexCandle[]> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/${engine}/markets/${market}/securities/${secid}/candles`,
|
||||
{
|
||||
interval: String(interval),
|
||||
from,
|
||||
till,
|
||||
},
|
||||
);
|
||||
return this.extractTable(data, 'candles').map((c) => ({
|
||||
open: parseFloat(c.open as string),
|
||||
close: parseFloat(c.close as string),
|
||||
high: parseFloat(c.high as string),
|
||||
low: parseFloat(c.low as string),
|
||||
value: parseFloat(c.value as string),
|
||||
volume: parseInt(c.volume as string, 10),
|
||||
begin: c.begin as string,
|
||||
end: c.end as string,
|
||||
}));
|
||||
}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities/${secid}`,
|
||||
{ from, till },
|
||||
);
|
||||
const tableName = Object.keys(data).find(
|
||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||||
);
|
||||
if (!tableName) return [];
|
||||
return this.extractTable(data, tableName).map((h) => ({
|
||||
tradeDate: h.TRADEDATE as string,
|
||||
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
||||
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
||||
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||||
volume: parseInt((h.VOLUME as string) || '0', 10),
|
||||
value: parseFloat((h.VALUE as string) || '0'),
|
||||
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
||||
}));
|
||||
}
|
||||
|
||||
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
|
||||
const data = await this.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ from, till },
|
||||
);
|
||||
const tableName = Object.keys(data).find(
|
||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||||
);
|
||||
if (!tableName) return [];
|
||||
return this.extractTable(data, tableName).map((h) => ({
|
||||
tradeDate: h.TRADEDATE as string,
|
||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||||
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||||
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
||||
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
||||
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import 'reflect-metadata';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexDividendsClient } from './moex-dividends.client';
|
||||
|
||||
describe('MoexDividendsClient', () => {
|
||||
let client: MoexDividendsClient;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
let extractTable: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
request = vi.fn();
|
||||
extractTable = vi.fn();
|
||||
client = new MoexDividendsClient({ request, extractTable } as unknown as MoexHttpClient);
|
||||
});
|
||||
|
||||
it('возвращает дивиденды для бумаги', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValue([
|
||||
{ secid: 'SBER', isin: 'RU0009029540', registryclosedate: '2025-07-10', value: '33.3', currencyid: 'RUB' },
|
||||
]);
|
||||
|
||||
const result = await client.getDividends('SBER');
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/securities/SBER/dividends');
|
||||
expect(result).toEqual([
|
||||
{ secid: 'SBER', isin: 'RU0009029540', registryCloseDate: '2025-07-10', value: 33.3, currencyId: 'RUB' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexDividend } from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexDividendsClient {
|
||||
constructor(private readonly http: MoexHttpClient) {}
|
||||
|
||||
async getDividends(secid: string): Promise<MoexDividend[]> {
|
||||
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}/dividends`);
|
||||
return this.http.extractTable(data, 'dividends').map((d) => ({
|
||||
secid: d.secid as string,
|
||||
isin: d.isin as string,
|
||||
registryCloseDate: d.registryclosedate as string,
|
||||
value: parseFloat(d.value as string),
|
||||
currencyId: (d.currencyid as string) || 'RUB',
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
import 'reflect-metadata';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexHistoryClient } from './moex-history.client';
|
||||
|
||||
describe('MoexHistoryClient', () => {
|
||||
let client: MoexHistoryClient;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
let extractTable: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
request = vi.fn();
|
||||
extractTable = vi.fn();
|
||||
client = new MoexHistoryClient({ request, extractTable } as unknown as MoexHttpClient);
|
||||
});
|
||||
|
||||
describe('getHistory', () => {
|
||||
it('возвращает историю торгов для акции', async () => {
|
||||
request.mockResolvedValue({ history: { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '322']] } });
|
||||
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '322' }]);
|
||||
|
||||
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { from: '2025-01-10', till: '2025-01-11' });
|
||||
expect(result).toEqual([
|
||||
{ tradeDate: '2025-01-10', open: null, low: null, high: null, close: 322, waprice: null, volume: 0, value: 0, numtrades: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('возвращает пустой массив если history таблица не найдена', async () => {
|
||||
request.mockResolvedValue({});
|
||||
|
||||
const result = await client.getHistory('SBER', '2025-01-10', '2025-01-11');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBondHistory', () => {
|
||||
it('возвращает историю торгов для облигации', async () => {
|
||||
request.mockResolvedValue({ 'history:': { columns: ['TRADEDATE', 'CLOSE'], data: [['2025-01-10', '98.5']] } });
|
||||
extractTable.mockReturnValue([{ TRADEDATE: '2025-01-10', CLOSE: '98.5' }]);
|
||||
|
||||
const result = await client.getBondHistory('SU26238RMFS4', '2025-01-10', '2025-01-11');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ tradeDate: '2025-01-10', close: 98.5, legalClosePrice: null, waprice: null, yieldClose: null, duration: null, accruedInt: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
50
apps/backend/src/modules/moex-client/moex-history.client.ts
Normal file
50
apps/backend/src/modules/moex-client/moex-history.client.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexHistoryEntry, MoexBondHistoryEntry } from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexHistoryClient {
|
||||
constructor(private readonly http: MoexHttpClient) {}
|
||||
|
||||
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities/${secid}`,
|
||||
{ from, till },
|
||||
);
|
||||
const tableName = Object.keys(data).find(
|
||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||||
);
|
||||
if (!tableName) return [];
|
||||
return this.http.extractTable(data, tableName).map((h) => ({
|
||||
tradeDate: h.TRADEDATE as string,
|
||||
open: h.OPEN != null ? parseFloat(h.OPEN as string) : null,
|
||||
low: h.LOW != null ? parseFloat(h.LOW as string) : null,
|
||||
high: h.HIGH != null ? parseFloat(h.HIGH as string) : null,
|
||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||||
volume: parseInt((h.VOLUME as string) || '0', 10),
|
||||
value: parseFloat((h.VALUE as string) || '0'),
|
||||
numtrades: parseInt((h.NUMTRADES as string) || '0', 10),
|
||||
}));
|
||||
}
|
||||
|
||||
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ from, till },
|
||||
);
|
||||
const tableName = Object.keys(data).find(
|
||||
(k) => k.startsWith('history') && !k.includes('cursor'),
|
||||
);
|
||||
if (!tableName) return [];
|
||||
return this.http.extractTable(data, tableName).map((h) => ({
|
||||
tradeDate: h.TRADEDATE as string,
|
||||
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
|
||||
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
|
||||
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
|
||||
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
|
||||
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,
|
||||
accruedInt: h.ACCINT != null ? parseFloat(h.ACCINT as string) : null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
132
apps/backend/src/modules/moex-client/moex-http.client.spec.ts
Normal file
132
apps/backend/src/modules/moex-client/moex-http.client.spec.ts
Normal file
@ -0,0 +1,132 @@
|
||||
import 'reflect-metadata';
|
||||
import axios from 'axios';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
|
||||
vi.mock('axios', () => ({
|
||||
default: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('MoexHttpClient', () => {
|
||||
let client: MoexHttpClient;
|
||||
let getMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
const mockConfig = {
|
||||
get: vi.fn((key: string, fallback?: unknown) => {
|
||||
const values: Record<string, unknown> = {
|
||||
'app.moex.baseUrl': 'https://iss.moex.test/iss',
|
||||
'app.moex.circuitBreakerThreshold': 5,
|
||||
'app.moex.circuitBreakerResetSeconds': 30,
|
||||
'app.moex.rateLimit': 10,
|
||||
};
|
||||
return values[key] ?? fallback;
|
||||
}),
|
||||
} as unknown as ConfigService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
getMock = vi.fn();
|
||||
vi.mocked(axios.create).mockReturnValue({ get: getMock } as never);
|
||||
client = new MoexHttpClient(mockConfig);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('создаёт axios instance с параметрами из конфига', () => {
|
||||
expect(axios.create).toHaveBeenCalledWith({
|
||||
baseURL: 'https://iss.moex.test/iss',
|
||||
timeout: 10000,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('request', () => {
|
||||
it('выполняет GET запрос с .json суффиксом и iss.meta=off', async () => {
|
||||
getMock.mockResolvedValueOnce({ data: { some: 'data' } });
|
||||
|
||||
const result = await client.request<{ some: string }>('/securities', { q: 'SBER' });
|
||||
|
||||
expect(getMock).toHaveBeenCalledWith('/securities.json', {
|
||||
params: { q: 'SBER', 'iss.meta': 'off' },
|
||||
});
|
||||
expect(result).toEqual({ some: 'data' });
|
||||
});
|
||||
|
||||
it('открывает circuit breaker после заданного числа ошибок', async () => {
|
||||
getMock.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(client.request('/test')).rejects.toThrow();
|
||||
}
|
||||
|
||||
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
|
||||
expect(getMock).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
|
||||
it('закрывает circuit breaker после resetMs', async () => {
|
||||
getMock.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await expect(client.request('/test')).rejects.toThrow();
|
||||
}
|
||||
|
||||
await expect(client.request('/test')).rejects.toThrow('Circuit breaker is open');
|
||||
|
||||
vi.advanceTimersByTime(30000);
|
||||
|
||||
getMock.mockResolvedValue({ data: 'ok' });
|
||||
const result = await client.request('/test');
|
||||
expect(result).toBe('ok');
|
||||
});
|
||||
|
||||
it('сбрасывает errorCount при успешном запросе', async () => {
|
||||
getMock
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockRejectedValueOnce(new Error('fail'))
|
||||
.mockResolvedValueOnce({ data: 'ok' });
|
||||
|
||||
await expect(client.request('/test')).rejects.toThrow('fail');
|
||||
await expect(client.request('/test')).rejects.toThrow('fail');
|
||||
const result = await client.request('/test');
|
||||
expect(result).toBe('ok');
|
||||
expect(getMock).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTable', () => {
|
||||
it('преобразует ISS columns/data формат в массив объектов', () => {
|
||||
const data = {
|
||||
securities: {
|
||||
columns: ['secid', 'name'],
|
||||
data: [
|
||||
['SBER', 'Сбербанк'],
|
||||
['VTBR', 'ВТБ'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = client.extractTable(data as Record<string, unknown>, 'securities');
|
||||
|
||||
expect(result).toEqual([
|
||||
{ secid: 'SBER', name: 'Сбербанк' },
|
||||
{ secid: 'VTBR', name: 'ВТБ' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('возвращает пустой массив если таблица не найдена', () => {
|
||||
const result = client.extractTable({}, 'nonexistent');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('возвращает пустой массив если нет columns', () => {
|
||||
const result = client.extractTable({ securities: { data: [] } } as unknown as Record<string, unknown>, 'securities');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
76
apps/backend/src/modules/moex-client/moex-http.client.ts
Normal file
76
apps/backend/src/modules/moex-client/moex-http.client.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import PQueue from 'p-queue';
|
||||
|
||||
@Injectable()
|
||||
export class MoexHttpClient {
|
||||
private readonly logger = new Logger(MoexHttpClient.name);
|
||||
private readonly client: AxiosInstance;
|
||||
private readonly queue: PQueue;
|
||||
private circuitOpen = false;
|
||||
private circuitErrorCount = 0;
|
||||
private readonly threshold: number;
|
||||
private readonly resetMs: number;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
|
||||
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
|
||||
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
|
||||
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: 10000,
|
||||
paramsSerializer: { indexes: null },
|
||||
});
|
||||
|
||||
this.queue = new PQueue({
|
||||
interval: 1000,
|
||||
intervalCap: rateLimit,
|
||||
});
|
||||
}
|
||||
|
||||
async request<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
if (this.circuitOpen) {
|
||||
throw new Error('Circuit breaker is open — MOEX requests paused');
|
||||
}
|
||||
|
||||
return this.queue.add(async () => {
|
||||
try {
|
||||
const jsonPath = path + '.json';
|
||||
const response = await this.client.get(jsonPath, {
|
||||
params: { ...params, 'iss.meta': 'off' },
|
||||
});
|
||||
this.circuitErrorCount = 0;
|
||||
return response.data as T;
|
||||
} catch (error) {
|
||||
this.circuitErrorCount++;
|
||||
if (this.circuitErrorCount >= this.threshold) {
|
||||
this.circuitOpen = true;
|
||||
this.logger.warn(`Circuit breaker opened after ${this.threshold} errors`);
|
||||
setTimeout(() => {
|
||||
this.circuitOpen = false;
|
||||
this.circuitErrorCount = 0;
|
||||
this.logger.log('Circuit breaker reset');
|
||||
}, this.resetMs);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}) as Promise<T>;
|
||||
}
|
||||
|
||||
extractTable(data: Record<string, unknown>, name: string): Record<string, unknown>[] {
|
||||
const table = data[name] as Record<string, unknown> | undefined;
|
||||
if (!table || !table.columns || !table.data) return [];
|
||||
const columns = table.columns as string[];
|
||||
const rows = table.data as unknown[][];
|
||||
return rows.map((row) => {
|
||||
const obj: Record<string, unknown> = {};
|
||||
columns.forEach((col, i) => {
|
||||
obj[col] = row[i];
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,118 @@
|
||||
import 'reflect-metadata';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexMarketDataClient } from './moex-market-data.client';
|
||||
|
||||
describe('MoexMarketDataClient', () => {
|
||||
let client: MoexMarketDataClient;
|
||||
let request: ReturnType<typeof vi.fn>;
|
||||
let extractTable: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
request = vi.fn();
|
||||
extractTable = vi.fn();
|
||||
client = new MoexMarketDataClient({ request, extractTable } as unknown as MoexHttpClient);
|
||||
});
|
||||
|
||||
describe('getShareMarketData', () => {
|
||||
it('возвращает рыночные данные акции из securities и marketdata таблиц', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable
|
||||
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' }])
|
||||
.mockReturnValueOnce([{ SECID: 'SBER', BOARDID: 'TQBR', BID: '321', OFFER: '322', OPEN: '320', LOW: '319', HIGH: '323', LAST: '322.35', LASTCHANGE: '1.15', LASTCHANGEPRCNT: '0.36', VOLTODAY: '1925163', VALTODAY: '620184479', WAPRICE: '321.9', NUMTRADES: '12345', ISSUECAPITALIZATION: '6958336818320', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
|
||||
|
||||
const result = await client.getShareMarketData('SBER');
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/shares/securities/SBER', { boards: 'TQBR' });
|
||||
expect(result).toMatchObject({ secid: 'SBER', boardid: 'TQBR', shortName: 'Сбербанк', last: 322.35, bid: 321, offer: 322 });
|
||||
});
|
||||
|
||||
it('возвращает null если бумага не найдена', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValueOnce([]).mockReturnValueOnce([]);
|
||||
|
||||
const result = await client.getShareMarketData('INVALID');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getShareMarketDataBatch', () => {
|
||||
it('возвращает массив рыночных данных для нескольких бумаг', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable
|
||||
.mockReturnValueOnce([
|
||||
{ SECID: 'SBER', BOARDID: 'TQBR', SHORTNAME: 'Сбербанк', PREVPRICE: '320' },
|
||||
{ SECID: 'VTBR', BOARDID: 'TQBR', SHORTNAME: 'ВТБ', PREVPRICE: '50' },
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{ SECID: 'SBER', BOARDID: 'TQBR', LAST: '322', BID: '321', OFFER: '323' },
|
||||
{ SECID: 'VTBR', BOARDID: 'TQBR', LAST: '50.5', BID: '50.1', OFFER: '50.8' },
|
||||
]);
|
||||
|
||||
const results = await client.getShareMarketDataBatch(['SBER', 'VTBR']);
|
||||
|
||||
expect(results).toHaveLength(2);
|
||||
expect(results[0].secid).toBe('SBER');
|
||||
expect(results[1].secid).toBe('VTBR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBondData', () => {
|
||||
it('возвращает данные облигации из securities таблицы', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValueOnce([
|
||||
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', PREVWAPRICE: '98.5', COUPONVALUE: '34.5', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
|
||||
]);
|
||||
|
||||
const result = await client.getBondData('SU26238RMFS4');
|
||||
|
||||
expect(request).toHaveBeenCalledWith('/engines/stock/markets/bonds/securities/SU26238RMFS4', { boards: 'TQCB' });
|
||||
expect(result).toMatchObject({ secid: 'SU26238RMFS4', shortName: 'ОФЗ 26238' });
|
||||
});
|
||||
|
||||
it('возвращает null если облигация не найдена', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValueOnce([]);
|
||||
|
||||
const result = await client.getBondData('INVALID');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBondMarketData', () => {
|
||||
it('возвращает рыночные данные облигации из marketdata таблицы', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValueOnce([{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', BID: '98', OFFER: '99', YIELD: '7.5', DURATION: '1500', VOLTODAY: '1000', VALTODAY: '98500', NUMTRADES: '50', TRADINGSTATUS: 'T', UPDATETIME: '10:30:00' }]);
|
||||
|
||||
const result = await client.getBondMarketData('SU26238RMFS4');
|
||||
|
||||
expect(result).toMatchObject({ secid: 'SU26238RMFS4', last: 98.5, bid: 98, offer: 99, yield: 7.5 });
|
||||
});
|
||||
|
||||
it('возвращает null если marketdata не найдена', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable.mockReturnValueOnce([]);
|
||||
|
||||
const result = await client.getBondMarketData('INVALID');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBondPositionDataBatch', () => {
|
||||
it('возвращает массив позиций по облигациям', async () => {
|
||||
request.mockResolvedValue({});
|
||||
extractTable
|
||||
.mockReturnValueOnce([
|
||||
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', SHORTNAME: 'ОФЗ 26238', COUPONVALUE: '34.5', COUPONPERCENT: '7', NEXTCOUPON: '2025-01-15', MATDATE: '2041-05-15', FACEVALUE: '1000', ISIN: 'RU000A1038T7' },
|
||||
])
|
||||
.mockReturnValueOnce([
|
||||
{ SECID: 'SU26238RMFS4', BOARDID: 'TQCB', LAST: '98.5', YIELD: '7.5', DURATION: '1500', BID: '98', OFFER: '99' },
|
||||
]);
|
||||
|
||||
const results = await client.getBondPositionDataBatch(['SU26238RMFS4']);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].secid).toBe('SU26238RMFS4');
|
||||
expect(results[0].price).toBe(98.5);
|
||||
});
|
||||
});
|
||||
});
|
||||
219
apps/backend/src/modules/moex-client/moex-market-data.client.ts
Normal file
219
apps/backend/src/modules/moex-client/moex-market-data.client.ts
Normal file
@ -0,0 +1,219 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import {
|
||||
MoexShareMarketData,
|
||||
MoexBondData,
|
||||
MoexBondMarketData,
|
||||
MoexBondPositionData,
|
||||
} from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexMarketDataClient {
|
||||
constructor(private readonly http: MoexHttpClient) {}
|
||||
|
||||
async getShareMarketData(secid: string, boardId = 'TQBR'): Promise<MoexShareMarketData | null> {
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.http.extractTable(data, 'securities');
|
||||
const share = rows.find((r) => r.BOARDID === boardId);
|
||||
if (!share) return null;
|
||||
|
||||
const mktRows = this.http.extractTable(data, 'marketdata');
|
||||
const mkt = mktRows.find((r) => r.BOARDID === boardId);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (share?.SHORTNAME as string) || '',
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((share.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
async getShareMarketDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQBR',
|
||||
): Promise<MoexShareMarketData[]> {
|
||||
const params: Record<string, string> = { boards: boardId };
|
||||
if (secids.length > 0) {
|
||||
params.securities = secids.join(',');
|
||||
}
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/shares/securities`,
|
||||
params,
|
||||
);
|
||||
const securities = this.http.extractTable(data, 'securities');
|
||||
const marketdata = this.http.extractTable(data, 'marketdata');
|
||||
|
||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
||||
const filteredSecurities = secidSet
|
||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
||||
: securities;
|
||||
|
||||
return filteredSecurities.map((sec) => {
|
||||
const secid = sec.SECID as string;
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (sec?.SHORTNAME as string) || '',
|
||||
bid: mkt ? parseFloat((mkt.BID as string) || '') : null,
|
||||
offer: mkt ? parseFloat((mkt.OFFER as string) || '') : null,
|
||||
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
|
||||
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
|
||||
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
|
||||
last: mkt
|
||||
? parseFloat((mkt.LAST as string) || '')
|
||||
: parseFloat((sec?.PREVPRICE as string) || ''),
|
||||
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
|
||||
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
|
||||
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
|
||||
value: mkt ? parseFloat((mkt.VALTODAY as string) || '0') : 0,
|
||||
waprice: mkt ? parseFloat((mkt.WAPRICE as string) || '') : null,
|
||||
numtrades: mkt ? parseInt((mkt.NUMTRADES as string) || '0', 10) : 0,
|
||||
issueCapitalization: mkt ? parseFloat((mkt.ISSUECAPITALIZATION as string) || '') : null,
|
||||
tradingStatus: (mkt?.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt?.UPDATETIME as string) || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async getBondData(secid: string, boardId = 'TQCB'): Promise<MoexBondData | null> {
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const rows = this.http.extractTable(data, 'securities');
|
||||
const bond =
|
||||
rows.find((r) => r.BOARDID === boardId && r.PREVWAPRICE != null) ||
|
||||
rows.find((r) => r.PREVWAPRICE != null) ||
|
||||
rows[0];
|
||||
if (!bond) return null;
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: boardId,
|
||||
shortName: (bond.SHORTNAME as string) || '',
|
||||
prevWaprice: parseFloat((bond.PREVWAPRICE as string) || '') || null,
|
||||
yieldAtPrevWaprice: parseFloat((bond.YIELDATPREVWAPRICE as string) || '') || null,
|
||||
couponValue: bond.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
nextCoupon: (bond.NEXTCOUPON as string) || null,
|
||||
accruedInt: bond.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
prevPrice: parseFloat((bond.PREVPRICE as string) || '') || null,
|
||||
lotSize: parseInt((bond.LOTSIZE as string) || '1', 10),
|
||||
faceValue: parseFloat((bond.FACEVALUE as string) || '1000'),
|
||||
matDate: (bond.MATDATE as string) || '',
|
||||
couponPeriod: parseInt((bond.COUPONPERIOD as string) || '0', 10),
|
||||
issueSize: parseInt((bond.ISSUESIZE as string) || '0', 10),
|
||||
isin: (bond.ISIN as string) || '',
|
||||
couponPercent: bond.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
offerDate: (bond.OFFERDATE as string) || null,
|
||||
buybackDate: (bond.BUYBACKDATE as string) || null,
|
||||
bondType: (bond.BONDTYPE as string) || '',
|
||||
bondSubType: (bond.BONDSUBTYPE as string) || '',
|
||||
listLevel: parseInt((bond.LISTLEVEL as string) || '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
async getBondMarketData(secid: string, boardId = 'TQCB'): Promise<MoexBondMarketData | null> {
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities/${secid}`,
|
||||
{ boards: boardId },
|
||||
);
|
||||
const mktRows = this.http.extractTable(data, 'marketdata');
|
||||
const mkt =
|
||||
mktRows.find((r) => r.BOARDID === boardId && r.LAST != null) ||
|
||||
mktRows.find((r) => r.LAST != null) ||
|
||||
mktRows.find((r) => r.SECID === secid);
|
||||
if (!mkt) return null;
|
||||
|
||||
return {
|
||||
secid,
|
||||
bid: mkt.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
open: mkt.OPEN != null ? parseFloat(mkt.OPEN as string) : null,
|
||||
low: mkt.LOW != null ? parseFloat(mkt.LOW as string) : null,
|
||||
high: mkt.HIGH != null ? parseFloat(mkt.HIGH as string) : null,
|
||||
last: mkt.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yield: mkt.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
waprice: mkt.WAPRICE != null ? parseFloat(mkt.WAPRICE as string) : null,
|
||||
yieldAtWaprice: mkt.YIELDATWAPRICE != null ? parseFloat(mkt.YIELDATWAPRICE as string) : null,
|
||||
duration: mkt.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
volume: parseInt((mkt.VOLTODAY as string) || '0', 10),
|
||||
value: parseFloat((mkt.VALTODAY as string) || '0'),
|
||||
numtrades: parseInt((mkt.NUMTRADES as string) || '0', 10),
|
||||
tradingStatus: (mkt.TRADINGSTATUS as string) || '',
|
||||
updateTime: (mkt.UPDATETIME as string) || '',
|
||||
};
|
||||
}
|
||||
|
||||
async getBondPositionDataBatch(
|
||||
secids: string[],
|
||||
boardId = 'TQCB',
|
||||
): Promise<MoexBondPositionData[]> {
|
||||
const params: Record<string, string> = { boards: boardId };
|
||||
if (secids.length > 0) {
|
||||
params.securities = secids.join(',');
|
||||
}
|
||||
const data = await this.http.request<Record<string, unknown>>(
|
||||
`/engines/stock/markets/bonds/securities`,
|
||||
params,
|
||||
);
|
||||
const securities = this.http.extractTable(data, 'securities');
|
||||
const marketdata = this.http.extractTable(data, 'marketdata');
|
||||
|
||||
const secidSet = secids.length > 0 ? new Set(secids) : null;
|
||||
const filteredSecurities = secidSet
|
||||
? securities.filter((r) => secidSet.has(r.SECID as string))
|
||||
: securities;
|
||||
|
||||
return filteredSecurities.map((bond) => {
|
||||
const secid = bond.SECID as string;
|
||||
const mkt =
|
||||
marketdata.find((r) => r.SECID === secid && r.BOARDID === boardId && r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid && r.LAST != null) ||
|
||||
marketdata.find((r) => r.SECID === secid);
|
||||
|
||||
return {
|
||||
secid,
|
||||
boardid: (bond.BOARDID as string) || boardId,
|
||||
shortName: (bond?.SHORTNAME as string) || '',
|
||||
price: mkt?.LAST != null ? parseFloat(mkt.LAST as string) : null,
|
||||
yieldToMaturity: mkt?.YIELD != null ? parseFloat(mkt.YIELD as string) : null,
|
||||
duration: mkt?.DURATION != null ? parseFloat(mkt.DURATION as string) : null,
|
||||
couponValue: bond?.COUPONVALUE != null ? parseFloat(bond.COUPONVALUE as string) : null,
|
||||
couponPercent:
|
||||
bond?.COUPONPERCENT != null ? parseFloat(bond.COUPONPERCENT as string) : null,
|
||||
nextCouponDate: (bond?.NEXTCOUPON as string) || null,
|
||||
matDate: (bond?.MATDATE as string) || null,
|
||||
accruedInt: bond?.ACCRUEDINT != null ? parseFloat(bond.ACCRUEDINT as string) : null,
|
||||
faceValue: parseFloat((bond?.FACEVALUE as string) || '1000'),
|
||||
bid: mkt?.BID != null ? parseFloat(mkt.BID as string) : null,
|
||||
offer: mkt?.OFFER != null ? parseFloat(mkt.OFFER as string) : null,
|
||||
couponPeriod: parseInt((bond?.COUPONPERIOD as string) || '0', 10),
|
||||
bondType: (bond?.BONDTYPE as string) || null,
|
||||
offerDate: (bond?.OFFERDATE as string) || null,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,67 @@
|
||||
import 'reflect-metadata';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexSecuritiesClient } from './moex-securities.client';
|
||||
|
||||
describe('MoexSecuritiesClient', () => {
|
||||
let client: MoexSecuritiesClient;
|
||||
let httpMock: { request: ReturnType<typeof vi.fn>; extractTable: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
httpMock = {
|
||||
request: vi.fn(),
|
||||
extractTable: vi.fn(),
|
||||
};
|
||||
client = new MoexSecuritiesClient(httpMock as unknown as MoexHttpClient);
|
||||
});
|
||||
|
||||
describe('searchSecurities', () => {
|
||||
it('выполняет поиск по запросу и нормализует результаты', async () => {
|
||||
httpMock.request.mockResolvedValue({});
|
||||
httpMock.extractTable.mockReturnValue([
|
||||
{
|
||||
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: '1', issuesize: '21586948000',
|
||||
facevalue: '3', faceunit: 'SUR', issuedate: '2007-07-20', typename: 'Акция обыкновенная',
|
||||
group: 'stock_shares', type: 'common_share', isqualifiedinvestors: '0',
|
||||
morningsession: '1', eveningsession: '1',
|
||||
},
|
||||
]);
|
||||
|
||||
const results = await client.searchSecurities('SBER');
|
||||
|
||||
expect(httpMock.request).toHaveBeenCalledWith('/securities', { q: 'SBER' });
|
||||
expect(results).toEqual([
|
||||
{
|
||||
secid: 'SBER', isin: 'RU0009029540', name: 'Сбербанк России ПАО ао',
|
||||
shortName: 'Сбербанк', latName: 'Sberbank', listLevel: 1, issueSize: 21586948000,
|
||||
faceValue: 3, faceUnit: 'SUR', issueDate: '2007-07-20', typeName: 'Акция обыкновенная',
|
||||
group: 'stock_shares', type: 'common_share', isQualifiedInvestors: false,
|
||||
morningSession: true, eveningSession: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSecurityDescription', () => {
|
||||
it('возвращает описание бумаги из description таблицы', async () => {
|
||||
httpMock.request.mockResolvedValue({});
|
||||
httpMock.extractTable.mockReturnValue([
|
||||
{ name: 'ISIN', value: 'RU0009029540' },
|
||||
{ name: 'SHORTNAME', value: 'Сбербанк' },
|
||||
]);
|
||||
|
||||
const result = await client.getSecurityDescription('SBER');
|
||||
|
||||
expect(httpMock.request).toHaveBeenCalledWith('/securities/SBER');
|
||||
expect(result).toMatchObject({ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк' });
|
||||
});
|
||||
|
||||
it('возвращает null если description пуст', async () => {
|
||||
httpMock.request.mockResolvedValue({});
|
||||
httpMock.extractTable.mockReturnValue([]);
|
||||
|
||||
const result = await client.getSecurityDescription('INVALID');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexHttpClient } from './moex-http.client';
|
||||
import { MoexSecurityDescription } from './moex-client.types';
|
||||
|
||||
@Injectable()
|
||||
export class MoexSecuritiesClient {
|
||||
constructor(private readonly http: MoexHttpClient) {}
|
||||
|
||||
async searchSecurities(query: string): Promise<MoexSecurityDescription[]> {
|
||||
const data = await this.http.request<Record<string, unknown>>('/securities', { q: query });
|
||||
return this.http.extractTable(data, 'securities').map((s) => ({
|
||||
secid: s.secid as string,
|
||||
isin: s.isin as string,
|
||||
name: s.name as string,
|
||||
shortName: s.shortName as string,
|
||||
latName: (s.latName as string) || null,
|
||||
listLevel: parseInt(s.listLevel as string, 10) || 0,
|
||||
issueSize: parseInt(s.issuesize as string, 10) || 0,
|
||||
faceValue: parseFloat(s.facevalue as string) || 0,
|
||||
faceUnit: (s.faceunit as string) || '',
|
||||
issueDate: (s.issuedate as string) || '',
|
||||
typeName: (s.typename as string) || '',
|
||||
group: (s.group as string) || '',
|
||||
type: (s.type as string) || '',
|
||||
isQualifiedInvestors: (s.isqualifiedinvestors as string) === '1',
|
||||
morningSession: (s.morningsession as string) === '1',
|
||||
eveningSession: (s.eveningsession as string) === '1',
|
||||
}));
|
||||
}
|
||||
|
||||
async getSecurityDescription(secid: string): Promise<MoexSecurityDescription | null> {
|
||||
const data = await this.http.request<Record<string, unknown>>(`/securities/${secid}`);
|
||||
const rows = this.http.extractTable(data, 'description');
|
||||
if (rows.length === 0) return null;
|
||||
const map = new Map(rows.map((r) => [r.name, r.value]));
|
||||
return {
|
||||
secid,
|
||||
isin: (map.get('ISIN') as string) || '',
|
||||
name: (map.get('NAME') as string) || '',
|
||||
shortName: (map.get('SHORTNAME') as string) || '',
|
||||
latName: (map.get('LATNAME') as string) || null,
|
||||
listLevel: parseInt((map.get('LISTLEVEL') as string) || '0', 10),
|
||||
issueSize: parseInt((map.get('ISSUESIZE') as string) || '0', 10),
|
||||
faceValue: parseFloat((map.get('FACEVALUE') as string) || '0'),
|
||||
faceUnit: (map.get('FACEUNIT') as string) || '',
|
||||
issueDate: (map.get('ISSUEDATE') as string) || '',
|
||||
typeName: (map.get('TYPENAME') as string) || '',
|
||||
group: (map.get('GROUP') as string) || '',
|
||||
type: (map.get('TYPE') as string) || '',
|
||||
isQualifiedInvestors: (map.get('ISQUALIFIEDINVESTORS') as string) === '1',
|
||||
morningSession: (map.get('MORNINGSESSION') as string) === '1',
|
||||
eveningSession: (map.get('EVENINGSESSION') as string) === '1',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [PortfolioController],
|
||||
providers: [PortfolioService],
|
||||
exports: [PortfolioService],
|
||||
|
||||
@ -2,7 +2,9 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import configuration from '../../config/configuration';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
@ -11,7 +13,7 @@ import { PortfolioAccessDeniedException } from '../../common/exceptions/portfoli
|
||||
describe('PortfolioService', () => {
|
||||
let service: PortfolioService;
|
||||
let prisma: PrismaService;
|
||||
let moexClient: MoexClientService;
|
||||
let moexMarketData: MoexMarketDataClient;
|
||||
let module: TestingModule;
|
||||
|
||||
const mockPortfolio = (overrides: Record<string, unknown> = {}) => ({
|
||||
@ -66,14 +68,20 @@ describe('PortfolioService', () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MoexClientService,
|
||||
provide: MoexSecuritiesClient,
|
||||
useValue: { getSecurityDescription: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: MoexMarketDataClient,
|
||||
useValue: {
|
||||
getShareMarketDataBatch: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
getSecurityDescription: vi.fn(),
|
||||
getDividends: vi.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MoexDividendsClient,
|
||||
useValue: { getDividends: vi.fn() },
|
||||
},
|
||||
{
|
||||
provide: CacheService,
|
||||
useValue: {
|
||||
@ -85,7 +93,7 @@ describe('PortfolioService', () => {
|
||||
|
||||
service = module.get<PortfolioService>(PortfolioService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
moexClient = module.get<MoexClientService>(MoexClientService);
|
||||
moexMarketData = module.get<MoexMarketDataClient>(MoexMarketDataClient);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@ -139,11 +147,11 @@ describe('PortfolioService', () => {
|
||||
mockPortfolio({ positions: [sharePosition, bondPosition] }) as any,
|
||||
]);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
] as any);
|
||||
|
||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'OFZ 26238',
|
||||
@ -237,7 +245,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||
] as any);
|
||||
|
||||
@ -283,7 +291,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
] as any);
|
||||
|
||||
@ -325,7 +333,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26238RMFS5',
|
||||
shortName: 'OFZ 26238',
|
||||
@ -369,7 +377,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250 },
|
||||
] as any);
|
||||
|
||||
@ -405,7 +413,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([] as any);
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([] as any);
|
||||
|
||||
const result = await service.getPositionsWithPrices(1);
|
||||
|
||||
@ -461,7 +469,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 250, lastChange: 5, lastChangePrcnt: 2 },
|
||||
{ secid: 'GAZP', shortName: 'Gazprom', last: 160, lastChange: 3, lastChangePrcnt: 1.5 },
|
||||
] as any);
|
||||
@ -513,7 +521,7 @@ describe('PortfolioService', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mocked(moexClient.getShareMarketDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getShareMarketDataBatch).mockResolvedValue([
|
||||
{ secid: 'SBER', shortName: 'Sberbank', last: 120 },
|
||||
{ secid: 'GAZP', shortName: 'Gazprom', last: 180 },
|
||||
] as any);
|
||||
|
||||
@ -3,7 +3,9 @@ import {
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { PortfolioAccessDeniedException } from '../../common/exceptions/portfolio-access.exception';
|
||||
@ -58,7 +60,9 @@ export interface EnrichedPosition {
|
||||
export class PortfolioService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexSecurities: MoexSecuritiesClient,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexDividends: MoexDividendsClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -209,7 +213,7 @@ export class PortfolioService {
|
||||
|
||||
if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0');
|
||||
|
||||
const desc = await this.moexClient.getSecurityDescription(dto.secid);
|
||||
const desc = await this.moexSecurities.getSecurityDescription(dto.secid);
|
||||
if (!desc) throw new BadRequestException(`Security ${dto.secid} not found in MOEX`);
|
||||
|
||||
const type = desc.group === 'stock_bonds' ? 'bond' : 'share';
|
||||
@ -339,7 +343,7 @@ export class PortfolioService {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata',
|
||||
['shares', cacheKey],
|
||||
() => this.moexClient.getShareMarketDataBatch(secids),
|
||||
() => this.moexMarketData.getShareMarketDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
@ -354,7 +358,7 @@ export class PortfolioService {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'batchdata',
|
||||
['bonds', cacheKey],
|
||||
() => this.moexClient.getBondPositionDataBatch(secids),
|
||||
() => this.moexMarketData.getBondPositionDataBatch(secids),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return new Map(data.map((d) => [d.secid, d]));
|
||||
@ -371,7 +375,7 @@ export class PortfolioService {
|
||||
const { data } = await this.cache.getOrFetch(
|
||||
'dividends',
|
||||
[cacheKey],
|
||||
() => this.moexClient.getDividends(secid),
|
||||
() => this.moexDividends.getDividends(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
return { secid, dividends: data };
|
||||
|
||||
@ -1,20 +1,20 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ScreenerService } from './screener.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ScreenerType } from './dto/screener-query.dto';
|
||||
|
||||
describe('ScreenerService', () => {
|
||||
let service: ScreenerService;
|
||||
let cache: CacheService;
|
||||
const moexClient = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
|
||||
const moexMarketData = { getShareMarketDataBatch: vi.fn(), getBondPositionDataBatch: vi.fn() };
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ScreenerService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: CacheService, useValue: { getOrFetch: vi.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
@ -34,7 +34,7 @@ describe('ScreenerService', () => {
|
||||
lastChange: 5, lastChangePrcnt: 2, issueCapitalization: 1e9,
|
||||
}];
|
||||
|
||||
moexClient.getShareMarketDataBatch.mockResolvedValue(mockShares);
|
||||
moexMarketData.getShareMarketDataBatch.mockResolvedValue(mockShares);
|
||||
|
||||
vi.mocked(cache.getOrFetch).mockImplementation(async (_prefix, _keys, fetchFn) => ({
|
||||
data: await fetchFn(),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ScreenerQueryDto, ScreenerType } from './dto/screener-query.dto';
|
||||
import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto';
|
||||
@ -7,7 +7,7 @@ import { ScreenerItemDto, ScreenerResultDto } from './dto/screener-response.dto'
|
||||
@Injectable()
|
||||
export class ScreenerService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -38,7 +38,7 @@ export class ScreenerService {
|
||||
[type],
|
||||
async () => {
|
||||
if (type === ScreenerType.SHARE) {
|
||||
const shares = await this.moexClient.getShareMarketDataBatch([]);
|
||||
const shares = await this.moexMarketData.getShareMarketDataBatch([]);
|
||||
return shares.map(
|
||||
(s): ScreenerItemDto => ({
|
||||
secid: s.secid,
|
||||
@ -61,7 +61,7 @@ export class ScreenerService {
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
const bonds = await this.moexClient.getBondPositionDataBatch([]);
|
||||
const bonds = await this.moexMarketData.getBondPositionDataBatch([]);
|
||||
return bonds.map(
|
||||
(b): ScreenerItemDto => ({
|
||||
secid: b.secid,
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { SecuritiesController } from './securities.controller';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { ScreenerService } from './screener.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [SecuritiesController],
|
||||
providers: [SecuritiesService, ScreenerService],
|
||||
exports: [SecuritiesService],
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { SecuritiesService } from './securities.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
describe('SecuritiesService', () => {
|
||||
let service: SecuritiesService;
|
||||
let moexClient: Pick<MoexClientService, 'searchSecurities'>;
|
||||
let moexSecurities: Pick<MoexSecuritiesClient, 'searchSecurities'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
moexSecurities = {
|
||||
searchSecurities: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
@ -24,7 +24,7 @@ describe('SecuritiesService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SecuritiesService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -33,7 +33,7 @@ describe('SecuritiesService', () => {
|
||||
});
|
||||
|
||||
it('returns supported securities only and normalizes SUR currency to RUB', async () => {
|
||||
vi.mocked(moexClient.searchSecurities).mockResolvedValue([
|
||||
vi.mocked(moexSecurities.searchSecurities).mockResolvedValue([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
@ -118,7 +118,7 @@ describe('SecuritiesService', () => {
|
||||
expect.any(Function),
|
||||
'searchTtl',
|
||||
);
|
||||
expect(moexClient.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||
expect(moexSecurities.searchSecurities).toHaveBeenCalledWith('SbEr');
|
||||
});
|
||||
|
||||
it('filters by type and applies limit without live MOEX dependency', async () => {
|
||||
@ -169,6 +169,6 @@ describe('SecuritiesService', () => {
|
||||
price: null,
|
||||
},
|
||||
]);
|
||||
expect(moexClient.searchSecurities).not.toHaveBeenCalled();
|
||||
expect(moexSecurities.searchSecurities).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { SecurityType } from './dto/search-query.dto';
|
||||
|
||||
@ -16,7 +16,7 @@ export interface SearchResultItem {
|
||||
@Injectable()
|
||||
export class SecuritiesService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexSecurities: MoexSecuritiesClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
@ -25,7 +25,7 @@ export class SecuritiesService {
|
||||
'search',
|
||||
[query.toLowerCase()],
|
||||
async () => {
|
||||
const results = await this.moexClient.searchSecurities(query);
|
||||
const results = await this.moexSecurities.searchSecurities(query);
|
||||
return results
|
||||
.map((s): SearchResultItem | null => {
|
||||
const type =
|
||||
@ -64,7 +64,7 @@ export class SecuritiesService {
|
||||
|
||||
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
|
||||
try {
|
||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||||
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||
if (!desc) return null;
|
||||
return {
|
||||
secid: desc.secid,
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MoexClientModule } from '../moex-client/moex-client.module';
|
||||
import { SharesController } from './shares.controller';
|
||||
import { SharesService } from './shares.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexClientModule],
|
||||
controllers: [SharesController],
|
||||
providers: [SharesService],
|
||||
exports: [SharesService],
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
import { SharesService } from './shares.service';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
|
||||
describe('SharesService', () => {
|
||||
let service: SharesService;
|
||||
let moexClient: Pick<MoexClientService, 'getSecurityDescription' | 'getShareMarketData'>;
|
||||
let moexSecurities: Pick<MoexSecuritiesClient, 'getSecurityDescription'>;
|
||||
let moexMarketData: Pick<MoexMarketDataClient, 'getShareMarketData'>;
|
||||
let cache: Pick<CacheService, 'getOrFetch'>;
|
||||
|
||||
beforeEach(async () => {
|
||||
moexClient = {
|
||||
moexSecurities = {
|
||||
getSecurityDescription: vi.fn(),
|
||||
};
|
||||
moexMarketData = {
|
||||
getShareMarketData: vi.fn(),
|
||||
};
|
||||
cache = {
|
||||
@ -25,7 +31,10 @@ describe('SharesService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SharesService,
|
||||
{ provide: MoexClientService, useValue: moexClient },
|
||||
{ provide: MoexSecuritiesClient, useValue: moexSecurities },
|
||||
{ provide: MoexMarketDataClient, useValue: moexMarketData },
|
||||
{ provide: MoexDividendsClient, useValue: { getDividends: vi.fn() } },
|
||||
{ provide: MoexHistoryClient, useValue: { getHistory: vi.fn() } },
|
||||
{ provide: CacheService, useValue: cache },
|
||||
],
|
||||
}).compile();
|
||||
@ -34,7 +43,7 @@ describe('SharesService', () => {
|
||||
});
|
||||
|
||||
it('returns normalized SBER share spec and market data without live MOEX dependency', async () => {
|
||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||||
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
name: 'Сбербанк России ПАО ао',
|
||||
@ -52,7 +61,7 @@ describe('SharesService', () => {
|
||||
morningSession: true,
|
||||
eveningSession: true,
|
||||
});
|
||||
vi.mocked(moexClient.getShareMarketData).mockResolvedValue({
|
||||
vi.mocked(moexMarketData.getShareMarketData).mockResolvedValue({
|
||||
secid: 'SBER',
|
||||
boardid: 'TQBR',
|
||||
shortName: 'Сбербанк',
|
||||
@ -75,14 +84,14 @@ describe('SharesService', () => {
|
||||
|
||||
const result = await service.getShare('SBER');
|
||||
|
||||
expect(moexClient.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
||||
expect(moexSecurities.getSecurityDescription).toHaveBeenCalledWith('SBER');
|
||||
expect(cache.getOrFetch).toHaveBeenCalledWith(
|
||||
'marketdata',
|
||||
['shares', 'SBER'],
|
||||
expect.any(Function),
|
||||
'marketDataTtl',
|
||||
);
|
||||
expect(moexClient.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||
expect(moexMarketData.getShareMarketData).toHaveBeenCalledWith('SBER');
|
||||
expect(result.data).toMatchObject({
|
||||
secid: 'SBER',
|
||||
isin: 'RU0009029540',
|
||||
@ -110,7 +119,7 @@ describe('SharesService', () => {
|
||||
});
|
||||
|
||||
it('throws EntityNotFoundException for non-share security', async () => {
|
||||
vi.mocked(moexClient.getSecurityDescription).mockResolvedValue({
|
||||
vi.mocked(moexSecurities.getSecurityDescription).mockResolvedValue({
|
||||
secid: 'SU26238RMFS5',
|
||||
isin: 'RU000A1038V6',
|
||||
name: 'ОФЗ 26238',
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MoexClientService } from '../moex-client/moex-client.service';
|
||||
import { MoexSecuritiesClient } from '../moex-client/moex-securities.client';
|
||||
import { MoexMarketDataClient } from '../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../moex-client/moex-dividends.client';
|
||||
import { MoexHistoryClient } from '../moex-client/moex-history.client';
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { ApiEnvelopePayload } from '../../common/dto/api-response.dto';
|
||||
import { EntityNotFoundException } from '../../common/exceptions/entity-not-found.exception';
|
||||
@ -7,12 +10,15 @@ import { EntityNotFoundException } from '../../common/exceptions/entity-not-foun
|
||||
@Injectable()
|
||||
export class SharesService {
|
||||
constructor(
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexSecurities: MoexSecuritiesClient,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexDividends: MoexDividendsClient,
|
||||
private readonly moexHistory: MoexHistoryClient,
|
||||
private readonly cache: CacheService,
|
||||
) {}
|
||||
|
||||
async getShare(secid: string) {
|
||||
const desc = await this.moexClient.getSecurityDescription(secid);
|
||||
const desc = await this.moexSecurities.getSecurityDescription(secid);
|
||||
if (
|
||||
!desc ||
|
||||
!(
|
||||
@ -31,7 +37,7 @@ export class SharesService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['shares', secid],
|
||||
() => this.moexClient.getShareMarketData(secid),
|
||||
() => this.moexMarketData.getShareMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
@ -79,7 +85,7 @@ export class SharesService {
|
||||
} = await this.cache.getOrFetch(
|
||||
'marketdata',
|
||||
['shares', secid],
|
||||
() => this.moexClient.getShareMarketData(secid),
|
||||
() => this.moexMarketData.getShareMarketData(secid),
|
||||
'marketDataTtl',
|
||||
);
|
||||
|
||||
@ -111,7 +117,7 @@ export class SharesService {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'dividends',
|
||||
[secid],
|
||||
() => this.moexClient.getDividends(secid),
|
||||
() => this.moexDividends.getDividends(secid),
|
||||
'dividendsTtl',
|
||||
);
|
||||
|
||||
@ -130,7 +136,7 @@ export class SharesService {
|
||||
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
|
||||
'history',
|
||||
['shares', secid, from, till],
|
||||
() => this.moexClient.getHistory(secid, from, till),
|
||||
() => this.moexHistory.getHistory(secid, from, till),
|
||||
'historyTtl',
|
||||
);
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import { MoexClientService } from '../../moex-client/moex-client.service';
|
||||
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';
|
||||
@ -9,10 +10,8 @@ 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 moex = {
|
||||
getDividends: vi.fn(),
|
||||
getBondPositionDataBatch: vi.fn(),
|
||||
} as unknown as MoexClientService;
|
||||
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;
|
||||
|
||||
@ -42,7 +41,7 @@ describe('BrokerEventsService', () => {
|
||||
it('throws 404 for missing account', async () => {
|
||||
vi.mocked(accounts.findById).mockResolvedValue(null);
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -62,7 +61,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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([]);
|
||||
@ -87,7 +86,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map([['uid-sber', { name: 'Sberbank', currency: 'RUB' }]]),
|
||||
});
|
||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
||||
{
|
||||
secid: 'SBER',
|
||||
isin: 'RU000A0JS',
|
||||
@ -117,7 +116,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -144,7 +143,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map([['uid-bond-1', { name: 'OFZ 26248', currency: 'RUB' }]]),
|
||||
});
|
||||
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'SU26248RMFS4',
|
||||
couponValue: 35.4,
|
||||
@ -172,7 +171,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -207,8 +206,8 @@ describe('BrokerEventsService', () => {
|
||||
['uid-2', { name: 'Working' }],
|
||||
]),
|
||||
});
|
||||
vi.mocked(moex.getDividends).mockRejectedValueOnce(new Error('MOEX error'));
|
||||
vi.mocked(moex.getDividends).mockResolvedValueOnce([
|
||||
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' },
|
||||
]);
|
||||
|
||||
@ -218,7 +217,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -239,7 +238,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map([['uid-1', { name: 'No Amount' }]]),
|
||||
});
|
||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
||||
{ secid: 'NO_AMT', isin: 'RU', registryCloseDate: '2026-06-25', value: 0, currencyId: 'RUB' },
|
||||
]);
|
||||
|
||||
@ -249,7 +248,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -279,10 +278,10 @@ describe('BrokerEventsService', () => {
|
||||
['uid-2', { name: 'OFZ' }],
|
||||
]),
|
||||
});
|
||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
||||
{ secid: 'SBER', isin: 'RU1', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
|
||||
]);
|
||||
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'BOND1',
|
||||
couponValue: 50,
|
||||
@ -310,7 +309,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -337,7 +336,7 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map([['uid-1', { name: 'Sber' }]]),
|
||||
});
|
||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||
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' },
|
||||
@ -349,7 +348,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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);
|
||||
@ -377,10 +376,10 @@ describe('BrokerEventsService', () => {
|
||||
],
|
||||
instruments: new Map(),
|
||||
});
|
||||
vi.mocked(moex.getDividends).mockResolvedValue([
|
||||
vi.mocked(moexDividends.getDividends).mockResolvedValue([
|
||||
{ secid: 'SBER', isin: 'RU', registryCloseDate: '2026-06-25', value: 30, currencyId: 'RUB' },
|
||||
]);
|
||||
vi.mocked(moex.getBondPositionDataBatch).mockResolvedValue([
|
||||
vi.mocked(moexMarketData.getBondPositionDataBatch).mockResolvedValue([
|
||||
{
|
||||
secid: 'BOND1',
|
||||
couponValue: 50,
|
||||
@ -407,7 +406,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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',
|
||||
@ -467,7 +466,7 @@ describe('BrokerEventsService', () => {
|
||||
cachedAt: null,
|
||||
});
|
||||
|
||||
const service = new BrokerEventsService(accounts, portfolio, moex, operations, cache);
|
||||
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',
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CacheService } from '../../cache/cache.service';
|
||||
import { MoexClientService } from '../../moex-client/moex-client.service';
|
||||
import { MoexMarketDataClient } from '../../moex-client/moex-market-data.client';
|
||||
import { MoexDividendsClient } from '../../moex-client/moex-dividends.client';
|
||||
import { ApiEnvelopePayload } from '../../../common/dto/api-response.dto';
|
||||
import { EntityNotFoundException } from '../../../common/exceptions/entity-not-found.exception';
|
||||
import { TBANK_CACHE_KEYS } from '../tbank.config';
|
||||
@ -46,7 +47,8 @@ export class BrokerEventsService {
|
||||
constructor(
|
||||
private readonly accountsService: BrokerAccountsService,
|
||||
private readonly portfolioService: BrokerPortfolioService,
|
||||
private readonly moexClient: MoexClientService,
|
||||
private readonly moexMarketData: MoexMarketDataClient,
|
||||
private readonly moexDividends: MoexDividendsClient,
|
||||
private readonly operationsService: BrokerOperationsService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {}
|
||||
@ -139,7 +141,7 @@ export class BrokerEventsService {
|
||||
|
||||
let dividends: { registryCloseDate: string; value: number; currencyId: string }[];
|
||||
try {
|
||||
dividends = await this.moexClient.getDividends(ticker);
|
||||
dividends = await this.moexDividends.getDividends(ticker);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@ -199,7 +201,7 @@ export class BrokerEventsService {
|
||||
faceValue: number;
|
||||
}[];
|
||||
try {
|
||||
bondData = await this.moexClient.getBondPositionDataBatch(secids);
|
||||
bondData = await this.moexMarketData.getBondPositionDataBatch(secids);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user