From f2be35d544a97ff905a80a5627442cc259db3356 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 13 Jun 2026 19:02:52 +0300 Subject: [PATCH] feat: add securities search endpoint with .json fix for MOEX ISS --- .../moex-client/moex-client.service.spec.ts | 11 +-- .../moex-client/moex-client.service.ts | 3 +- .../securities/dto/search-query.dto.ts | 25 ++++++ .../securities/securities.controller.spec.ts | 39 ++++++++++ .../securities/securities.controller.ts | 21 +++++ .../modules/securities/securities.module.ts | 12 +++ .../securities/securities.service.spec.ts | 34 ++++++++ .../modules/securities/securities.service.ts | 77 +++++++++++++++++++ 8 files changed, 214 insertions(+), 8 deletions(-) create mode 100644 apps/backend/src/modules/securities/dto/search-query.dto.ts create mode 100644 apps/backend/src/modules/securities/securities.controller.spec.ts create mode 100644 apps/backend/src/modules/securities/securities.controller.ts create mode 100644 apps/backend/src/modules/securities/securities.module.ts create mode 100644 apps/backend/src/modules/securities/securities.service.spec.ts create mode 100644 apps/backend/src/modules/securities/securities.service.ts diff --git a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts index ff370da..cb0cc50 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.spec.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.spec.ts @@ -25,19 +25,16 @@ describe('MoexClientService', () => { describe('searchSecurities', () => { it('should return results for SBER query', async () => { const results = await service.searchSecurities('SBER'); - expect(Array.isArray(results)).toBe(true); - if (results.length > 0) { - expect(results[0].secid).toBeDefined(); - } + expect(results.length).toBeGreaterThan(0); + expect(results[0].secid).toBeDefined(); }, 15000); }); describe('getShareMarketData', () => { it('should return market data for SBER', async () => { const data = await service.getShareMarketData('SBER'); - if (data) { - expect(data.secid).toBe('SBER'); - } + expect(data).toBeDefined(); + expect(data!.secid).toBe('SBER'); }, 15000); }); }); diff --git a/apps/backend/src/modules/moex-client/moex-client.service.ts b/apps/backend/src/modules/moex-client/moex-client.service.ts index e940ff8..9e80389 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.ts @@ -58,7 +58,8 @@ export class MoexClientService { return this.queue.add(async () => { try { - const response = await this.client.get(path, { + const jsonPath = path + '.json'; + const response = await this.client.get(jsonPath, { params: { ...params, 'iss.meta': 'off' }, }); this.circuitErrorCount = 0; diff --git a/apps/backend/src/modules/securities/dto/search-query.dto.ts b/apps/backend/src/modules/securities/dto/search-query.dto.ts new file mode 100644 index 0000000..51b16c9 --- /dev/null +++ b/apps/backend/src/modules/securities/dto/search-query.dto.ts @@ -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; +} diff --git a/apps/backend/src/modules/securities/securities.controller.spec.ts b/apps/backend/src/modules/securities/securities.controller.spec.ts new file mode 100644 index 0000000..7b30671 --- /dev/null +++ b/apps/backend/src/modules/securities/securities.controller.spec.ts @@ -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); + service = module.get(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); + }); +}); diff --git a/apps/backend/src/modules/securities/securities.controller.ts b/apps/backend/src/modules/securities/securities.controller.ts new file mode 100644 index 0000000..d07b06e --- /dev/null +++ b/apps/backend/src/modules/securities/securities.controller.ts @@ -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 } }; + } +} diff --git a/apps/backend/src/modules/securities/securities.module.ts b/apps/backend/src/modules/securities/securities.module.ts new file mode 100644 index 0000000..4a84fc6 --- /dev/null +++ b/apps/backend/src/modules/securities/securities.module.ts @@ -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 {} diff --git a/apps/backend/src/modules/securities/securities.service.spec.ts b/apps/backend/src/modules/securities/securities.service.spec.ts new file mode 100644 index 0000000..bc68f1c --- /dev/null +++ b/apps/backend/src/modules/securities/securities.service.spec.ts @@ -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); + }); + + 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); +}); diff --git a/apps/backend/src/modules/securities/securities.service.ts b/apps/backend/src/modules/securities/securities.service.ts new file mode 100644 index 0000000..4a42ae6 --- /dev/null +++ b/apps/backend/src/modules/securities/securities.service.ts @@ -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 { + 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 { + 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; + } + } +}