feat: add securities search endpoint with .json fix for MOEX ISS
This commit is contained in:
parent
5315473973
commit
f2be35d544
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
25
apps/backend/src/modules/securities/dto/search-query.dto.ts
Normal file
25
apps/backend/src/modules/securities/dto/search-query.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
21
apps/backend/src/modules/securities/securities.controller.ts
Normal file
21
apps/backend/src/modules/securities/securities.controller.ts
Normal 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/backend/src/modules/securities/securities.module.ts
Normal file
12
apps/backend/src/modules/securities/securities.module.ts
Normal 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 {}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
77
apps/backend/src/modules/securities/securities.service.ts
Normal file
77
apps/backend/src/modules/securities/securities.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user