MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1
@ -0,0 +1,3 @@
|
||||
import { StockMarketDataDto } from './share-response.dto';
|
||||
|
||||
export class ShareMarketDataResponseDto extends StockMarketDataDto {}
|
||||
68
apps/backend/src/modules/shares/dto/share-response.dto.ts
Normal file
68
apps/backend/src/modules/shares/dto/share-response.dto.ts
Normal file
@ -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;
|
||||
}
|
||||
28
apps/backend/src/modules/shares/shares.controller.ts
Normal file
28
apps/backend/src/modules/shares/shares.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
12
apps/backend/src/modules/shares/shares.module.ts
Normal file
12
apps/backend/src/modules/shares/shares.module.ts
Normal file
@ -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 {}
|
||||
37
apps/backend/src/modules/shares/shares.service.spec.ts
Normal file
37
apps/backend/src/modules/shares/shares.service.spec.ts
Normal file
@ -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>(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);
|
||||
});
|
||||
105
apps/backend/src/modules/shares/shares.service.ts
Normal file
105
apps/backend/src/modules/shares/shares.service.ts
Normal file
@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user