feat: add bonds endpoint with market data and history

This commit is contained in:
Sergey Krylov 2026-06-13 19:07:18 +03:00
parent fdb8b52e38
commit 45042d9cc7
6 changed files with 304 additions and 1 deletions

View File

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

View File

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

View File

@ -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>(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);
});

View File

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

View File

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

View File

@ -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 {