From 235c179ff813dac3312b849928d11734bccba823 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 10 Sep 2026 22:30:46 +0300 Subject: [PATCH] feat: add bond details, yields and credit ratings --- package-lock.json | 304 ++++++++++++++ package.json | 1 + src/tbank/bond.controller.ts | 101 +++++ src/tbank/bond.dto.ts | 154 +++++++ src/tbank/bond.service.spec.ts | 481 ++++++++++++++++++++++ src/tbank/bond.service.ts | 587 +++++++++++++++++++++++++++ src/tbank/instrument-market.ts | 44 ++ src/tbank/instrument-position.ts | 101 +++++ src/tbank/moex.client.spec.ts | 91 +++++ src/tbank/moex.client.ts | 54 +++ src/tbank/ncr-ratings.client.spec.ts | 85 ++++ src/tbank/ncr-ratings.client.ts | 107 +++++ src/tbank/stock-position.ts | 103 +---- src/tbank/stock.service.ts | 56 +-- src/tbank/tbank-connection.module.ts | 6 + src/tbank/tbank-market.types.ts | 26 ++ src/tbank/tbank.client.ts | 42 ++ test/bonds.e2e-spec.ts | 174 ++++++++ 18 files changed, 2371 insertions(+), 146 deletions(-) create mode 100644 src/tbank/bond.controller.ts create mode 100644 src/tbank/bond.dto.ts create mode 100644 src/tbank/bond.service.spec.ts create mode 100644 src/tbank/bond.service.ts create mode 100644 src/tbank/instrument-market.ts create mode 100644 src/tbank/instrument-position.ts create mode 100644 src/tbank/ncr-ratings.client.spec.ts create mode 100644 src/tbank/ncr-ratings.client.ts create mode 100644 test/bonds.e2e-spec.ts diff --git a/package-lock.json b/package-lock.json index e3d32ec..c54e158 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@prisma/client": "^7.9.1", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.0", + "cheerio": "1.1.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", @@ -4852,6 +4853,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/brace-expansion": { "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", @@ -5139,6 +5146,48 @@ "dev": true, "license": "MIT" }, + "node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -5562,6 +5611,34 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5896,6 +5973,61 @@ "node": ">=0.3.1" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", @@ -6034,6 +6166,31 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -6057,6 +6214,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -7264,6 +7433,37 @@ "dev": true, "license": "MIT" }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -9386,6 +9586,18 @@ "node": ">=8" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -9583,6 +9795,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -11788,6 +12049,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/undici": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -12166,6 +12436,40 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 6592248..b42918a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@prisma/client": "^7.9.1", "bcrypt": "^6.0.0", "better-sqlite3": "^12.6.0", + "cheerio": "1.1.2", "class-transformer": "^0.5.1", "class-validator": "^0.15.1", "cookie-parser": "^1.4.7", diff --git a/src/tbank/bond.controller.ts b/src/tbank/bond.controller.ts new file mode 100644 index 0000000..57c0363 --- /dev/null +++ b/src/tbank/bond.controller.ts @@ -0,0 +1,101 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import { AccessPayload } from '../auth/auth.types'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { ApiProblemResponses } from '../common/decorators/api-safe-error-response.decorator'; +import { BondService } from './bond.service'; +import { + BondCandlesDto, + BondMetricsDto, + BondCandlesQueryDto, + BondCouponsDto, + BondDto, + BondTickerParamsDto, + BondPositionDto, + BondQuoteDto, + BondSearchQueryDto, + BondSearchResultDto, +} from './bond.dto'; + +@ApiTags('bonds') +@ApiProblemResponses() +@ApiBearerAuth() +@UseGuards(JwtAuthGuard) +@Controller('bonds') +export class BondController { + constructor(private readonly bonds: BondService) {} + @Get(':uid/metrics') + @ApiOkResponse({ type: BondMetricsDto }) + metrics( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.bonds.metrics(request.user.sub, uid); + } + @Get() + @ApiOkResponse({ type: [BondSearchResultDto] }) + search( + @Req() request: Request & { user: AccessPayload }, + @Query() query: BondSearchQueryDto, + ) { + return this.bonds.search(request.user.sub, query.query); + } + @Get('by-ticker/:ticker') + @ApiOkResponse({ type: BondDto }) + getByTicker( + @Req() request: Request & { user: AccessPayload }, + @Param() params: BondTickerParamsDto, + ) { + return this.bonds.getByTicker(request.user.sub, params.ticker); + } + @Get(':uid') + @ApiOkResponse({ type: BondDto }) + get( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.bonds.get(request.user.sub, uid); + } + @Get(':uid/quote') + @ApiOkResponse({ type: BondQuoteDto }) + quote( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.bonds.quote(request.user.sub, uid); + } + @Get(':uid/candles') + @ApiOkResponse({ type: BondCandlesDto }) + candles( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + @Query() query: BondCandlesQueryDto, + ) { + return this.bonds.candles(request.user.sub, uid, query.period); + } + @Get(':uid/position') + @ApiOkResponse({ type: BondPositionDto }) + position( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.bonds.position(request.user.sub, uid); + } + @Get(':uid/coupons') + @ApiOkResponse({ type: BondCouponsDto }) + coupons( + @Req() request: Request & { user: AccessPayload }, + @Param('uid', new ParseUUIDPipe()) uid: string, + ) { + return this.bonds.coupons(request.user.sub, uid); + } +} diff --git a/src/tbank/bond.dto.ts b/src/tbank/bond.dto.ts new file mode 100644 index 0000000..d0b86b6 --- /dev/null +++ b/src/tbank/bond.dto.ts @@ -0,0 +1,154 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { MoneyDto } from './portfolio.dto'; +import { + StockSearchQueryDto, + StockTickerParamsDto, + StockCandlesQueryDto, + StockCandlesDto, + StockPositionDto, +} from './stock.dto'; +export class BondSearchQueryDto extends StockSearchQueryDto {} +export class BondTickerParamsDto extends StockTickerParamsDto {} +export class BondCandlesQueryDto extends StockCandlesQueryDto {} +export class BondCandlesDto extends StockCandlesDto {} +export class BondPositionDto extends StockPositionDto {} +export class BondDto { + @ApiProperty({ format: 'uuid' }) instrumentUid: string; + @ApiProperty({ + description: 'Unambiguous ticker URL segment, or the exact instrument UID.', + }) + slug: string; + @ApiProperty() name: string; + @ApiProperty({ type: String, nullable: true }) isin: string | null; + @ApiProperty({ type: String, nullable: true }) classCode: string | null; + @ApiProperty({ type: String, nullable: true }) realExchange: string | null; + @ApiProperty({ type: Boolean, nullable: true }) apiTradeAvailable: + boolean | null; + @ApiProperty({ type: String, nullable: true }) ticker: string | null; + @ApiProperty({ type: String, nullable: true }) logoName: string | null; + @ApiProperty({ type: String, nullable: true }) currency: string | null; + @ApiProperty({ type: String, nullable: true }) sector: string | null; + @ApiProperty({ type: Number, nullable: true }) lot: number | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + historyFrom: string | null; + @ApiProperty({ type: MoneyDto, nullable: true }) nominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + initialNominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) maturityDate: string | null; + @ApiProperty({ type: String, nullable: true }) callDate: string | null; + @ApiProperty({ type: String, nullable: true }) bondType: string | null; + @ApiProperty({ type: String, nullable: true }) riskLevel: string | null; + @ApiProperty({ type: Number, nullable: true }) couponQuantityPerYear: + number | null; + @ApiProperty({ type: Boolean, nullable: true }) floatingCoupon: + boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) perpetual: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) amortization: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) subordinated: boolean | null; +} + +export class BondSearchResultDto extends BondDto { + @ApiProperty({ + type: String, + nullable: true, + description: 'Clean price in percent of nominal, not money.', + }) + pricePercent: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: + string | null; +} +export class BondQuoteDto { + @ApiProperty({ type: String, nullable: true }) pricePercent: string | null; + @ApiProperty({ + type: MoneyDto, + nullable: true, + description: + 'Indicative clean value at the current nominal; no FX conversion.', + }) + cleanPrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) aci: MoneyDto | null; + @ApiProperty({ + type: MoneyDto, + nullable: true, + description: + 'Indicative clean value plus current accrued interest, excluding fees.', + }) + dirtyPrice: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) changePercentagePoints: + string | null; + @ApiProperty({ type: String, nullable: true }) previousCloseDate: + string | null; + @ApiProperty({ type: String, nullable: true }) tradingStatus: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) priceAt: + string | null; + @ApiProperty({ type: [String] }) unavailable: string[]; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} +export class BondCouponDto { + @ApiProperty({ type: String, nullable: true }) number: string | null; + @ApiProperty({ type: String, nullable: true }) paymentDate: string | null; + @ApiProperty({ type: String, nullable: true }) recordDate: string | null; + @ApiProperty({ type: String, nullable: true }) couponType: string | null; + @ApiProperty({ type: Number, nullable: true }) periodDays: number | null; + @ApiProperty({ type: MoneyDto, nullable: true }) perBond: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) + estimatedAmount: MoneyDto | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) timing: + 'upcoming' | 'past' | 'undated'; +} +export class BondCouponsDto { + @ApiProperty({ type: [BondCouponDto] }) coupons: BondCouponDto[]; + @ApiProperty() from: string; + @ApiProperty() to: string; + @ApiProperty() observedAt: string; + @ApiProperty({ type: String, nullable: true }) positionAt: string | null; + @ApiProperty() estimateUnavailable: boolean; + @ApiProperty() positionIsStale: boolean; +} + +export class BondYieldDto { + @ApiProperty({ description: 'MOEX effective annual yield in percent.' }) + value: string; + @ApiProperty() pricePercent: string; + @ApiProperty({ format: 'date' }) date: string; + @ApiProperty({ + type: String, + nullable: true, + description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', + }) + dateType: string | null; + @ApiProperty({ format: 'date-time' }) tradeAt: string; +} +export class BondCreditRatingDto { + @ApiProperty({ enum: ['NCR'] }) agency: 'NCR'; + @ApiProperty({ type: String, nullable: true }) value: string | null; + @ApiProperty({ enum: ['active', 'withdrawn'] }) status: + 'active' | 'withdrawn'; + @ApiProperty({ format: 'date' }) date: string; + @ApiProperty({ format: 'uri' }) sourceUrl: string; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} +export class BondMetricsDto { + @ApiProperty({ type: BondCreditRatingDto, nullable: true }) + creditRating: BondCreditRatingDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) coupon: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) couponDate: + string | null; + @ApiProperty({ type: String, nullable: true }) couponType: string | null; + @ApiProperty({ type: Number, nullable: true }) couponPeriodDays: + number | null; + @ApiProperty({ + type: String, + nullable: true, + description: + 'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', + }) + couponYieldPercent: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + couponYieldPriceAt: string | null; + @ApiProperty({ type: BondYieldDto, nullable: true }) + effectiveYield: BondYieldDto | null; + @ApiProperty({ type: [String] }) unavailable: string[]; + @ApiProperty({ format: 'date-time' }) observedAt: string; +} diff --git a/src/tbank/bond.service.spec.ts b/src/tbank/bond.service.spec.ts new file mode 100644 index 0000000..310c0a3 --- /dev/null +++ b/src/tbank/bond.service.spec.ts @@ -0,0 +1,481 @@ +jest.mock('./tbank-access.service', () => ({ + TbankAccessService: class TbankAccessService {}, +})); +import { + ConflictException, + NotFoundException, + ServiceUnavailableException, + UnauthorizedException, +} from '@nestjs/common'; +import { BondService } from './bond.service'; +import { TbankAccessService } from './tbank-access.service'; +import { TbankClient } from './tbank.client'; +import { NcrRatingsClient } from './ncr-ratings.client'; +import { MoexClient } from './moex.client'; +import { PortfolioService } from './portfolio.service'; +const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); +const money = (units: string, currency = 'rub') => ({ units, currency }); +const cacheClock = performance; +const bond = { + uid: 'uid', + ticker: 'BOND', + isin: 'ISIN', + instrumentKind: 'INSTRUMENT_TYPE_BOND', + nominal: money('500'), + initialNominal: money('1000'), + aciValue: money('12'), + amortizationFlag: true, + first_1dayCandleDate: time('2024-01-01'), +}; +function setup() { + let generation = 'a'; + const tbank = { + findBonds: jest.fn().mockResolvedValue([bond]), + getBond: jest.fn().mockResolvedValue(bond), + getLastPrice: jest.fn().mockResolvedValue({ + instrumentUid: 'uid', + price: { units: '98', nano: 125000000 }, + time: time('2026-09-10T10:00:00Z'), + }), + getLastPrices: jest.fn().mockResolvedValue([]), + getTradingStatus: jest + .fn() + .mockResolvedValue('SECURITY_TRADING_STATUS_NORMAL_TRADING'), + getCandles: jest.fn().mockResolvedValue([ + { time: time('2026-09-09'), close: { units: '99' }, isComplete: true }, + { time: time('2026-09-10'), close: { units: '100' }, isComplete: true }, + ]), + getBondCoupons: jest.fn().mockResolvedValue([]), + }; + const access = { + withConnection: jest.fn( + (user: string, handler: (context: unknown) => unknown) => + handler({ token: user, generation }), + ), + }; + const portfolios = { + getAll: jest.fn().mockResolvedValue({ + positions: [], + lastSuccessfulAt: '2026-09-10T00:00:00Z', + isStale: false, + }), + }; + const ratings = { rating: jest.fn().mockResolvedValue(null) }; + const moex = { bondYield: jest.fn().mockResolvedValue(null) }; + const service = new BondService( + access as unknown as TbankAccessService, + tbank as unknown as TbankClient, + portfolios as unknown as PortfolioService, + moex as unknown as MoexClient, + ratings as unknown as NcrRatingsClient, + ); + return { + service, + ratings, + moex, + tbank, + access, + portfolios, + changeGeneration: () => { + generation = 'b'; + }, + }; +} +describe('bond market use case', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')); + jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now()); + }); + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + it('shows the next coupon and its annualized current yield without extra T-Bank calls', async () => { + const { service, tbank, moex } = setup(); + tbank.getBond.mockResolvedValue({ ...bond, classCode: 'TQOB' }); + tbank.getBondCoupons.mockResolvedValue([ + { + couponDate: time('2026-12-10'), + payOneBond: money('40'), + couponPeriod: 91, + couponType: 'COUPON_TYPE_VARIABLE', + }, + { + couponDate: time('2026-10-10'), + payOneBond: money('30'), + couponPeriod: 90, + couponType: 'COUPON_TYPE_FIX', + }, + { + couponDate: time('2026-09-01'), + payOneBond: money('90'), + couponPeriod: 90, + }, + ]); + const effectiveYield = { + value: '15.6262', + pricePercent: '53.384', + date: '2041-05-15', + dateType: 'MATDATE', + tradeAt: '2026-09-10T18:49:39Z', + }; + moex.bondYield.mockResolvedValue(effectiveYield); + const [metrics] = await Promise.all([ + service.metrics('user', 'uid'), + service.coupons('user', 'uid'), + service.quote('user', 'uid'), + ]); + expect(metrics).toMatchObject({ + coupon: { value: '30', currency: 'RUB' }, + couponDate: '2026-10-10', + couponType: 'COUPON_TYPE_FIX', + couponPeriodDays: 90, + couponYieldPercent: '24.7983', + effectiveYield, + }); + expect(moex.bondYield).toHaveBeenCalledWith('BOND', 'ISIN', 'TQOB'); + expect(tbank.getBond).toHaveBeenCalledTimes(1); + expect(tbank.getBondCoupons).toHaveBeenCalledTimes(1); + expect(tbank.getLastPrice).toHaveBeenCalledTimes(1); + }); + it.each(['unknown', 'currency', 'period', 'zero-price'])( + 'does not invent coupon yield with %s inputs', + async (scenario) => { + const { service, tbank } = setup(); + tbank.getBondCoupons.mockResolvedValue([ + { + couponDate: time('2026-10-10'), + payOneBond: + scenario === 'unknown' + ? undefined + : money('30', scenario === 'currency' ? 'usd' : 'rub'), + couponPeriod: scenario === 'period' ? 0 : 90, + }, + { + couponDate: time('2026-12-10'), + payOneBond: money('50'), + couponPeriod: 90, + }, + ]); + if (scenario === 'zero-price') + tbank.getLastPrice.mockResolvedValue({ price: { units: '0' } }); + expect(await service.metrics('user', 'uid')).toMatchObject({ + couponDate: '2026-10-10', + couponYieldPercent: null, + }); + }, + ); + it('keeps coupons when MOEX fails and never substitutes a risk level for a credit rating', async () => { + const { service, tbank, moex } = setup(); + tbank.getBond.mockResolvedValue({ + ...bond, + classCode: 'TQOB', + floatingCouponFlag: true, + riskLevel: 'RISK_LEVEL_LOW', + }); + moex.bondYield.mockRejectedValue(new ServiceUnavailableException()); + expect(await service.metrics('user', 'uid')).toMatchObject({ + effectiveYield: null, + coupon: null, + couponType: 'COUPON_TYPE_FLOATING', + couponYieldPercent: null, + unavailable: ['yield'], + }); + tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException()); + await expect(service.metrics('other', 'uid')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + it('returns only the rating of the exact issue and isolates a registry outage', async () => { + const { service, ratings } = setup(); + const creditRating = { + agency: 'NCR', + value: 'AAA.ru', + status: 'active', + date: '2026-04-17', + sourceUrl: 'https://ratings.ru/ratings/issues/123/', + observedAt: '2026-09-10T10:00:00Z', + }; + ratings.rating.mockResolvedValue(creditRating); + expect(await service.metrics('user', 'uid')).toMatchObject({ + creditRating, + }); + expect(ratings.rating).toHaveBeenCalledWith('ISIN'); + ratings.rating.mockRejectedValue(new ServiceUnavailableException()); + expect(await service.metrics('other', 'uid')).toMatchObject({ + creditRating: null, + unavailable: ['rating'], + }); + }); + it('opens concurrent sections within one upstream metadata request budget', async () => { + const { service, tbank } = setup(); + tbank.getBond + .mockReset() + .mockResolvedValueOnce(bond) + .mockRejectedValue( + new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), + ); + await expect( + Promise.all([ + service.getByTicker('user', 'BOND'), + service.quote('user', 'uid'), + service.candles('user', 'uid'), + service.coupons('user', 'uid'), + service.metrics('user', 'uid'), + ]), + ).resolves.toHaveLength(5); + expect(tbank.getBond).toHaveBeenCalledTimes(1); + expect(tbank.findBonds).toHaveBeenCalledTimes(1); + }); + it('searches thirty issues without a separate ticker lookup for each result', async () => { + const { service, tbank } = setup(); + const issues = Array.from({ length: 30 }, (_, index) => ({ + ...bond, + uid: `uid-${index}`, + isin: `ISIN-${index}`, + ticker: `BOND-${index}`, + })); + tbank.findBonds.mockResolvedValue(issues); + tbank.getBond.mockImplementation((_token: string, uid: string) => + Promise.resolve(issues.find((item) => item.uid === uid)), + ); + expect(await service.search('user', 'BOND')).toHaveLength(30); + expect(tbank.findBonds).toHaveBeenCalledTimes(1); + expect(tbank.getBond).toHaveBeenCalledTimes(30); + expect(tbank.getLastPrices).toHaveBeenCalledTimes(1); + await service.getByTicker('user', 'BOND-0'); + await Promise.all([ + service.quote('user', 'uid-0'), + service.candles('user', 'uid-0'), + service.coupons('user', 'uid-0'), + ]); + expect(tbank.getBond).toHaveBeenCalledTimes(30); + expect(tbank.findBonds).toHaveBeenCalledTimes(2); + }); + it('refreshes current nominal and accrued interest after the metadata TTL', async () => { + const { service, tbank } = setup(); + await service.get('user', 'uid'); + await jest.advanceTimersByTimeAsync(30_001); + tbank.getBond.mockResolvedValue({ + ...bond, + nominal: money('400'), + aciValue: money('3'), + }); + expect(await service.quote('user', 'uid')).toMatchObject({ + cleanPrice: { value: '392.5' }, + dirtyPrice: { value: '395.5' }, + }); + expect(tbank.getBond).toHaveBeenCalledTimes(2); + }); + it('keeps late metadata responses within their original connection generation', async () => { + const { service, tbank, changeGeneration } = setup(); + let resolveOld!: (value: typeof bond) => void; + tbank.getBond.mockReturnValueOnce( + new Promise((resolve) => { + resolveOld = resolve; + }), + ); + const oldQuote = service.quote('user', 'uid'); + changeGeneration(); + tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') }); + expect(await service.quote('user', 'uid')).toMatchObject({ + cleanPrice: { value: '392.5' }, + }); + resolveOld(bond); + await oldQuote; + expect(await service.quote('user', 'uid')).toMatchObject({ + cleanPrice: { value: '392.5' }, + }); + expect(tbank.getBond).toHaveBeenCalledTimes(2); + }); + it('keeps percent quotes separate from money and uses current amortized nominal', async () => { + const { service } = setup(); + expect(await service.quote('user', 'uid')).toMatchObject({ + pricePercent: '98.125', + cleanPrice: { value: '490.625', currency: 'RUB' }, + aci: { value: '12', currency: 'RUB' }, + dirtyPrice: { value: '502.625', currency: 'RUB' }, + changePercentagePoints: '-0.875', + previousCloseDate: '2026-09-09', + }); + }); + it('does not invent dirty prices or mix currencies when accrued interest is absent or mismatched', async () => { + const { service, tbank } = setup(); + tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined }); + expect(await service.quote('user', 'uid')).toMatchObject({ + aci: null, + dirtyPrice: null, + }); + tbank.getBond.mockResolvedValueOnce({ + ...bond, + aciValue: money('12', 'usd'), + }); + expect(await service.quote('other', 'uid')).toMatchObject({ + dirtyPrice: null, + }); + tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined }); + expect(await service.quote('third', 'uid')).toMatchObject({ + pricePercent: '98.125', + cleanPrice: null, + dirtyPrice: null, + }); + }); + it('preserves absent coupon flags and dates without reporting false or zero', async () => { + const { service } = setup(); + expect(await service.get('user', 'uid')).toMatchObject({ + floatingCoupon: null, + perpetual: null, + couponQuantityPerYear: null, + maturityDate: null, + nominal: { value: '500', currency: 'RUB' }, + amortization: true, + }); + }); + it('keeps distinct issues, deduplicates ISIN listings, and prices the selected exact UID', async () => { + const { service, tbank } = setup(); + const listings = [ + { ...bond, uid: 'off', apiTradeAvailableFlag: false }, + { ...bond, uid: 'uid', apiTradeAvailableFlag: true }, + { ...bond, uid: 'second', isin: 'OTHER', ticker: 'OTHER' }, + { ...bond, uid: 'share', instrumentKind: 'INSTRUMENT_TYPE_SHARE' }, + ]; + tbank.findBonds.mockResolvedValue(listings); + tbank.getBond.mockImplementation((_token: string, uid: string) => + Promise.resolve(listings.find((b) => b.uid === uid)), + ); + tbank.getLastPrices.mockResolvedValue([ + { instrumentUid: 'uid', price: { units: '99' } }, + { instrumentUid: 'off', price: { units: '1' } }, + ]); + const results = await service.search('user', 'BOND'); + expect(results.map((x) => x.instrumentUid)).toEqual(['uid', 'second']); + expect(results[0]).toMatchObject({ pricePercent: '99', slug: 'uid' }); + expect((await service.get('user', 'off')).slug).toBe('off'); + }); + it('rejects ambiguous tickers and wrong instrument identities', async () => { + const { service, tbank } = setup(); + tbank.findBonds.mockResolvedValue([ + bond, + { ...bond, uid: 'other', isin: 'OTHER' }, + ]); + await expect(service.getByTicker('user', 'bond')).rejects.toBeInstanceOf( + ConflictException, + ); + tbank.getBond.mockResolvedValue({ ...bond, uid: 'other' }); + await expect(service.get('user', 'uid')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + it('preserves catalog results on enrichment outage but never suppresses auth failures', async () => { + const { service, tbank } = setup(); + tbank.getBond.mockRejectedValue(new ServiceUnavailableException()); + tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException()); + expect(await service.search('user', 'BOND')).toMatchObject([ + { instrumentUid: 'uid', pricePercent: null, nominal: { value: '500' } }, + ]); + tbank.getBond.mockRejectedValue(new UnauthorizedException()); + await expect(service.search('other', 'BOND')).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + it('keeps daily candles in percent, deduplicates boundaries and requires an all-history start', async () => { + const { service, tbank } = setup(); + const history = await service.candles('user', 'uid', 'all'); + expect(history.currency).toBeNull(); + expect(history.candles.map((c) => c.close)).toEqual(['99', '100']); + for (const [, , from, to] of tbank.getCandles.mock.calls as [ + string, + string, + Date, + Date, + ][]) + expect(to.valueOf() - from.valueOf()).toBeLessThanOrEqual(365 * 86400000); + tbank.getBond.mockResolvedValue({ + ...bond, + first_1dayCandleDate: undefined, + }); + await expect(service.candles('other', 'uid', 'all')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }); + it('isolates caches by user and connection generation and retries failures', async () => { + const { service, tbank, changeGeneration } = setup(); + await Promise.all([service.get('a', 'uid'), service.get('a', 'uid')]); + expect(tbank.getBond).toHaveBeenCalledTimes(1); + await service.get('b', 'uid'); + changeGeneration(); + await service.get('a', 'uid'); + expect(tbank.getBond).toHaveBeenCalledTimes(3); + tbank.getBond.mockRejectedValueOnce(new ServiceUnavailableException()); + await expect(service.get('c', 'uid')).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + await expect(service.get('c', 'uid')).resolves.toMatchObject({ + instrumentUid: 'uid', + }); + }); + it('estimates only future unfixed coupons from positive exact-UID bond holdings', async () => { + const { service, tbank, portfolios } = setup(); + const holding = { + instrumentUid: 'uid', + type: 'BOND', + quantity: '2', + averagePurchasePrice: { value: '490', currency: 'RUB' }, + currentValue: { value: '981.25', currency: 'RUB' }, + absoluteReturn: { value: '1.25', currency: 'RUB' }, + }; + portfolios.getAll.mockResolvedValue({ + positions: [ + holding, + { ...holding, instrumentUid: 'other', quantity: '99' }, + { ...holding, type: 'STOCK', quantity: '999' }, + ], + lastSuccessfulAt: '2026-09-10T00:00:00Z', + isStale: true, + }); + tbank.getBondCoupons.mockResolvedValue([ + { + couponNumber: '1', + couponDate: time('2026-10-01'), + fixDate: time('2026-09-29'), + payOneBond: money('10'), + }, + { + couponNumber: '2', + couponDate: time('2026-10-01'), + fixDate: time('2026-09-09'), + payOneBond: money('10'), + }, + { + couponNumber: '3', + couponDate: time('2026-10-01'), + payOneBond: money('10'), + }, + { + couponNumber: '4', + couponDate: time('2026-10-01'), + fixDate: time('2026-09-29'), + }, + { + couponNumber: '5', + couponDate: time('2025-10-01'), + payOneBond: money('10'), + }, + ]); + const result = await service.coupons('user', 'uid'); + expect( + result.coupons.find((c) => c.number === '1')?.estimatedAmount, + ).toEqual({ value: '20', currency: 'RUB' }); + expect( + result.coupons + .filter((c) => c.number !== '1') + .every((c) => c.estimatedAmount === null), + ).toBe(true); + expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull(); + expect(result.positionIsStale).toBe(true); + expect(await service.position('user', 'uid')).toMatchObject({ + quantity: '2', + currentValue: { value: '981.25' }, + }); + }); +}); diff --git a/src/tbank/bond.service.ts b/src/tbank/bond.service.ts new file mode 100644 index 0000000..38950bd --- /dev/null +++ b/src/tbank/bond.service.ts @@ -0,0 +1,587 @@ +import { + timestamp, + moscowDay, + optional, + previousClose, +} from './instrument-market'; +import { + Injectable, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { LRUCache } from 'lru-cache'; +import { + decimal, + decimalFromString, + divide, + formatDecimal, + multiply, + subtract, + add, +} from './decimal'; +import { NcrRatingsClient } from './ncr-ratings.client'; +import { MoexClient } from './moex.client'; +import { PortfolioService } from './portfolio.service'; +import { instrumentPosition } from './instrument-position'; +import { + TbankAccessService, + type TbankConnectionContext, +} from './tbank-access.service'; +import { TbankClient, type TbankMoney } from './tbank.client'; +import type { TbankBond } from './tbank-market.types'; +import { + BondMetricsDto, + BondCandlesDto, + BondCandlesQueryDto, + BondDto, + BondPositionDto, + BondQuoteDto, + BondCouponsDto, + BondSearchResultDto, +} from './bond.dto'; + +const dayMs = 86_400_000; + +@Injectable() +export class BondService { + private readonly cache = new LRUCache>({ + max: 300, + ttl: 30_000, + }); + constructor( + private readonly access: TbankAccessService, + private readonly tbank: TbankClient, + private readonly portfolios: PortfolioService, + private readonly moex: MoexClient, + private readonly ratings: NcrRatingsClient, + ) {} + + search(userId: string, query: string): Promise { + return this.cached(userId, `search:${query}`, async (connection) => { + const { token } = connection; + const found = await this.tbank.findBonds(token, query); + const bonds = selectBonds(found, query).slice(0, 30); + if (!bonds.length) return []; + // Batch prices and share metadata with detail sections. Resolve readable + // aliases only when an issue is opened, not for every search result. + const [details, prices] = await Promise.all([ + Promise.all( + bonds.map( + async (bond) => + (await optional( + () => + this.bond(userId, connection, bond.uid!) + .then(toBond) + .catch((error: unknown) => { + if (error instanceof NotFoundException) + return toBond(bond); + throw error; + }), + 'details', + [], + )) ?? toBond(bond), + ), + ), + optional( + () => + this.tbank.getLastPrices( + token, + bonds.map((bond) => bond.uid!), + ), + 'prices', + [], + ), + ]); + return details.map((detail, index) => { + const short = toBond(bonds[index]); + const last = prices?.find( + (price) => price.instrumentUid === detail.instrumentUid, + ); + return { + ...detail, + classCode: detail.classCode ?? short.classCode, + isin: detail.isin ?? short.isin, + apiTradeAvailable: short.apiTradeAvailable, + pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null, + priceAt: timestamp(last?.time), + }; + }); + }); + } + get(userId: string, uid: string): Promise { + return this.cached(userId, `bond:${uid}`, async (connection) => { + const result = toBond(await this.bond(userId, connection, uid)); + if ( + !result.ticker || + !/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(result.ticker) + ) + return result; + try { + const match = await this.tickerMatch(userId, result.ticker); + if (match.uid === uid) result.slug = result.ticker.toUpperCase(); + } catch (error) { + // A readable alias is optional for an exact UID URL, never a reason to substitute another listing. + if ( + !(error instanceof NotFoundException) && + !(error instanceof ConflictException) && + !(error instanceof ServiceUnavailableException) + ) + throw error; + } + return result; + }); + } + async getByTicker(userId: string, ticker: string): Promise { + const match = await this.tickerMatch(userId, ticker); + return { + ...(await this.get(userId, match.uid!)), + slug: ticker.trim().toUpperCase(), + }; + } + private tickerMatch(userId: string, ticker: string): Promise { + const normalized = ticker.trim().toUpperCase(); + return this.cached(userId, `ticker:${normalized}`, async (connection) => { + const { token } = connection; + const found = await this.tbank.findBonds(token, normalized); + const matches = selectBonds( + found.filter( + (item) => item.ticker?.trim().toUpperCase() === normalized, + ), + normalized, + ); + if (!matches.length) throw new NotFoundException(); + if (matches.length > 1) + throw new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }); + return matches[0]; + }); + } + quote(userId: string, uid: string): Promise { + return this.cached(userId, `quote:${uid}`, async (connection) => { + const { token } = connection; + const instrument = await this.bond(userId, connection, uid); + const unavailable: string[] = []; + const [last, tradingStatus] = await Promise.all([ + optional( + () => this.tbank.getLastPrice(token, uid), + 'price', + unavailable, + ), + optional( + () => this.tbank.getTradingStatus(token, uid), + 'status', + unavailable, + ), + ]); + const priceAt = timestamp(last?.time); + const pricePercent = last?.price + ? formatDecimal(decimal(last.price)) + : null; + const nominal = money(instrument.nominal); + const aci = money(instrument.aciValue); + const cleanPrice = + pricePercent !== null && + nominal && + decimalFromString(nominal.value).value > 0n + ? { + currency: nominal.currency, + value: formatDecimal( + divide( + multiply( + decimalFromString(pricePercent), + decimalFromString(nominal.value), + ), + decimalFromString('100'), + 9, + ), + ), + } + : null; + const dirtyPrice = + cleanPrice && aci && aci.currency === cleanPrice.currency + ? { + currency: cleanPrice.currency, + value: formatDecimal( + add( + decimalFromString(cleanPrice.value), + decimalFromString(aci.value), + ), + ), + } + : null; + const previous = priceAt + ? await optional( + async () => + previousClose( + await this.tbank.getCandles( + token, + uid, + new Date(Date.parse(priceAt) - 20 * dayMs), + new Date(priceAt), + ), + priceAt, + ), + 'comparison', + unavailable, + ) + : null; + return { + pricePercent, + cleanPrice, + aci, + dirtyPrice, + priceAt, + tradingStatus, + changePercentagePoints: + last?.price && previous?.close + ? formatDecimal( + subtract(decimal(last.price), decimal(previous.close)), + ) + : null, + previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, + unavailable, + observedAt: new Date().toISOString(), + }; + }); + } + metrics(userId: string, uid: string): Promise { + return this.cached(userId, `metrics:${uid}`, async (connection) => { + const instrument = await this.bond(userId, connection, uid); + const today = moscowDay(new Date().toISOString()); + const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; + const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; + const unavailable: string[] = []; + const [events, quote, effectiveYield, creditRating] = await Promise.all([ + optional( + () => this.couponEvents(userId, connection, uid, from, to), + 'coupons', + unavailable, + ), + optional(() => this.quote(userId, uid), 'quote', unavailable), + instrument.ticker && instrument.isin && instrument.classCode + ? optional( + () => + this.moex.bondYield( + instrument.ticker!, + instrument.isin!, + instrument.classCode!, + ), + 'yield', + unavailable, + ) + : null, + instrument.isin + ? optional( + () => this.ratings.rating(instrument.isin!), + 'rating', + unavailable, + ) + : null, + ]); + const next = events + ?.filter((event) => { + const date = timestamp(event.couponDate)?.slice(0, 10); + return date && date >= today; + }) + .sort( + (a, b) => + Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds), + )[0]; + const coupon = money(next?.payOneBond); + const period = next?.couponPeriod; + const couponPeriodDays = + period && Number.isInteger(period) && period > 0 ? period : null; + const clean = quote?.cleanPrice; + const couponYieldPercent = + coupon && + couponPeriodDays && + clean && + coupon.currency === clean.currency && + decimalFromString(coupon.value).value >= 0n && + decimalFromString(clean.value).value > 0n + ? formatDecimal( + divide( + multiply( + decimalFromString(coupon.value), + decimalFromString('36500'), + ), + multiply( + decimalFromString(String(couponPeriodDays)), + decimalFromString(clean.value), + ), + 4, + ), + ) + : null; + const couponType = + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' + ? next.couponType + : instrument.floatingCouponFlag === true + ? 'COUPON_TYPE_FLOATING' + : null; + return { + creditRating, + coupon, + couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, + couponType, + couponPeriodDays, + couponYieldPercent, + couponYieldPriceAt: + couponYieldPercent !== null ? (quote?.priceAt ?? null) : null, + effectiveYield, + unavailable, + observedAt: new Date().toISOString(), + }; + }); + } + private couponEvents( + userId: string, + connection: TbankConnectionContext, + uid: string, + from: string, + to: string, + ) { + return this.cachedForConnection( + userId, + connection, + `coupon-events:${uid}:${from}:${to}`, + () => this.tbank.getBondCoupons(connection.token, uid, from, to), + ); + } + coupons(userId: string, uid: string): Promise { + return this.cached(userId, `coupons:${uid}`, async (connection) => { + await this.bond(userId, connection, uid); + const today = moscowDay(new Date().toISOString()); + const from = `${Number(today.slice(0, 4)) - 5}${today.slice(4)}`; + const to = `${Number(today.slice(0, 4)) + 1}${today.slice(4)}`; + const unavailable: string[] = []; + const [events, position] = await Promise.all([ + this.couponEvents(userId, connection, uid, from, to), + optional(() => this.position(userId, uid), 'position', unavailable), + ]); + const quantity = + position?.accounts.length && + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) + ? decimalFromString(position.quantity) + : null; + const coupons = events + .map((event) => { + const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; + const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; + const perBond = money(event.payOneBond); + const timing = !paymentDate + ? ('undated' as const) + : paymentDate >= today + ? ('upcoming' as const) + : ('past' as const); + return { + number: event.couponNumber ?? null, + paymentDate, + recordDate, + perBond, + couponType: event.couponType ?? null, + periodDays: event.couponPeriod ?? null, + timing, + estimatedAmount: + quantity && + perBond && + timing === 'upcoming' && + recordDate && + recordDate >= today + ? { + currency: perBond.currency, + value: formatDecimal( + multiply(quantity, decimalFromString(perBond.value)), + ), + } + : null, + }; + }) + .sort((a, b) => + (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''), + ); + return { + coupons, + from, + to, + observedAt: new Date().toISOString(), + positionAt: quantity ? (position?.observedAt ?? null) : null, + estimateUnavailable: unavailable.length > 0, + positionIsStale: position?.isStale ?? false, + }; + }); + } + candles( + userId: string, + uid: string, + period: BondCandlesQueryDto['period'] = 'month', + ): Promise { + return this.cached( + userId, + `candles:${uid}:${period}`, + async (connection) => { + const { token } = connection; + const bond = await this.bond(userId, connection, uid); + const historyFrom = timestamp(bond.first_1dayCandleDate); + const to = new Date(); + const from = new Date(to); + from.setUTCMonth( + from.getUTCMonth() - + (period === 'month' ? 1 : period === 'halfYear' ? 6 : 12), + ); + if (period === 'all') { + if (!historyFrom) + throw new ServiceUnavailableException({ + code: 'BOND_HISTORY_START_UNAVAILABLE', + }); + from.setTime(Date.parse(historyFrom)); + } else if (historyFrom && Date.parse(historyFrom) > from.valueOf()) + from.setTime(Date.parse(historyFrom)); + const collected = new Map(); + // Conservative one-year windows also work with older deployed RPC limits. + for (let start = from.valueOf(); start < to.valueOf();) { + const end = Math.min(start + 365 * dayMs, to.valueOf()); + for (const candle of await this.tbank.getCandles( + token, + uid, + new Date(start), + new Date(end), + )) { + const time = timestamp(candle.time); + if ( + time && + candle.close && + Date.parse(time) >= from.valueOf() - dayMs && + Date.parse(time) <= to.valueOf() + ) + collected.set(time, { + time, + close: formatDecimal(decimal(candle.close)), + isComplete: candle.isComplete === true, + }); + } + start = end; + } + return { + candles: [...collected.values()].sort((a, b) => + a.time.localeCompare(b.time), + ), + currency: null, + historyFrom, + observedAt: new Date().toISOString(), + }; + }, + ); + } + async position(userId: string, uid: string): Promise { + return this.cached(userId, `position:${uid}`, async () => + instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'), + ); + } + private bond( + userId: string, + connection: TbankConnectionContext, + uid: string, + ): Promise { + return this.cachedForConnection( + userId, + connection, + `metadata:${uid}`, + async () => { + const bond = await this.tbank.getBond(connection.token, uid); + if (!bond || bond.uid !== uid) throw new NotFoundException(); + return bond; + }, + ); + } + private cached( + userId: string, + key: string, + operation: (connection: TbankConnectionContext) => Promise, + ): Promise { + return this.access.withConnection(userId, (connection) => + this.cachedForConnection(userId, connection, key, () => + operation(connection), + ), + ); + } + private cachedForConnection( + userId: string, + { generation }: TbankConnectionContext, + key: string, + operation: () => Promise, + ): Promise { + const cacheKey = JSON.stringify([userId, generation, key]); + const cached = this.cache.get(cacheKey); + if (cached) return cached as Promise; + const pending = operation(); + this.cache.set(cacheKey, pending); + return pending.catch((error) => { + if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey); + throw error; + }); + } +} + +function selectBonds(found: TbankBond[], query: string): TbankBond[] { + const seen = new Set(); + return found + .filter( + (bond) => bond.uid && bond.instrumentKind === 'INSTRUMENT_TYPE_BOND', + ) + .sort( + (a, b) => + Number(b.apiTradeAvailableFlag === true) - + Number(a.apiTradeAvailableFlag === true) || + Number(b.ticker?.toUpperCase() === query.toUpperCase()) - + Number(a.ticker?.toUpperCase() === query.toUpperCase()) || + Number(b.classCode === 'TQOB') - Number(a.classCode === 'TQOB') || + a.uid!.localeCompare(b.uid!), + ) + .filter((bond) => { + // A missing ISIN must not collapse unrelated catalog instruments. + const key = bond.isin?.trim() + ? `isin:${bond.isin.trim().toUpperCase()}` + : `uid:${bond.uid!}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function toBond(bond: TbankBond): BondDto { + return { + instrumentUid: bond.uid!, + slug: bond.uid!, + name: bond.name?.trim() || bond.ticker || bond.uid!, + ticker: bond.ticker?.trim() || null, + isin: bond.isin?.trim() || null, + classCode: bond.classCode?.trim() || null, + realExchange: bond.realExchange || null, + apiTradeAvailable: bond.apiTradeAvailableFlag ?? null, + logoName: bond.brand?.logoName?.trim() || null, + currency: bond.currency?.toUpperCase() || null, + bondType: bond.bondType || null, + sector: bond.sector || null, + lot: bond.lot && bond.lot > 0 ? bond.lot : null, + historyFrom: timestamp(bond.first_1dayCandleDate), + nominal: money(bond.nominal), + initialNominal: money(bond.initialNominal), + aci: money(bond.aciValue), + maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null, + callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null, + couponQuantityPerYear: bond.couponQuantityPerYear ?? null, + floatingCoupon: bond.floatingCouponFlag ?? null, + perpetual: bond.perpetualFlag ?? null, + amortization: bond.amortizationFlag ?? null, + subordinated: bond.subordinatedFlag ?? null, + riskLevel: bond.riskLevel || null, + }; +} + +function money(value?: TbankMoney) { + const currency = value?.currency?.toUpperCase(); + return value && currency + ? { currency, value: formatDecimal(decimal(value)) } + : null; +} diff --git a/src/tbank/instrument-market.ts b/src/tbank/instrument-market.ts new file mode 100644 index 0000000..0c11302 --- /dev/null +++ b/src/tbank/instrument-market.ts @@ -0,0 +1,44 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import type { TbankTimestamp } from './tbank.client'; +import type { TbankCandle } from './tbank-market.types'; +export function timestamp(value?: TbankTimestamp): string | null { + const seconds = Number(value?.seconds); + return Number.isFinite(seconds) && seconds > 0 + ? new Date(seconds * 1000).toISOString() + : null; +} +export function moscowDay(iso: string): string { + return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10); +} +export function previousClose( + candles: TbankCandle[], + priceAt: string, +): TbankCandle | null { + return ( + candles + .filter((candle) => { + const time = timestamp(candle.time); + return ( + candle.isComplete && + candle.close && + time && + time.slice(0, 10) < moscowDay(priceAt) + ); + }) + .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? + null + ); +} +export async function optional( + operation: () => Promise, + name: string, + unavailable: string[], +): Promise { + try { + return await operation(); + } catch (error) { + if (!(error instanceof ServiceUnavailableException)) throw error; + unavailable.push(name); + return null; + } +} diff --git a/src/tbank/instrument-position.ts b/src/tbank/instrument-position.ts new file mode 100644 index 0000000..2e53a24 --- /dev/null +++ b/src/tbank/instrument-position.ts @@ -0,0 +1,101 @@ +import { + add, + decimalFromString, + divide, + formatDecimal, + multiply, +} from './decimal'; +import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; +import { StockPositionDto } from './stock.dto'; + +export function instrumentPosition( + portfolio: PortfolioDto, + uid: string, + type: 'STOCK' | 'BOND', +): StockPositionDto { + const positions = portfolio.positions.filter( + (p) => p.type === type && p.instrumentUid === uid, + ); + const zero = decimalFromString('0'); + const quantity = positions + .map((p) => decimalFromString(p.quantity)) + .reduce(add, zero); + const costs = positions.map(cost); + const totalCost = sumComplete(costs); + const currentValue = sumComplete(positions.map((p) => p.currentValue)); + const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); + const positive = + positions.length > 0 && + positions.every((p) => decimalFromString(p.quantity).value > 0n); + return { + quantity: formatDecimal(quantity), + currentValue, + absoluteReturn, + averagePurchasePrice: + positive && totalCost + ? { + currency: totalCost.currency, + value: formatDecimal( + divide(decimalFromString(totalCost.value), quantity, 9), + ), + } + : null, + returnPercent: positive ? percent(absoluteReturn, totalCost) : null, + accounts: positions.map((p) => ({ + ...p, + returnPercent: + decimalFromString(p.quantity).value > 0n + ? percent(p.absoluteReturn, cost(p)) + : null, + })), + isStale: portfolio.isStale, + observedAt: portfolio.lastSuccessfulAt, + }; +} +function cost(position: PortfolioPositionDto): MoneyDto | null { + const price = position.averagePurchasePrice; + return price + ? { + currency: price.currency, + value: formatDecimal( + multiply( + decimalFromString(price.value), + decimalFromString(position.quantity), + ), + ), + } + : null; +} +function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { + const currency = values[0]?.currency; + if (!currency || values.some((v) => !v || v.currency !== currency)) + return null; + return { + currency, + value: formatDecimal( + values.reduce( + (sum, value) => add(sum, decimalFromString(value!.value)), + decimalFromString('0'), + ), + ), + }; +} +function percent( + result: MoneyDto | null, + base: MoneyDto | null, +): string | null { + if ( + !base || + !result || + base.currency !== result.currency || + decimalFromString(base.value).value <= 0n + ) + return null; + return formatDecimal( + divide( + multiply(decimalFromString(result.value), decimalFromString('100')), + decimalFromString(base.value), + 6, + ), + ); +} diff --git a/src/tbank/moex.client.spec.ts b/src/tbank/moex.client.spec.ts index eab1151..bc01d6a 100644 --- a/src/tbank/moex.client.spec.ts +++ b/src/tbank/moex.client.spec.ts @@ -1,6 +1,97 @@ import { MoexClient } from './moex.client'; describe('MoexClient', () => { + afterEach(() => jest.restoreAllMocks()); + it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + securities: { + columns: ['SECID', 'ISIN', 'BOARDID'], + data: [['BOND', 'RU000TEST', 'TQOB']], + }, + marketdata_yields: { + columns: [ + 'SECID', + 'BOARDID', + 'EFFECTIVEYIELD', + 'PRICE', + 'YIELDDATE', + 'YIELDDATETYPE', + 'TRADEMOMENT', + ], + data: [ + [ + 'BOND', + 'OTHER', + 99, + 20, + '2028-12-01', + 'OFFERDATE', + '2026-09-10 18:49:00', + ], + [ + 'BOND', + 'TQOB', + -0.5, + 101, + '2028-12-01', + 'OFFERDATE', + '2026-09-10 18:49:00', + ], + ], + }, + }), + } as Response); + const client = new MoexClient(); + expect(await client.bondYield('BOND', 'RU000TEST', 'TQOB')).toEqual({ + value: '-0.5', + pricePercent: '101', + date: '2028-12-01', + dateType: 'OFFERDATE', + tradeAt: '2026-09-10T15:49:00.000Z', + }); + expect(await client.bondYield('BOND', 'WRONG', 'TQOB')).toBeNull(); + expect(await client.bondYield('BOND', 'RU000TEST', 'OTHER')).toBeNull(); + }); + it.each([null, 0])( + 'does not report unknown yield as zero: %s', + async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + securities: { + columns: ['SECID', 'ISIN', 'BOARDID'], + data: [['BOND', 'ISIN', 'TQOB']], + }, + marketdata_yields: { + columns: [ + 'SECID', + 'BOARDID', + 'EFFECTIVEYIELD', + 'PRICE', + 'YIELDDATE', + 'TRADEMOMENT', + ], + data: [ + [ + 'BOND', + 'TQOB', + value, + 100, + '2030-01-01', + '2026-09-10 12:00:00', + ], + ], + }, + }), + } as Response); + const result = await new MoexClient().bondYield('BOND', 'ISIN', 'TQOB'); + expect(result?.value ?? null).toBe(value === null ? null : '0'); + }, + ); it('treats a successful response without the requested block as no events', async () => { const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ ok: true, diff --git a/src/tbank/moex.client.ts b/src/tbank/moex.client.ts index afc56e5..925407c 100644 --- a/src/tbank/moex.client.ts +++ b/src/tbank/moex.client.ts @@ -10,9 +10,63 @@ export type MoexBondEvent = type IssBlock = { columns?: unknown; data?: unknown }; type IssResponse = Record; +export type MoexBondYield = { + value: string; + pricePercent: string; + date: string; + dateType: string | null; + tradeAt: string; +}; @Injectable() export class MoexClient { + async bondYield( + secid: string, + isin: string, + board: string, + ): Promise { + const query = new URLSearchParams({ + 'iss.meta': 'off', + 'iss.only': 'securities,marketdata_yields', + }); + const response = await this.request( + `${baseUrl}/engines/stock/markets/bonds/securities/${encodeURIComponent(secid)}.json?${query}`, + ); + if (!response) return null; + const listing = parseRows(response.securities).find( + (row) => + row.secid === secid && row.isin === isin && row.boardid === board, + ); + if (!listing) return null; + const row = parseRows(response.marketdata_yields).find( + (row) => row.secid === secid && row.boardid === board, + ); + if (!row) return null; + const value = numberText(row.effectiveyield); + const pricePercent = numberText(row.price); + const date = text(row.yielddate); + const tradeAt = text(row.trademoment); + if ( + value === null || + pricePercent === null || + Number(pricePercent) <= 0 || + !date || + !/^\d{4}-\d{2}-\d{2}$/.test(date) || + !Number.isFinite(Date.parse(date)) || + !tradeAt || + !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) + ) + return null; + const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; + if (!Number.isFinite(Date.parse(tradeIso))) return null; + return { + value, + pricePercent, + date, + dateType: text(row.yielddatetype), + tradeAt: new Date(tradeIso).toISOString(), + }; + } async bondEvents(secid: string): Promise { const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const [coupons, offers, description] = await Promise.all([ diff --git a/src/tbank/ncr-ratings.client.spec.ts b/src/tbank/ncr-ratings.client.spec.ts new file mode 100644 index 0000000..2ed3cbf --- /dev/null +++ b/src/tbank/ncr-ratings.client.spec.ts @@ -0,0 +1,85 @@ +import { ServiceUnavailableException } from '@nestjs/common'; +import { NcrRatingsClient } from './ncr-ratings.client'; +const isin = 'RU000A10EW44'; +const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => + `ЭмитентОблигация${value}${code}REG17.04.2026`; +const table = (rows: string) => + `${rows}
ЛицоВыпускРейтингISINРегномерДата
`; +const response = (html: string) => + ({ ok: true, text: () => Promise.resolve(html) }) as Response; +const cacheClock = performance; +describe('public NCR issue ratings', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')); + jest.spyOn(cacheClock, 'now').mockImplementation(() => Date.now()); + }); + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + it('matches only exact ISIN and shares one public registry download', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockResolvedValue(response(table(row()))); + const client = new NcrRatingsClient(); + const [rating, missing] = await Promise.all([ + client.rating(isin), + client.rating('RU000A1065B1'), + ]); + expect(rating).toEqual({ + agency: 'NCR', + value: 'AAA.ru', + status: 'active', + date: '2026-04-17', + sourceUrl: 'https://ratings.ru/ratings/issues/123/', + observedAt: '2026-09-10T10:00:00.000Z', + }); + expect(missing).toBeNull(); + expect(await client.rating('issuer-name')).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(6 * 60 * 60_000 + 1); + await client.rating(isin); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + it('preserves withdrawal and never displays an issuer rating as an issue rating', async () => { + jest + .spyOn(global, 'fetch') + .mockResolvedValue(response(table(row('рейтинг отозван')))); + expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ + status: 'withdrawn', + value: null, + }); + }); + it.each([ + 'Service unavailable', + table(row('AAA.ru', isin, 'https://untrusted.test/')), + table(row('unrecognized')), + table(row() + row('AA.ru')), + table(''), + ])( + 'reports changed or ambiguous registry as unavailable, not unrated', + async (html) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(html)); + await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + }, + ); + it('bounds outage retries then recovers without retaining a false missing rating', async () => { + const fetchMock = jest + .spyOn(global, 'fetch') + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValue(response(table(row()))); + const client = new NcrRatingsClient(); + await expect(client.rating(isin)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + await expect(client.rating(isin)).rejects.toBeInstanceOf( + ServiceUnavailableException, + ); + expect(fetchMock).toHaveBeenCalledTimes(1); + await jest.advanceTimersByTimeAsync(60_001); + expect(await client.rating(isin)).toMatchObject({ value: 'AAA.ru' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/tbank/ncr-ratings.client.ts b/src/tbank/ncr-ratings.client.ts new file mode 100644 index 0000000..6b40676 --- /dev/null +++ b/src/tbank/ncr-ratings.client.ts @@ -0,0 +1,107 @@ +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { load } from 'cheerio'; +import { LRUCache } from 'lru-cache'; + +const registryUrl = 'https://ratings.ru/ratings/issues/'; +export type NcrRating = { + agency: 'NCR'; + value: string | null; + status: 'active' | 'withdrawn'; + date: string; + sourceUrl: string; + observedAt: string; +}; + +@Injectable() +export class NcrRatingsClient { + // Public registry contains no user data. Share one download across all issues. + private readonly cache = new LRUCache< + string, + Promise> + >({ max: 1, ttl: 6 * 60 * 60_000 }); + + async rating(isin: string): Promise { + const key = isin.trim().toUpperCase(); + if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(key)) return null; + let pending = this.cache.get('registry'); + if (!pending) { + pending = this.registry().catch(() => { + // Bound outage traffic too; never present a failed download as no rating. + if (this.cache.get('registry') === pending) + this.cache.set('registry', pending, { ttl: 60_000 }); + throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); + }); + this.cache.set('registry', pending); + } + return (await pending).get(key) ?? null; + } + + private async registry(): Promise> { + const response = await fetch(registryUrl, { + signal: AbortSignal.timeout(10_000), + }); + if (!response.ok) throw new Error('NCR unavailable'); + const $ = load(await response.text()); + const table = $('#issues-table'); + const headings = table + .find('thead th') + .map((_, cell) => $(cell).text().trim()) + .get(); + if ( + table.length !== 1 || + headings.length !== 6 || + headings[2] !== 'Рейтинг' || + headings[3] !== 'ISIN' || + headings[5] !== 'Дата' + ) + throw new Error('NCR registry changed'); + const observedAt = new Date().toISOString(); + const result = new Map(); + table.find('tbody tr').each((_, row) => { + const cells = $(row).find('td'); + if (cells.length !== 6) throw new Error('NCR row changed'); + const isin = cells.eq(3).text().trim().toUpperCase(); + // Unassigned issues cannot safely be matched to a held instrument. + if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; + const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); + const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; + if ( + !withdrawn && + !/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test( + raw, + ) + ) + throw new Error('NCR rating changed'); + const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( + cells.eq(5).text().trim(), + ); + const href = cells.eq(3).find('a').attr('href'); + if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) + throw new Error('NCR identity missing'); + const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; + if ( + !Number.isFinite(Date.parse(date)) || + new Date(date).toISOString().slice(0, 10) !== date + ) + throw new Error('NCR date invalid'); + const rating: NcrRating = { + agency: 'NCR', + value: withdrawn ? null : raw, + status: withdrawn ? 'withdrawn' : 'active', + date, + sourceUrl: `https://ratings.ru${href}`, + observedAt, + }; + const previous = result.get(isin); + if ( + previous && + previous.date === date && + (previous.value !== rating.value || previous.status !== rating.status) + ) + throw new Error('NCR rating ambiguous'); + if (!previous || previous.date < date) result.set(isin, rating); + }); + if (!result.size) throw new Error('NCR registry empty'); + return result; + } +} diff --git a/src/tbank/stock-position.ts b/src/tbank/stock-position.ts index 7b7b3d7..aa422cb 100644 --- a/src/tbank/stock-position.ts +++ b/src/tbank/stock-position.ts @@ -1,100 +1,5 @@ -import { - add, - decimalFromString, - divide, - formatDecimal, - multiply, -} from './decimal'; -import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto'; -import { StockPositionDto } from './stock.dto'; - -export function stockPosition( - portfolio: PortfolioDto, - uid: string, -): StockPositionDto { - const positions = portfolio.positions.filter( - (p) => p.type === 'STOCK' && p.instrumentUid === uid, - ); - const zero = decimalFromString('0'); - const quantity = positions - .map((p) => decimalFromString(p.quantity)) - .reduce(add, zero); - const costs = positions.map(cost); - const totalCost = sumComplete(costs); - const currentValue = sumComplete(positions.map((p) => p.currentValue)); - const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); - const positive = - positions.length > 0 && - positions.every((p) => decimalFromString(p.quantity).value > 0n); - return { - quantity: formatDecimal(quantity), - currentValue, - absoluteReturn, - averagePurchasePrice: - positive && totalCost - ? { - currency: totalCost.currency, - value: formatDecimal( - divide(decimalFromString(totalCost.value), quantity, 9), - ), - } - : null, - returnPercent: positive ? percent(absoluteReturn, totalCost) : null, - accounts: positions.map((p) => ({ - ...p, - returnPercent: - decimalFromString(p.quantity).value > 0n - ? percent(p.absoluteReturn, cost(p)) - : null, - })), - isStale: portfolio.isStale, - observedAt: portfolio.lastSuccessfulAt, - }; -} -function cost(position: PortfolioPositionDto): MoneyDto | null { - const price = position.averagePurchasePrice; - return price - ? { - currency: price.currency, - value: formatDecimal( - multiply( - decimalFromString(price.value), - decimalFromString(position.quantity), - ), - ), - } - : null; -} -function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { - const currency = values[0]?.currency; - if (!currency || values.some((v) => !v || v.currency !== currency)) - return null; - return { - currency, - value: formatDecimal( - values.reduce( - (sum, value) => add(sum, decimalFromString(value!.value)), - decimalFromString('0'), - ), - ), - }; -} -function percent( - result: MoneyDto | null, - base: MoneyDto | null, -): string | null { - if ( - !base || - !result || - base.currency !== result.currency || - decimalFromString(base.value).value <= 0n - ) - return null; - return formatDecimal( - divide( - multiply(decimalFromString(result.value), decimalFromString('100')), - decimalFromString(base.value), - 6, - ), - ); +import { instrumentPosition } from './instrument-position'; +import type { PortfolioDto } from './portfolio.dto'; +export function stockPosition(portfolio: PortfolioDto, uid: string) { + return instrumentPosition(portfolio, uid, 'STOCK'); } diff --git a/src/tbank/stock.service.ts b/src/tbank/stock.service.ts index 044e4ba..7d0f606 100644 --- a/src/tbank/stock.service.ts +++ b/src/tbank/stock.service.ts @@ -1,3 +1,10 @@ +import { + timestamp, + moscowDay, + optional, + previousClose, +} from './instrument-market'; +export { previousClose } from './instrument-market'; import { Injectable, ConflictException, @@ -16,12 +23,8 @@ import { import { PortfolioService } from './portfolio.service'; import { stockPosition } from './stock-position'; import { TbankAccessService } from './tbank-access.service'; -import { TbankClient, TbankTimestamp } from './tbank.client'; -import type { - TbankCandle, - TbankFundamentals, - TbankShare, -} from './tbank-market.types'; +import { TbankClient } from './tbank.client'; +import type { TbankFundamentals, TbankShare } from './tbank-market.types'; import { StockCandlesDto, StockCandlesQueryDto, @@ -458,47 +461,6 @@ function stock(share: TbankShare): StockDto { historyFrom: timestamp(share.first_1dayCandleDate), }; } -function timestamp(value?: TbankTimestamp): string | null { - const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > 0 - ? new Date(seconds * 1000).toISOString() - : null; -} -function moscowDay(iso: string): string { - return new Date(Date.parse(iso) + 3 * 3_600_000).toISOString().slice(0, 10); -} -export function previousClose( - candles: TbankCandle[], - priceAt: string, -): TbankCandle | null { - return ( - candles - .filter((candle) => { - const time = timestamp(candle.time); - return ( - candle.isComplete && - candle.close && - time && - time.slice(0, 10) < moscowDay(priceAt) - ); - }) - .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? - null - ); -} -async function optional( - operation: () => Promise, - name: string, - unavailable: string[], -): Promise { - try { - return await operation(); - } catch (error) { - if (!(error instanceof ServiceUnavailableException)) throw error; - unavailable.push(name); - return null; - } -} function metrics( values: TbankFundamentals | null, sector?: string, diff --git a/src/tbank/tbank-connection.module.ts b/src/tbank/tbank-connection.module.ts index f22a998..689903c 100644 --- a/src/tbank/tbank-connection.module.ts +++ b/src/tbank/tbank-connection.module.ts @@ -1,3 +1,6 @@ +import { NcrRatingsClient } from './ncr-ratings.client'; +import { BondController } from './bond.controller'; +import { BondService } from './bond.service'; import { StockController } from './stock.controller'; import { StockService } from './stock.service'; import { Module } from '@nestjs/common'; @@ -22,6 +25,7 @@ import { IncomeAnalyticsService } from './income-analytics.service'; @Module({ controllers: [ StockController, + BondController, TbankConnectionController, BrokerageAccountsController, BrokerageOperationsController, @@ -31,6 +35,7 @@ import { IncomeAnalyticsService } from './income-analytics.service'; ], providers: [ StockService, + BondService, TbankClient, TbankConnectionService, BrokerageAccountsService, @@ -38,6 +43,7 @@ import { IncomeAnalyticsService } from './income-analytics.service'; InstrumentMetadataService, PortfolioService, MoexClient, + NcrRatingsClient, UpcomingEventsService, TbankTokenService, TbankAccessService, diff --git a/src/tbank/tbank-market.types.ts b/src/tbank/tbank-market.types.ts index fdd97b6..f577f49 100644 --- a/src/tbank/tbank-market.types.ts +++ b/src/tbank/tbank-market.types.ts @@ -1,5 +1,6 @@ import type { TbankDecimal, + TbankMoney, TbankInstrument, TbankTimestamp, } from './tbank.client'; @@ -41,3 +42,28 @@ export type TbankFundamentals = { totalDebtMrq?: number; netDebtToEbitda?: number; }; + +export type TbankBond = Omit & { + nominal?: TbankMoney; + initialNominal?: TbankMoney; + aciValue?: TbankMoney; + maturityDate?: TbankTimestamp; + callDate?: TbankTimestamp; + couponQuantityPerYear?: number; + floatingCouponFlag?: boolean; + perpetualFlag?: boolean; + amortizationFlag?: boolean; + subordinatedFlag?: boolean; + bondType?: string; + riskLevel?: string; +}; +export type TbankCoupon = { + couponNumber?: string; + couponDate?: TbankTimestamp; + fixDate?: TbankTimestamp; + payOneBond?: TbankMoney; + couponType?: string; + couponStartDate?: TbankTimestamp; + couponEndDate?: TbankTimestamp; + couponPeriod?: number; +}; diff --git a/src/tbank/tbank.client.ts b/src/tbank/tbank.client.ts index f44b349..df670de 100644 --- a/src/tbank/tbank.client.ts +++ b/src/tbank/tbank.client.ts @@ -21,6 +21,8 @@ import { StructuredLogger } from '../common/logging/structured-logger.service'; import type { TbankShare, + TbankBond, + TbankCoupon, TbankCandle, TbankLastPrice, TbankFundamentals, @@ -306,6 +308,46 @@ export class TbankClient { ).then((response) => response.instruments ?? []); } + findBonds(token: string, query: string): Promise { + return this.call<{ instruments?: TbankBond[] }>( + token, + this.sparseInstruments, + 'findInstrument', + { + query, + instrumentKind: 'INSTRUMENT_TYPE_BOND', + }, + ).then((response) => response.instruments ?? []); + } + getBond(token: string, uid: string): Promise { + return this.call<{ instrument?: TbankBond }>( + token, + this.sparseInstruments, + 'bondBy', + { + idType: 'INSTRUMENT_ID_TYPE_UID', + id: uid, + }, + ).then((response) => response.instrument ?? null); + } + getBondCoupons( + token: string, + uid: string, + from: string, + to: string, + ): Promise { + return this.call<{ events?: TbankCoupon[] }>( + token, + this.sparseInstruments, + 'getBondCoupons', + { + instrumentId: uid, + from: { seconds: String(utcSeconds(from)) }, + to: { seconds: String(utcSeconds(to)) }, + }, + ).then((response) => response.events ?? []); + } + getShare(token: string, uid: string): Promise { return this.call<{ instrument?: TbankShare }>( token, diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts new file mode 100644 index 0000000..8afb98e --- /dev/null +++ b/test/bonds.e2e-spec.ts @@ -0,0 +1,174 @@ +import { + INestApplication, + ConflictException, + NotFoundException, + ServiceUnavailableException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test } from '@nestjs/testing'; +import request from 'supertest'; +import type { Server } from 'node:http'; +import { AppModule } from '../src/app.module'; +import { configureApp } from '../src/main'; +import { BondService } from '../src/tbank/bond.service'; + +const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; +describe('Bond HTTP contract', () => { + let app: INestApplication; + const bonds = { + getByTicker: jest.fn(), + get: jest.fn(), + search: jest.fn(), + candles: jest.fn(), + metrics: jest.fn(), + }; + const token = new JwtService().sign( + { sub: 'bond-reader', type: 'access' }, + { secret: process.env.JWT_SECRET }, + ); + beforeAll(async () => { + const module = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(BondService) + .useValue(bonds) + .compile(); + app = module.createNestApplication(); + configureApp(app); + await app.init(); + }); + beforeEach(() => { + jest.clearAllMocks(); + bonds.get.mockResolvedValue({ instrumentUid: uid, name: 'Test' }); + bonds.search.mockResolvedValue([]); + bonds.getByTicker.mockResolvedValue({ + instrumentUid: uid, + slug: 'SU26238RMFS4', + }); + }); + afterAll(() => app.close()); + it('requires authentication before invoking the use case', async () => { + await request(app.getHttpServer()).get(`/api/v1/bonds/${uid}`).expect(401); + expect(bonds.get).not.toHaveBeenCalled(); + }); + it.each([ + '/bonds/not-a-uid', + '/bonds/not-a-uid/metrics', + '/bonds/by-ticker/%20', + '/bonds/by-ticker/' + 'S'.repeat(81), + '/bonds?query=x', + '/bonds?query=', + `/bonds/${uid}/candles?period=century`, + '/brokerage-accounts/all/operations?instrumentUid=not-a-uid', + ])('validates %s in the configured pipeline', async (path) => { + await request(app.getHttpServer()) + .get(`/api/v1${path}`) + .set('Authorization', `Bearer ${token}`) + .expect(400); + expect(bonds.get).not.toHaveBeenCalled(); + expect(bonds.candles).not.toHaveBeenCalled(); + }); + it('serves coupon, yield and agency rating through the authenticated metrics endpoint', async () => { + const metrics = { + coupon: { currency: 'RUB', value: '30' }, + couponDate: '2026-10-10', + couponType: 'COUPON_TYPE_FIX', + couponPeriodDays: 90, + couponYieldPercent: '12.5', + couponYieldPriceAt: '2026-09-10T10:00:00Z', + effectiveYield: { + value: '15.6', + pricePercent: '53.4', + date: '2041-05-15', + dateType: 'MATDATE', + tradeAt: '2026-09-10T10:00:00Z', + }, + creditRating: { + agency: 'NCR', + value: 'AAA.ru', + status: 'active', + date: '2026-04-17', + sourceUrl: 'https://ratings.ru/ratings/issues/123/', + observedAt: '2026-09-10T10:00:00Z', + }, + unavailable: [], + observedAt: '2026-09-10T10:00:00Z', + }; + bonds.metrics.mockResolvedValue(metrics); + await request(app.getHttpServer()) + .get(`/api/v1/bonds/${uid}/metrics`) + .expect(401); + expect(bonds.metrics).not.toHaveBeenCalled(); + const response = await request(app.getHttpServer()) + .get(`/api/v1/bonds/${uid}/metrics`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(response.body).toEqual(metrics); + expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid); + }); + it('uses verified identity and supports a direct instrument URL', async () => { + await request(app.getHttpServer()) + .get(`/api/v1/bonds/${uid}`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid); + }); + it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])( + 'resolves ticker %s through the authenticated endpoint', + async (ticker) => { + const path = `/api/v1/bonds/by-ticker/${encodeURIComponent(ticker)}`; + await request(app.getHttpServer()).get(path).expect(401); + const response = await request(app.getHttpServer()) + .get(path) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(response.body).toMatchObject({ + instrumentUid: uid, + slug: 'SU26238RMFS4', + }); + expect(bonds.getByTicker).toHaveBeenCalledWith('bond-reader', ticker); + }, + ); + it('returns an explicit conflict for an ambiguous ticker', async () => { + bonds.getByTicker.mockRejectedValueOnce( + new ConflictException({ code: 'BOND_TICKER_AMBIGUOUS' }), + ); + const response = await request(app.getHttpServer()) + .get('/api/v1/bonds/by-ticker/SAME') + .set('Authorization', `Bearer ${token}`) + .expect(409); + expect(response.body).toMatchObject({ code: 'BOND_TICKER_AMBIGUOUS' }); + }); + it('returns listing identity and nullable search prices through the search endpoint', async () => { + const item = { + instrumentUid: uid, + name: 'Sber', + classCode: 'TQBR', + isin: 'ISIN', + apiTradeAvailable: true, + price: { currency: 'RUB', value: '306.12' }, + priceAt: null, + }; + bonds.search.mockResolvedValue([item]); + const response = await request(app.getHttpServer()) + .get('/api/v1/bonds?query=SU26238RMFS4') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(bonds.search).toHaveBeenCalledWith('bond-reader', 'SU26238RMFS4'); + expect(response.body).toEqual([item]); + }); + it('preserves safe not-found and upstream errors', async () => { + bonds.get.mockRejectedValueOnce(new NotFoundException()); + await request(app.getHttpServer()) + .get(`/api/v1/bonds/${uid}`) + .set('Authorization', `Bearer ${token}`) + .expect(404); + bonds.get.mockRejectedValueOnce( + new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }), + ); + const response = await request(app.getHttpServer()) + .get(`/api/v1/bonds/${uid}`) + .set('Authorization', `Bearer ${token}`) + .expect(503); + expect(response.body).toMatchObject({ code: 'TBANK_UNAVAILABLE' }); + expect(response.body).not.toHaveProperty('stack'); + }); +});