feat: add bonds endpoint with market data and history
This commit is contained in:
parent
fdb8b52e38
commit
45042d9cc7
31
apps/backend/src/modules/bonds/bonds.controller.ts
Normal file
31
apps/backend/src/modules/bonds/bonds.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/backend/src/modules/bonds/bonds.module.ts
Normal file
10
apps/backend/src/modules/bonds/bonds.module.ts
Normal 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 {}
|
||||
37
apps/backend/src/modules/bonds/bonds.service.spec.ts
Normal file
37
apps/backend/src/modules/bonds/bonds.service.spec.ts
Normal 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);
|
||||
});
|
||||
124
apps/backend/src/modules/bonds/bonds.service.ts
Normal file
124
apps/backend/src/modules/bonds/bonds.service.ts
Normal 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 },
|
||||
};
|
||||
}
|
||||
}
|
||||
101
apps/backend/src/modules/bonds/dto/bond-response.dto.ts
Normal file
101
apps/backend/src/modules/bonds/dto/bond-response.dto.ts
Normal 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;
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user