feat: add bond details, yields and credit ratings
This commit is contained in:
Generated
+304
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user