feat: add securities search endpoint with .json fix for MOEX ISS

This commit is contained in:
Sergey Krylov 2026-06-13 19:02:52 +03:00
parent 5315473973
commit f2be35d544
8 changed files with 214 additions and 8 deletions

View File

@ -25,19 +25,16 @@ describe('MoexClientService', () => {
describe('searchSecurities', () => { describe('searchSecurities', () => {
it('should return results for SBER query', async () => { it('should return results for SBER query', async () => {
const results = await service.searchSecurities('SBER'); const results = await service.searchSecurities('SBER');
expect(Array.isArray(results)).toBe(true); expect(results.length).toBeGreaterThan(0);
if (results.length > 0) { expect(results[0].secid).toBeDefined();
expect(results[0].secid).toBeDefined();
}
}, 15000); }, 15000);
}); });
describe('getShareMarketData', () => { describe('getShareMarketData', () => {
it('should return market data for SBER', async () => { it('should return market data for SBER', async () => {
const data = await service.getShareMarketData('SBER'); const data = await service.getShareMarketData('SBER');
if (data) { expect(data).toBeDefined();
expect(data.secid).toBe('SBER'); expect(data!.secid).toBe('SBER');
}
}, 15000); }, 15000);
}); });
}); });

View File

@ -58,7 +58,8 @@ export class MoexClientService {
return this.queue.add(async () => { return this.queue.add(async () => {
try { try {
const response = await this.client.get(path, { const jsonPath = path + '.json';
const response = await this.client.get(jsonPath, {
params: { ...params, 'iss.meta': 'off' }, params: { ...params, 'iss.meta': 'off' },
}); });
this.circuitErrorCount = 0; this.circuitErrorCount = 0;

View File

@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsString, IsOptional, IsEnum, MinLength, MaxLength } from 'class-validator';
export enum SecurityType {
ALL = 'all',
SHARE = 'share',
BOND = 'bond',
}
export class SearchQueryDto {
@ApiProperty({ description: 'Поисковый запрос (тикер, название, ISIN)' })
@IsString()
@MinLength(1)
@MaxLength(100)
q!: string;
@ApiPropertyOptional({ enum: SecurityType, default: SecurityType.ALL })
@IsOptional()
@IsEnum(SecurityType)
type?: SecurityType = SecurityType.ALL;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
limit?: number = 20;
}

View File

@ -0,0 +1,39 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service';
import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesController', () => {
let controller: SecuritiesController;
let service: SecuritiesService;
const mockResults = [
{ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк', type: 'share', listLevel: 1, currency: 'RUB', price: null },
];
const mockService = {
search: vi.fn().mockResolvedValue(mockResults),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SecuritiesController],
providers: [
{ provide: SecuritiesService, useValue: mockService },
],
}).compile();
controller = module.get<SecuritiesController>(SecuritiesController);
service = module.get<SecuritiesService>(SecuritiesService);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('should return search results', async () => {
const result = await controller.search({ q: 'SBER', type: SecurityType.ALL, limit: 5 });
expect(result.data).toEqual(mockResults);
expect(service.search).toHaveBeenCalledWith('SBER', SecurityType.ALL, 5);
});
});

View File

@ -0,0 +1,21 @@
import { Controller, Get, Query, ValidationPipe } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { SecuritiesService } from './securities.service';
import { SearchQueryDto, SecurityType } from './dto/search-query.dto';
@ApiTags('Securities')
@Controller('securities')
export class SecuritiesController {
constructor(private readonly securitiesService: SecuritiesService) {}
@Get('search')
@ApiOperation({ summary: 'Поиск по инструментам' })
async search(@Query(ValidationPipe) query: SearchQueryDto) {
const results = await this.securitiesService.search(
query.q,
query.type || SecurityType.ALL,
query.limit || 20,
);
return { data: results, meta: { cachedAt: null, fromCache: false } };
}
}

View File

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { CacheModule } from '../cache/cache.module';
import { SecuritiesController } from './securities.controller';
import { SecuritiesService } from './securities.service';
@Module({
imports: [CacheModule],
controllers: [SecuritiesController],
providers: [SecuritiesService],
exports: [SecuritiesService],
})
export class SecuritiesModule {}

View File

@ -0,0 +1,34 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import { SecuritiesService } from './securities.service';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import configuration from '../../config/configuration';
import { SecurityType } from './dto/search-query.dto';
describe('SecuritiesService', () => {
let service: SecuritiesService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [
SecuritiesService,
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
},
CacheService,
],
}).compile();
service = module.get<SecuritiesService>(SecuritiesService);
});
it('should return search results for SBER', async () => {
const results = await service.search('SBER', SecurityType.ALL, 5);
expect(results.length).toBeGreaterThan(0);
expect(results[0].secid).toBeDefined();
}, 15000);
});

View File

@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { MoexClientService } from '../moex-client/moex-client.service';
import { CacheService } from '../cache/cache.service';
import { SecurityType } from './dto/search-query.dto';
export interface SearchResultItem {
secid: string;
isin: string;
shortName: string;
type: 'share' | 'bond';
listLevel: number;
currency: string | null;
price: number | null;
}
@Injectable()
export class SecuritiesService {
constructor(
private readonly moexClient: MoexClientService,
private readonly cache: CacheService,
) {}
async search(query: string, type: SecurityType, limit: number): Promise<SearchResultItem[]> {
const { data } = await this.cache.getOrFetch(
'search',
[query.toLowerCase()],
async () => {
const results = await this.moexClient.searchSecurities(query);
return results
.map((s): SearchResultItem | null => {
const type = (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share')
? 'share' as const
: (s.group === 'stock_bonds' ? 'bond' as const : null);
if (!type) return null;
return {
secid: s.secid,
isin: s.isin,
shortName: s.shortName,
type,
listLevel: s.listLevel,
currency: s.faceUnit === 'SUR' ? 'RUB' : s.faceUnit || null,
price: null,
};
})
.filter((r): r is SearchResultItem => r !== null);
},
'searchTtl',
);
let filtered = data;
if (type === SecurityType.SHARE) {
filtered = data.filter((r) => r.type === 'share');
} else if (type === SecurityType.BOND) {
filtered = data.filter((r) => r.type === 'bond');
}
return filtered.slice(0, limit);
}
async getShareBrief(secid: string): Promise<SearchResultItem | null> {
try {
const desc = await this.moexClient.getSecurityDescription(secid);
if (!desc) return null;
return {
secid: desc.secid,
isin: desc.isin,
shortName: desc.shortName,
type: 'share',
listLevel: desc.listLevel,
currency: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit,
price: null,
};
} catch {
return null;
}
}
}