From 45042d9cc72c724d60ec604a5af461d76ac5ab7f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sat, 13 Jun 2026 19:07:18 +0300 Subject: [PATCH] feat: add bonds endpoint with market data and history --- .../src/modules/bonds/bonds.controller.ts | 31 +++++ .../backend/src/modules/bonds/bonds.module.ts | 10 ++ .../src/modules/bonds/bonds.service.spec.ts | 37 ++++++ .../src/modules/bonds/bonds.service.ts | 124 ++++++++++++++++++ .../modules/bonds/dto/bond-response.dto.ts | 101 ++++++++++++++ .../moex-client/moex-client.service.ts | 2 +- 6 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/modules/bonds/bonds.controller.ts create mode 100644 apps/backend/src/modules/bonds/bonds.module.ts create mode 100644 apps/backend/src/modules/bonds/bonds.service.spec.ts create mode 100644 apps/backend/src/modules/bonds/bonds.service.ts create mode 100644 apps/backend/src/modules/bonds/dto/bond-response.dto.ts diff --git a/apps/backend/src/modules/bonds/bonds.controller.ts b/apps/backend/src/modules/bonds/bonds.controller.ts new file mode 100644 index 0000000..613431a --- /dev/null +++ b/apps/backend/src/modules/bonds/bonds.controller.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { BondsService } from './bonds.service'; + +@ApiTags('Bonds') +@Controller('securities/bonds') +export class BondsController { + constructor(private readonly bondsService: BondsService) {} + + @Get(':secid') + @ApiOperation({ summary: 'Получить спецификацию облигации' }) + async getBond(@Param('secid') secid: string) { + return this.bondsService.getBond(secid); + } + + @Get(':secid/marketdata') + @ApiOperation({ summary: 'Получить рыночные данные облигации' }) + async getMarketData(@Param('secid') secid: string) { + return this.bondsService.getMarketData(secid); + } + + @Get(':secid/history') + @ApiOperation({ summary: 'Получить дневную историю торгов облигации' }) + async getHistory( + @Param('secid') secid: string, + @Query('from') from: string, + @Query('till') till: string, + ) { + return this.bondsService.getHistory(secid, from, till); + } +} diff --git a/apps/backend/src/modules/bonds/bonds.module.ts b/apps/backend/src/modules/bonds/bonds.module.ts new file mode 100644 index 0000000..b3daab4 --- /dev/null +++ b/apps/backend/src/modules/bonds/bonds.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { BondsController } from './bonds.controller'; +import { BondsService } from './bonds.service'; + +@Module({ + controllers: [BondsController], + providers: [BondsService], + exports: [BondsService], +}) +export class BondsModule {} diff --git a/apps/backend/src/modules/bonds/bonds.service.spec.ts b/apps/backend/src/modules/bonds/bonds.service.spec.ts new file mode 100644 index 0000000..e380db7 --- /dev/null +++ b/apps/backend/src/modules/bonds/bonds.service.spec.ts @@ -0,0 +1,37 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import { BondsService } from './bonds.service'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; +import configuration from '../../config/configuration'; + +describe('BondsService', () => { + let service: BondsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + imports: [ConfigModule.forRoot({ load: [configuration] })], + providers: [ + BondsService, + MoexClientService, + { + provide: 'CACHE_MANAGER', + useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() }, + }, + CacheService, + ], + }).compile(); + + service = module.get(BondsService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return OFZ bond data for SU26207RMFS9', async () => { + const result = await service.getBond('SU26207RMFS9'); + expect(result.data.secid).toBe('SU26207RMFS9'); + expect(result.data.marketData).toBeDefined(); + }, 15000); +}); diff --git a/apps/backend/src/modules/bonds/bonds.service.ts b/apps/backend/src/modules/bonds/bonds.service.ts new file mode 100644 index 0000000..c8e0e44 --- /dev/null +++ b/apps/backend/src/modules/bonds/bonds.service.ts @@ -0,0 +1,124 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { MoexClientService } from '../moex-client/moex-client.service'; +import { CacheService } from '../cache/cache.service'; + +@Injectable() +export class BondsService { + constructor( + private readonly moexClient: MoexClientService, + private readonly cache: CacheService, + ) {} + + async getBond(secid: string) { + const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch( + 'bond', + [secid], + () => this.moexClient.getBondData(secid), + 'securityTtl', + ); + + if (!bond) { + throw new NotFoundException(`Bond ${secid} not found`); + } + + const { data: mkt } = await this.cache.getOrFetch( + 'marketdata', + ['bonds', secid], + () => this.moexClient.getBondMarketData(secid), + 'marketDataTtl', + ); + + return { + data: { + secid: bond.secid, + isin: bond.isin, + name: bond.shortName, + shortName: bond.shortName, + latName: null, + listLevel: bond.listLevel, + issueSize: bond.issueSize, + faceValue: bond.faceValue, + faceUnit: bond.isin.startsWith('XS') ? 'USD' : 'RUB', + matDate: bond.matDate, + couponValue: bond.couponValue ?? 0, + couponPercent: bond.couponPercent, + couponPeriod: bond.couponPeriod, + nextCoupon: bond.nextCoupon, + accruedInt: bond.accruedInt ?? 0, + bondType: bond.bondType, + bondSubType: bond.bondSubType, + offerDate: bond.offerDate, + buybackDate: bond.buybackDate, + marketData: { + price: mkt?.last ?? bond.prevPrice ?? 0, + yieldToMaturity: mkt?.yield ?? bond.yieldAtPrevWaprice ?? null, + duration: mkt?.duration ?? null, + accruedInt: bond.accruedInt ?? 0, + couponValue: bond.couponValue ?? 0, + couponPercent: bond.couponPercent, + nextCouponDate: bond.nextCoupon, + open: mkt?.open ?? 0, + high: mkt?.high ?? null, + low: mkt?.low ?? null, + volume: mkt?.volume ?? 0, + updatedAt: mkt?.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime + : new Date().toISOString(), + }, + }, + meta: { fromCache, cachedAt }, + }; + } + + async getMarketData(secid: string) { + const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch( + 'marketdata', + ['bonds', secid], + () => this.moexClient.getBondMarketData(secid), + 'marketDataTtl', + ); + + if (!mkt) { + throw new NotFoundException(`Market data for bond ${secid} not found`); + } + + return { + data: { + price: mkt.last ?? 0, + yieldToMaturity: mkt.yield ?? null, + duration: mkt.duration ?? null, + accruedInt: 0, + couponValue: 0, + couponPercent: null, + nextCouponDate: null, + open: mkt.open ?? 0, + high: mkt.high ?? null, + low: mkt.low ?? null, + volume: mkt.volume ?? 0, + updatedAt: mkt.updateTime + ? new Date().toISOString().split('T')[0] + 'T' + mkt.updateTime + : new Date().toISOString(), + }, + meta: { fromCache, cachedAt }, + }; + } + + async getHistory(secid: string, from: string, till: string) { + const { data, fromCache, cachedAt } = await this.cache.getOrFetch( + 'history', + ['bonds', secid, from, till], + () => this.moexClient.getBondHistory(secid, from, till), + 'historyTtl', + ); + + return { + data: data.map((h) => ({ + date: h.tradeDate, + closePrice: h.legalClosePrice ?? h.close ?? 0, + yieldClose: h.yieldClose ?? null, + duration: h.duration ?? null, + })), + meta: { fromCache, cachedAt }, + }; + } +} diff --git a/apps/backend/src/modules/bonds/dto/bond-response.dto.ts b/apps/backend/src/modules/bonds/dto/bond-response.dto.ts new file mode 100644 index 0000000..bd4f347 --- /dev/null +++ b/apps/backend/src/modules/bonds/dto/bond-response.dto.ts @@ -0,0 +1,101 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class BondMarketDataDto { + @ApiProperty({ description: 'Цена в % от номинала', example: 100.45 }) + price!: number; + + @ApiPropertyOptional({ example: 12.71 }) + yieldToMaturity!: number | null; + + @ApiPropertyOptional() + duration!: number | null; + + @ApiProperty({ example: 29.48 }) + accruedInt!: number; + + @ApiProperty({ example: 40.64 }) + couponValue!: number; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent!: number | null; + + @ApiPropertyOptional({ example: '2026-08-05' }) + nextCouponDate!: string | null; + + @ApiProperty() + open!: number; + + @ApiPropertyOptional() + high!: number | null; + + @ApiPropertyOptional() + low!: number | null; + + @ApiProperty() + volume!: number; + + @ApiProperty() + updatedAt!: string; +} + +export class BondResponseDto { + @ApiProperty({ example: 'SU26207RMFS9' }) + secid!: string; + + @ApiProperty({ example: 'RU000A0JS3W6' }) + isin!: string; + + @ApiProperty({ example: 'ОФЗ-ПД 26207 03/02/27' }) + name!: string; + + @ApiProperty({ example: 'ОФЗ 26207' }) + shortName!: string; + + @ApiPropertyOptional() + latName!: string | null; + + @ApiProperty({ example: 1 }) + listLevel!: number; + + @ApiProperty({ example: 370200604 }) + issueSize!: number; + + @ApiProperty({ example: 1000 }) + faceValue!: number; + + @ApiProperty({ example: 'RUB' }) + faceUnit!: string; + + @ApiProperty({ example: '2027-02-03' }) + matDate!: string; + + @ApiProperty({ example: 40.64 }) + couponValue!: number; + + @ApiPropertyOptional({ example: 8.15 }) + couponPercent!: number | null; + + @ApiProperty({ example: 182 }) + couponPeriod!: number; + + @ApiProperty({ example: '2026-08-05' }) + nextCoupon!: string | null; + + @ApiProperty({ example: 29.48 }) + accruedInt!: number; + + @ApiProperty({ example: 'Фикс с известным купоном' }) + bondType!: string; + + @ApiProperty({ example: 'До погашения' }) + bondSubType!: string; + + @ApiPropertyOptional() + offerDate!: string | null; + + @ApiPropertyOptional() + buybackDate!: string | null; + + @ApiProperty() + marketData!: BondMarketDataDto; +} diff --git a/apps/backend/src/modules/moex-client/moex-client.service.ts b/apps/backend/src/modules/moex-client/moex-client.service.ts index 9e80389..f54e056 100644 --- a/apps/backend/src/modules/moex-client/moex-client.service.ts +++ b/apps/backend/src/modules/moex-client/moex-client.service.ts @@ -182,7 +182,7 @@ export class MoexClientService { { boards: boardId }, ); const rows = this.extractTable(data, 'securities'); - const bond = rows.find((r) => r.BOARDID === boardId); + const bond = rows.find((r) => r.BOARDID === boardId) || rows[0]; if (!bond) return null; return {