diff --git a/apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts b/apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts new file mode 100644 index 0000000..9ae5550 --- /dev/null +++ b/apps/backend/src/modules/shares/dto/share-marketdata-response.dto.ts @@ -0,0 +1,3 @@ +import { StockMarketDataDto } from './share-response.dto'; + +export class ShareMarketDataResponseDto extends StockMarketDataDto {} diff --git a/apps/backend/src/modules/shares/dto/share-response.dto.ts b/apps/backend/src/modules/shares/dto/share-response.dto.ts new file mode 100644 index 0000000..8c0640b --- /dev/null +++ b/apps/backend/src/modules/shares/dto/share-response.dto.ts @@ -0,0 +1,68 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class StockMarketDataDto { + @ApiProperty({ example: 322.35 }) + price!: number; + + @ApiProperty({ example: 1.15 }) + change!: number; + + @ApiProperty({ example: 0.36 }) + changePercent!: number; + + @ApiProperty({ example: 321.3 }) + open!: number; + + @ApiPropertyOptional({ example: 322.66 }) + high!: number | null; + + @ApiPropertyOptional({ example: 321.2 }) + low!: number | null; + + @ApiProperty({ example: 1925163 }) + volume!: number; + + @ApiProperty({ example: 620184479 }) + value!: number; + + @ApiPropertyOptional({ example: 6958336818320 }) + issueCapitalization!: number | null; + + @ApiProperty() + updatedAt!: string; +} + +export class ShareResponseDto { + @ApiProperty({ example: 'SBER' }) + secid!: string; + + @ApiProperty({ example: 'RU0009029540' }) + isin!: string; + + @ApiProperty({ example: 'Сбербанк России ПАО ао' }) + name!: string; + + @ApiProperty({ example: 'Сбербанк' }) + shortName!: string; + + @ApiPropertyOptional() + latName!: string | null; + + @ApiProperty({ example: 1 }) + listLevel!: number; + + @ApiProperty({ example: 21586948000 }) + issueSize!: number; + + @ApiProperty({ example: 3 }) + faceValue!: number; + + @ApiProperty({ example: 'RUB' }) + faceUnit!: string; + + @ApiProperty({ example: 'common_share' }) + type!: string; + + @ApiProperty() + marketData!: StockMarketDataDto; +} diff --git a/apps/backend/src/modules/shares/shares.controller.ts b/apps/backend/src/modules/shares/shares.controller.ts new file mode 100644 index 0000000..db3f7af --- /dev/null +++ b/apps/backend/src/modules/shares/shares.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { SharesService } from './shares.service'; + +@ApiTags('Shares') +@Controller('securities/shares') +export class SharesController { + constructor(private readonly sharesService: SharesService) {} + + @Get(':secid') + @ApiOperation({ summary: 'Получить спецификацию акции' }) + async getShare(@Param('secid') secid: string) { + const share = await this.sharesService.getShare(secid); + return { data: share, meta: { cachedAt: null, fromCache: false } }; + } + + @Get(':secid/marketdata') + @ApiOperation({ summary: 'Получить рыночные данные акции' }) + async getMarketData(@Param('secid') secid: string) { + return this.sharesService.getMarketData(secid); + } + + @Get(':secid/dividends') + @ApiOperation({ summary: 'Получить дивиденды' }) + async getDividends(@Param('secid') secid: string) { + return this.sharesService.getDividends(secid); + } +} diff --git a/apps/backend/src/modules/shares/shares.module.ts b/apps/backend/src/modules/shares/shares.module.ts new file mode 100644 index 0000000..cdce14e --- /dev/null +++ b/apps/backend/src/modules/shares/shares.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { CacheModule } from '../cache/cache.module'; +import { SharesController } from './shares.controller'; +import { SharesService } from './shares.service'; + +@Module({ + imports: [CacheModule], + controllers: [SharesController], + providers: [SharesService], + exports: [SharesService], +}) +export class SharesModule {} diff --git a/apps/backend/src/modules/shares/shares.service.spec.ts b/apps/backend/src/modules/shares/shares.service.spec.ts new file mode 100644 index 0000000..33e499d --- /dev/null +++ b/apps/backend/src/modules/shares/shares.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { SharesService } from './shares.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; + +describe('SharesService', () => { + let service: SharesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + SharesService, + MoexClientService, + { + provide: 'CACHE_MANAGER', + useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, + }, + CacheService, + ], + }).compile(); + + service = module.get(SharesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return SBER share data', async () => { + const share = await service.getShare('SBER'); + expect(share.secid).toBe('SBER'); + expect(share.marketData).toBeDefined(); + }, 15000); +}); diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts new file mode 100644 index 0000000..fb7cf2f --- /dev/null +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -0,0 +1,105 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +@Injectable() +export class SharesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async getShare(secid: string) { + const desc = await this.moexClient.getSecurityDescription(secid); + if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) { + throw new NotFoundException(`Share ${secid} not found`); + } + + const { data: marketData } = await this.cache.getOrFetch( + 'marketdata', + ['shares', secid], + () => this.moexClient.getShareMarketData(secid), + 'marketDataTtl', + ); + + const price = marketData?.last ?? (marketData ? null : 0); + const change = marketData?.lastChange ?? 0; + const changePercent = marketData?.lastChangePrcnt ?? 0; + + return { + secid: desc.secid, + isin: desc.isin, + name: desc.name, + shortName: desc.shortName, + latName: desc.latName, + listLevel: desc.listLevel, + issueSize: desc.issueSize, + faceValue: desc.faceValue, + faceUnit: desc.faceUnit === 'SUR' ? 'RUB' : desc.faceUnit, + type: desc.type, + marketData: { + price: price ?? 0, + change, + changePercent, + open: marketData?.open ?? 0, + high: marketData?.high ?? null, + low: marketData?.low ?? null, + volume: marketData?.volume ?? 0, + value: marketData?.value ?? 0, + issueCapitalization: marketData?.issueCapitalization ?? null, + updatedAt: marketData?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, + }; + } + + async getMarketData(secid: string) { + const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch( + 'marketdata', + ['shares', secid], + () => this.moexClient.getShareMarketData(secid), + 'marketDataTtl', + ); + + if (!marketData) { + throw new NotFoundException(`Market data for ${secid} not found`); + } + + return { + data: { + price: marketData.last ?? 0, + change: marketData.lastChange ?? 0, + changePercent: marketData.lastChangePrcnt ?? 0, + open: marketData.open ?? 0, + high: marketData.high ?? null, + low: marketData.low ?? null, + volume: marketData.volume ?? 0, + value: marketData.value ?? 0, + issueCapitalization: marketData.issueCapitalization ?? null, + updatedAt: marketData.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + marketData.updateTime + : new Date().toISOString(), + }, + meta: { fromCache, cachedAt }, + }; + } + + async getDividends(secid: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'dividends', + [secid], + () => this.moexClient.getDividends(secid), + 'dividendsTtl', + ); + + return { + data: data.map((d) => ({ + registryCloseDate: d.registryCloseDate, + value: d.value, + currency: d.currencyId, + })), + meta: { fromCache, cachedAt }, + }; + } +}