feat: add bond details, yields and credit ratings

This commit is contained in:
2026-09-10 22:30:46 +03:00
parent f24fd98fbc
commit 235c179ff8
18 changed files with 2371 additions and 146 deletions
+304
View File
@@ -22,6 +22,7 @@
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0", "better-sqlite3": "^12.6.0",
"cheerio": "1.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
@@ -4852,6 +4853,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
@@ -5139,6 +5146,48 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/chokidar": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -5562,6 +5611,34 @@
"node": ">= 8" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -5896,6 +5973,61 @@
"node": ">=0.3.1" "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": { "node_modules/dotenv": {
"version": "17.4.1", "version": "17.4.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz",
@@ -6034,6 +6166,31 @@
"node": ">= 0.8" "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": { "node_modules/end-of-stream": {
"version": "1.4.5", "version": "1.4.5",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
@@ -6057,6 +6214,18 @@
"node": ">=10.13.0" "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": { "node_modules/env-paths": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz",
@@ -7264,6 +7433,37 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -9386,6 +9586,18 @@
"node": ">=8" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -9583,6 +9795,55 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -11788,6 +12049,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/undici-types": {
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -12166,6 +12436,40 @@
"url": "https://opencollective.com/webpack" "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": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+1
View File
@@ -42,6 +42,7 @@
"@prisma/client": "^7.9.1", "@prisma/client": "^7.9.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-sqlite3": "^12.6.0", "better-sqlite3": "^12.6.0",
"cheerio": "1.1.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.15.1", "class-validator": "^0.15.1",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
+101
View File
@@ -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);
}
}
+154
View File
@@ -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;
}
+481
View File
@@ -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' },
});
});
});
+587
View File
@@ -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<string, Promise<object>>({
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<BondSearchResultDto[]> {
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<BondDto> {
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<BondDto> {
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<TbankBond> {
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<BondQuoteDto> {
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<BondMetricsDto> {
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<BondCouponsDto> {
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<BondCandlesDto> {
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<string, BondCandlesDto['candles'][number]>();
// 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<BondPositionDto> {
return this.cached(userId, `position:${uid}`, async () =>
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
);
}
private bond(
userId: string,
connection: TbankConnectionContext,
uid: string,
): Promise<TbankBond> {
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<T extends object>(
userId: string,
key: string,
operation: (connection: TbankConnectionContext) => Promise<T>,
): Promise<T> {
return this.access.withConnection(userId, (connection) =>
this.cachedForConnection(userId, connection, key, () =>
operation(connection),
),
);
}
private cachedForConnection<T extends object>(
userId: string,
{ generation }: TbankConnectionContext,
key: string,
operation: () => Promise<T>,
): Promise<T> {
const cacheKey = JSON.stringify([userId, generation, key]);
const cached = this.cache.get(cacheKey);
if (cached) return cached as Promise<T>;
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<string>();
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;
}
+44
View File
@@ -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<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
+101
View File
@@ -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,
),
);
}
+91
View File
@@ -1,6 +1,97 @@
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
describe('MoexClient', () => { 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 () => { it('treats a successful response without the requested block as no events', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true, ok: true,
+54
View File
@@ -10,9 +10,63 @@ export type MoexBondEvent =
type IssBlock = { columns?: unknown; data?: unknown }; type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>; type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = {
value: string;
pricePercent: string;
date: string;
dateType: string | null;
tradeAt: string;
};
@Injectable() @Injectable()
export class MoexClient { export class MoexClient {
async bondYield(
secid: string,
isin: string,
board: string,
): Promise<MoexBondYield | null> {
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<MoexBondEvent[]> { async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([ const [coupons, offers, description] = await Promise.all([
+85
View File
@@ -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/') =>
`<tr><td>Эмитент</td><td>Облигация</td><td>${value}</td><td><a href="${href}">${code}</a></td><td>REG</td><td>17.04.2026</td></tr>`;
const table = (rows: string) =>
`<table id="issues-table"><thead><tr><th>Лицо</th><th>Выпуск</th><th>Рейтинг</th><th>ISIN</th><th>Регномер</th><th>Дата</th></tr></thead><tbody>${rows}</tbody></table>`;
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([
'<html>Service unavailable</html>',
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);
});
});
+107
View File
@@ -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<Map<string, NcrRating>>
>({ max: 1, ttl: 6 * 60 * 60_000 });
async rating(isin: string): Promise<NcrRating | null> {
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<Map<string, NcrRating>> {
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<string, NcrRating>();
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;
}
}
+4 -99
View File
@@ -1,100 +1,5 @@
import { import { instrumentPosition } from './instrument-position';
add, import type { PortfolioDto } from './portfolio.dto';
decimalFromString, export function stockPosition(portfolio: PortfolioDto, uid: string) {
divide, return instrumentPosition(portfolio, uid, 'STOCK');
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,
),
);
} }
+9 -47
View File
@@ -1,3 +1,10 @@
import {
timestamp,
moscowDay,
optional,
previousClose,
} from './instrument-market';
export { previousClose } from './instrument-market';
import { import {
Injectable, Injectable,
ConflictException, ConflictException,
@@ -16,12 +23,8 @@ import {
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { stockPosition } from './stock-position'; import { stockPosition } from './stock-position';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { TbankClient, TbankTimestamp } from './tbank.client'; import { TbankClient } from './tbank.client';
import type { import type { TbankFundamentals, TbankShare } from './tbank-market.types';
TbankCandle,
TbankFundamentals,
TbankShare,
} from './tbank-market.types';
import { import {
StockCandlesDto, StockCandlesDto,
StockCandlesQueryDto, StockCandlesQueryDto,
@@ -458,47 +461,6 @@ function stock(share: TbankShare): StockDto {
historyFrom: timestamp(share.first_1dayCandleDate), 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<T>(
operation: () => Promise<T>,
name: string,
unavailable: string[],
): Promise<T | null> {
try {
return await operation();
} catch (error) {
if (!(error instanceof ServiceUnavailableException)) throw error;
unavailable.push(name);
return null;
}
}
function metrics( function metrics(
values: TbankFundamentals | null, values: TbankFundamentals | null,
sector?: string, sector?: string,
+6
View File
@@ -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 { StockController } from './stock.controller';
import { StockService } from './stock.service'; import { StockService } from './stock.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@@ -22,6 +25,7 @@ import { IncomeAnalyticsService } from './income-analytics.service';
@Module({ @Module({
controllers: [ controllers: [
StockController, StockController,
BondController,
TbankConnectionController, TbankConnectionController,
BrokerageAccountsController, BrokerageAccountsController,
BrokerageOperationsController, BrokerageOperationsController,
@@ -31,6 +35,7 @@ import { IncomeAnalyticsService } from './income-analytics.service';
], ],
providers: [ providers: [
StockService, StockService,
BondService,
TbankClient, TbankClient,
TbankConnectionService, TbankConnectionService,
BrokerageAccountsService, BrokerageAccountsService,
@@ -38,6 +43,7 @@ import { IncomeAnalyticsService } from './income-analytics.service';
InstrumentMetadataService, InstrumentMetadataService,
PortfolioService, PortfolioService,
MoexClient, MoexClient,
NcrRatingsClient,
UpcomingEventsService, UpcomingEventsService,
TbankTokenService, TbankTokenService,
TbankAccessService, TbankAccessService,
+26
View File
@@ -1,5 +1,6 @@
import type { import type {
TbankDecimal, TbankDecimal,
TbankMoney,
TbankInstrument, TbankInstrument,
TbankTimestamp, TbankTimestamp,
} from './tbank.client'; } from './tbank.client';
@@ -41,3 +42,28 @@ export type TbankFundamentals = {
totalDebtMrq?: number; totalDebtMrq?: number;
netDebtToEbitda?: number; netDebtToEbitda?: number;
}; };
export type TbankBond = Omit<TbankShare, 'shareType'> & {
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;
};
+42
View File
@@ -21,6 +21,8 @@ import { StructuredLogger } from '../common/logging/structured-logger.service';
import type { import type {
TbankShare, TbankShare,
TbankBond,
TbankCoupon,
TbankCandle, TbankCandle,
TbankLastPrice, TbankLastPrice,
TbankFundamentals, TbankFundamentals,
@@ -306,6 +308,46 @@ export class TbankClient {
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>(
token,
this.sparseInstruments,
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_BOND',
},
).then((response) => response.instruments ?? []);
}
getBond(token: string, uid: string): Promise<TbankBond | null> {
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<TbankCoupon[]> {
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<TbankShare | null> { getShare(token: string, uid: string): Promise<TbankShare | null> {
return this.call<{ instrument?: TbankShare }>( return this.call<{ instrument?: TbankShare }>(
token, token,
+174
View File
@@ -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<Server>;
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');
});
});