From d850f3f4fd891b1898ff7d0475a646e3690d2962 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 13 Jun 2026 19:08:33 +0300 Subject: [PATCH] feat: add candles module and share history endpoint --- .../src/modules/candles/candles.controller.ts | 28 +++++++++++ .../src/modules/candles/candles.module.ts | 10 ++++ .../modules/candles/candles.service.spec.ts | 44 +++++++++++++++++ .../src/modules/candles/candles.service.ts | 47 +++++++++++++++++++ .../modules/candles/dto/candles-query.dto.ts | 21 +++++++++ .../src/modules/shares/shares.controller.ts | 12 ++++- .../src/modules/shares/shares.service.ts | 22 +++++++++ 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/modules/candles/candles.controller.ts create mode 100644 apps/backend/src/modules/candles/candles.module.ts create mode 100644 apps/backend/src/modules/candles/candles.service.spec.ts create mode 100644 apps/backend/src/modules/candles/candles.service.ts create mode 100644 apps/backend/src/modules/candles/dto/candles-query.dto.ts diff --git a/apps/backend/src/modules/candles/candles.controller.ts b/apps/backend/src/modules/candles/candles.controller.ts new file mode 100644 index 0000000..15747d9 --- /dev/null +++ b/apps/backend/src/modules/candles/candles.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get, Param, Query, ValidationPipe } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { CandlesService } from './candles.service'; +import { CandlesQueryDto } from './dto/candles-query.dto'; + +@ApiTags('Candles') +@Controller('securities') +export class CandlesController { + constructor(private readonly candlesService: CandlesService) {} + + @Get('shares/:secid/candles') + @ApiOperation({ summary: 'Получить свечи акции' }) + async getShareCandles( + @Param('secid') secid: string, + @Query(ValidationPipe) query: CandlesQueryDto, + ) { + return this.candlesService.getCandles('shares', secid, query.interval, query.from, query.till); + } + + @Get('bonds/:secid/candles') + @ApiOperation({ summary: 'Получить свечи облигации' }) + async getBondCandles( + @Param('secid') secid: string, + @Query(ValidationPipe) query: CandlesQueryDto, + ) { + return this.candlesService.getCandles('bonds', secid, query.interval, query.from, query.till); + } +} diff --git a/apps/backend/src/modules/candles/candles.module.ts b/apps/backend/src/modules/candles/candles.module.ts new file mode 100644 index 0000000..79f8a35 --- /dev/null +++ b/apps/backend/src/modules/candles/candles.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CandlesController } from './candles.controller'; +import { CandlesService } from './candles.service'; + +@Module({ + controllers: [CandlesController], + providers: [CandlesService], + exports: [CandlesService], +}) +export class CandlesModule {} diff --git a/apps/backend/src/modules/candles/candles.service.spec.ts b/apps/backend/src/modules/candles/candles.service.spec.ts new file mode 100644 index 0000000..35833e4 --- /dev/null +++ b/apps/backend/src/modules/candles/candles.service.spec.ts @@ -0,0 +1,44 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { CandlesService } from './candles.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; +import { CandleInterval } from './dto/candles-query.dto'; + +describe('CandlesService', () => { + let service: CandlesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + CandlesService, + MoexClientService, + { + provide: 'CACHE_MANAGER', + useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, + }, + CacheService, + ], + }).compile(); + + service = module.get(CandlesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return daily candles for SBER', async () => { + const result = await service.getCandles( + 'shares', + 'SBER', + CandleInterval.DAY, + '2026-05-01', + '2026-06-01', + ); + expect(result.data.length).toBeGreaterThan(0); + expect(result.data[0].open).toBeDefined(); + }, 15000); +}); diff --git a/apps/backend/src/modules/candles/candles.service.ts b/apps/backend/src/modules/candles/candles.service.ts new file mode 100644 index 0000000..8754624 --- /dev/null +++ b/apps/backend/src/modules/candles/candles.service.ts @@ -0,0 +1,47 @@ +import { Injectable } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import { CandleInterval } from './dto/candles-query.dto'; + +@Injectable() +export class CandlesService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + private mapInterval(interval: CandleInterval): 60 | 24 { + return interval === CandleInterval.HOUR ? 60 : 24; + } + + async getCandles( + market: 'shares' | 'bonds', + secid: string, + interval: CandleInterval, + from: string, + till: string, + ) { + const moexInterval = this.mapInterval(interval); + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'candles', + [market, secid, String(moexInterval), from, till], + () => + this.moexClient.getCandles('stock', market, secid, moexInterval, from, till), + 'candlesTtl', + ); + + return { + data: data.map((c) => ({ + open: c.open, + high: c.high, + low: c.low, + close: c.close, + volume: c.volume, + value: c.value, + begin: c.begin, + end: c.end, + })), + meta: { fromCache, cachedAt }, + }; + } +} diff --git a/apps/backend/src/modules/candles/dto/candles-query.dto.ts b/apps/backend/src/modules/candles/dto/candles-query.dto.ts new file mode 100644 index 0000000..d853b37 --- /dev/null +++ b/apps/backend/src/modules/candles/dto/candles-query.dto.ts @@ -0,0 +1,21 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsEnum, IsDateString } from 'class-validator'; + +export enum CandleInterval { + HOUR = '1h', + DAY = '24h', +} + +export class CandlesQueryDto { + @ApiProperty({ enum: CandleInterval }) + @IsEnum(CandleInterval) + interval!: CandleInterval; + + @ApiProperty({ format: 'date', example: '2025-06-13' }) + @IsDateString() + from!: string; + + @ApiProperty({ format: 'date', example: '2026-06-13' }) + @IsDateString() + till!: string; +} diff --git a/apps/backend/src/modules/shares/shares.controller.ts b/apps/backend/src/modules/shares/shares.controller.ts index db3f7af..ea8a41d 100644 --- a/apps/backend/src/modules/shares/shares.controller.ts +++ b/apps/backend/src/modules/shares/shares.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param } from '@nestjs/common'; +import { Controller, Get, Param, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { SharesService } from './shares.service'; @@ -25,4 +25,14 @@ export class SharesController { async getDividends(@Param('secid') secid: string) { return this.sharesService.getDividends(secid); } + + @Get(':secid/history') + @ApiOperation({ summary: 'Получить дневную историю торгов акции' }) + async getHistory( + @Param('secid') secid: string, + @Query('from') from: string, + @Query('till') till: string, + ) { + return this.sharesService.getHistory(secid, from, till); + } } diff --git a/apps/backend/src/modules/shares/shares.service.ts b/apps/backend/src/modules/shares/shares.service.ts index fb7cf2f..0afc476 100644 --- a/apps/backend/src/modules/shares/shares.service.ts +++ b/apps/backend/src/modules/shares/shares.service.ts @@ -102,4 +102,26 @@ export class SharesService { meta: { fromCache, cachedAt }, }; } + + async getHistory(secid: string, from: string, till: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'history', + ['shares', secid, from, till], + () => this.moexClient.getHistory(secid, from, till), + 'historyTtl', + ); + + return { + data: data.map((h) => ({ + date: h.tradeDate, + open: h.open ?? 0, + high: h.high ?? 0, + low: h.low ?? 0, + close: h.close ?? 0, + volume: h.volume, + value: h.value, + })), + meta: { fromCache, cachedAt }, + }; + } }