MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1

Merged
ksv741 merged 21 commits from feat/mvp-implementation into main 2026-06-13 21:07:34 +03:00
6 changed files with 253 additions and 0 deletions
Showing only changes of commit f0a9a13d57 - Show all commits

View File

@ -0,0 +1,3 @@
import { StockMarketDataDto } from './share-response.dto';
export class ShareMarketDataResponseDto extends StockMarketDataDto {}

View 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;
}

View 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);
}
}

View 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 {}

View 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);
});

View 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 },
};
}
}