Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f216d520 | ||
|
|
e4232138d0 | ||
|
|
d6e6c4ace5 | ||
|
|
0862d56c4e | ||
|
|
7c0150c56b | ||
|
|
39f2ce5bc4 | ||
|
|
4c916829ea | ||
|
|
386c91670e | ||
|
|
9437b6a311 | ||
|
|
0ec7ae51af | ||
|
|
1946192be9 |
@@ -12,7 +12,7 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
IMAGE: git.personal.ksv741.ru/wealthledger/backend
|
IMAGE: nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend
|
||||||
RELEASE_TAG: ${{ gitea.ref_name }}
|
RELEASE_TAG: ${{ gitea.ref_name }}
|
||||||
REVISION: ${{ gitea.sha }}
|
REVISION: ${{ gitea.sha }}
|
||||||
DOCKER_CONFIG: ${{ runner.temp }}/wealthledger-backend-docker
|
DOCKER_CONFIG: ${{ runner.temp }}/wealthledger-backend-docker
|
||||||
@@ -31,14 +31,14 @@ jobs:
|
|||||||
- run: npx --no-install prisma generate
|
- run: npx --no-install prisma generate
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
- run: npm run test:e2e
|
- run: npm run test:e2e
|
||||||
- name: Login with dedicated package writer
|
- name: Login with dedicated Nexus writer
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.NEXUS_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
test -n "$REGISTRY_USERNAME" && test -n "$REGISTRY_TOKEN"
|
test -n "$REGISTRY_USERNAME" && test -n "$REGISTRY_TOKEN"
|
||||||
install -d -m 700 "$DOCKER_CONFIG"
|
install -d -m 700 "$DOCKER_CONFIG"
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login git.personal.ksv741.ru --username "$REGISTRY_USERNAME" --password-stdin
|
printf '%s' "$REGISTRY_TOKEN" | docker login nexus.personal.ksv741.ru --username "$REGISTRY_USERNAME" --password-stdin
|
||||||
- name: Publish immutable release tag
|
- name: Publish immutable release tag
|
||||||
run: |
|
run: |
|
||||||
VERSION="${RELEASE_TAG#v}"
|
VERSION="${RELEASE_TAG#v}"
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://github.com/actions/checkout@v4
|
- uses: https://github.com/actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
- name: Record checked revision
|
- name: Record checked revision
|
||||||
run: git rev-parse HEAD
|
run: git rev-parse HEAD
|
||||||
- uses: https://github.com/actions/setup-node@v4
|
- uses: https://github.com/actions/setup-node@v4
|
||||||
@@ -22,3 +24,11 @@ jobs:
|
|||||||
run: npm run verify
|
run: npm run verify
|
||||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||||
run: npm run test:e2e
|
run: npm run test:e2e
|
||||||
|
- name: Analyze develop with SonarQube
|
||||||
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||||
|
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||||
|
uses: https://github.com/SonarSource/sonarqube-scan-action@aecaf43ae57e412bd97d70ef9ce6076e672fe0a9
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
|
with:
|
||||||
|
args: -Dsonar.qualitygate.wait=true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ lerna-debug.log*
|
|||||||
|
|
||||||
# Tests
|
# Tests
|
||||||
/coverage
|
/coverage
|
||||||
|
/.scannerwork
|
||||||
/.nyc_output
|
/.nyc_output
|
||||||
|
|
||||||
# IDEs and editors
|
# IDEs and editors
|
||||||
|
|||||||
@@ -1,5 +1,44 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* investment reads return data/meta resource envelopes and locally
|
||||||
|
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
|
||||||
|
synchronization endpoints.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
|
||||||
|
|
||||||
|
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* correct Nexus routing and SonarQube host ([7c0150c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7c0150c56b2dab44347b55344e04c821689543e7))
|
||||||
|
|
||||||
|
## [0.2.2](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.1...v0.2.2) (2026-09-19)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* update Nexus registry host ([4c91682](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4c916829eac23326a42c09873e32908cfc9f422c))
|
||||||
|
|
||||||
|
## [0.2.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.0...v0.2.1) (2026-09-18)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* use shared Nexus account [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([9437b6a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9437b6a311cd51230b8381c8643c6ea308454d38))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* add backend SonarQube analysis [#23](https://git.personal.ksv741.ru/WealthLedger/backend/issues/23) ([1946192](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1946192be975eeea75432ec39b27d19952742b15))
|
||||||
|
* publish backend images to Nexus [#25](https://git.personal.ksv741.ru/WealthLedger/backend/issues/25) ([0ec7ae5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ec7ae51af080288a31dc6e65a738a4bd6396436))
|
||||||
|
|
||||||
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.1.0...v0.2.0) (2026-09-16)
|
## [0.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.1.0...v0.2.0) (2026-09-16)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -38,10 +38,13 @@ npm run test:container -- wealthledger-backend:production
|
|||||||
|
|
||||||
Gitea `image.yml` запускается на защищённом release tag `vX.Y.Z`, проверяет
|
Gitea `image.yml` запускается на защищённом release tag `vX.Y.Z`, проверяет
|
||||||
package/lock version и canonical gates. Runner `ubuntu-latest` должен иметь
|
package/lock version и canonical gates. Runner `ubuntu-latest` должен иметь
|
||||||
Docker/buildx. Dedicated package writer задаётся Actions secrets
|
Docker/buildx. Образы хранятся в Nexus:
|
||||||
`REGISTRY_USERNAME`/`REGISTRY_TOKEN`; server использует отдельную read-only
|
`nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend`. Пользователь `wealthledger-ci`
|
||||||
identity. Публикуются semver tags, без `latest`; существующие
|
задаётся Actions secrets `NEXUS_USERNAME`/`NEXUS_PASSWORD`; эта же учётная
|
||||||
release tags не перезаписываются. Production выбирает опубликованный version tag.
|
запись используется сервером для загрузки образов. Docker endpoint и token realm должны использовать
|
||||||
|
HTTPS; в `docker-hosted` включается запрет redeploy. Прежние Gitea secrets
|
||||||
|
сохраняются на время перехода и не передаются в Nexus.
|
||||||
|
Публикуются semver tags, без `latest`; существующие release tags не перезаписываются. Production выбирает опубликованный version tag.
|
||||||
|
|
||||||
Compose topology принадлежит workspace, эксплуатационная последовательность —
|
Compose topology принадлежит workspace, эксплуатационная последовательность —
|
||||||
[документация](https://git.personal.ksv741.ru/WealthLedger/docs),
|
[документация](https://git.personal.ksv741.ru/WealthLedger/docs),
|
||||||
@@ -58,9 +61,9 @@ npm run verify
|
|||||||
```
|
```
|
||||||
|
|
||||||
Он запускает zero-warning lint,
|
Он запускает zero-warning lint,
|
||||||
application/test typechecks, Jest и build. `npm run lint:fix` — отдельная
|
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||||
записывающая команда, `npm run test:cov` — диагностика без общего числового
|
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||||
threshold. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
||||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
||||||
выполните обе команды.
|
выполните обе команды.
|
||||||
|
|
||||||
@@ -102,6 +105,38 @@ Required checks настраиваются отдельно в Gitea: налич
|
|||||||
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
|
||||||
после проверки значения.
|
после проверки значения.
|
||||||
|
|
||||||
|
## SonarQube
|
||||||
|
|
||||||
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
|
использует `sonar-project.properties`. Анализируются исходники `src`; unit specs
|
||||||
|
и HTTP specs учитываются как тесты. Внешние T-Bank proto contracts исключены,
|
||||||
|
generated Prisma client находится за пределами `sonar.sources`.
|
||||||
|
|
||||||
|
`npm run verify` запускает unit tests с покрытием один раз. Jest штатно создаёт
|
||||||
|
`coverage/lcov.info`; scanner импортирует этот отчёт. Покрытие HTTP/e2e tests
|
||||||
|
в него не входит, сами HTTP suites остаются обязательным отдельным шагом CI.
|
||||||
|
|
||||||
|
Создайте в SonarQube проект с ключом `wealthledger-backend`, а в Gitea → backend →
|
||||||
|
Settings → Actions → Secrets добавьте `SONAR_TOKEN` с правом анализа этого проекта.
|
||||||
|
Токен не сохраняется в файлах. Community Edition 9.9.8 получает анализ только
|
||||||
|
при push в `develop`, после успешных `verify` и SQLite/e2e checks. CI ожидает
|
||||||
|
Quality Gate; отрицательный результат или ошибка scanner завершает job с ошибкой.
|
||||||
|
PR и другие ветки проходят штатные проверки без отправки анализа.
|
||||||
|
Переименование основной ветки SonarQube не требуется; `sonar.branch.name` не задаётся.
|
||||||
|
|
||||||
|
Runner должен поддерживать Docker actions и иметь доступ к GitHub, Docker Hub
|
||||||
|
и HTTPS-адресу SonarQube с действующим сертификатом для этого имени.
|
||||||
|
Официальный Docker action закреплён на commit версии 2.3.0, как в frontend.
|
||||||
|
`tsconfig.sonar.json` содержит совместимые со старым анализатором настройки
|
||||||
|
модулей и decorators с target ES2022, без новых compiler options приложения.
|
||||||
|
Штатная компиляция приложения остаётся на ES2023.
|
||||||
|
После обновления сервера пересмотрите scanner и необходимость отдельного tsconfig.
|
||||||
|
|
||||||
|
Локально после `nvm use --silent`, `npm ci` и генерации Prisma client выполните
|
||||||
|
`npm run verify`, затем `sonar-scanner` с совместимым SonarScanner CLI и
|
||||||
|
`SONAR_TOKEN` в окружении. Первый запуск CI после merge должен подтвердить
|
||||||
|
загрузку отчёта и покрытия: локальные проверки не доказывают доступность сервера.
|
||||||
|
|
||||||
## Документация
|
## Документация
|
||||||
|
|
||||||
- [AGENTS: правила и источники](AGENTS.md)
|
- [AGENTS: правила и источники](AGENTS.md)
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.0",
|
"version": "1.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.4",
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
"test:container": "node test/container-smoke.cjs",
|
"test:container": "node test/container-smoke.cjs",
|
||||||
"test:durability": "node test/bank-cards-durability.cjs",
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||||
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm test -- --runInBand && npm run build",
|
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||||
"release:preview": "release-it --dry-run --ci",
|
"release:preview": "release-it --dry-run --ci",
|
||||||
"release:prepare": "release-it --ci",
|
"release:prepare": "release-it --ci",
|
||||||
"commitlint": "commitlint"
|
"commitlint": "commitlint"
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
BEGIN IMMEDIATE;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankAccount" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"openedOn" TEXT,
|
||||||
|
"upstreamOrder" INTEGER NOT NULL,
|
||||||
|
"operationsRevision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"historyFrom" DATETIME,
|
||||||
|
"historyBoundary" TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId"),
|
||||||
|
CONSTRAINT "TbankAccount_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankPortfolio" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"mappingVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId"),
|
||||||
|
CONSTRAINT "TbankPortfolio_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankOperation" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"brokerOperationId" TEXT NOT NULL,
|
||||||
|
"occurredAt" DATETIME NOT NULL,
|
||||||
|
"instrumentUid" TEXT,
|
||||||
|
"category" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"mappingVersion" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId", "brokerOperationId"),
|
||||||
|
CONSTRAINT "TbankOperation_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankOperationCoverage" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"instrumentPredicate" TEXT NOT NULL DEFAULT '',
|
||||||
|
"from" DATETIME NOT NULL,
|
||||||
|
"to" DATETIME NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"asOf" DATETIME NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "accountId", "instrumentPredicate", "from", "to"),
|
||||||
|
CONSTRAINT "TbankOperationCoverage_userId_revision_accountId_fkey" FOREIGN KEY ("userId", "revision", "accountId") REFERENCES "TbankAccount" ("userId", "revision", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankForecast" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"scopeKey" TEXT NOT NULL,
|
||||||
|
"membershipRevision" INTEGER NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"dependencyRevisions" JSONB NOT NULL,
|
||||||
|
"sourceTimestamps" JSONB NOT NULL,
|
||||||
|
"calculatedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "revision", "scopeKey"),
|
||||||
|
CONSTRAINT "TbankForecast_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankInstrument" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"uid" TEXT NOT NULL,
|
||||||
|
"figi" TEXT,
|
||||||
|
"ticker" TEXT,
|
||||||
|
"instrumentType" TEXT NOT NULL,
|
||||||
|
"metadata" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "uid")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankCatalogDiscovery" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentType" TEXT NOT NULL,
|
||||||
|
"query" TEXT NOT NULL,
|
||||||
|
"instrumentUids" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentType", "query")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankInstrumentLookup" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"identifierType" TEXT NOT NULL,
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"matchedUids" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "identifierType", "identifier")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankInstrumentSource" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentUid" TEXT NOT NULL,
|
||||||
|
"source" TEXT NOT NULL,
|
||||||
|
"kind" TEXT NOT NULL,
|
||||||
|
"rangeKey" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentUid", "source", "kind", "rangeKey"),
|
||||||
|
CONSTRAINT "TbankInstrumentSource_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankCandle" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentUid" TEXT NOT NULL,
|
||||||
|
"interval" TEXT NOT NULL,
|
||||||
|
"time" DATETIME NOT NULL,
|
||||||
|
"close" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
"isComplete" BOOLEAN NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentUid", "interval", "time"),
|
||||||
|
CONSTRAINT "TbankCandle_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankCandleCoverage" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentUid" TEXT NOT NULL,
|
||||||
|
"interval" TEXT NOT NULL,
|
||||||
|
"from" DATETIME NOT NULL,
|
||||||
|
"to" DATETIME NOT NULL,
|
||||||
|
"isComplete" BOOLEAN NOT NULL,
|
||||||
|
"fetchedAt" DATETIME NOT NULL,
|
||||||
|
"dataRevision" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
|
||||||
|
PRIMARY KEY ("provider", "instrumentUid", "interval", "from", "to"),
|
||||||
|
CONSTRAINT "TbankCandleCoverage_provider_instrumentUid_fkey" FOREIGN KEY ("provider", "instrumentUid") REFERENCES "TbankInstrument" ("provider", "uid") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankSyncWork" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"scopeKey" TEXT NOT NULL,
|
||||||
|
"intent" JSONB NOT NULL,
|
||||||
|
"state" TEXT NOT NULL,
|
||||||
|
"attemptId" TEXT NOT NULL,
|
||||||
|
"processRunId" TEXT NOT NULL,
|
||||||
|
"priority" INTEGER NOT NULL,
|
||||||
|
"progress" JSONB NOT NULL,
|
||||||
|
"errorCode" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "TbankSyncWork_userId_revision_fkey" FOREIGN KEY ("userId", "revision") REFERENCES "TbankConnection" ("userId", "revision") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TbankSyncStage" (
|
||||||
|
"workId" TEXT NOT NULL,
|
||||||
|
"attemptId" TEXT NOT NULL,
|
||||||
|
"sourceKey" TEXT NOT NULL,
|
||||||
|
"itemKey" TEXT NOT NULL,
|
||||||
|
"payload" JSONB NOT NULL,
|
||||||
|
|
||||||
|
PRIMARY KEY ("workId", "attemptId", "sourceKey", "itemKey"),
|
||||||
|
CONSTRAINT "TbankSyncStage_workId_attemptId_fkey" FOREIGN KEY ("workId", "attemptId") REFERENCES "TbankSyncWork" ("id", "attemptId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
CREATE TABLE "new_TbankConnection" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"encryptedToken" TEXT NOT NULL,
|
||||||
|
"connectedAt" DATETIME NOT NULL,
|
||||||
|
"revision" TEXT NOT NULL,
|
||||||
|
"invalidToken" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"membershipAt" DATETIME,
|
||||||
|
"membershipRevision" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CONSTRAINT "TbankConnection_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
-- Preserve credentials and assign an opaque revision to each existing connection.
|
||||||
|
INSERT INTO "new_TbankConnection" ("connectedAt", "encryptedToken", "userId", "revision") SELECT "connectedAt", "encryptedToken", "userId", lower(hex(randomblob(16))) FROM "TbankConnection";
|
||||||
|
DROP TABLE "TbankConnection";
|
||||||
|
ALTER TABLE "new_TbankConnection" RENAME TO "TbankConnection";
|
||||||
|
CREATE UNIQUE INDEX "TbankConnection_userId_key" ON "TbankConnection"("userId");
|
||||||
|
CREATE UNIQUE INDEX "TbankConnection_userId_revision_key" ON "TbankConnection"("userId", "revision");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankOperation_userId_revision_accountId_occurredAt_brokerOperationId_idx" ON "TbankOperation"("userId", "revision", "accountId", "occurredAt", "brokerOperationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankOperation_userId_revision_accountId_instrumentUid_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "instrumentUid", "occurredAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankOperation_userId_revision_accountId_category_occurredAt_idx" ON "TbankOperation"("userId", "revision", "accountId", "category", "occurredAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankInstrument_provider_figi_idx" ON "TbankInstrument"("provider", "figi");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankInstrument_provider_ticker_instrumentType_idx" ON "TbankInstrument"("provider", "ticker", "instrumentType");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "TbankSyncWork_userId_revision_state_priority_idx" ON "TbankSyncWork"("userId", "revision", "state", "priority");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TbankSyncWork_userId_revision_scopeKey_key" ON "TbankSyncWork"("userId", "revision", "scopeKey");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "TbankSyncWork_id_attemptId_key" ON "TbankSyncWork"("id", "attemptId");
|
||||||
|
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
@@ -25,7 +25,212 @@ model TbankConnection {
|
|||||||
userId String @unique
|
userId String @unique
|
||||||
encryptedToken String
|
encryptedToken String
|
||||||
connectedAt DateTime
|
connectedAt DateTime
|
||||||
|
revision String @default(uuid())
|
||||||
|
invalidToken Boolean @default(false)
|
||||||
|
membershipAt DateTime?
|
||||||
|
membershipRevision Int @default(0)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
accounts TbankAccount[]
|
||||||
|
forecasts TbankForecast[]
|
||||||
|
work TbankSyncWork[]
|
||||||
|
|
||||||
|
@@unique([userId, revision])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankAccount {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
type String
|
||||||
|
name String
|
||||||
|
openedOn String?
|
||||||
|
upstreamOrder Int
|
||||||
|
operationsRevision Int @default(0)
|
||||||
|
historyFrom DateTime?
|
||||||
|
historyBoundary String @default("unknown")
|
||||||
|
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
portfolio TbankPortfolio?
|
||||||
|
operations TbankOperation[]
|
||||||
|
coverage TbankOperationCoverage[]
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankPortfolio {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
payload Json
|
||||||
|
mappingVersion Int @default(1)
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankOperation {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
brokerOperationId String
|
||||||
|
occurredAt DateTime
|
||||||
|
instrumentUid String?
|
||||||
|
category String
|
||||||
|
payload Json
|
||||||
|
mappingVersion Int @default(1)
|
||||||
|
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId, brokerOperationId])
|
||||||
|
@@index([userId, revision, accountId, occurredAt, brokerOperationId])
|
||||||
|
@@index([userId, revision, accountId, instrumentUid, occurredAt])
|
||||||
|
@@index([userId, revision, accountId, category, occurredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankOperationCoverage {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
accountId String
|
||||||
|
instrumentPredicate String @default("")
|
||||||
|
from DateTime
|
||||||
|
to DateTime
|
||||||
|
fetchedAt DateTime
|
||||||
|
asOf DateTime
|
||||||
|
account TbankAccount @relation(fields: [userId, revision, accountId], references: [userId, revision, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, accountId, instrumentPredicate, from, to])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankForecast {
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
scopeKey String
|
||||||
|
membershipRevision Int
|
||||||
|
payload Json
|
||||||
|
dependencyRevisions Json
|
||||||
|
sourceTimestamps Json
|
||||||
|
calculatedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, revision, scopeKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankInstrument {
|
||||||
|
provider String
|
||||||
|
uid String
|
||||||
|
figi String?
|
||||||
|
ticker String?
|
||||||
|
instrumentType String
|
||||||
|
metadata Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
sources TbankInstrumentSource[]
|
||||||
|
candles TbankCandle[]
|
||||||
|
candleCoverage TbankCandleCoverage[]
|
||||||
|
|
||||||
|
@@id([provider, uid])
|
||||||
|
@@index([provider, figi])
|
||||||
|
@@index([provider, ticker, instrumentType])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankCatalogDiscovery {
|
||||||
|
provider String
|
||||||
|
instrumentType String
|
||||||
|
query String
|
||||||
|
instrumentUids Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
|
||||||
|
@@id([provider, instrumentType, query])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty and ambiguous successful UID/FIGI/ticker lookups are distinct from failures.
|
||||||
|
model TbankInstrumentLookup {
|
||||||
|
provider String
|
||||||
|
identifierType String
|
||||||
|
identifier String
|
||||||
|
matchedUids Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
|
||||||
|
@@id([provider, identifierType, identifier])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source records contain no holdings, personal estimates or credentials errors.
|
||||||
|
model TbankInstrumentSource {
|
||||||
|
provider String
|
||||||
|
instrumentUid String
|
||||||
|
source String
|
||||||
|
kind String
|
||||||
|
rangeKey String
|
||||||
|
payload Json
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([provider, instrumentUid, source, kind, rangeKey])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankCandle {
|
||||||
|
provider String
|
||||||
|
instrumentUid String
|
||||||
|
interval String
|
||||||
|
time DateTime
|
||||||
|
close String
|
||||||
|
payload Json
|
||||||
|
isComplete Boolean
|
||||||
|
fetchedAt DateTime
|
||||||
|
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([provider, instrumentUid, interval, time])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankCandleCoverage {
|
||||||
|
provider String
|
||||||
|
instrumentUid String
|
||||||
|
interval String
|
||||||
|
from DateTime
|
||||||
|
to DateTime
|
||||||
|
isComplete Boolean
|
||||||
|
fetchedAt DateTime
|
||||||
|
dataRevision Int @default(1)
|
||||||
|
instrument TbankInstrument @relation(fields: [provider, instrumentUid], references: [provider, uid], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([provider, instrumentUid, interval, from, to])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankSyncWork {
|
||||||
|
id String @id
|
||||||
|
userId String
|
||||||
|
revision String
|
||||||
|
scopeKey String
|
||||||
|
intent Json
|
||||||
|
state String
|
||||||
|
attemptId String
|
||||||
|
processRunId String
|
||||||
|
priority Int
|
||||||
|
progress Json
|
||||||
|
errorCode String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
connection TbankConnection @relation(fields: [userId, revision], references: [userId, revision], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
staged TbankSyncStage[]
|
||||||
|
|
||||||
|
@@unique([userId, revision, scopeKey])
|
||||||
|
@@index([userId, revision, state, priority])
|
||||||
|
@@unique([id, attemptId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model TbankSyncStage {
|
||||||
|
workId String
|
||||||
|
attemptId String
|
||||||
|
sourceKey String
|
||||||
|
itemKey String
|
||||||
|
payload Json
|
||||||
|
work TbankSyncWork @relation(fields: [workId, attemptId], references: [id, attemptId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([workId, attemptId, sourceKey, itemKey])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
sonar.projectKey=wealthledger-backend
|
||||||
|
sonar.projectName=WealthLedger backend
|
||||||
|
sonar.host.url=https://sonarqube.personal.ksv741.ru
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
sonar.sources=src
|
||||||
|
sonar.tests=src,test
|
||||||
|
sonar.test.inclusions=src/**/*.spec.ts,test/**/*.e2e-spec.ts
|
||||||
|
sonar.exclusions=src/**/*.spec.ts,src/integrations/tbank/proto/contracts/**
|
||||||
|
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||||
|
# SonarQube 9.9 embeds an older TypeScript analyzer than the application compiler.
|
||||||
|
sonar.typescript.tsconfigPaths=tsconfig.sonar.json
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
@@ -64,6 +65,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
UpcomingEventsModule,
|
UpcomingEventsModule,
|
||||||
InstrumentsModule,
|
InstrumentsModule,
|
||||||
TbankConnectionModule,
|
TbankConnectionModule,
|
||||||
|
TbankSyncModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -0,0 +1,320 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import type {
|
||||||
|
Prisma,
|
||||||
|
TbankInstrumentLookup as MetadataLookupRow,
|
||||||
|
TbankInstrument as MetadataRow,
|
||||||
|
} from '../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||||
|
|
||||||
|
export type MetadataIdentity = {
|
||||||
|
instrumentUid?: string | null;
|
||||||
|
figi?: string | null;
|
||||||
|
};
|
||||||
|
export type MetadataLookup = {
|
||||||
|
type: 'uid' | 'figi';
|
||||||
|
id: string;
|
||||||
|
data: TbankInstrument | null;
|
||||||
|
};
|
||||||
|
const provider = 'tbank';
|
||||||
|
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
|
||||||
|
const metadataReadBatchSize = 400;
|
||||||
|
export function metadataKey(identity: MetadataIdentity) {
|
||||||
|
return JSON.stringify([
|
||||||
|
identity.instrumentUid?.trim() || null,
|
||||||
|
identity.figi?.trim() || null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstrumentMetadataRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async readMany(identities: MetadataIdentity[]) {
|
||||||
|
const identifiers = identities.flatMap((identity) => [
|
||||||
|
...(identity.instrumentUid?.trim()
|
||||||
|
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||||
|
: []),
|
||||||
|
...(identity.figi?.trim()
|
||||||
|
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
if (!identifiers.length)
|
||||||
|
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const lookups: MetadataLookupRow[] = [];
|
||||||
|
for (const identifierType of ['uid', 'figi']) {
|
||||||
|
const values = [
|
||||||
|
...new Set(
|
||||||
|
identifiers
|
||||||
|
.filter((entry) => entry.identifierType === identifierType)
|
||||||
|
.map((entry) => entry.identifier),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < values.length;
|
||||||
|
offset += metadataReadBatchSize
|
||||||
|
)
|
||||||
|
lookups.push(
|
||||||
|
...(await tx.tbankInstrumentLookup.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
identifierType,
|
||||||
|
identifier: {
|
||||||
|
in: values.slice(offset, offset + metadataReadBatchSize),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const uids = [
|
||||||
|
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
|
||||||
|
];
|
||||||
|
const instruments: MetadataRow[] = [];
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < uids.length;
|
||||||
|
offset += metadataReadBatchSize
|
||||||
|
)
|
||||||
|
instruments.push(
|
||||||
|
...(await tx.tbankInstrument.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
uid: { in: uids.slice(offset, offset + metadataReadBatchSize) },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const byIdentifier = new Map(
|
||||||
|
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
|
||||||
|
);
|
||||||
|
const byUid = new Map(instruments.map((row) => [row.uid, row]));
|
||||||
|
return new Map(
|
||||||
|
identities.map((identity) => [
|
||||||
|
metadataKey(identity),
|
||||||
|
selectMetadata(identity, byIdentifier, byUid),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async reserve(
|
||||||
|
attemptOwner: { userId: string; revision: string },
|
||||||
|
identity: MetadataIdentity,
|
||||||
|
runId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const valid = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...attemptOwner, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!valid.count) return null;
|
||||||
|
const scope = {
|
||||||
|
...attemptOwner,
|
||||||
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === runId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority: 1,
|
||||||
|
intent: { source: 'metadata', ...identity },
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
work(userId: string, revision: string, identity: MetadataIdentity) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision_scopeKey: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const aliases = new Map(
|
||||||
|
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||||
|
);
|
||||||
|
for (const result of results) {
|
||||||
|
if (!result.data) continue;
|
||||||
|
const uid =
|
||||||
|
result.data.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||||
|
const figi =
|
||||||
|
result.data.figi?.trim() ||
|
||||||
|
(result.type === 'figi' ? result.id : null);
|
||||||
|
for (const [type, id] of [
|
||||||
|
['uid', uid],
|
||||||
|
['figi', figi],
|
||||||
|
] as const) {
|
||||||
|
if (id && uid && !aliases.has(`${type}:${id}`))
|
||||||
|
aliases.set(`${type}:${id}`, {
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
data: { ...result.data, uid },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const result of aliases.values()) {
|
||||||
|
const instrument = result.data;
|
||||||
|
const uid =
|
||||||
|
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
|
||||||
|
// FIGI is an alias, never a fabricated provider UID.
|
||||||
|
if (instrument && !uid)
|
||||||
|
throw new Error('Metadata response has no instrument UID');
|
||||||
|
if (instrument && uid) {
|
||||||
|
const key = { provider, uid };
|
||||||
|
const previous = await tx.tbankInstrument.findUnique({
|
||||||
|
where: { provider_uid: key },
|
||||||
|
});
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify(instrument),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
const data = {
|
||||||
|
metadata: payload,
|
||||||
|
figi:
|
||||||
|
instrument.figi ?? (result.type === 'figi' ? result.id : null),
|
||||||
|
ticker: instrument.ticker ?? null,
|
||||||
|
instrumentType: instrument.instrumentType ?? '',
|
||||||
|
fetchedAt: now,
|
||||||
|
};
|
||||||
|
await tx.tbankInstrument.upsert({
|
||||||
|
where: { provider_uid: key },
|
||||||
|
create: { ...key, ...data },
|
||||||
|
update: {
|
||||||
|
...data,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
identifierType: result.type,
|
||||||
|
identifier: result.id,
|
||||||
|
};
|
||||||
|
const matchedUids = instrument && uid ? [uid] : [];
|
||||||
|
const previous = await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentLookup.upsert({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
create: { ...key, matchedUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
matchedUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
...(success ? { connection: { invalidToken: false } } : {}),
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
state: success ? 'complete' : 'failed',
|
||||||
|
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (success)
|
||||||
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: true },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectMetadata(
|
||||||
|
identity: MetadataIdentity,
|
||||||
|
lookups: ReadonlyMap<string, MetadataLookupRow>,
|
||||||
|
instruments: ReadonlyMap<string, MetadataRow>,
|
||||||
|
) {
|
||||||
|
const resolve = (type: string, id: string | null | undefined) => {
|
||||||
|
const lookup = lookups.get(`${type}:${id?.trim()}`);
|
||||||
|
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
|
||||||
|
const instrument = uid ? instruments.get(uid) : undefined;
|
||||||
|
const data = instrument ? (instrument.metadata as TbankInstrument) : null;
|
||||||
|
return { lookup, instrument, data };
|
||||||
|
};
|
||||||
|
const instrumentUid = identity.instrumentUid?.trim();
|
||||||
|
const instrumentFigi = identity.figi?.trim();
|
||||||
|
const uid = resolve('uid', instrumentUid);
|
||||||
|
const figi = resolve('figi', instrumentFigi);
|
||||||
|
const usefulUid = Boolean(uid.data?.name?.trim() || uid.data?.ticker?.trim());
|
||||||
|
const used =
|
||||||
|
usefulUid || !instrumentFigi ? [uid] : instrumentUid ? [uid, figi] : [figi];
|
||||||
|
const complete = used.every((row) => Boolean(row.lookup));
|
||||||
|
const fetchedAt =
|
||||||
|
used
|
||||||
|
.flatMap((row) => (row.lookup ? [row.lookup.fetchedAt] : []))
|
||||||
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null;
|
||||||
|
return {
|
||||||
|
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||||
|
complete,
|
||||||
|
fresh:
|
||||||
|
complete &&
|
||||||
|
fetchedAt !== null &&
|
||||||
|
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||||
|
fetchedAt,
|
||||||
|
dataRevision: used
|
||||||
|
.map(
|
||||||
|
(row) =>
|
||||||
|
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
|
||||||
|
)
|
||||||
|
.join('/'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import type { LRUCache } from 'lru-cache';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
|
||||||
import type { TbankInstrument } from './tbank.client';
|
|
||||||
|
|
||||||
describe('InstrumentMetadataService', () => {
|
|
||||||
const tbank = {
|
|
||||||
getInstrumentByFigi: jest.fn(),
|
|
||||||
getInstrumentByUid: jest.fn(),
|
|
||||||
};
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
let service: InstrumentMetadataService;
|
|
||||||
|
|
||||||
function rememberExpired(key: string, value: TbankInstrument): void {
|
|
||||||
const cache = (
|
|
||||||
service as unknown as { metadata: LRUCache<string, TbankInstrument> }
|
|
||||||
).metadata;
|
|
||||||
cache.set(key, value, { ttl: 1, start: performance.now() - 1000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
tbank.getInstrumentByFigi.mockReset();
|
|
||||||
tbank.getInstrumentByUid.mockReset();
|
|
||||||
service = new InstrumentMetadataService(tbank as never, logger as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefers the instrument UID when metadata is available', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({
|
|
||||||
name: 'Инструмент',
|
|
||||||
brand: { logoName: 'instrument.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1'),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
brand: { logoName: 'instrument.png' },
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to FIGI when the UID has no matching instrument', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
|
||||||
tbank.getInstrumentByFigi.mockResolvedValue({
|
|
||||||
ticker: 'SBER',
|
|
||||||
brand: { logoName: 'sber.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1'),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
ticker: 'SBER',
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('caches negative lookups and avoids repeated upstream requests', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('tries FIGI when UID metadata has no usable name or ticker', async () => {
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({ name: ' ', ticker: '' });
|
|
||||||
tbank.getInstrumentByFigi.mockResolvedValue({ name: 'Облигация' });
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1'),
|
|
||||||
).resolves.toMatchObject({ name: 'Облигация' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains expired successful metadata through temporary failures and refreshes it later', async () => {
|
|
||||||
const known = {
|
|
||||||
name: 'Облигация',
|
|
||||||
ticker: 'BOND',
|
|
||||||
brand: { logoName: 'bond.png' },
|
|
||||||
};
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValueOnce(known);
|
|
||||||
await service.resolve('token', 'uid-1', null);
|
|
||||||
rememberExpired('uid:uid-1', known);
|
|
||||||
tbank.getInstrumentByUid.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({
|
|
||||||
...known,
|
|
||||||
name: 'Новое название',
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', null),
|
|
||||||
).resolves.toMatchObject({ name: 'Новое название' });
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mask an invalid token as missing metadata or retry by FIGI', async () => {
|
|
||||||
const error = new Error('INVALID_TBANK_TOKEN');
|
|
||||||
tbank.getInstrumentByUid.mockRejectedValue(error);
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).rejects.toBe(
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps known metadata through an incomplete refresh without renewing its freshness', async () => {
|
|
||||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
|
||||||
rememberExpired('uid:uid-1', known);
|
|
||||||
tbank.getInstrumentByUid
|
|
||||||
.mockResolvedValueOnce({ name: ' ', ticker: '', isin: 'RU0000000001' })
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a known FIGI name after partial UID metadata and does not cache the partial response', async () => {
|
|
||||||
const known = { name: 'Облигация', ticker: 'BOND' };
|
|
||||||
rememberExpired('figi:figi-1', known);
|
|
||||||
tbank.getInstrumentByUid
|
|
||||||
.mockResolvedValueOnce({ isin: 'RU0000000001' })
|
|
||||||
.mockResolvedValueOnce({ name: 'Новое название' });
|
|
||||||
tbank.getInstrumentByFigi.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual(
|
|
||||||
known,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', 'figi-1')).resolves.toEqual({
|
|
||||||
name: 'Новое название',
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
expect(tbank.getInstrumentByFigi).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cold partial fields without caching them', async () => {
|
|
||||||
const partial = { isin: 'RU0000000001' };
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(partial);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
partial,
|
|
||||||
);
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toEqual(
|
|
||||||
partial,
|
|
||||||
);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('replaces an expired name with authoritative confirmed absence', async () => {
|
|
||||||
rememberExpired('uid:uid-1', { name: 'Старое название' });
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('isolates in-flight credentials while sharing successful public metadata', async () => {
|
|
||||||
const error = new Error('INVALID_TBANK_TOKEN');
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
let rejectInvalid: ((reason: Error) => void) | undefined;
|
|
||||||
tbank.getInstrumentByUid.mockImplementation((token: string) =>
|
|
||||||
token === 'invalid-token'
|
|
||||||
? new Promise((_resolve, reject) => {
|
|
||||||
rejectInvalid = reject;
|
|
||||||
})
|
|
||||||
: Promise.resolve(known),
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalid = expect(
|
|
||||||
service.resolve('invalid-token', 'uid-1', null),
|
|
||||||
).rejects.toBe(error);
|
|
||||||
const valid = service.resolve('valid-token', 'uid-1', null);
|
|
||||||
assert.ok(rejectInvalid);
|
|
||||||
rejectInvalid(error);
|
|
||||||
|
|
||||||
await invalid;
|
|
||||||
await expect(valid).resolves.toEqual(known);
|
|
||||||
await expect(
|
|
||||||
service.resolve('another-token', 'uid-1', null),
|
|
||||||
).resolves.toEqual(known);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns cached or nullable metadata without starting requests after abort', async () => {
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
rememberExpired('figi:figi-1', known);
|
|
||||||
const controller = new AbortController();
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-1', 'figi-1', controller.signal),
|
|
||||||
).resolves.toEqual(known);
|
|
||||||
await expect(
|
|
||||||
service.resolve('token', 'uid-2', 'figi-2', controller.signal),
|
|
||||||
).resolves.toBeNull();
|
|
||||||
expect(tbank.getInstrumentByUid).not.toHaveBeenCalled();
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns expired metadata promptly when the waiting caller aborts', async () => {
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
rememberExpired('uid:uid-1', known);
|
|
||||||
tbank.getInstrumentByUid.mockReturnValue(new Promise(() => {}));
|
|
||||||
const controller = new AbortController();
|
|
||||||
const result = service.resolve(
|
|
||||||
'token',
|
|
||||||
'uid-1',
|
|
||||||
'figi-1',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
|
|
||||||
controller.abort();
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(known);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a shared fetch alive after one caller aborts and caches its result', async () => {
|
|
||||||
const known = { name: 'Облигация' };
|
|
||||||
let finish: ((value: TbankInstrument) => void) | undefined;
|
|
||||||
tbank.getInstrumentByUid.mockReturnValue(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
finish = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const aborted = service.resolve(
|
|
||||||
'token',
|
|
||||||
'uid-1',
|
|
||||||
'figi-1',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
controller.abort();
|
|
||||||
await expect(aborted).resolves.toBeNull();
|
|
||||||
|
|
||||||
const waiting = service.resolve('token', 'uid-1', null);
|
|
||||||
assert.ok(finish);
|
|
||||||
finish(known);
|
|
||||||
|
|
||||||
await expect(waiting).resolves.toEqual(known);
|
|
||||||
await expect(
|
|
||||||
service.resolve('another-token', 'uid-1', null),
|
|
||||||
).resolves.toEqual(known);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not start FIGI lookup after an aborted UID lookup fails later', async () => {
|
|
||||||
let fail: ((reason: Error) => void) | undefined;
|
|
||||||
tbank.getInstrumentByUid.mockReturnValue(
|
|
||||||
new Promise((_resolve, reject) => {
|
|
||||||
fail = reject;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const result = service.resolve(
|
|
||||||
'token',
|
|
||||||
'uid-1',
|
|
||||||
'figi-1',
|
|
||||||
controller.signal,
|
|
||||||
);
|
|
||||||
controller.abort();
|
|
||||||
await expect(result).resolves.toBeNull();
|
|
||||||
|
|
||||||
assert.ok(fail);
|
|
||||||
fail(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,118 +1,208 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { LRUCache } from 'lru-cache';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from './tbank-access.service';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
|
import {
|
||||||
|
InstrumentMetadataRepository,
|
||||||
|
metadataKey,
|
||||||
|
type MetadataIdentity,
|
||||||
|
type MetadataLookup,
|
||||||
|
} from './instrument-metadata.repository';
|
||||||
|
export { metadataKey } from './instrument-metadata.repository';
|
||||||
|
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||||
|
|
||||||
const metadataForMs = 86_400_000;
|
type Producer = {
|
||||||
const maxCachedInstruments = 500;
|
identity: MetadataIdentity;
|
||||||
const missingInstrument = Symbol('missingInstrument');
|
attempt: TbankSyncAttempt;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
};
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InstrumentMetadataService {
|
export class InstrumentMetadataService {
|
||||||
private readonly metadata = new LRUCache<
|
private readonly runId = randomUUID();
|
||||||
string,
|
private readonly active = new Set<string>();
|
||||||
TbankInstrument | typeof missingInstrument
|
private readonly producers = new Map<string, Producer>();
|
||||||
>({
|
private readonly queue: Producer[] = [];
|
||||||
max: maxCachedInstruments,
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
ttl: metadataForMs,
|
private running = false;
|
||||||
noDeleteOnStaleGet: true,
|
|
||||||
});
|
|
||||||
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly repository: InstrumentMetadataRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async resolve(
|
savedMany(identities: MetadataIdentity[]) {
|
||||||
token: string,
|
return this.repository.readMany(identities);
|
||||||
instrumentUid: string | null | undefined,
|
}
|
||||||
figi: string | null | undefined,
|
|
||||||
signal?: AbortSignal,
|
async read(
|
||||||
): Promise<TbankInstrument | null> {
|
userId: string,
|
||||||
const uid = instrumentUid?.trim();
|
identity: MetadataIdentity,
|
||||||
let byUid: TbankInstrument | null = null;
|
observe = false,
|
||||||
if (uid) {
|
revision?: string,
|
||||||
byUid = await this.lookup(
|
) {
|
||||||
token,
|
const connection = await this.access.local(userId);
|
||||||
`uid:${uid}`,
|
if (revision && connection.revision !== revision) return null;
|
||||||
() => this.tbank.getInstrumentByUid(token, uid),
|
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||||
signal,
|
if (!observe && !data?.fresh)
|
||||||
|
await this.request(userId, connection.revision, identity);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
work: await this.repository.work(userId, connection.revision, identity),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
identity: MetadataIdentity,
|
||||||
|
force = false,
|
||||||
|
) {
|
||||||
|
identity = {
|
||||||
|
instrumentUid: identity.instrumentUid?.trim() || null,
|
||||||
|
figi: identity.figi?.trim() || null,
|
||||||
|
};
|
||||||
|
if (!identity.instrumentUid && !identity.figi) return null;
|
||||||
|
const normalized = identity;
|
||||||
|
const reserved = this.reservations.then(async () => {
|
||||||
|
const snapshot = (await this.savedMany([normalized])).get(
|
||||||
|
metadataKey(normalized),
|
||||||
);
|
);
|
||||||
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
|
if (!force && snapshot?.fresh) return null;
|
||||||
|
const result = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
normalized,
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
);
|
||||||
|
if (!result) return null;
|
||||||
|
const { id, attemptId } = result.work;
|
||||||
|
if (!result.launch) return id;
|
||||||
|
const attempt = { userId, revision, id, attemptId };
|
||||||
|
this.active.add(attemptId);
|
||||||
|
const key = metadataKey(normalized);
|
||||||
|
const producer = this.producers.get(key);
|
||||||
|
if (producer) producer.observers.push(attempt);
|
||||||
|
else {
|
||||||
|
const queued = { identity: normalized, attempt, observers: [] };
|
||||||
|
this.producers.set(key, queued);
|
||||||
|
this.queue.push(queued);
|
||||||
}
|
}
|
||||||
const instrumentFigi = figi?.trim();
|
if (!this.running) {
|
||||||
const byFigi = instrumentFigi
|
this.running = true;
|
||||||
? await this.lookup(
|
setImmediate(() => {
|
||||||
token,
|
void this.drain();
|
||||||
`figi:${instrumentFigi}`,
|
|
||||||
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
|
|
||||||
signal,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return byFigi ?? byUid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async lookup(
|
|
||||||
token: string,
|
|
||||||
key: string,
|
|
||||||
fetch: () => Promise<TbankInstrument | null>,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<TbankInstrument | null> {
|
|
||||||
const cached = this.metadata.get(key);
|
|
||||||
if (cached !== undefined)
|
|
||||||
return cached === missingInstrument ? null : cached;
|
|
||||||
if (signal?.aborted) return this.lastKnown(key);
|
|
||||||
const owner = createHash('sha256').update(token).digest('hex');
|
|
||||||
const requestKey = `${owner}:${key}`;
|
|
||||||
let pending = this.pending.get(requestKey);
|
|
||||||
if (!pending) {
|
|
||||||
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
|
|
||||||
this.pending.delete(requestKey);
|
|
||||||
});
|
|
||||||
this.pending.set(requestKey, pending);
|
|
||||||
}
|
|
||||||
if (!signal) return pending;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onAbort = () => resolve(this.lastKnown(key));
|
|
||||||
signal.addEventListener('abort', onAbort, { once: true });
|
|
||||||
void pending.then(resolve, reject).finally(() => {
|
|
||||||
signal.removeEventListener('abort', onAbort);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
return id;
|
||||||
private lastKnown(key: string): TbankInstrument | null {
|
});
|
||||||
const value = this.metadata.peek(key, { allowStale: true });
|
this.reservations = reserved.catch(() => undefined);
|
||||||
return value === missingInstrument ? null : (value ?? null);
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetch(
|
private async drain() {
|
||||||
key: string,
|
|
||||||
request: () => Promise<TbankInstrument | null>,
|
|
||||||
previous: TbankInstrument | null,
|
|
||||||
): Promise<TbankInstrument | null> {
|
|
||||||
try {
|
try {
|
||||||
const value = await request();
|
while (this.queue.length) {
|
||||||
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
|
const producer = this.queue.shift();
|
||||||
if (value === null || found)
|
if (!producer) break;
|
||||||
this.metadata.set(key, value ?? missingInstrument);
|
const { attempt, identity } = producer;
|
||||||
this.logger.event('debug', 'instrument-metadata.resolved', {
|
let success = false;
|
||||||
found,
|
try {
|
||||||
});
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
return found || value === null ? value : (previous ?? value);
|
if (token) {
|
||||||
|
const results: MetadataLookup[] = [];
|
||||||
|
let useful = false;
|
||||||
|
let uidFailed = false;
|
||||||
|
if (identity.instrumentUid) {
|
||||||
|
let data: TbankInstrument | null | undefined;
|
||||||
|
try {
|
||||||
|
data = await this.tbank.getInstrumentByUid(
|
||||||
|
token,
|
||||||
|
identity.instrumentUid,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
if (
|
||||||
code:
|
!identity.figi ||
|
||||||
error instanceof Error && 'response' in error
|
(error instanceof Error &&
|
||||||
? ((error as { response?: { code?: string } }).response?.code ??
|
error.message === 'INVALID_TBANK_TOKEN')
|
||||||
'UNKNOWN')
|
)
|
||||||
: 'UNKNOWN',
|
|
||||||
});
|
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
|
||||||
throw error;
|
throw error;
|
||||||
return error instanceof ServiceUnavailableException ? previous : null;
|
uidFailed = true;
|
||||||
|
}
|
||||||
|
if (data?.uid && data.uid !== identity.instrumentUid)
|
||||||
|
throw new Error('Metadata UID mismatch');
|
||||||
|
if (data !== undefined)
|
||||||
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
|
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||||
|
if (!useful && data && !identity.figi)
|
||||||
|
throw new Error('Incomplete metadata');
|
||||||
|
}
|
||||||
|
if (!useful && identity.figi) {
|
||||||
|
const data = await this.tbank.getInstrumentByFigi(
|
||||||
|
token,
|
||||||
|
identity.figi,
|
||||||
|
);
|
||||||
|
if (data?.figi && data.figi !== identity.figi)
|
||||||
|
throw new Error('Metadata FIGI mismatch');
|
||||||
|
if (data && !data.name?.trim() && !data.ticker?.trim())
|
||||||
|
throw new Error('Incomplete metadata');
|
||||||
|
if (data === undefined || (uidFailed && !data))
|
||||||
|
throw new Error('Metadata source unavailable');
|
||||||
|
results.push({ type: 'figi', id: identity.figi, data });
|
||||||
|
// A successful FIGI response identifies its canonical UID too;
|
||||||
|
// a failed UID RPC is never persisted as a negative lookup.
|
||||||
|
if (
|
||||||
|
uidFailed &&
|
||||||
|
data?.uid === identity.instrumentUid &&
|
||||||
|
identity.instrumentUid
|
||||||
|
)
|
||||||
|
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!results.length ||
|
||||||
|
(results.some((result) => result.data) &&
|
||||||
|
!results.some(
|
||||||
|
(result) =>
|
||||||
|
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error('Incomplete metadata');
|
||||||
|
success = await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
results,
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(attempt);
|
||||||
|
else
|
||||||
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Remove the producer before awaiting finalization: a new request cannot
|
||||||
|
// attach to a producer whose observer list has already been drained.
|
||||||
|
this.producers.delete(metadataKey(identity));
|
||||||
|
for (const observer of [attempt, ...producer.observers]) {
|
||||||
|
await this.repository.finishObserver(observer, success);
|
||||||
|
this.active.delete(observer.attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
SupportedTbankAccount,
|
SupportedTbankAccount,
|
||||||
@@ -22,6 +24,13 @@ export type TbankAccountContext = TbankConnectionContext & {
|
|||||||
account: TbankAccount;
|
account: TbankAccount;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TbankSyncAttempt = {
|
||||||
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankAccessService {
|
export class TbankAccessService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -30,6 +39,219 @@ export class TbankAccessService {
|
|||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
async reserveMembership(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
processRunId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
force: boolean,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: { userId, revision, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
!tbankSourceNeedsRefresh(
|
||||||
|
'membership',
|
||||||
|
connection.membershipAt,
|
||||||
|
new Date(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const scope = { userId, revision, scopeKey: 'membership' };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === processRunId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId,
|
||||||
|
state: 'running',
|
||||||
|
intent: { source: 'membership' },
|
||||||
|
progress: {},
|
||||||
|
priority: 0,
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
membershipWork(userId: string, revision: string) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' },
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true, updatedAt: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||||
|
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||||
|
const result = await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
return result.count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
|
||||||
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
select: { connection: { select: { encryptedToken: true } } },
|
||||||
|
});
|
||||||
|
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publishMembership(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
accounts: TbankAccount[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
completeWork = false,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const supported = supportedTbankAccounts(accounts);
|
||||||
|
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
|
||||||
|
throw new Error('Duplicate T-Bank account identity');
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
|
// attempt supersession cannot interleave with the publication below.
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const { userId, revision } = attempt;
|
||||||
|
const previous = await tx.tbankAccount.findMany({
|
||||||
|
where: { userId, revision },
|
||||||
|
orderBy: { upstreamOrder: 'asc' },
|
||||||
|
select: { accountId: true, type: true, name: true, openedOn: true },
|
||||||
|
});
|
||||||
|
const next = supported.map(({ id, ...account }) => ({
|
||||||
|
accountId: id,
|
||||||
|
...account,
|
||||||
|
}));
|
||||||
|
const changed =
|
||||||
|
previous.length !== next.length ||
|
||||||
|
previous.some((account, index) => {
|
||||||
|
const incoming = next[index];
|
||||||
|
return (
|
||||||
|
account.accountId !== incoming.accountId ||
|
||||||
|
account.type !== incoming.type ||
|
||||||
|
account.name !== incoming.name ||
|
||||||
|
account.openedOn !== incoming.openedOn
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await tx.tbankAccount.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: { notIn: supported.map(({ id }) => id) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const [upstreamOrder, account] of next.entries()) {
|
||||||
|
await tx.tbankAccount.upsert({
|
||||||
|
where: {
|
||||||
|
userId_revision_accountId: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: account.accountId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { userId, revision, ...account, upstreamOrder },
|
||||||
|
update: { ...account, upstreamOrder },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.tbankConnection.update({
|
||||||
|
where: { userId_revision: { userId, revision } },
|
||||||
|
data: {
|
||||||
|
membershipAt: fetchedAt,
|
||||||
|
membershipRevision: { increment: changed ? 1 : 0 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (completeWork)
|
||||||
|
await tx.tbankSyncWork.update({
|
||||||
|
where: { id: attempt.id },
|
||||||
|
data: { state: 'complete', progress: { accounts: supported.length } },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: { ...attempt, state: 'running' },
|
||||||
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
await tx.tbankConnection.update({
|
||||||
|
where: {
|
||||||
|
userId_revision: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { invalidToken: true },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This boundary neither decrypts credentials nor starts discovery/sync.
|
||||||
|
// Missing membership is distinct from a successfully observed empty list.
|
||||||
|
async local(userId: string, accountId?: string) {
|
||||||
|
const connection = await this.prisma.tbankConnection.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
revision: true,
|
||||||
|
invalidToken: true,
|
||||||
|
membershipAt: true,
|
||||||
|
membershipRevision: true,
|
||||||
|
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!connection)
|
||||||
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||||
|
if (connection.invalidToken)
|
||||||
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||||
|
if (
|
||||||
|
connection.membershipAt &&
|
||||||
|
accountId &&
|
||||||
|
accountId !== 'all' &&
|
||||||
|
!connection.accounts.some((account) => account.accountId === accountId)
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
return connection;
|
||||||
|
}
|
||||||
|
|
||||||
async listSupportedAccounts(
|
async listSupportedAccounts(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<SupportedTbankAccount[]> {
|
): Promise<SupportedTbankAccount[]> {
|
||||||
@@ -97,6 +319,8 @@ export class TbankAccessService {
|
|||||||
});
|
});
|
||||||
if (!connection)
|
if (!connection)
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||||
|
if (connection.invalidToken)
|
||||||
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||||
return {
|
return {
|
||||||
token: this.tokens.decrypt(connection.encryptedToken),
|
token: this.tokens.decrypt(connection.encryptedToken),
|
||||||
generation: createHash('sha256')
|
generation: createHash('sha256')
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from './tbank-access.service';
|
||||||
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TbankMembershipService {
|
||||||
|
private readonly processRunId = randomUUID();
|
||||||
|
private readonly active = new Map<string, Promise<void>>();
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
request(userId: string, revision: string, force = false) {
|
||||||
|
const reservation = this.reservations.then(async () => {
|
||||||
|
const requested = await this.access.reserveMembership(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
this.processRunId,
|
||||||
|
new Set(this.active.keys()),
|
||||||
|
force,
|
||||||
|
);
|
||||||
|
if (!requested) return null;
|
||||||
|
const { id, attemptId } = requested.work;
|
||||||
|
if (requested.launch) {
|
||||||
|
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
.then(() => this.execute({ userId, revision, id, attemptId }))
|
||||||
|
.catch(() =>
|
||||||
|
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||||
|
userId,
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => this.active.delete(attemptId));
|
||||||
|
this.active.set(attemptId, done);
|
||||||
|
}
|
||||||
|
return { id, done: this.active.get(attemptId) ?? Promise.resolve() };
|
||||||
|
});
|
||||||
|
this.reservations = reservation.catch(() => undefined);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async execute(attempt: TbankSyncAttempt) {
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (!token) return;
|
||||||
|
await this.access.publishMembership(
|
||||||
|
attempt,
|
||||||
|
await this.tbank.getOpenAccounts(token),
|
||||||
|
new Date(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await this.access.failMembership(
|
||||||
|
attempt,
|
||||||
|
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
|
describe('request-driven source freshness', () => {
|
||||||
|
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||||
|
'%s expires at its boundary without changing source time',
|
||||||
|
(source) => {
|
||||||
|
const fetchedAt = new Date('2026-09-01Z');
|
||||||
|
const ttl = tbankSourceTtlMs[source];
|
||||||
|
expect(
|
||||||
|
tbankSourceNeedsRefresh(
|
||||||
|
source,
|
||||||
|
fetchedAt,
|
||||||
|
new Date(fetchedAt.valueOf() + ttl - 1000),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
tbankSourceNeedsRefresh(
|
||||||
|
source,
|
||||||
|
fetchedAt,
|
||||||
|
new Date(fetchedAt.valueOf() + ttl),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(tbankSourceNeedsRefresh(source, fetchedAt, fetchedAt, true)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(tbankSourceNeedsRefresh(source, null, fetchedAt)).toBe(true);
|
||||||
|
expect(fetchedAt.toISOString()).toBe('2026-09-01T00:00:00.000Z');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||||
|
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||||
|
export const tbankSourceTtlMs = {
|
||||||
|
membership: 300_000,
|
||||||
|
portfolio: 60_000,
|
||||||
|
quote: 60_000,
|
||||||
|
candleTail: 60_000,
|
||||||
|
operationsTail: 300_000,
|
||||||
|
metadata: 86_400_000,
|
||||||
|
catalog: 86_400_000,
|
||||||
|
company: 86_400_000,
|
||||||
|
calendar: 86_400_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function tbankSourceNeedsRefresh(
|
||||||
|
source: keyof typeof tbankSourceTtlMs,
|
||||||
|
fetchedAt: Date | null,
|
||||||
|
now: Date,
|
||||||
|
force = false,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
force ||
|
||||||
|
!fetchedAt ||
|
||||||
|
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,17 +7,30 @@ export {
|
|||||||
export {
|
export {
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCandle,
|
type TbankCandle,
|
||||||
|
type TbankCoupon,
|
||||||
type TbankFundamentals,
|
type TbankFundamentals,
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
} from './tbank-market.types';
|
} from './tbank-market.types';
|
||||||
export { TbankTokenService } from './tbank-token.service';
|
export { TbankTokenService } from './tbank-token.service';
|
||||||
|
export { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
export {
|
export {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
type TbankInstrument,
|
type TbankInstrument,
|
||||||
type TbankMoney,
|
type TbankMoney,
|
||||||
type TbankOperationItem,
|
type TbankOperationItem,
|
||||||
|
type TbankOperationsByCursorResponse,
|
||||||
type TbankPortfolio,
|
type TbankPortfolio,
|
||||||
type TbankPositions,
|
type TbankPositions,
|
||||||
type TbankTimestamp,
|
type TbankTimestamp,
|
||||||
} from './tbank.client';
|
} from './tbank.client';
|
||||||
|
|
||||||
|
export { TbankMembershipService } from './tbank-membership.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
|
export { metadataKey } from './instrument-metadata.service';
|
||||||
|
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ export type TbankPortfolio = {
|
|||||||
};
|
};
|
||||||
export type TbankPositions = { money?: TbankMoney[] };
|
export type TbankPositions = { money?: TbankMoney[] };
|
||||||
export type TbankInstrument = {
|
export type TbankInstrument = {
|
||||||
|
uid?: string;
|
||||||
|
figi?: string;
|
||||||
|
instrumentType?: string;
|
||||||
ticker?: string;
|
ticker?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
isin?: string;
|
isin?: string;
|
||||||
@@ -80,6 +83,7 @@ export type TbankDividend = {
|
|||||||
};
|
};
|
||||||
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
|
||||||
export type TbankOperationItem = {
|
export type TbankOperationItem = {
|
||||||
|
state?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -233,8 +237,8 @@ export class TbankClient {
|
|||||||
this.logger.info('tbank.client.configured');
|
this.logger.info('tbank.client.configured');
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateToken(token: string): Promise<void> {
|
validateToken(token: string): Promise<TbankAccount[]> {
|
||||||
await this.getOpenAccounts(token);
|
return this.getOpenAccounts(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
getOpenAccounts(token: string): Promise<TbankAccount[]> {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TbankMembershipService } from './tbank-membership.service';
|
||||||
|
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
|
||||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
@@ -9,12 +11,15 @@ import { TbankClient } from './tbank.client';
|
|||||||
TbankClient,
|
TbankClient,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
|
TbankMembershipService,
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
|
InstrumentMetadataRepository,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TbankClient,
|
TbankClient,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
|
TbankMembershipService,
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,429 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
jest.mock('../../integrations/tbank/tbank-access.service', () => ({
|
|
||||||
TbankAccessService: class {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import type { TbankConnectionContext } from '../../integrations/tbank/tbank-access.service';
|
|
||||||
import { IncomeAnalyticsService } from './income/income-analytics.service';
|
|
||||||
import { BrokerageOperationsService } from './operations/application/brokerage-operations.service';
|
|
||||||
import { PortfolioService } from './portfolio/application/portfolio.service';
|
|
||||||
import { UpcomingEventsService } from './upcoming-events/upcoming-events.service';
|
|
||||||
|
|
||||||
function setup() {
|
|
||||||
const context = {
|
|
||||||
token: 'token',
|
|
||||||
generation: 'first',
|
|
||||||
accounts: [
|
|
||||||
{ id: 'a', name: 'Основной', openedOn: '2020-01-01' },
|
|
||||||
{ id: 'b', name: 'ИИС', openedOn: '2026-01-01' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const access = {
|
|
||||||
withSupportedAccounts: jest.fn(
|
|
||||||
(_user: string, handler: (value: typeof context) => unknown) =>
|
|
||||||
handler(context),
|
|
||||||
),
|
|
||||||
withAccountForConnection: jest.fn(
|
|
||||||
(
|
|
||||||
_connection: TbankConnectionContext,
|
|
||||||
_id: string,
|
|
||||||
handler: () => unknown,
|
|
||||||
) => handler(),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const tbank = {
|
|
||||||
getOperationsByCursor: jest.fn(),
|
|
||||||
getPortfolio: jest.fn().mockResolvedValue({ positions: [] }),
|
|
||||||
getPositions: jest.fn().mockResolvedValue({ money: [] }),
|
|
||||||
getDividends: jest.fn().mockResolvedValue([]),
|
|
||||||
getInstrumentByFigi: jest.fn().mockResolvedValue({ name: 'Облигация' }),
|
|
||||||
};
|
|
||||||
const metadata = { resolve: jest.fn().mockResolvedValue(null) };
|
|
||||||
const requestContext = { set: jest.fn() };
|
|
||||||
const operations = new BrokerageOperationsService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
requestContext as never,
|
|
||||||
metadata as never,
|
|
||||||
);
|
|
||||||
const portfolio = new PortfolioService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
requestContext as never,
|
|
||||||
metadata as never,
|
|
||||||
);
|
|
||||||
const moex = { bondEvents: jest.fn().mockResolvedValue([]) };
|
|
||||||
const events = new UpcomingEventsService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
moex as never,
|
|
||||||
requestContext as never,
|
|
||||||
{ info: jest.fn(), event: jest.fn() } as never,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
context,
|
|
||||||
access,
|
|
||||||
tbank,
|
|
||||||
operations,
|
|
||||||
portfolio,
|
|
||||||
events,
|
|
||||||
moex,
|
|
||||||
income: new IncomeAnalyticsService(operations),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const operation = (
|
|
||||||
id: string,
|
|
||||||
second: number,
|
|
||||||
type = 'OPERATION_TYPE_COUPON',
|
|
||||||
value = 1,
|
|
||||||
) => ({
|
|
||||||
id,
|
|
||||||
date: { seconds: String(second) },
|
|
||||||
type,
|
|
||||||
payment: { currency: 'RUB', units: value },
|
|
||||||
});
|
|
||||||
const query = { from: '2025-01-01', to: '2026-12-31', limit: 2 };
|
|
||||||
|
|
||||||
describe('All accounts', () => {
|
|
||||||
it('does not truncate instrument history when an account opening date is missing', async () => {
|
|
||||||
const { context, operations, tbank } = setup();
|
|
||||||
Object.assign(context.accounts[0], { openedOn: null });
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({ items: [] });
|
|
||||||
await operations.getAll('user', { instrumentUid: 'common' });
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
expect.objectContaining({
|
|
||||||
from: { seconds: '0' },
|
|
||||||
instrumentId: 'common',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('filters upstream by UID, keeps sold-position history and binds continuations to the instrument', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(
|
|
||||||
_token: string,
|
|
||||||
request: { instrumentId: string; accountId: string },
|
|
||||||
) => ({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
...operation(
|
|
||||||
request.accountId + '-dividend',
|
|
||||||
9,
|
|
||||||
'OPERATION_TYPE_DIVIDEND',
|
|
||||||
),
|
|
||||||
instrumentUid: request.instrumentId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...operation('wrong-paper', 8, 'OPERATION_TYPE_DIVIDEND'),
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const first = await operations.getAll('user', {
|
|
||||||
instrumentUid: 'common',
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
assert.ok(first.nextCursor);
|
|
||||||
expect(first.items).toHaveLength(1);
|
|
||||||
expect(first.items[0].instrumentUid).toBe('common');
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
expect.objectContaining({
|
|
||||||
instrumentId: 'common',
|
|
||||||
from: { seconds: '1577836800' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
operations.getAll('user', {
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
limit: 1,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
}),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
const second = await operations.getAll('user', {
|
|
||||||
instrumentUid: 'common',
|
|
||||||
limit: 1,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
});
|
|
||||||
expect(second.items[0].instrumentUid).toBe('common');
|
|
||||||
expect(second.items[0].id).not.toBe(first.items[0].id);
|
|
||||||
expect(second.hasNext).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => jest.useRealTimers());
|
|
||||||
|
|
||||||
it('merges exact amounts by currency and keeps equal instruments on different accounts', async () => {
|
|
||||||
const { portfolio, tbank } = setup();
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: 'SAME',
|
|
||||||
instrumentType: 'share',
|
|
||||||
quantity: { units: 1 },
|
|
||||||
currentPrice: { currency: 'RUB', nano: 100000000 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
tbank.getPositions.mockImplementation((_token: string, id: string) => ({
|
|
||||||
money: [{ currency: id === 'a' ? 'RUB' : 'USD', nano: 200000000 }],
|
|
||||||
}));
|
|
||||||
const result = await portfolio.getAll('user');
|
|
||||||
expect(result.structure.currencies).toMatchObject([
|
|
||||||
{
|
|
||||||
currency: 'RUB',
|
|
||||||
totalValue: '0.4',
|
|
||||||
categories: [
|
|
||||||
{ type: 'STOCK', value: '0.2', sharePercent: '50' },
|
|
||||||
{ type: 'BOND', value: '0' },
|
|
||||||
{ type: 'OTHER', value: '0' },
|
|
||||||
{ type: 'CASH', value: '0.2', sharePercent: '50' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ currency: 'USD', totalValue: '0.2' },
|
|
||||||
]);
|
|
||||||
expect(result.totalsByCurrency).toEqual([
|
|
||||||
{ currency: 'RUB', value: '0.4' },
|
|
||||||
{ currency: 'USD', value: '0.2' },
|
|
||||||
]);
|
|
||||||
expect(result.cashBalances).toEqual([
|
|
||||||
{ currency: 'RUB', value: '0.2' },
|
|
||||||
{ currency: 'USD', value: '0.2' },
|
|
||||||
]);
|
|
||||||
expect(result.positions.map((position) => position.account?.id)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects missing access and a partial portfolio instead of publishing incomplete totals', async () => {
|
|
||||||
const { portfolio, access, tbank } = setup();
|
|
||||||
access.withSupportedAccounts.mockImplementationOnce(() => {
|
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
|
||||||
});
|
|
||||||
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ConflictException,
|
|
||||||
);
|
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
|
||||||
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
await expect(portfolio.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('marks an aggregate stale and uses its oldest snapshot timestamp', async () => {
|
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-01T10:00:00Z'));
|
|
||||||
const { portfolio, tbank } = setup();
|
|
||||||
const first = await portfolio.getAll('user');
|
|
||||||
jest.setSystemTime(new Date('2026-09-01T10:01:00Z'));
|
|
||||||
tbank.getPortfolio.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
const result = await portfolio.getAll('user');
|
|
||||||
expect(result.isStale).toBe(true);
|
|
||||||
expect(result.lastSuccessfulAt).toBe(first.lastSuccessfulAt);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('paginates interleaved streams without dropping buffered rows, preserving duplicate IDs', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(
|
|
||||||
_token: string,
|
|
||||||
{ accountId, cursor }: { accountId: string; cursor?: string },
|
|
||||||
) => {
|
|
||||||
if (accountId === 'a')
|
|
||||||
return cursor
|
|
||||||
? { items: [operation('last', 3)] }
|
|
||||||
: {
|
|
||||||
items: [operation('same', 9), operation('a2', 5)],
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'a-next',
|
|
||||||
};
|
|
||||||
return { items: [operation('same', 8), operation('b2', 4)] };
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const first = await operations.getAll('user', query);
|
|
||||||
assert.ok(first.nextCursor);
|
|
||||||
const second = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
});
|
|
||||||
const repeated = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
cursor: first.nextCursor,
|
|
||||||
});
|
|
||||||
assert.ok(second.nextCursor);
|
|
||||||
const third = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
cursor: second.nextCursor,
|
|
||||||
});
|
|
||||||
expect(first.items.map((item) => [item.id, item.account?.id])).toEqual([
|
|
||||||
['same', 'a'],
|
|
||||||
['same', 'b'],
|
|
||||||
]);
|
|
||||||
expect(second.items.map((item) => item.id)).toEqual(['a2', 'b2']);
|
|
||||||
expect(repeated).toEqual(second);
|
|
||||||
expect(third.items.map((item) => item.id)).toEqual(['last']);
|
|
||||||
expect(third.hasNext).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('continues through empty filtered pages and breaks ties by account', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(_token: string, { cursor }: { cursor?: string }) =>
|
|
||||||
cursor
|
|
||||||
? { items: [operation('income', 5)] }
|
|
||||||
: {
|
|
||||||
items: [operation('fee', 8, 'OPERATION_TYPE_BROKER_FEE')],
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'next',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const page = await operations.getAll('user', {
|
|
||||||
...query,
|
|
||||||
categories: ['income'],
|
|
||||||
});
|
|
||||||
expect(page.items.map((item) => item.account?.id)).toEqual(['a', 'b']);
|
|
||||||
expect(page.items.every((item) => item.category === 'income')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects damaged cursor chains and partial streams', async () => {
|
|
||||||
const { operations, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [],
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'loop',
|
|
||||||
});
|
|
||||||
await expect(operations.getAll('user', query)).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
tbank.getOperationsByCursor.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException(),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
operations.getAll('another-user', query),
|
|
||||||
).rejects.toBeInstanceOf(ServiceUnavailableException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('binds continuations to user, connection, accounts and filters', async () => {
|
|
||||||
const { operations, context, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [operation('first', 8), operation('last', 4)],
|
|
||||||
});
|
|
||||||
const first = await operations.getAll('user', query);
|
|
||||||
assert.ok(first.nextCursor);
|
|
||||||
const next = { ...query, cursor: first.nextCursor };
|
|
||||||
await expect(operations.getAll('other', next)).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
operations.getAll('user', { ...next, categories: ['fee'] }),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
context.generation = 'replaced';
|
|
||||||
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
context.generation = 'first';
|
|
||||||
context.accounts.pop();
|
|
||||||
await expect(operations.getAll('user', next)).rejects.toBeInstanceOf(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collects income across the same cohort and computes comparisons from all receipts', async () => {
|
|
||||||
const { income, tbank } = setup();
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(
|
|
||||||
_token: string,
|
|
||||||
{ accountId, from }: { accountId: string; from: { seconds: string } },
|
|
||||||
) => {
|
|
||||||
const year = new Date(Number(from.seconds) * 1000).getUTCFullYear();
|
|
||||||
const second =
|
|
||||||
Date.parse(
|
|
||||||
year === 2025 ? '2025-08-15T12:00:00Z' : '2026-08-15T12:00:00Z',
|
|
||||||
) / 1000;
|
|
||||||
return {
|
|
||||||
items:
|
|
||||||
year === 2025 && accountId === 'b'
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
operation(
|
|
||||||
'same',
|
|
||||||
second,
|
|
||||||
'OPERATION_TYPE_COUPON',
|
|
||||||
year === 2025 ? 2 : 3,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const result = await income.analytics(
|
|
||||||
'user',
|
|
||||||
'all',
|
|
||||||
'2026-08-01',
|
|
||||||
'2026-08-31',
|
|
||||||
);
|
|
||||||
expect(result.totalsByCurrency[0].total.value).toBe('6');
|
|
||||||
expect(
|
|
||||||
result.yearComparison?.totalsByCurrency[0].compared.total.value,
|
|
||||||
).toBe('2');
|
|
||||||
expect(
|
|
||||||
result.yearComparison?.totalsByCurrency[0].difference.total.value,
|
|
||||||
).toBe('4');
|
|
||||||
expect(result.entries.map((entry) => entry.account?.id)).toEqual([
|
|
||||||
'a',
|
|
||||||
'b',
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
new Set(result.entries.map((entry) => entry.instrumentKey)).size,
|
|
||||||
).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves separate future events and fails the section when one source fails', async () => {
|
|
||||||
const { events, tbank, moex } = setup();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: 'BOND',
|
|
||||||
ticker: 'BOND',
|
|
||||||
instrumentType: 'bond',
|
|
||||||
quantity: { units: 2 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
moex.bondEvents.mockResolvedValue([
|
|
||||||
{ type: 'coupon', date: today, value: '0.1', currency: 'RUB' },
|
|
||||||
]);
|
|
||||||
const result = await events.getAll('user');
|
|
||||||
expect(result.events.map((event) => event.account?.id)).toEqual(['a', 'b']);
|
|
||||||
expect(result.incomeTotalsByCurrency).toEqual([
|
|
||||||
{ currency: 'RUB', value: '0.4' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockResolvedValueOnce({ positions: [] })
|
|
||||||
.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
await expect(events.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns valid empty results for an empty account set', async () => {
|
|
||||||
const { context, portfolio, operations, income, events, tbank } = setup();
|
|
||||||
context.accounts = [];
|
|
||||||
expect((await portfolio.getAll('user')).positions).toEqual([]);
|
|
||||||
expect((await operations.getAll('user', query)).hasNext).toBe(false);
|
|
||||||
expect((await income.summary('user', 'all')).totalsByCurrency).toEqual([]);
|
|
||||||
expect((await events.getAll('user')).events).toEqual([]);
|
|
||||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
|
|
||||||
@@ -44,3 +45,10 @@ export class BrokerageAccountDto {
|
|||||||
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
|
||||||
portfolioSummary?: PortfolioSummaryDto | null;
|
portfolioSummary?: PortfolioSummaryDto | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerageAccountsResourceDto {
|
||||||
|
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
|
||||||
|
declare data: BrokerageAccountDto[] | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||||
|
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
@@ -15,8 +16,11 @@ export class BrokerageAccountsController {
|
|||||||
constructor(private readonly accounts: BrokerageAccountsService) {}
|
constructor(private readonly accounts: BrokerageAccountsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: BrokerageAccountDto, isArray: true })
|
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
||||||
list(@Req() request: Request & { user: AccessPayload }) {
|
list(
|
||||||
return this.accounts.list(request.user.sub);
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
|
) {
|
||||||
|
return this.accounts.list(request.user.sub, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
@@ -5,8 +6,9 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
|||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule],
|
imports: [AuthModule, TbankModule, PortfolioModule],
|
||||||
controllers: [BrokerageAccountsController],
|
controllers: [BrokerageAccountsController],
|
||||||
providers: [BrokerageAccountsService],
|
providers: [BrokerageAccountsService],
|
||||||
|
exports: [BrokerageAccountsService],
|
||||||
})
|
})
|
||||||
export class BrokerageAccountsModule {}
|
export class BrokerageAccountsModule {}
|
||||||
|
|||||||
@@ -1,249 +1,70 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class PrismaService {},
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
|
||||||
|
jest.mock('../portfolio/application/portfolio.service', () => ({
|
||||||
|
PortfolioService: class PortfolioService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
const meta = investmentResourceMeta({
|
||||||
import { TbankTokenService } from '../../../integrations/tbank/tbank-token.service';
|
resourceKey: 'membership',
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
describe('BrokerageAccountsService', () => {
|
fetchedAt: new Date(),
|
||||||
const encryptionKey = 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=';
|
freshForMs: 300_000,
|
||||||
const prisma = { tbankConnection: { findUnique: jest.fn() } };
|
work: null,
|
||||||
const config = { getOrThrow: jest.fn(() => encryptionKey) };
|
|
||||||
const tbank = {
|
|
||||||
getOpenAccounts: jest.fn(),
|
|
||||||
getPortfolio: jest.fn(),
|
|
||||||
getPositions: jest.fn(),
|
|
||||||
};
|
|
||||||
const tokens = new TbankTokenService(config as never);
|
|
||||||
const access = new TbankAccessService(
|
|
||||||
prisma as never,
|
|
||||||
tokens,
|
|
||||||
tbank as never,
|
|
||||||
);
|
|
||||||
const service = new BrokerageAccountsService(access, tbank as never);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
|
||||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not call T-Bank without a connection', async () => {
|
describe('saved brokerage account projection', () => {
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue(null);
|
const portfolio = { saved: jest.fn() };
|
||||||
|
const service = new BrokerageAccountsService(portfolio as never);
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||||
expect.any(ConflictException),
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
);
|
membership: { ...meta, availability: 'missing' },
|
||||||
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
|
accounts: [],
|
||||||
|
});
|
||||||
|
expect((await service.list('owner', true)).data).toBeNull();
|
||||||
|
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
||||||
|
expect((await service.list('owner', true)).data).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes supported accounts and ignores unsupported ones', async () => {
|
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
portfolio.saved.mockResolvedValue({
|
||||||
encryptedToken: tokens.encrypt('token'),
|
membership: meta,
|
||||||
});
|
accounts: [
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
id: 'brokerage',
|
id: 'a',
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF',
|
name: 'A',
|
||||||
name: ' Основной ',
|
|
||||||
openedDate: { seconds: '1723507200' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'debit',
|
|
||||||
type: 'ACCOUNT_TYPE_DEBIT',
|
|
||||||
name: 'Дебетовый',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iis',
|
|
||||||
type: 'ACCOUNT_TYPE_TINKOFF_IIS',
|
|
||||||
name: ' ',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toEqual([
|
|
||||||
{
|
|
||||||
id: 'brokerage',
|
|
||||||
type: 'BROKERAGE',
|
type: 'BROKERAGE',
|
||||||
name: 'Основной',
|
|
||||||
openedOn: '2024-08-13',
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [],
|
|
||||||
stockPositionCount: 0,
|
|
||||||
bondPositionCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'iis',
|
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
|
||||||
name: 'ИИС',
|
|
||||||
openedOn: null,
|
openedOn: null,
|
||||||
portfolioSummary: {
|
meta,
|
||||||
totalsByCurrency: [],
|
snapshot: {
|
||||||
stockPositionCount: 0,
|
totalsByCurrency: [
|
||||||
bondPositionCount: 0,
|
{ currency: 'RUB', value: '100000000.000000001' },
|
||||||
|
],
|
||||||
|
positions: [{ type: 'STOCK' }, { type: 'BOND' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps upstream order and adds exact summaries without loading metadata', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'first', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
|
|
||||||
{ id: 'second', type: 'ACCOUNT_TYPE_TINKOFF_IIS', name: 'Второй' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
positions: [
|
|
||||||
{
|
{
|
||||||
quantity: { units: 2 },
|
id: 'b',
|
||||||
currentPrice: { currency: 'rub', units: 3 },
|
name: 'B',
|
||||||
|
type: 'BROKERAGE',
|
||||||
|
openedOn: null,
|
||||||
|
meta: { ...meta, availability: 'missing' },
|
||||||
|
snapshot: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
|
||||||
.mockResolvedValueOnce({ positions: [] });
|
|
||||||
tbank.getPositions
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'RUB', nano: 500000000 }] })
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 4 }] });
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toEqual([
|
|
||||||
{
|
|
||||||
id: 'first',
|
|
||||||
type: 'BROKERAGE',
|
|
||||||
name: 'Первый',
|
|
||||||
openedOn: null,
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '6.5' }],
|
|
||||||
stockPositionCount: 0,
|
|
||||||
bondPositionCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'second',
|
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
|
||||||
name: 'Второй',
|
|
||||||
openedOn: null,
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [{ currency: 'USD', value: '4' }],
|
|
||||||
stockPositionCount: 0,
|
|
||||||
bondPositionCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(tbank.getPortfolio).toHaveBeenCalledWith('token', 'first');
|
|
||||||
expect(tbank.getPositions).toHaveBeenCalledWith('token', 'second');
|
|
||||||
});
|
});
|
||||||
|
const response = await service.list('owner', true);
|
||||||
it('counts non-zero stock and bond positions even without a valuation', async () => {
|
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||||
encryptedToken: tokens.encrypt('token'),
|
stockPositionCount: 1,
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{ instrumentType: 'share', quantity: { units: 200 } },
|
|
||||||
{ instrumentType: 'SHARE', quantity: { units: -5 } },
|
|
||||||
{ instrumentType: 'share', quantity: { units: 0, nano: 1 } },
|
|
||||||
{ instrumentType: 'bond', quantity: { units: 80 } },
|
|
||||||
{ instrumentType: 'bond', quantity: { units: 0 } },
|
|
||||||
{ instrumentType: 'share' },
|
|
||||||
{ instrumentType: 'etf', quantity: { units: 10 } },
|
|
||||||
{ instrumentType: 'currency', quantity: { units: 100 } },
|
|
||||||
{ quantity: { units: 1 } },
|
|
||||||
],
|
|
||||||
virtualPositions: [{ instrumentType: 'bond', quantity: { units: 1 } }],
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toMatchObject([
|
|
||||||
{
|
|
||||||
portfolioSummary: {
|
|
||||||
totalsByCurrency: [],
|
|
||||||
stockPositionCount: 3,
|
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||||
it('localizes an unavailable summary without suppressing another account', async () => {
|
expect(response.meta.availability).toBe('partial');
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'unavailable', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Первый' },
|
|
||||||
{ id: 'available', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Второй' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce({ positions: [] });
|
|
||||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toMatchObject([
|
|
||||||
{ id: 'unavailable', portfolioSummary: null },
|
|
||||||
{ id: 'available', portfolioSummary: { totalsByCurrency: [] } },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mask an invalid token when the other summary input is unavailable', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([
|
|
||||||
{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Основной' },
|
|
||||||
]);
|
|
||||||
tbank.getPortfolio.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
tbank.getPositions.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains the connection when T-Bank rejects its token', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty list from T-Bank unchanged', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('propagates an unavailable T-Bank response', async () => {
|
|
||||||
prisma.tbankConnection.findUnique.mockResolvedValue({
|
|
||||||
encryptedToken: tokens.encrypt('token'),
|
|
||||||
});
|
|
||||||
tbank.getOpenAccounts.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.list('user-1')).rejects.toEqual(
|
|
||||||
expect.any(ServiceUnavailableException),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,80 +1,65 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isZero } from '../../../common/decimal';
|
import { createHash } from 'node:crypto';
|
||||||
import {
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
TbankAccessService,
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
TbankClient,
|
|
||||||
} from '../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../integrations/tbank/decimal';
|
|
||||||
import { totalsByCurrency } from '../portfolio/portfolio.api';
|
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageAccountsService {
|
export class BrokerageAccountsService {
|
||||||
constructor(
|
constructor(private readonly portfolio: PortfolioService) {}
|
||||||
private readonly access: TbankAccessService,
|
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async list(userId: string): Promise<BrokerageAccountDto[]> {
|
async list(userId: string, observe = false) {
|
||||||
return this.access.withSupportedAccounts(
|
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||||
userId,
|
const data: BrokerageAccountDto[] | null =
|
||||||
async ({ token, accounts }) =>
|
saved.membership.availability === 'missing'
|
||||||
Promise.all(
|
? null
|
||||||
accounts.map(async (account) => {
|
: saved.accounts.map((account) => ({
|
||||||
const [portfolioResult, positionsResult] = await Promise.allSettled(
|
id: account.id,
|
||||||
[
|
name: account.name,
|
||||||
this.tbank.getPortfolio(token, account.id),
|
type: account.type as BrokerageAccountDto['type'],
|
||||||
this.tbank.getPositions(token, account.id),
|
openedOn: account.openedOn,
|
||||||
],
|
portfolioSummary: account.snapshot
|
||||||
);
|
? {
|
||||||
if (
|
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||||
portfolioResult.status === 'rejected' &&
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
!isTbankUnavailable(portfolioResult.reason)
|
(position) => position.type === 'STOCK',
|
||||||
)
|
).length,
|
||||||
throw portfolioResult.reason;
|
bondPositionCount: account.snapshot.positions.filter(
|
||||||
if (
|
(position) => position.type === 'BOND',
|
||||||
positionsResult.status === 'rejected' &&
|
).length,
|
||||||
!isTbankUnavailable(positionsResult.reason)
|
|
||||||
)
|
|
||||||
throw positionsResult.reason;
|
|
||||||
if (
|
|
||||||
portfolioResult.status === 'rejected' ||
|
|
||||||
positionsResult.status === 'rejected'
|
|
||||||
)
|
|
||||||
return { ...account, portfolioSummary: null };
|
|
||||||
const positions = portfolioResult.value.positions ?? [];
|
|
||||||
let stockPositionCount = 0;
|
|
||||||
let bondPositionCount = 0;
|
|
||||||
for (const position of positions) {
|
|
||||||
if (isZero(decimal(position.quantity ?? {}))) continue;
|
|
||||||
const type = position.instrumentType?.toLowerCase();
|
|
||||||
if (type === 'share') stockPositionCount += 1;
|
|
||||||
if (type === 'bond') bondPositionCount += 1;
|
|
||||||
}
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
return {
|
return {
|
||||||
...account,
|
data,
|
||||||
portfolioSummary: {
|
meta: investmentResourceMeta({
|
||||||
totalsByCurrency: totalsByCurrency(
|
resourceKey: 'accounts',
|
||||||
positions,
|
dataRevision: createHash('sha256')
|
||||||
positionsResult.value.money ?? [],
|
.update(
|
||||||
),
|
JSON.stringify([
|
||||||
stockPositionCount,
|
saved.membership.dataRevision,
|
||||||
bondPositionCount,
|
saved.accounts.map((account) => [
|
||||||
},
|
account.id,
|
||||||
};
|
account.meta.dataRevision,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest('base64url'),
|
||||||
|
availability:
|
||||||
|
data === null
|
||||||
|
? 'missing'
|
||||||
|
: saved.accounts.some((account) => !account.snapshot)
|
||||||
|
? 'partial'
|
||||||
|
: 'complete',
|
||||||
|
fetchedAt: saved.membership.lastSuccessfulAt
|
||||||
|
? new Date(saved.membership.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: 300_000,
|
||||||
|
work: saved.membership.sync.id
|
||||||
|
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||||
|
: null,
|
||||||
|
dependencies: saved.accounts.map((account) => account.meta),
|
||||||
}),
|
}),
|
||||||
),
|
};
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTbankUnavailable(error: unknown): boolean {
|
|
||||||
if (!(error instanceof ServiceUnavailableException)) return false;
|
|
||||||
const response = error.getResponse();
|
|
||||||
return (
|
|
||||||
typeof response === 'object' &&
|
|
||||||
response !== null &&
|
|
||||||
'code' in response &&
|
|
||||||
response.code === 'TBANK_UNAVAILABLE'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import type { Request } from 'express';
|
|||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsDto,
|
IncomeAnalyticsResourceDto,
|
||||||
IncomeAnalyticsQueryDto,
|
IncomeAnalyticsQueryDto,
|
||||||
IncomeSummaryDto,
|
IncomeSummaryResourceDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
|
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
@@ -19,31 +20,49 @@ export class IncomeAnalyticsController {
|
|||||||
constructor(private readonly income: IncomeAnalyticsService) {}
|
constructor(private readonly income: IncomeAnalyticsService) {}
|
||||||
|
|
||||||
@Get('all/income-summary')
|
@Get('all/income-summary')
|
||||||
@ApiOkResponse({ type: IncomeSummaryDto })
|
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||||
allSummary(@Req() request: Request & { user: AccessPayload }) {
|
allSummary(
|
||||||
return this.income.summary(request.user.sub, 'all');
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
|
) {
|
||||||
|
return this.income.summary(
|
||||||
|
request.user.sub,
|
||||||
|
'all',
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('all/income-analytics')
|
@Get('all/income-analytics')
|
||||||
@ApiOkResponse({ type: IncomeAnalyticsDto })
|
@ApiOkResponse({ type: IncomeAnalyticsResourceDto })
|
||||||
allAnalytics(
|
allAnalytics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: IncomeAnalyticsQueryDto,
|
@Query() query: IncomeAnalyticsQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.analytics(request.user.sub, 'all', query.from, query.to);
|
return this.income.analytics(
|
||||||
|
request.user.sub,
|
||||||
|
'all',
|
||||||
|
query.from,
|
||||||
|
query.to,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/income-summary')
|
@Get(':accountId/income-summary')
|
||||||
@ApiOkResponse({ type: IncomeSummaryDto })
|
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||||
summary(
|
summary(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.summary(request.user.sub, accountId);
|
return this.income.summary(
|
||||||
|
request.user.sub,
|
||||||
|
accountId,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/income-analytics')
|
@Get(':accountId/income-analytics')
|
||||||
@ApiOkResponse({ type: IncomeAnalyticsDto })
|
@ApiOkResponse({ type: IncomeAnalyticsResourceDto })
|
||||||
analytics(
|
analytics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@@ -54,6 +73,7 @@ export class IncomeAnalyticsController {
|
|||||||
accountId,
|
accountId,
|
||||||
query.from,
|
query.from,
|
||||||
query.to,
|
query.to,
|
||||||
|
query.sync === 'observe',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ import { IsOptional, Matches } from 'class-validator';
|
|||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvestmentReadQueryDto,
|
||||||
|
InvestmentResourceMetaDto,
|
||||||
|
} from '../investment-resource.dto';
|
||||||
|
|
||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export class IncomeAnalyticsQueryDto {
|
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({ format: 'date' })
|
@ApiPropertyOptional({ format: 'date' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Matches(dateOnly)
|
@Matches(dateOnly)
|
||||||
@@ -121,8 +126,8 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||||
@ApiProperty({ type: IncomeComparisonDto })
|
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||||
declare previous: IncomeComparisonDto;
|
declare previous: IncomeComparisonDto | null;
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||||
declare yearComparison: IncomeComparisonDto | null;
|
declare yearComparison: IncomeComparisonDto | null;
|
||||||
@@ -139,3 +144,17 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
|||||||
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
|
@ApiProperty({ type: IncomeAnalyticsEntryDto, isArray: true })
|
||||||
declare entries: IncomeAnalyticsEntryDto[];
|
declare entries: IncomeAnalyticsEntryDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class IncomeSummaryResourceDto {
|
||||||
|
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
|
||||||
|
declare data: IncomeSummaryDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncomeAnalyticsResourceDto {
|
||||||
|
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
|
||||||
|
declare data: IncomeAnalyticsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
|
jest.mock('../../../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||||
BrokerageOperationsService: class BrokerageOperationsService {},
|
BrokerageOperationsService: class BrokerageOperationsService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { IncomeAnalyticsService } from './income-analytics.service';
|
import { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
|
|
||||||
describe('IncomeAnalyticsService', () => {
|
describe('IncomeAnalyticsService', () => {
|
||||||
@@ -19,7 +24,10 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
build: (opened?: Date) => unknown[],
|
build: (opened?: Date) => unknown[],
|
||||||
) => {
|
) => {
|
||||||
build(undefined);
|
build(undefined);
|
||||||
return { pages: [[{ items }], [{ items: [] }], [{ items: [] }]] };
|
return [items, [], []].map((items, index) => ({
|
||||||
|
items,
|
||||||
|
meta: completeMeta(index),
|
||||||
|
}));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -57,13 +65,15 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
};
|
};
|
||||||
seed([payment]);
|
seed([payment]);
|
||||||
|
|
||||||
const result = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
'account',
|
'account',
|
||||||
'2026-01-01',
|
'2026-01-01',
|
||||||
'2026-01-31',
|
'2026-01-31',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
assert(result);
|
||||||
expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]);
|
expect(result.entries).toEqual([{ ...payment, instrumentKey: key }]);
|
||||||
expect(result.instruments).toEqual([
|
expect(result.instruments).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -92,13 +102,15 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
];
|
];
|
||||||
seed(selected);
|
seed(selected);
|
||||||
|
|
||||||
const result = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
'account',
|
'account',
|
||||||
'2026-01-01',
|
'2026-01-01',
|
||||||
'2026-01-31',
|
'2026-01-31',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
assert(result);
|
||||||
expect(result.entries).toEqual(
|
expect(result.entries).toEqual(
|
||||||
selected.map((item) => ({
|
selected.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -146,37 +158,28 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
build: (opened?: Date) => unknown[],
|
build: (opened?: Date) => unknown[],
|
||||||
) => {
|
) => {
|
||||||
build(undefined);
|
build(undefined);
|
||||||
return {
|
return [
|
||||||
pages: [
|
|
||||||
[
|
[
|
||||||
{
|
|
||||||
items: [
|
|
||||||
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
||||||
entry(
|
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||||
'transfer',
|
|
||||||
'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
|
||||||
'99',
|
|
||||||
'RUB',
|
|
||||||
),
|
|
||||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||||
],
|
],
|
||||||
},
|
[],
|
||||||
],
|
[],
|
||||||
[{ items: [] }],
|
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||||
[{ items: [] }],
|
|
||||||
],
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
'account',
|
'account',
|
||||||
'2026-01-01',
|
'2026-01-01',
|
||||||
'2026-01-31',
|
'2026-01-31',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const result = response.data;
|
||||||
|
assert(result);
|
||||||
expect(result.totalsByCurrency).toEqual([
|
expect(result.totalsByCurrency).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
@@ -222,3 +225,14 @@ function entry(
|
|||||||
commission: null,
|
commission: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function completeMeta(index: number) {
|
||||||
|
return investmentResourceMeta({
|
||||||
|
resourceKey: `period/${index}`,
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: new Date('2026-02-01Z'),
|
||||||
|
freshForMs: Infinity,
|
||||||
|
work: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
@@ -6,6 +7,11 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
IncomeAnalyticsResourceDto,
|
||||||
|
IncomeSummaryResourceDto,
|
||||||
|
} from './income-analytics.dto';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationDto,
|
BrokerageOperationDto,
|
||||||
@@ -13,7 +19,6 @@ import {
|
|||||||
} from '../operations/operations.api';
|
} from '../operations/operations.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsDto,
|
|
||||||
IncomeAnalyticsEntryDto,
|
IncomeAnalyticsEntryDto,
|
||||||
IncomeComparisonDto,
|
IncomeComparisonDto,
|
||||||
IncomeCurrencySummaryDto,
|
IncomeCurrencySummaryDto,
|
||||||
@@ -35,24 +40,32 @@ type Total = {
|
|||||||
export class IncomeAnalyticsService {
|
export class IncomeAnalyticsService {
|
||||||
constructor(private readonly operations: BrokerageOperationsService) {}
|
constructor(private readonly operations: BrokerageOperationsService) {}
|
||||||
|
|
||||||
async summary(userId: string, accountId: string): Promise<IncomeSummaryDto> {
|
async summary(
|
||||||
let plan: ReturnType<typeof planIncomePeriod> | undefined;
|
userId: string,
|
||||||
const collected = await this.operations.collectRanges(
|
accountId: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<IncomeSummaryResourceDto> {
|
||||||
|
let plan = planIncomePeriod(undefined, undefined);
|
||||||
|
const [selected] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
(openedAt) => {
|
(openedAt) => {
|
||||||
const planned = planIncomePeriod(undefined, undefined, openedAt);
|
plan = planIncomePeriod(undefined, undefined, openedAt);
|
||||||
plan = planned;
|
return [plan.selected];
|
||||||
return [planned.selected];
|
|
||||||
},
|
},
|
||||||
|
observe,
|
||||||
);
|
);
|
||||||
if (!plan) throw new Error('Income period was not planned');
|
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||||
return summaryResponse(
|
assert(selected.meta.lastSuccessfulAt);
|
||||||
|
return {
|
||||||
|
data: summaryResponse(
|
||||||
accountId,
|
accountId,
|
||||||
plan.selected,
|
plan.selected,
|
||||||
qualifying(collected.pages[0].flatMap((page) => page.items)),
|
qualifying(selected.items),
|
||||||
new Date().toISOString(),
|
selected.meta.lastSuccessfulAt,
|
||||||
);
|
),
|
||||||
|
meta: selected.meta,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async analytics(
|
async analytics(
|
||||||
@@ -60,52 +73,80 @@ export class IncomeAnalyticsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
from: string | undefined,
|
from: string | undefined,
|
||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
): Promise<IncomeAnalyticsDto> {
|
observe = false,
|
||||||
let plan: ReturnType<typeof planIncomePeriod> | undefined;
|
): Promise<IncomeAnalyticsResourceDto> {
|
||||||
const collected = await this.operations.collectRanges(
|
let plan = planIncomePeriod(from, to);
|
||||||
|
const periods = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
(openedAt) => {
|
(openedAt) => {
|
||||||
const planned = planIncomePeriod(from, to, openedAt);
|
plan = planIncomePeriod(from, to, openedAt);
|
||||||
plan = planned;
|
|
||||||
return [
|
return [
|
||||||
planned.selected,
|
plan.selected,
|
||||||
planned.previous,
|
plan.previous,
|
||||||
...(planned.yearAgo ? [planned.yearAgo] : []),
|
...(plan.yearAgo ? [plan.yearAgo] : []),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
observe,
|
||||||
);
|
);
|
||||||
if (!plan) throw new Error('Income period was not planned');
|
const [current, prior, year] = periods;
|
||||||
const selected = qualifying(
|
const dependencies = periods.map((period) => period.meta);
|
||||||
collected.pages[0].flatMap((page) => page.items),
|
if (!plan.yearAgo)
|
||||||
).map((entry) => ({
|
dependencies.push({
|
||||||
|
...investmentResourceMeta({
|
||||||
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
|
dataRevision: 'unavailable',
|
||||||
|
availability: 'missing',
|
||||||
|
fetchedAt: null,
|
||||||
|
freshForMs: 0,
|
||||||
|
work: null,
|
||||||
|
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const states = periods.map((period) => period.meta.sync);
|
||||||
|
const work =
|
||||||
|
states.find((state) => state.state === 'running') ??
|
||||||
|
states.find((state) => state.state === 'failed');
|
||||||
|
const meta = investmentResourceMeta({
|
||||||
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||||
|
dataRevision: current.meta.dataRevision,
|
||||||
|
availability: current.meta.availability,
|
||||||
|
fetchedAt: current.meta.lastSuccessfulAt
|
||||||
|
? new Date(current.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
|
||||||
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
|
coverage: current.meta.coverage,
|
||||||
|
dependencies,
|
||||||
|
});
|
||||||
|
if (current.items === null) return { data: null, meta };
|
||||||
|
assert(current.meta.lastSuccessfulAt);
|
||||||
|
const selected = qualifying(current.items).map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
||||||
}));
|
}));
|
||||||
const previous = qualifying(
|
return {
|
||||||
collected.pages[1].flatMap((page) => page.items),
|
data: {
|
||||||
);
|
...summaryResponse(
|
||||||
const yearAgo = plan.yearAgo
|
|
||||||
? qualifying(collected.pages[2].flatMap((page) => page.items))
|
|
||||||
: null;
|
|
||||||
const calculatedAt = new Date().toISOString();
|
|
||||||
const base = summaryResponse(
|
|
||||||
accountId,
|
accountId,
|
||||||
plan.selected,
|
plan.selected,
|
||||||
selected,
|
selected,
|
||||||
calculatedAt,
|
current.meta.lastSuccessfulAt,
|
||||||
);
|
),
|
||||||
return {
|
previous:
|
||||||
...base,
|
prior.items === null
|
||||||
previous: comparison(selected, plan.previous, previous),
|
? null
|
||||||
|
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||||
yearComparison:
|
yearComparison:
|
||||||
plan.yearAgo && yearAgo
|
plan.yearAgo && year?.items !== null && year
|
||||||
? comparison(selected, plan.yearAgo, yearAgo)
|
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||||
: null,
|
: null,
|
||||||
bucketGranularity: plan.granularity,
|
bucketGranularity: plan.granularity,
|
||||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||||
instruments: instruments(selected),
|
instruments: instruments(selected),
|
||||||
entries: selected,
|
entries: selected,
|
||||||
|
},
|
||||||
|
meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { IncomeAnalyticsService } from './income-analytics.service';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { planIncomePeriod } from './income-period';
|
||||||
@@ -8,5 +8,6 @@ import { IncomeAnalyticsService } from './income-analytics.service';
|
|||||||
imports: [AuthModule, OperationsModule],
|
imports: [AuthModule, OperationsModule],
|
||||||
controllers: [IncomeAnalyticsController],
|
controllers: [IncomeAnalyticsController],
|
||||||
providers: [IncomeAnalyticsService],
|
providers: [IncomeAnalyticsService],
|
||||||
|
exports: [IncomeAnalyticsService],
|
||||||
})
|
})
|
||||||
export class IncomeModule {}
|
export class IncomeModule {}
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
type TbankShare,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import {
|
||||||
|
candleGaps,
|
||||||
|
candleWindows,
|
||||||
|
utcDay,
|
||||||
|
type CandleRange,
|
||||||
|
} from '../domain/candle-coverage';
|
||||||
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
|
type CandleInput = {
|
||||||
|
uid: string;
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
period: 'month' | 'halfYear' | 'year' | 'all';
|
||||||
|
range?: CandleRange;
|
||||||
|
};
|
||||||
|
type Producer = {
|
||||||
|
input: CandleInput;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
|
now: Date;
|
||||||
|
force: boolean;
|
||||||
|
priority: number;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
ranges?: CandleRange[];
|
||||||
|
nextWindow?: number;
|
||||||
|
};
|
||||||
|
const months = { month: 1, halfYear: 6, year: 12 };
|
||||||
|
const tailFreshForMs = 60_000;
|
||||||
|
const prefetchReadBatchSize = 100;
|
||||||
|
function scope(input: CandleInput) {
|
||||||
|
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||||
|
}
|
||||||
|
function rangeFor(
|
||||||
|
input: CandleInput,
|
||||||
|
instrument: TbankShare,
|
||||||
|
now: Date,
|
||||||
|
): CandleRange {
|
||||||
|
if (input.range) return input.range;
|
||||||
|
const from = utcDay(now);
|
||||||
|
const history = timestamp(instrument.first_1dayCandleDate);
|
||||||
|
if (input.period === 'all') {
|
||||||
|
if (!history)
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
code:
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
||||||
|
: 'BOND_HISTORY_START_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
|
} else {
|
||||||
|
from.setUTCMonth(from.getUTCMonth() - months[input.period]);
|
||||||
|
if (history && Date.parse(history) > from.valueOf())
|
||||||
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
|
}
|
||||||
|
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
|
||||||
|
}
|
||||||
|
@Injectable()
|
||||||
|
export class CandleService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly producers = new Map<string, Producer>();
|
||||||
|
private readonly queue: Producer[] = [];
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
private running = false;
|
||||||
|
private readonly prefetches = new Map<string, Promise<void>>();
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CandleRepository,
|
||||||
|
private readonly sources: CatalogEnrichmentService,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async read(userId: string, input: CandleInput, observe = false) {
|
||||||
|
const { revision } = await this.access.local(userId);
|
||||||
|
const [detail] = await this.sources.readMany(
|
||||||
|
userId,
|
||||||
|
input.instrumentType,
|
||||||
|
[input.uid],
|
||||||
|
true,
|
||||||
|
['detail'],
|
||||||
|
);
|
||||||
|
if (detail.meta.availability === 'complete' && !detail.instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const now = new Date();
|
||||||
|
const range = detail.instrument
|
||||||
|
? rangeFor(input, detail.instrument, now)
|
||||||
|
: null;
|
||||||
|
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||||
|
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||||
|
if (!observe && (!saved || gaps.length))
|
||||||
|
await this.request(userId, revision, input, now);
|
||||||
|
const work = await this.repository.work(userId, revision, scope(input));
|
||||||
|
const fetchedAt =
|
||||||
|
saved?.coverage
|
||||||
|
.map((row) => row.fetchedAt)
|
||||||
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? null;
|
||||||
|
const covered = saved?.coverage.length ? true : false;
|
||||||
|
const completeCoverage = (saved?.coverage ?? [])
|
||||||
|
.filter((row) => row.isComplete || row.from >= utcDay(now))
|
||||||
|
.map((row) => ({ ...row, isComplete: true }));
|
||||||
|
const missing = range ? candleGaps(range, completeCoverage, now) : [];
|
||||||
|
const complete = Boolean(saved && range && !missing.length);
|
||||||
|
const publicRange = (value: CandleRange) => ({
|
||||||
|
from: value.from.toISOString(),
|
||||||
|
to: value.to.toISOString(),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
data:
|
||||||
|
!saved || !covered
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
candles: saved.data,
|
||||||
|
currency:
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? detail.instrument?.currency?.toUpperCase() || null
|
||||||
|
: null,
|
||||||
|
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||||
|
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||||
|
},
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: scope(input),
|
||||||
|
dataRevision: saved?.dataRevision ?? '0',
|
||||||
|
availability: !covered ? 'missing' : complete ? 'complete' : 'partial',
|
||||||
|
fetchedAt,
|
||||||
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
|
work,
|
||||||
|
coverage: {
|
||||||
|
requested: range ? [publicRange(range)] : [],
|
||||||
|
completeRanges: range
|
||||||
|
? completeCoverage.flatMap((row) => {
|
||||||
|
const from = new Date(
|
||||||
|
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||||
|
);
|
||||||
|
const to = new Date(
|
||||||
|
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||||
|
);
|
||||||
|
return from < to ? [publicRange({ from, to })] : [];
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
missingRanges: missing.map(publicRange),
|
||||||
|
asOf: fetchedAt?.toISOString() ?? null,
|
||||||
|
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||||
|
? 'known'
|
||||||
|
: 'unknown',
|
||||||
|
historyState: complete
|
||||||
|
? 'complete'
|
||||||
|
: work?.state === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: work?.state === 'running'
|
||||||
|
? 'loading'
|
||||||
|
: covered
|
||||||
|
? 'partial'
|
||||||
|
: 'missing',
|
||||||
|
},
|
||||||
|
dependencies: [
|
||||||
|
detail.meta,
|
||||||
|
...(saved?.coverage ?? []).map((row) =>
|
||||||
|
investmentResourceMeta({
|
||||||
|
resourceKey: `${scope(input)}/${row.from.toISOString()}/${row.to.toISOString()}`,
|
||||||
|
dataRevision: String(row.dataRevision),
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: row.fetchedAt,
|
||||||
|
freshForMs: row.isComplete
|
||||||
|
? Number.POSITIVE_INFINITY
|
||||||
|
: tailFreshForMs,
|
||||||
|
work: null,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
prefetch(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
positions: { instrumentUid?: string | null; type: string }[],
|
||||||
|
) {
|
||||||
|
const key = JSON.stringify([
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
positions
|
||||||
|
.map((position) => [position.instrumentUid, position.type])
|
||||||
|
.sort(),
|
||||||
|
]);
|
||||||
|
const existing = this.prefetches.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const work = this.prefetchPositions(userId, revision, positions).finally(
|
||||||
|
() => this.prefetches.delete(key),
|
||||||
|
);
|
||||||
|
this.prefetches.set(key, work);
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prefetchPositions(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
positions: { instrumentUid?: string | null; type: string }[],
|
||||||
|
) {
|
||||||
|
const now = new Date();
|
||||||
|
const unique = new Map(
|
||||||
|
positions
|
||||||
|
.filter(
|
||||||
|
(position) =>
|
||||||
|
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type),
|
||||||
|
)
|
||||||
|
.map((position) => [position.instrumentUid ?? '', position]),
|
||||||
|
);
|
||||||
|
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||||
|
[];
|
||||||
|
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
|
||||||
|
for (const instrumentType of ['share', 'bond'] as const) {
|
||||||
|
const uids = [...unique]
|
||||||
|
.filter(
|
||||||
|
([, position]) =>
|
||||||
|
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
|
||||||
|
)
|
||||||
|
.map(([uid]) => uid);
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < uids.length;
|
||||||
|
offset += prefetchReadBatchSize
|
||||||
|
) {
|
||||||
|
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
|
||||||
|
details.push(
|
||||||
|
...(await this.sources.readMany(userId, instrumentType, batch, true, [
|
||||||
|
'detail',
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
coverage.push(...(await this.repository.coverage(batch)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
|
||||||
|
for (const [uid, position] of unique) {
|
||||||
|
const instrumentType: CandleInput['instrumentType'] =
|
||||||
|
position.type === 'STOCK' ? 'share' : 'bond';
|
||||||
|
const detail = byUid.get(uid);
|
||||||
|
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
|
||||||
|
const plan = planInvestmentScreen(
|
||||||
|
{ screen: 'account' },
|
||||||
|
{
|
||||||
|
membershipKnown: true,
|
||||||
|
accounts: [],
|
||||||
|
instruments: [
|
||||||
|
{
|
||||||
|
uid,
|
||||||
|
type: position.type as 'STOCK' | 'BOND',
|
||||||
|
historyFrom: history ? new Date(history) : null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!detail?.instrument ||
|
||||||
|
plan.sources.some((source) => source.kind === 'candles')
|
||||||
|
) {
|
||||||
|
const input = { uid, instrumentType, period: 'all' as const };
|
||||||
|
const range =
|
||||||
|
detail?.instrument && history
|
||||||
|
? rangeFor(input, detail.instrument, now)
|
||||||
|
: null;
|
||||||
|
if (
|
||||||
|
!range ||
|
||||||
|
candleGaps(
|
||||||
|
range,
|
||||||
|
coverage.filter((row) => row.instrumentUid === uid),
|
||||||
|
now,
|
||||||
|
).length
|
||||||
|
)
|
||||||
|
await this.request(userId, revision, input, now, false, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
input: CandleInput,
|
||||||
|
now = new Date(),
|
||||||
|
force = false,
|
||||||
|
priority = 1,
|
||||||
|
) {
|
||||||
|
const pending = this.reservations.then(async () => {
|
||||||
|
const reserved = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
scope(input),
|
||||||
|
{
|
||||||
|
uid: input.uid,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
ranges: [],
|
||||||
|
period: input.period,
|
||||||
|
now: now.toISOString(),
|
||||||
|
force,
|
||||||
|
},
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
priority,
|
||||||
|
);
|
||||||
|
if (!reserved) return null;
|
||||||
|
const attempt = {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
id: reserved.work.id,
|
||||||
|
attemptId: reserved.work.attemptId,
|
||||||
|
};
|
||||||
|
if (reserved.launch) {
|
||||||
|
this.active.add(attempt.attemptId);
|
||||||
|
const producer = this.producers.get(scope(input));
|
||||||
|
if (producer) producer.observers.push(attempt);
|
||||||
|
else {
|
||||||
|
const next = { input, attempt, now, force, priority, observers: [] };
|
||||||
|
this.producers.set(scope(input), next);
|
||||||
|
this.queue.push(next);
|
||||||
|
}
|
||||||
|
if (!this.running) {
|
||||||
|
this.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attempt.id;
|
||||||
|
});
|
||||||
|
this.reservations = pending.catch(() => undefined);
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(producer: Producer) {
|
||||||
|
const { input, attempt, now } = producer;
|
||||||
|
const instrument = await this.sources.ensureDetail(
|
||||||
|
attempt.userId,
|
||||||
|
attempt.revision,
|
||||||
|
input.instrumentType,
|
||||||
|
input.uid,
|
||||||
|
);
|
||||||
|
if (!instrument) throw new NotFoundException();
|
||||||
|
const range = rangeFor(input, instrument, now);
|
||||||
|
if (!producer.ranges) {
|
||||||
|
const saved = await this.repository.read(input.uid, range);
|
||||||
|
producer.ranges = (
|
||||||
|
producer.force ? [range] : candleGaps(range, saved.coverage, now)
|
||||||
|
)
|
||||||
|
.flatMap(candleWindows)
|
||||||
|
.reverse();
|
||||||
|
producer.nextWindow = 0;
|
||||||
|
}
|
||||||
|
const ranges = producer.ranges;
|
||||||
|
const index = producer.nextWindow ?? 0;
|
||||||
|
let window = ranges[index];
|
||||||
|
if (window && !producer.force) {
|
||||||
|
const latest = await this.repository.read(input.uid, window);
|
||||||
|
const gaps = candleGaps(window, latest.coverage, now)
|
||||||
|
.flatMap(candleWindows)
|
||||||
|
.reverse();
|
||||||
|
ranges.splice(index, 1, ...gaps);
|
||||||
|
window = ranges[index];
|
||||||
|
}
|
||||||
|
if (window) {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (!token) return false;
|
||||||
|
const candles = await this.tbank.getCandles(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
window.from,
|
||||||
|
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||||
|
);
|
||||||
|
const rows = candles.flatMap((candle) => {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
if (!(await this.repository.stage(attempt, window, rows, now)))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
!producer.force &&
|
||||||
|
!(await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
{ uid: input.uid, ranges: [window] },
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
producer.nextWindow = index + 1;
|
||||||
|
if (producer.nextWindow < ranges.length) return null;
|
||||||
|
}
|
||||||
|
return this.repository.publish(attempt, {
|
||||||
|
uid: input.uid,
|
||||||
|
ranges: producer.force ? ranges : [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain() {
|
||||||
|
try {
|
||||||
|
while (this.queue.length) {
|
||||||
|
this.queue.sort((a, b) => a.priority - b.priority);
|
||||||
|
const producer = this.queue.shift();
|
||||||
|
if (!producer) break;
|
||||||
|
let success = false;
|
||||||
|
let code = 'TBANK_UNAVAILABLE';
|
||||||
|
try {
|
||||||
|
const result = await this.load(producer);
|
||||||
|
if (result === null) {
|
||||||
|
this.queue.push(producer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
success = result;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
|
if (error instanceof ServiceUnavailableException) {
|
||||||
|
const response = error.getResponse();
|
||||||
|
if (
|
||||||
|
typeof response === 'object' &&
|
||||||
|
'code' in response &&
|
||||||
|
typeof response.code === 'string'
|
||||||
|
)
|
||||||
|
code = response.code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!success) await this.repository.fail(producer.attempt, code);
|
||||||
|
this.active.delete(producer.attempt.attemptId);
|
||||||
|
for (const observer of producer.observers) {
|
||||||
|
if (
|
||||||
|
!success ||
|
||||||
|
!(await this.repository.completeObserver(
|
||||||
|
observer,
|
||||||
|
producer.attempt,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
await this.repository.fail(observer, code);
|
||||||
|
this.active.delete(observer.attemptId);
|
||||||
|
}
|
||||||
|
this.producers.delete(scope(producer.input));
|
||||||
|
if (success && !producer.force && producer.input.period !== 'all')
|
||||||
|
await this.request(
|
||||||
|
producer.attempt.userId,
|
||||||
|
producer.attempt.revision,
|
||||||
|
{ ...producer.input, period: 'all' },
|
||||||
|
producer.now,
|
||||||
|
false,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.logger.event('error', 'candles.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ConflictException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
catalogQuery,
|
||||||
|
catalogScope,
|
||||||
|
type CatalogQuery,
|
||||||
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import {
|
||||||
|
catalogSearchLimit,
|
||||||
|
selectCatalogInstruments,
|
||||||
|
} from './catalog-selection';
|
||||||
|
|
||||||
|
const tickerAmbiguityLimit = 2;
|
||||||
|
|
||||||
|
type Producer = {
|
||||||
|
input: CatalogQuery;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
|
observers: TbankSyncAttempt[];
|
||||||
|
force: boolean;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogDiscoveryService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly producers = new Map<string, Producer>();
|
||||||
|
private readonly queue: Producer[] = [];
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
private running = false;
|
||||||
|
private readonly completions = new Map<
|
||||||
|
string,
|
||||||
|
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||||
|
>();
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly enrichment: CatalogEnrichmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async read(userId: string, input: CatalogQuery, observe = false) {
|
||||||
|
input = catalogQuery(input);
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
|
const saved = await this.repository.read(input);
|
||||||
|
if (!observe) await this.request(userId, connection.revision, input);
|
||||||
|
return {
|
||||||
|
data: saved.data,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: catalogScope(input),
|
||||||
|
dataRevision: saved.dataRevision,
|
||||||
|
availability: saved.data === null ? 'missing' : 'complete',
|
||||||
|
fetchedAt: saved.fetchedAt,
|
||||||
|
freshForMs: tbankSourceTtlMs.catalog,
|
||||||
|
work: await this.repository.work(userId, connection.revision, input),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||||
|
const discovery = await this.read(userId, input, observe);
|
||||||
|
const sources = discovery.data
|
||||||
|
? await this.enrichment.readMany(
|
||||||
|
userId,
|
||||||
|
input.instrumentType,
|
||||||
|
discovery.data.map((item) => item.uid),
|
||||||
|
observe,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const dependencies = [
|
||||||
|
discovery.meta,
|
||||||
|
...sources.map((source) => source.meta),
|
||||||
|
];
|
||||||
|
return {
|
||||||
|
data:
|
||||||
|
discovery.data?.map((short) => ({
|
||||||
|
...short,
|
||||||
|
detail:
|
||||||
|
sources.find(
|
||||||
|
(source) =>
|
||||||
|
source.input.uid === short.uid &&
|
||||||
|
source.input.kind === 'detail',
|
||||||
|
)?.instrument ?? null,
|
||||||
|
price:
|
||||||
|
sources.find(
|
||||||
|
(source) =>
|
||||||
|
source.input.uid === short.uid &&
|
||||||
|
source.input.kind === 'last-price',
|
||||||
|
)?.price ?? null,
|
||||||
|
})) ?? null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability:
|
||||||
|
discovery.data === null
|
||||||
|
? 'missing'
|
||||||
|
: sources.every((source) => source.meta.availability === 'complete')
|
||||||
|
? 'complete'
|
||||||
|
: 'partial',
|
||||||
|
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||||
|
? new Date(discovery.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: tbankSourceTtlMs.catalog,
|
||||||
|
work: discovery.meta.sync.id
|
||||||
|
? {
|
||||||
|
id: discovery.meta.sync.id,
|
||||||
|
state: discovery.meta.sync.state,
|
||||||
|
errorCode: discovery.meta.sync.errorCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
const sources = await this.enrichment.readMany(
|
||||||
|
userId,
|
||||||
|
instrumentType,
|
||||||
|
[uid],
|
||||||
|
observe,
|
||||||
|
['detail'],
|
||||||
|
);
|
||||||
|
const source = sources[0];
|
||||||
|
if (source.meta.availability === 'complete' && !source.instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const instrument = source.instrument;
|
||||||
|
let slug = uid;
|
||||||
|
const dependencies = [source.meta];
|
||||||
|
if (
|
||||||
|
instrument?.ticker &&
|
||||||
|
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker)
|
||||||
|
) {
|
||||||
|
const alias = await this.read(
|
||||||
|
userId,
|
||||||
|
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
if (alias.data?.length === 1 && alias.data[0].uid === uid)
|
||||||
|
slug = instrument.ticker.toUpperCase();
|
||||||
|
dependencies.push(alias.meta);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: instrument ? { ...instrument, uid, slug } : null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `${instrumentType}/detail/${uid}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability: source.meta.availability,
|
||||||
|
fetchedAt: source.meta.lastSuccessfulAt
|
||||||
|
? new Date(source.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
|
work: source.meta.sync.id
|
||||||
|
? {
|
||||||
|
id: source.meta.sync.id,
|
||||||
|
state: source.meta.sync.state,
|
||||||
|
errorCode: source.meta.sync.errorCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async byTicker(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
ticker: string,
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
const lookup = await this.read(
|
||||||
|
userId,
|
||||||
|
{ instrumentType, query: ticker, exactTicker: true },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
if (lookup.data?.length === 0) throw new NotFoundException();
|
||||||
|
if (lookup.data && lookup.data.length > 1)
|
||||||
|
throw new ConflictException({
|
||||||
|
code:
|
||||||
|
instrumentType === 'share'
|
||||||
|
? 'STOCK_TICKER_AMBIGUOUS'
|
||||||
|
: 'BOND_TICKER_AMBIGUOUS',
|
||||||
|
});
|
||||||
|
const result = lookup.data
|
||||||
|
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||||
|
: null;
|
||||||
|
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||||
|
return {
|
||||||
|
data: result?.data
|
||||||
|
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||||
|
: null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability: result?.meta.availability ?? 'missing',
|
||||||
|
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||||
|
? new Date(lookup.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
|
work: lookup.meta.sync.id
|
||||||
|
? {
|
||||||
|
id: lookup.meta.sync.id,
|
||||||
|
state: lookup.meta.sync.state,
|
||||||
|
errorCode: lookup.meta.sync.errorCode,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
query: CatalogQuery,
|
||||||
|
force = false,
|
||||||
|
) {
|
||||||
|
const input = catalogQuery(query);
|
||||||
|
const reserved = this.reservations.then(async () => {
|
||||||
|
const saved = await this.repository.read(input);
|
||||||
|
if (
|
||||||
|
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const reservation = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
input,
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
);
|
||||||
|
if (!reservation) return null;
|
||||||
|
const { id, attemptId } = reservation.work;
|
||||||
|
if (!reservation.launch) {
|
||||||
|
const current = this.producers.get(catalogScope(input));
|
||||||
|
if (current) current.force ||= force;
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
const attempt = { userId, revision, id, attemptId };
|
||||||
|
this.active.add(attemptId);
|
||||||
|
let resolve = () => {};
|
||||||
|
const done = new Promise<void>((finish) => {
|
||||||
|
resolve = finish;
|
||||||
|
});
|
||||||
|
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||||
|
const key = catalogScope(input);
|
||||||
|
const producer = this.producers.get(key);
|
||||||
|
if (producer) {
|
||||||
|
producer.observers.push(attempt);
|
||||||
|
producer.force ||= force;
|
||||||
|
} else {
|
||||||
|
const queued = { input, attempt, observers: [], force };
|
||||||
|
this.producers.set(key, queued);
|
||||||
|
this.queue.push(queued);
|
||||||
|
}
|
||||||
|
if (!this.running) {
|
||||||
|
this.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
this.reservations = reserved.catch(() => undefined);
|
||||||
|
return reserved;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
||||||
|
const id = await this.request(userId, revision, query, true);
|
||||||
|
if (id)
|
||||||
|
await Promise.all(
|
||||||
|
[...this.completions.values()]
|
||||||
|
.filter((entry) => entry.workId === id)
|
||||||
|
.map((entry) => entry.done),
|
||||||
|
);
|
||||||
|
const saved = await this.repository.read(catalogQuery(query));
|
||||||
|
const sources = await this.enrichment.readMany(
|
||||||
|
userId,
|
||||||
|
query.instrumentType,
|
||||||
|
saved.data?.map((item) => item.uid) ?? [],
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...(id ? [id] : []),
|
||||||
|
...sources.flatMap((source) =>
|
||||||
|
source.meta.sync.id ? [source.meta.sync.id] : [],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain() {
|
||||||
|
try {
|
||||||
|
while (this.queue.length) {
|
||||||
|
const producer = this.queue.shift();
|
||||||
|
if (!producer) break;
|
||||||
|
const { input, attempt } = producer;
|
||||||
|
let success = false;
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (token) {
|
||||||
|
const found =
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? await this.tbank.findShares(token, input.query)
|
||||||
|
: await this.tbank.findBonds(token, input.query);
|
||||||
|
success = await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
input,
|
||||||
|
selectCatalogInstruments(
|
||||||
|
input.exactTicker
|
||||||
|
? found.filter(
|
||||||
|
(item) =>
|
||||||
|
item.ticker?.trim().toUpperCase() === input.query,
|
||||||
|
)
|
||||||
|
: found,
|
||||||
|
input.query,
|
||||||
|
input.instrumentType,
|
||||||
|
).slice(
|
||||||
|
0,
|
||||||
|
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
|
||||||
|
),
|
||||||
|
new Date(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(attempt);
|
||||||
|
else
|
||||||
|
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (success) {
|
||||||
|
const saved = await this.repository.read(input);
|
||||||
|
for (const observer of [attempt, ...producer.observers])
|
||||||
|
if (!input.exactTicker || saved.data?.length === 1)
|
||||||
|
await this.enrichment.requestMany(
|
||||||
|
observer.userId,
|
||||||
|
observer.revision,
|
||||||
|
input.instrumentType,
|
||||||
|
saved.data?.map((item) => item.uid) ?? [],
|
||||||
|
producer.force,
|
||||||
|
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.producers.delete(catalogScope(input));
|
||||||
|
for (const observer of [attempt, ...producer.observers]) {
|
||||||
|
await this.repository.finish(observer, success);
|
||||||
|
this.active.delete(observer.attemptId);
|
||||||
|
this.completions.get(observer.attemptId)?.resolve();
|
||||||
|
this.completions.delete(observer.attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const completion of this.completions.values()) completion.resolve();
|
||||||
|
this.completions.clear();
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.logger.event('error', 'catalog-discovery.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,665 @@
|
|||||||
|
import type { TbankDividend } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||||
|
import {
|
||||||
|
decimalFromString,
|
||||||
|
divide,
|
||||||
|
formatDecimal,
|
||||||
|
} from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import {
|
||||||
|
MILLISECONDS_PER_DAY,
|
||||||
|
MILLISECONDS_PER_SECOND,
|
||||||
|
} from '../../../../common/time';
|
||||||
|
import { MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
|
import type {
|
||||||
|
TbankCandle,
|
||||||
|
TbankFundamentals,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankCoupon } from '../../../../integrations/tbank/tbank-market.types';
|
||||||
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
type TbankBond,
|
||||||
|
type TbankShare,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { TbankLastPrice } from '../../../../integrations/tbank/tbank-market.types';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
catalogScope,
|
||||||
|
type CatalogSource,
|
||||||
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
|
|
||||||
|
type Producer = {
|
||||||
|
input: CatalogSource;
|
||||||
|
attempt: TbankSyncAttempt;
|
||||||
|
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogEnrichmentService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly producers = new Map<string, Producer>();
|
||||||
|
private readonly queue: Producer[] = [];
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
private running = false;
|
||||||
|
private readonly completions = new Map<
|
||||||
|
string,
|
||||||
|
{ workId: string; done: Promise<void>; resolve: () => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
async ensureDetail(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uid: string,
|
||||||
|
) {
|
||||||
|
const ids = await this.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
instrumentType,
|
||||||
|
[uid],
|
||||||
|
false,
|
||||||
|
['detail'],
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
[...this.completions.values()]
|
||||||
|
.filter((entry) => ids.includes(entry.workId))
|
||||||
|
.map((entry) => entry.done),
|
||||||
|
);
|
||||||
|
const rows = await this.repository.readSources([
|
||||||
|
{ instrumentType, uid, kind: 'detail' },
|
||||||
|
]);
|
||||||
|
if (!rows.length) throw new Error('Instrument dependency missing');
|
||||||
|
return (rows[0].payload as CatalogPayload).instrument ?? null;
|
||||||
|
}
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly moex: MoexClient,
|
||||||
|
private readonly ratings: NcrRatingsClient,
|
||||||
|
private readonly candles: CandleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async readMany(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uids: string[],
|
||||||
|
observe: boolean,
|
||||||
|
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||||
|
range?: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ticker?: string;
|
||||||
|
tickers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const { revision } = await this.access.local(userId);
|
||||||
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
|
const rows = inputs.length ? await this.repository.readSources(inputs) : [];
|
||||||
|
if (!observe)
|
||||||
|
await this.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
instrumentType,
|
||||||
|
uids,
|
||||||
|
false,
|
||||||
|
kinds,
|
||||||
|
range,
|
||||||
|
);
|
||||||
|
const work = inputs.length
|
||||||
|
? await this.repository.workMany(userId, revision, inputs)
|
||||||
|
: [];
|
||||||
|
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
||||||
|
return inputs.map((input) => {
|
||||||
|
const row = rows.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||||
|
);
|
||||||
|
const payload = row?.payload as CatalogPayload | undefined;
|
||||||
|
return {
|
||||||
|
input,
|
||||||
|
version: row
|
||||||
|
? {
|
||||||
|
instrumentUid: row.instrumentUid,
|
||||||
|
source: row.source,
|
||||||
|
kind: row.kind,
|
||||||
|
rangeKey: row.rangeKey,
|
||||||
|
dataRevision: row.dataRevision,
|
||||||
|
fetchedAt: row.fetchedAt,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
payload,
|
||||||
|
instrument: payload?.instrument ?? null,
|
||||||
|
price: payload?.price ?? null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: catalogScope(input),
|
||||||
|
dataRevision: String(row?.dataRevision ?? 0),
|
||||||
|
availability: row ? 'complete' : 'missing',
|
||||||
|
fetchedAt: row?.fetchedAt ?? null,
|
||||||
|
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||||
|
work: byScope.get(catalogScope(input)) ?? null,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestMany(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uids: string[],
|
||||||
|
force = false,
|
||||||
|
kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
|
||||||
|
range?: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ticker?: string;
|
||||||
|
tickers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
|
const reservation = this.reservations.then(async () => {
|
||||||
|
const rows = inputs.length
|
||||||
|
? await this.repository.readSources(inputs)
|
||||||
|
: [];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const input of inputs) {
|
||||||
|
const row = rows.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!tbankSourceNeedsRefresh(
|
||||||
|
sourcePolicy(input.kind),
|
||||||
|
row?.fetchedAt ?? null,
|
||||||
|
new Date(),
|
||||||
|
force,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const reserved = await this.repository.reserve(
|
||||||
|
{ userId, revision },
|
||||||
|
input,
|
||||||
|
this.runId,
|
||||||
|
this.active,
|
||||||
|
);
|
||||||
|
if (!reserved) continue;
|
||||||
|
const { id, attemptId } = reserved.work;
|
||||||
|
ids.push(id);
|
||||||
|
if (!reserved.launch) continue;
|
||||||
|
const attempt = { userId, revision, id, attemptId };
|
||||||
|
this.active.add(attemptId);
|
||||||
|
let resolve = () => {};
|
||||||
|
const done = new Promise<void>((finish) => {
|
||||||
|
resolve = finish;
|
||||||
|
});
|
||||||
|
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||||
|
const key = producerKey(input);
|
||||||
|
const producer = this.producers.get(key);
|
||||||
|
if (producer) producer.observers.push({ attempt, input });
|
||||||
|
else {
|
||||||
|
const queued = { input, attempt, observers: [] };
|
||||||
|
this.producers.set(key, queued);
|
||||||
|
this.queue.push(queued);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (this.queue.length && !this.running) {
|
||||||
|
this.running = true;
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
});
|
||||||
|
this.reservations = reservation.catch(() => undefined);
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async section(
|
||||||
|
userId: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uid: string,
|
||||||
|
section: 'quote' | 'company' | 'metrics',
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
const kinds: CatalogSource['kind'][] =
|
||||||
|
section === 'company'
|
||||||
|
? ['detail', 'description', 'fundamentals']
|
||||||
|
: [
|
||||||
|
...(instrumentType === 'bond'
|
||||||
|
? ['bond-current' as const]
|
||||||
|
: ['detail' as const]),
|
||||||
|
'last-price',
|
||||||
|
'trading-status',
|
||||||
|
'comparison',
|
||||||
|
...(section === 'metrics'
|
||||||
|
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const sources = await this.readMany(
|
||||||
|
userId,
|
||||||
|
instrumentType,
|
||||||
|
[uid],
|
||||||
|
observe,
|
||||||
|
kinds,
|
||||||
|
);
|
||||||
|
const first = sources[0];
|
||||||
|
if (first.meta.availability === 'complete' && !first.instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const values = sources.reduce<CatalogPayload>(
|
||||||
|
(result, source) => ({ ...result, ...source.payload }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const dependencies = sources.map((source) => source.meta);
|
||||||
|
const complete = dependencies.every(
|
||||||
|
(dependency) => dependency.availability === 'complete',
|
||||||
|
);
|
||||||
|
const meta = investmentResourceMeta({
|
||||||
|
resourceKey: `${instrumentType}/${section}/${uid}`,
|
||||||
|
dataRevision: dependencies
|
||||||
|
.map((dependency) => dependency.dataRevision)
|
||||||
|
.join('/'),
|
||||||
|
availability: !first.instrument
|
||||||
|
? 'missing'
|
||||||
|
: complete
|
||||||
|
? 'complete'
|
||||||
|
: 'partial',
|
||||||
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
|
: null,
|
||||||
|
freshForMs:
|
||||||
|
section === 'company'
|
||||||
|
? tbankSourceTtlMs.company
|
||||||
|
: tbankSourceTtlMs.quote,
|
||||||
|
work: null,
|
||||||
|
dependencies,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
meta,
|
||||||
|
unavailable: sources
|
||||||
|
.filter(
|
||||||
|
(source) =>
|
||||||
|
source.meta.availability !== 'complete' ||
|
||||||
|
source.meta.sync.state === 'failed',
|
||||||
|
)
|
||||||
|
.map((source) => source.input.kind),
|
||||||
|
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async load(
|
||||||
|
input: CatalogSource,
|
||||||
|
token: string,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
): Promise<CatalogPayload> {
|
||||||
|
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||||
|
const instrument =
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? await this.tbank.getShare(token, input.uid)
|
||||||
|
: await this.tbank.getBond(token, input.uid);
|
||||||
|
if (instrument && instrument.uid !== input.uid)
|
||||||
|
throw new Error('Catalog UID mismatch');
|
||||||
|
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||||
|
return { instrument: instrument ? publicInstrument(instrument) : null };
|
||||||
|
}
|
||||||
|
if (input.kind === 'trading-status')
|
||||||
|
return {
|
||||||
|
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
||||||
|
};
|
||||||
|
if (input.kind === 'comparison') {
|
||||||
|
const prices = await this.repository.readSources([
|
||||||
|
{ ...input, kind: 'last-price', rangeKey: undefined },
|
||||||
|
]);
|
||||||
|
if (!prices.length) throw new Error('Price dependency missing');
|
||||||
|
const price = (prices[0].payload as CatalogPayload).price;
|
||||||
|
const priceAt = timestamp(price?.time);
|
||||||
|
if (!priceAt) return { candle: null };
|
||||||
|
const now = new Date();
|
||||||
|
const range = {
|
||||||
|
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
|
||||||
|
to: new Date(priceAt),
|
||||||
|
};
|
||||||
|
const saved = await this.candles.read(input.uid, range);
|
||||||
|
const gaps = candleGaps(range, saved.coverage, now);
|
||||||
|
for (const gap of gaps) {
|
||||||
|
const response = await this.tbank.getCandles(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
gap.from,
|
||||||
|
gap.to,
|
||||||
|
);
|
||||||
|
const rows = response.map((candle) => {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close)
|
||||||
|
throw new Error('Invalid candle response');
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (!(await this.candles.stage(attempt, gap, rows, now)))
|
||||||
|
throw new Error('Cancelled candle comparison');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gaps.length &&
|
||||||
|
!(await this.candles.publish(
|
||||||
|
attempt,
|
||||||
|
{ uid: input.uid, ranges: gaps },
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
throw new Error('Cancelled candle comparison');
|
||||||
|
const values = (await this.candles.read(input.uid, range)).data.map(
|
||||||
|
(row) => {
|
||||||
|
const value = divide(
|
||||||
|
decimalFromString(row.close),
|
||||||
|
decimalFromString('1'),
|
||||||
|
quotationScale,
|
||||||
|
).value;
|
||||||
|
return {
|
||||||
|
time: {
|
||||||
|
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
|
||||||
|
},
|
||||||
|
close: {
|
||||||
|
units: String(value / quotationUnits),
|
||||||
|
nano: Number(value % quotationUnits),
|
||||||
|
},
|
||||||
|
isComplete: row.isComplete,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { candle: previousClose(values, priceAt) };
|
||||||
|
}
|
||||||
|
const details = await this.repository.readSources([
|
||||||
|
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||||
|
...(input.instrumentType === 'bond'
|
||||||
|
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
const instrument = details
|
||||||
|
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||||
|
.find(Boolean);
|
||||||
|
if (
|
||||||
|
!instrument &&
|
||||||
|
!['dividends', 'coupons', 'bond-events'].includes(input.kind)
|
||||||
|
)
|
||||||
|
throw new Error('Instrument dependency missing');
|
||||||
|
if (input.kind === 'description')
|
||||||
|
return {
|
||||||
|
description: instrument?.assetUid
|
||||||
|
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (input.kind === 'fundamentals')
|
||||||
|
return {
|
||||||
|
fundamentals: instrument?.assetUid
|
||||||
|
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||||
|
const year = Number(input.rangeKey);
|
||||||
|
const [from, to] = input.rangeKey?.startsWith('[')
|
||||||
|
? (JSON.parse(input.rangeKey) as [string, string])
|
||||||
|
: [
|
||||||
|
`${year - payoutHistoryYears}-01-01`,
|
||||||
|
`${year + payoutFutureYears}-01-01`,
|
||||||
|
];
|
||||||
|
return input.kind === 'coupons'
|
||||||
|
? {
|
||||||
|
coupons: await this.tbank.getBondCoupons(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
dividends: await this.tbank.getDividends(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'bond-events') {
|
||||||
|
const ticker = instrument?.ticker || input.ticker;
|
||||||
|
if (!ticker) throw new Error('Bond ticker missing');
|
||||||
|
return { bondEvents: await this.moex.bondEvents(ticker) };
|
||||||
|
}
|
||||||
|
if (input.kind === 'yield')
|
||||||
|
return {
|
||||||
|
effectiveYield:
|
||||||
|
instrument?.ticker && instrument.isin && instrument.classCode
|
||||||
|
? await this.moex.bondYield(
|
||||||
|
instrument.ticker,
|
||||||
|
instrument.isin,
|
||||||
|
instrument.classCode,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
if (input.kind === 'rating')
|
||||||
|
return {
|
||||||
|
creditRating: instrument?.isin
|
||||||
|
? await this.ratings.rating(instrument.isin)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
throw new Error('Unsupported catalog source');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain() {
|
||||||
|
try {
|
||||||
|
while (this.queue.length) {
|
||||||
|
const producer = this.queue.shift();
|
||||||
|
if (!producer) break;
|
||||||
|
const batch = [producer];
|
||||||
|
// One batch RPC for queued prices sharing the same authorized producer.
|
||||||
|
if (producer.input.kind === 'last-price') {
|
||||||
|
for (
|
||||||
|
let i = 0;
|
||||||
|
i < this.queue.length && batch.length < catalogSearchLimit;
|
||||||
|
) {
|
||||||
|
const next = this.queue[i];
|
||||||
|
if (
|
||||||
|
next.input.kind === 'last-price' &&
|
||||||
|
next.attempt.userId === producer.attempt.userId &&
|
||||||
|
next.attempt.revision === producer.attempt.revision
|
||||||
|
) {
|
||||||
|
batch.push(next);
|
||||||
|
this.queue.splice(i, 1);
|
||||||
|
} else i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let prices: TbankLastPrice[] | undefined;
|
||||||
|
let payload: CatalogPayload | undefined;
|
||||||
|
let failed = false;
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(producer.attempt);
|
||||||
|
if (!token) failed = true;
|
||||||
|
else if (producer.input.kind === 'last-price')
|
||||||
|
prices = await this.tbank.getLastPrices(
|
||||||
|
token,
|
||||||
|
batch.map((item) => item.input.uid),
|
||||||
|
);
|
||||||
|
else
|
||||||
|
payload = await this.load(producer.input, token, producer.attempt);
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||||
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
|
else
|
||||||
|
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const item of batch) {
|
||||||
|
const price =
|
||||||
|
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
||||||
|
null;
|
||||||
|
const value =
|
||||||
|
item.input.kind === 'last-price'
|
||||||
|
? {
|
||||||
|
price: price
|
||||||
|
? {
|
||||||
|
instrumentUid: item.input.uid,
|
||||||
|
price: price.price,
|
||||||
|
time: price.time,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
: payload;
|
||||||
|
// Keep accepting observers while publication awaits SQLite.
|
||||||
|
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
||||||
|
for (const observer of item.observers) {
|
||||||
|
const success =
|
||||||
|
!failed &&
|
||||||
|
value !== undefined &&
|
||||||
|
(await this.repository.publishSource(
|
||||||
|
observer.attempt,
|
||||||
|
observer.input,
|
||||||
|
value,
|
||||||
|
new Date(),
|
||||||
|
producer.attempt,
|
||||||
|
));
|
||||||
|
await this.repository.finish(observer.attempt, success);
|
||||||
|
this.active.delete(observer.attempt.attemptId);
|
||||||
|
this.completions.get(observer.attempt.attemptId)?.resolve();
|
||||||
|
this.completions.delete(observer.attempt.attemptId);
|
||||||
|
}
|
||||||
|
this.producers.delete(producerKey(item.input));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
for (const completion of this.completions.values()) completion.resolve();
|
||||||
|
this.completions.clear();
|
||||||
|
this.active.clear();
|
||||||
|
this.producers.clear();
|
||||||
|
this.queue.length = 0;
|
||||||
|
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function sourceInputs(
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
uids: string[],
|
||||||
|
kinds: CatalogSource['kind'][],
|
||||||
|
range?: {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ticker?: string;
|
||||||
|
tickers?: Record<string, string>;
|
||||||
|
},
|
||||||
|
): CatalogSource[] {
|
||||||
|
return kinds.flatMap((kind) =>
|
||||||
|
[...new Set(uids)].map((uid) => ({
|
||||||
|
instrumentType,
|
||||||
|
uid,
|
||||||
|
kind,
|
||||||
|
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
|
||||||
|
? { ticker: range?.tickers?.[uid] || range?.ticker }
|
||||||
|
: {}),
|
||||||
|
...(['coupons', 'dividends'].includes(kind)
|
||||||
|
? {
|
||||||
|
rangeKey: range
|
||||||
|
? JSON.stringify([range.from, range.to])
|
||||||
|
: String(new Date().getUTCFullYear()),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function publicInstrument(value: TbankShare | TbankBond) {
|
||||||
|
const bond = value as TbankBond;
|
||||||
|
return {
|
||||||
|
uid: value.uid,
|
||||||
|
figi: value.figi,
|
||||||
|
assetUid: value.assetUid,
|
||||||
|
name: value.name,
|
||||||
|
ticker: value.ticker,
|
||||||
|
isin: value.isin,
|
||||||
|
classCode: value.classCode,
|
||||||
|
realExchange: value.realExchange,
|
||||||
|
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||||
|
currency: value.currency,
|
||||||
|
shareType: (value as TbankShare).shareType,
|
||||||
|
sector: value.sector,
|
||||||
|
lot: value.lot,
|
||||||
|
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||||
|
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||||
|
nominal: bond.nominal,
|
||||||
|
initialNominal: bond.initialNominal,
|
||||||
|
aciValue: bond.aciValue,
|
||||||
|
maturityDate: bond.maturityDate,
|
||||||
|
callDate: bond.callDate,
|
||||||
|
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||||
|
floatingCouponFlag: bond.floatingCouponFlag,
|
||||||
|
perpetualFlag: bond.perpetualFlag,
|
||||||
|
amortizationFlag: bond.amortizationFlag,
|
||||||
|
subordinatedFlag: bond.subordinatedFlag,
|
||||||
|
bondType: bond.bondType,
|
||||||
|
riskLevel: bond.riskLevel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCloseLookbackDays = 20;
|
||||||
|
const previousCloseLookbackMs =
|
||||||
|
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||||
|
const payoutHistoryYears = 5;
|
||||||
|
const payoutFutureYears = 2;
|
||||||
|
export type CatalogPayload = {
|
||||||
|
instrument?: TbankShare | TbankBond | null;
|
||||||
|
price?: TbankLastPrice | null;
|
||||||
|
candle?: TbankCandle | null;
|
||||||
|
tradingStatus?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
fundamentals?: TbankFundamentals | null;
|
||||||
|
coupons?: TbankCoupon[];
|
||||||
|
dividends?: TbankDividend[];
|
||||||
|
bondEvents?: MoexBondEvent[];
|
||||||
|
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||||
|
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||||
|
};
|
||||||
|
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||||
|
if (
|
||||||
|
[
|
||||||
|
'last-price',
|
||||||
|
'bond-current',
|
||||||
|
'trading-status',
|
||||||
|
'comparison',
|
||||||
|
'yield',
|
||||||
|
].includes(kind)
|
||||||
|
)
|
||||||
|
return 'quote';
|
||||||
|
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||||
|
if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar';
|
||||||
|
return 'metadata';
|
||||||
|
}
|
||||||
|
function producerKey(input: CatalogSource) {
|
||||||
|
return input.instrumentType === 'bond' &&
|
||||||
|
(input.kind === 'detail' || input.kind === 'bond-current')
|
||||||
|
? `bond-metadata:${input.uid}`
|
||||||
|
: catalogScope(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotationScale = 9;
|
||||||
|
const quotationUnits = 1_000_000_000n;
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
export const catalogSearchLimit = 30;
|
||||||
|
|
||||||
|
export function selectCatalogInstruments<T extends TbankShare>(
|
||||||
|
found: T[],
|
||||||
|
query: string,
|
||||||
|
instrumentType: 'share' | 'bond',
|
||||||
|
): (T & { uid: string })[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const kind =
|
||||||
|
instrumentType === 'share'
|
||||||
|
? 'INSTRUMENT_TYPE_SHARE'
|
||||||
|
: 'INSTRUMENT_TYPE_BOND';
|
||||||
|
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||||
|
return found
|
||||||
|
.filter(
|
||||||
|
(instrument): instrument is T & { uid: string } =>
|
||||||
|
Boolean(instrument.uid?.trim()) && instrument.instrumentKind === kind,
|
||||||
|
)
|
||||||
|
.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 === preferredClass) -
|
||||||
|
Number(a.classCode === preferredClass) ||
|
||||||
|
a.uid.localeCompare(b.uid),
|
||||||
|
)
|
||||||
|
.filter((instrument) => {
|
||||||
|
const key = instrument.isin?.trim()
|
||||||
|
? `isin:${instrument.isin.trim().toUpperCase()}`
|
||||||
|
: `uid:${instrument.uid}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,205 +1,85 @@
|
|||||||
import {
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
ConflictException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { LRUCache } from 'lru-cache';
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
import { formatDecimal } from '../../../../../common/decimal';
|
||||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
|
||||||
TbankAccessService,
|
|
||||||
TbankClient,
|
|
||||||
type TbankConnectionContext,
|
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { optional, timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import { BondDto, BondSearchResultDto } from '../presentation/bond.dto';
|
import {
|
||||||
|
BondDto,
|
||||||
|
BondResourceDto,
|
||||||
|
BondSearchResourceDto,
|
||||||
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
type IdentifiedBond = TbankBond & { uid: string };
|
type IdentifiedBond = TbankBond & { uid: string };
|
||||||
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
|
|
||||||
const SEARCH_RESULTS_LIMIT = 30;
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondCatalogService {
|
export class BondCatalogService {
|
||||||
private readonly cache = new LRUCache<string, Promise<object>>({
|
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||||
max: 300,
|
async search(
|
||||||
ttl: 30_000,
|
userId: string,
|
||||||
});
|
query: string,
|
||||||
constructor(
|
observe = false,
|
||||||
private readonly access: TbankAccessService,
|
): Promise<BondSearchResourceDto> {
|
||||||
private readonly tbank: TbankClient,
|
const result = await this.discovery.search(
|
||||||
) {}
|
userId,
|
||||||
search(userId: string, query: string): Promise<BondSearchResultDto[]> {
|
{ instrumentType: 'bond', query },
|
||||||
return this.cached(userId, `search:${query}`, async (connection) => {
|
observe,
|
||||||
const { token } = connection;
|
|
||||||
const found = await this.tbank.findBonds(token, query);
|
|
||||||
const bonds = selectBonds(found, query).slice(0, SEARCH_RESULTS_LIMIT);
|
|
||||||
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 {
|
||||||
|
...result,
|
||||||
|
data:
|
||||||
|
result.data?.map((item) => {
|
||||||
|
const short = toBond(item);
|
||||||
|
const detail = item.detail
|
||||||
|
? toBond({ ...item.detail, uid: item.uid })
|
||||||
|
: short;
|
||||||
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
classCode: detail.classCode ?? short.classCode,
|
classCode: detail.classCode ?? short.classCode,
|
||||||
isin: detail.isin ?? short.isin,
|
isin: detail.isin ?? short.isin,
|
||||||
apiTradeAvailable: short.apiTradeAvailable,
|
apiTradeAvailable: short.apiTradeAvailable,
|
||||||
pricePercent: last?.price ? formatDecimal(decimal(last.price)) : null,
|
pricePercent: last?.price
|
||||||
|
? formatDecimal(decimal(last.price))
|
||||||
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
});
|
}) ?? null,
|
||||||
});
|
|
||||||
}
|
|
||||||
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<IdentifiedBond> {
|
async get(
|
||||||
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];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
bond(
|
|
||||||
userId: string,
|
userId: string,
|
||||||
connection: TbankConnectionContext,
|
|
||||||
uid: string,
|
uid: string,
|
||||||
): Promise<IdentifiedBond> {
|
observe = false,
|
||||||
return this.cachedForConnection(
|
): Promise<BondResourceDto> {
|
||||||
|
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data
|
||||||
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async getByTicker(
|
||||||
|
userId: string,
|
||||||
|
ticker: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<BondResourceDto> {
|
||||||
|
const result = await this.discovery.byTicker(
|
||||||
userId,
|
userId,
|
||||||
connection,
|
'bond',
|
||||||
`metadata:${uid}`,
|
ticker,
|
||||||
async () => {
|
observe,
|
||||||
const bond = await this.tbank.getBond(connection.token, uid);
|
|
||||||
if (!bond || bond.uid !== uid) throw new NotFoundException();
|
|
||||||
return { ...bond, uid };
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data
|
||||||
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
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): IdentifiedBond[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return found
|
|
||||||
.filter(
|
|
||||||
(bond): bond is IdentifiedBond =>
|
|
||||||
Boolean(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: IdentifiedBond): BondDto {
|
function toBond(bond: IdentifiedBond): BondDto {
|
||||||
|
|||||||
@@ -1,80 +1,207 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
|
divide,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { MoexClient } from '../../../../../integrations/moex/moex.api';
|
|
||||||
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr.api';
|
|
||||||
import {
|
import {
|
||||||
TbankClient,
|
TbankAccessService,
|
||||||
type TbankConnectionContext,
|
type TbankCoupon,
|
||||||
|
type TbankBond,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import { PortfolioService } from '../../../portfolio/portfolio.api';
|
|
||||||
import {
|
import {
|
||||||
moscowDay,
|
PortfolioService,
|
||||||
optional,
|
ForecastService,
|
||||||
timestamp,
|
} from '../../../portfolio/portfolio.api';
|
||||||
} from '../../application/instrument-market';
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
import {
|
import {
|
||||||
BondCouponsDto,
|
BondCouponsDto,
|
||||||
BondMetricsDto,
|
BondCouponsResourceDto,
|
||||||
BondPositionDto,
|
BondPositionDto,
|
||||||
|
BondMetricsDto,
|
||||||
|
BondMetricsResourceDto,
|
||||||
} from '../presentation/bond.dto';
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
import { BondMarketService } from './bond-market.service';
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
import { BondCatalogService } from './bond-catalog.service';
|
import { bondQuote } from './bond-market.service';
|
||||||
|
|
||||||
const ISO_YEAR_LENGTH = 4;
|
const ISO_YEAR_LENGTH = 4;
|
||||||
const INCOME_HISTORY_YEARS = 5;
|
const INCOME_HISTORY_YEARS = 5;
|
||||||
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
const COUPON_YIELD_DECIMAL_PLACES = 4;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondIncomeService {
|
export class BondIncomeService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: BondCatalogService,
|
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
private readonly portfolios: PortfolioService,
|
private readonly portfolios: PortfolioService,
|
||||||
private readonly market: BondMarketService,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
private readonly moex: MoexClient,
|
private readonly access: TbankAccessService,
|
||||||
private readonly ratings: NcrRatingsClient,
|
private readonly forecasts: ForecastService,
|
||||||
) {}
|
) {}
|
||||||
metrics(userId: string, uid: string): Promise<BondMetricsDto> {
|
async metrics(
|
||||||
return this.catalog.cached(userId, `metrics:${uid}`, async (connection) => {
|
userId: string,
|
||||||
const instrument = await this.catalog.bond(userId, connection, uid);
|
uid: string,
|
||||||
const { ticker, isin, classCode } = instrument;
|
observe = false,
|
||||||
|
): Promise<BondMetricsResourceDto> {
|
||||||
|
const result = await this.sources.section(
|
||||||
|
userId,
|
||||||
|
'bond',
|
||||||
|
uid,
|
||||||
|
'metrics',
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
data: result.values.instrument
|
||||||
|
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||||
|
: null,
|
||||||
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async readPosition(userId: string, uid: string, observe = false) {
|
||||||
|
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||||
|
return {
|
||||||
|
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
||||||
|
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async coupons(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<BondCouponsResourceDto> {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const unavailable: string[] = [];
|
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||||
const [events, quote, effectiveYield, creditRating] = await Promise.all([
|
const sources = await this.sources.readMany(
|
||||||
optional(
|
userId,
|
||||||
() => this.couponEvents(userId, connection, uid, from, to),
|
'bond',
|
||||||
'coupons',
|
[uid],
|
||||||
unavailable,
|
observe,
|
||||||
),
|
['detail', 'coupons'],
|
||||||
optional(() => this.market.quote(userId, uid), 'quote', unavailable),
|
{ from, to },
|
||||||
ticker && isin && classCode
|
);
|
||||||
? optional(
|
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||||
() => this.moex.bondYield(ticker, isin, classCode),
|
throw new NotFoundException();
|
||||||
'yield',
|
const events = sources[1].payload?.coupons;
|
||||||
unavailable,
|
const position = portfolio.data
|
||||||
|
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||||
|
: null;
|
||||||
|
const projected = events
|
||||||
|
? bondCoupons(
|
||||||
|
events,
|
||||||
|
position,
|
||||||
|
{ from, to, today },
|
||||||
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
|
!portfolio.data,
|
||||||
)
|
)
|
||||||
|
: undefined;
|
||||||
|
const result = await this.forecasts.result({
|
||||||
|
userId,
|
||||||
|
revision: connection.revision,
|
||||||
|
membershipRevision: connection.membershipRevision,
|
||||||
|
scopeKey: `bond/coupons/${JSON.stringify([uid, from, to])}`,
|
||||||
|
portfolio: portfolio.meta,
|
||||||
|
sources,
|
||||||
|
candidate:
|
||||||
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
|
});
|
||||||
|
return result.data || !projected
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
data: bondCoupons(
|
||||||
|
events ?? [],
|
||||||
|
null,
|
||||||
|
{ from, to, today },
|
||||||
|
projected.observedAt,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
meta: { ...result.meta, availability: 'partial' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bondCoupons(
|
||||||
|
events: TbankCoupon[],
|
||||||
|
position: BondPositionDto | null,
|
||||||
|
window: { from: string; to: string; today: string },
|
||||||
|
observedAt: string,
|
||||||
|
positionUnavailable: boolean,
|
||||||
|
): BondCouponsDto {
|
||||||
|
const { from, to, today } = window;
|
||||||
|
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,
|
: null,
|
||||||
isin
|
};
|
||||||
? optional(() => this.ratings.rating(isin), 'rating', unavailable)
|
})
|
||||||
: null,
|
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
||||||
]);
|
return {
|
||||||
|
coupons,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
observedAt: observedAt,
|
||||||
|
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
||||||
|
estimateUnavailable: positionUnavailable,
|
||||||
|
positionIsStale: position?.isStale ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bondMetrics(
|
||||||
|
values: CatalogPayload,
|
||||||
|
unavailable: string[],
|
||||||
|
observedAt: string,
|
||||||
|
): BondMetricsDto {
|
||||||
|
const instrument = values.instrument as TbankBond;
|
||||||
|
const events = values.coupons;
|
||||||
|
const quote = bondQuote(values, unavailable, observedAt);
|
||||||
|
const effectiveYield = values.effectiveYield ?? null;
|
||||||
|
const creditRating = values.creditRating ?? null;
|
||||||
|
const today = moscowDay(new Date().toISOString());
|
||||||
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const next = events
|
const next = events
|
||||||
?.filter((event) => {
|
?.filter((event) => {
|
||||||
const date = timestamp(event.couponDate)?.slice(0, 10);
|
const date = timestamp(event.couponDate)?.slice(0, 10);
|
||||||
return date && date >= today;
|
return date && date >= today && date <= to;
|
||||||
})
|
})
|
||||||
.sort(
|
.sort(
|
||||||
(a, b) =>
|
(a, b) => Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
|
||||||
Number(a.couponDate?.seconds) - Number(b.couponDate?.seconds),
|
|
||||||
)[0];
|
)[0];
|
||||||
const coupon = money(next?.payOneBond);
|
const coupon = money(next?.payOneBond);
|
||||||
const period = next?.couponPeriod;
|
const period = next?.couponPeriod;
|
||||||
@@ -119,90 +246,6 @@ export class BondIncomeService {
|
|||||||
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
|
couponYieldPercent !== null ? (quote?.priceAt ?? null) : null,
|
||||||
effectiveYield,
|
effectiveYield,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
private couponEvents(
|
|
||||||
userId: string,
|
|
||||||
connection: TbankConnectionContext,
|
|
||||||
uid: string,
|
|
||||||
from: string,
|
|
||||||
to: string,
|
|
||||||
) {
|
|
||||||
return this.catalog.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.catalog.cached(userId, `coupons:${uid}`, async (connection) => {
|
|
||||||
await this.catalog.bond(userId, connection, uid);
|
|
||||||
const today = moscowDay(new Date().toISOString());
|
|
||||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
|
||||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async position(userId: string, uid: string): Promise<BondPositionDto> {
|
|
||||||
return this.catalog.cached(userId, `position:${uid}`, async () =>
|
|
||||||
instrumentPosition(await this.portfolios.getAll(userId), uid, 'BOND'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { CandleService } from '../../application/candle.service';
|
||||||
|
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
@@ -7,58 +13,67 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
optional,
|
|
||||||
previousClose,
|
|
||||||
timestamp,
|
|
||||||
} from '../../application/instrument-market';
|
|
||||||
import {
|
|
||||||
BondCandlesDto,
|
|
||||||
BondCandlesQueryDto,
|
BondCandlesQueryDto,
|
||||||
BondQuoteDto,
|
BondQuoteDto,
|
||||||
|
BondQuoteResourceDto,
|
||||||
} from '../presentation/bond.dto';
|
} from '../presentation/bond.dto';
|
||||||
|
|
||||||
const dayMs = 86_400_000;
|
|
||||||
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
import { BondCatalogService } from './bond-catalog.service';
|
|
||||||
|
|
||||||
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
|
|
||||||
const HALF_YEAR_MONTHS = 6;
|
|
||||||
const YEAR_MONTHS = 12;
|
|
||||||
const CANDLE_WINDOW_DAYS = 365;
|
|
||||||
const PRICE_DECIMAL_PLACES = 9;
|
const PRICE_DECIMAL_PLACES = 9;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BondMarketService {
|
export class BondMarketService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: BondCatalogService,
|
private readonly history: CandleService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
quote(userId: string, uid: string): Promise<BondQuoteDto> {
|
async quote(
|
||||||
return this.catalog.cached(userId, `quote:${uid}`, async (connection) => {
|
userId: string,
|
||||||
const { token } = connection;
|
uid: string,
|
||||||
const instrument = await this.catalog.bond(userId, connection, uid);
|
observe = false,
|
||||||
const unavailable: string[] = [];
|
): Promise<BondQuoteResourceDto> {
|
||||||
const [last, tradingStatus] = await Promise.all([
|
const result = await this.sources.section(
|
||||||
optional(
|
userId,
|
||||||
() => this.tbank.getLastPrice(token, uid),
|
'bond',
|
||||||
'price',
|
uid,
|
||||||
unavailable,
|
'quote',
|
||||||
),
|
observe,
|
||||||
optional(
|
);
|
||||||
() => this.tbank.getTradingStatus(token, uid),
|
return {
|
||||||
'status',
|
data: result.values.instrument
|
||||||
unavailable,
|
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||||
),
|
: null,
|
||||||
]);
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
candles(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
period: BondCandlesQueryDto['period'] = 'month',
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
return this.history.read(
|
||||||
|
userId,
|
||||||
|
{ uid, instrumentType: 'bond', period },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bondQuote(
|
||||||
|
values: CatalogPayload,
|
||||||
|
unavailable: string[],
|
||||||
|
observedAt: string,
|
||||||
|
): BondQuoteDto {
|
||||||
|
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||||
|
const instrument = values.instrument as TbankBond | undefined;
|
||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const pricePercent = last?.price
|
const pricePercent = last?.price ? formatDecimal(decimal(last.price)) : null;
|
||||||
? formatDecimal(decimal(last.price))
|
const nominal = money(instrument?.nominal);
|
||||||
: null;
|
const aci = money(instrument?.aciValue);
|
||||||
const nominal = money(instrument.nominal);
|
|
||||||
const aci = money(instrument.aciValue);
|
|
||||||
const cleanPrice =
|
const cleanPrice =
|
||||||
pricePercent !== null &&
|
pricePercent !== null &&
|
||||||
nominal &&
|
nominal &&
|
||||||
@@ -89,24 +104,6 @@ export class BondMarketService {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const previous = priceAt
|
|
||||||
? await optional(
|
|
||||||
async () =>
|
|
||||||
previousClose(
|
|
||||||
await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
uid,
|
|
||||||
new Date(
|
|
||||||
Date.parse(priceAt) - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
|
|
||||||
),
|
|
||||||
new Date(priceAt),
|
|
||||||
),
|
|
||||||
priceAt,
|
|
||||||
),
|
|
||||||
'comparison',
|
|
||||||
unavailable,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
pricePercent,
|
pricePercent,
|
||||||
cleanPrice,
|
cleanPrice,
|
||||||
@@ -116,83 +113,10 @@ export class BondMarketService {
|
|||||||
tradingStatus,
|
tradingStatus,
|
||||||
changePercentagePoints:
|
changePercentagePoints:
|
||||||
last?.price && previous?.close
|
last?.price && previous?.close
|
||||||
? formatDecimal(
|
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
|
||||||
subtract(decimal(last.price), decimal(previous.close)),
|
|
||||||
)
|
|
||||||
: null,
|
: null,
|
||||||
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
candles(
|
|
||||||
userId: string,
|
|
||||||
uid: string,
|
|
||||||
period: BondCandlesQueryDto['period'] = 'month',
|
|
||||||
): Promise<BondCandlesDto> {
|
|
||||||
return this.catalog.cached(
|
|
||||||
userId,
|
|
||||||
`candles:${uid}:${period}`,
|
|
||||||
async (connection) => {
|
|
||||||
const { token } = connection;
|
|
||||||
const bond = await this.catalog.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'
|
|
||||||
? HALF_YEAR_MONTHS
|
|
||||||
: YEAR_MONTHS),
|
|
||||||
);
|
|
||||||
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 + CANDLE_WINDOW_DAYS * 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(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
import {
|
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||||
ConflictException,
|
import { bondQuote } from './bond-market.service';
|
||||||
NotFoundException,
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
ServiceUnavailableException,
|
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||||
UnauthorizedException,
|
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||||
} from '@nestjs/common';
|
PrismaService: class {},
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { MoexClient } from '../../../../../integrations/moex/moex.client';
|
|
||||||
import { NcrRatingsClient } from '../../../../../integrations/ncr/ncr-ratings.client';
|
|
||||||
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
|
||||||
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
|
|
||||||
import { BondIncomeService } from './bond-income.service';
|
|
||||||
import { BondMarketService } from './bond-market.service';
|
|
||||||
import { BondCatalogService } from './bond-catalog.service';
|
|
||||||
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
|
|
||||||
TbankAccessService: class TbankAccessService {},
|
|
||||||
}));
|
}));
|
||||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||||
const money = (units: string, currency = 'rub') => ({ units, currency });
|
const money = (units: string, currency = 'rub') => ({ units, currency });
|
||||||
const cacheClock = performance;
|
|
||||||
const bond = {
|
const bond = {
|
||||||
uid: 'uid',
|
uid: 'uid',
|
||||||
ticker: 'BOND',
|
ticker: 'BOND',
|
||||||
@@ -30,418 +18,22 @@ const bond = {
|
|||||||
amortizationFlag: true,
|
amortizationFlag: true,
|
||||||
first_1dayCandleDate: time('2024-01-01'),
|
first_1dayCandleDate: time('2024-01-01'),
|
||||||
};
|
};
|
||||||
function setup() {
|
describe('bond financial projections', () => {
|
||||||
let generation = 'a';
|
beforeEach(() =>
|
||||||
const tbank = {
|
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
|
||||||
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 BondCatalogService(
|
|
||||||
access as unknown as TbankAccessService,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
);
|
||||||
const market = new BondMarketService(
|
afterEach(() => jest.useRealTimers());
|
||||||
service,
|
it('estimates only future unfixed payouts from exact UID holdings', () => {
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
const income = new BondIncomeService(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
portfolios as unknown as PortfolioService,
|
|
||||||
market,
|
|
||||||
moex as unknown as MoexClient,
|
|
||||||
ratings as unknown as NcrRatingsClient,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
market,
|
|
||||||
income,
|
|
||||||
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 { tbank, moex, market, income } = 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([
|
|
||||||
income.metrics('user', 'uid'),
|
|
||||||
income.coupons('user', 'uid'),
|
|
||||||
market.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 { tbank, income } = 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 income.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 { tbank, moex, income } = setup();
|
|
||||||
tbank.getBond.mockResolvedValue({
|
|
||||||
...bond,
|
|
||||||
classCode: 'TQOB',
|
|
||||||
floatingCouponFlag: true,
|
|
||||||
riskLevel: 'RISK_LEVEL_LOW',
|
|
||||||
});
|
|
||||||
moex.bondYield.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await income.metrics('user', 'uid')).toMatchObject({
|
|
||||||
effectiveYield: null,
|
|
||||||
coupon: null,
|
|
||||||
couponType: 'COUPON_TYPE_FLOATING',
|
|
||||||
couponYieldPercent: null,
|
|
||||||
unavailable: ['yield'],
|
|
||||||
});
|
|
||||||
tbank.getBondCoupons.mockRejectedValue(new UnauthorizedException());
|
|
||||||
await expect(income.metrics('other', 'uid')).rejects.toBeInstanceOf(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('returns only the rating of the exact issue and isolates a registry outage', async () => {
|
|
||||||
const { ratings, income } = 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 income.metrics('user', 'uid')).toMatchObject({
|
|
||||||
creditRating,
|
|
||||||
});
|
|
||||||
expect(ratings.rating).toHaveBeenCalledWith('ISIN');
|
|
||||||
ratings.rating.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await income.metrics('other', 'uid')).toMatchObject({
|
|
||||||
creditRating: null,
|
|
||||||
unavailable: ['rating'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('opens concurrent sections within one upstream metadata request budget', async () => {
|
|
||||||
const { service, tbank, market, income } = setup();
|
|
||||||
tbank.getBond
|
|
||||||
.mockReset()
|
|
||||||
.mockResolvedValueOnce(bond)
|
|
||||||
.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
Promise.all([
|
|
||||||
service.getByTicker('user', 'BOND'),
|
|
||||||
market.quote('user', 'uid'),
|
|
||||||
market.candles('user', 'uid'),
|
|
||||||
income.coupons('user', 'uid'),
|
|
||||||
income.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, market, income } = 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([
|
|
||||||
market.quote('user', 'uid-0'),
|
|
||||||
market.candles('user', 'uid-0'),
|
|
||||||
income.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, market } = setup();
|
|
||||||
await service.get('user', 'uid');
|
|
||||||
await jest.advanceTimersByTimeAsync(30_001);
|
|
||||||
tbank.getBond.mockResolvedValue({
|
|
||||||
...bond,
|
|
||||||
nominal: money('400'),
|
|
||||||
aciValue: money('3'),
|
|
||||||
});
|
|
||||||
expect(await market.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 { tbank, changeGeneration, market } = setup();
|
|
||||||
let resolveOld: ((value: typeof bond) => void) | undefined;
|
|
||||||
tbank.getBond.mockReturnValueOnce(
|
|
||||||
new Promise((resolve) => {
|
|
||||||
resolveOld = resolve;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const oldQuote = market.quote('user', 'uid');
|
|
||||||
changeGeneration();
|
|
||||||
tbank.getBond.mockResolvedValue({ ...bond, nominal: money('400') });
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
cleanPrice: { value: '392.5' },
|
|
||||||
});
|
|
||||||
assert.ok(resolveOld);
|
|
||||||
resolveOld(bond);
|
|
||||||
await oldQuote;
|
|
||||||
expect(await market.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 { market } = setup();
|
|
||||||
expect(await market.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 { tbank, market } = setup();
|
|
||||||
tbank.getBond.mockResolvedValueOnce({ ...bond, aciValue: undefined });
|
|
||||||
expect(await market.quote('user', 'uid')).toMatchObject({
|
|
||||||
aci: null,
|
|
||||||
dirtyPrice: null,
|
|
||||||
});
|
|
||||||
tbank.getBond.mockResolvedValueOnce({
|
|
||||||
...bond,
|
|
||||||
aciValue: money('12', 'usd'),
|
|
||||||
});
|
|
||||||
expect(await market.quote('other', 'uid')).toMatchObject({
|
|
||||||
dirtyPrice: null,
|
|
||||||
});
|
|
||||||
tbank.getBond.mockResolvedValueOnce({ ...bond, nominal: undefined });
|
|
||||||
expect(await market.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' },
|
|
||||||
{ ...bond, uid: undefined },
|
|
||||||
{ ...bond, uid: '' },
|
|
||||||
];
|
|
||||||
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 { tbank, market } = setup();
|
|
||||||
const history = await market.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(market.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 { tbank, portfolios, income } = setup();
|
|
||||||
const holding = {
|
const holding = {
|
||||||
instrumentUid: 'uid',
|
instrumentUid: 'uid',
|
||||||
type: 'BOND',
|
type: 'BOND',
|
||||||
quantity: '2',
|
quantity: '2',
|
||||||
averagePurchasePrice: { value: '490', currency: 'RUB' },
|
averagePurchasePrice: null,
|
||||||
currentValue: { value: '981.25', currency: 'RUB' },
|
currentValue: { value: '981.25', currency: 'RUB' },
|
||||||
absoluteReturn: { value: '1.25', currency: 'RUB' },
|
absoluteReturn: null,
|
||||||
};
|
};
|
||||||
portfolios.getAll.mockResolvedValue({
|
const position = instrumentPosition(
|
||||||
|
{
|
||||||
positions: [
|
positions: [
|
||||||
holding,
|
holding,
|
||||||
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
{ ...holding, instrumentUid: 'other', quantity: '99' },
|
||||||
@@ -449,8 +41,12 @@ describe('bond market use case', () => {
|
|||||||
],
|
],
|
||||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||||
isStale: true,
|
isStale: true,
|
||||||
});
|
} as PortfolioDto,
|
||||||
tbank.getBondCoupons.mockResolvedValue([
|
'uid',
|
||||||
|
'BOND',
|
||||||
|
);
|
||||||
|
const result = bondCoupons(
|
||||||
|
[
|
||||||
{
|
{
|
||||||
couponNumber: '1',
|
couponNumber: '1',
|
||||||
couponDate: time('2026-10-01'),
|
couponDate: time('2026-10-01'),
|
||||||
@@ -473,26 +69,109 @@ describe('bond market use case', () => {
|
|||||||
couponDate: time('2026-10-01'),
|
couponDate: time('2026-10-01'),
|
||||||
fixDate: time('2026-09-29'),
|
fixDate: time('2026-09-29'),
|
||||||
},
|
},
|
||||||
{
|
],
|
||||||
couponNumber: '5',
|
position,
|
||||||
couponDate: time('2025-10-01'),
|
{ from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' },
|
||||||
payOneBond: money('10'),
|
'source-time',
|
||||||
},
|
false,
|
||||||
]);
|
);
|
||||||
const result = await income.coupons('user', 'uid');
|
expect(result.coupons[0].estimatedAmount).toEqual({
|
||||||
|
value: '20',
|
||||||
|
currency: 'RUB',
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
result.coupons.find((c) => c.number === '1')?.estimatedAmount,
|
result.coupons.slice(1).every((row) => row.estimatedAmount === null),
|
||||||
).toEqual({ value: '20', currency: 'RUB' });
|
|
||||||
expect(
|
|
||||||
result.coupons
|
|
||||||
.filter((c) => c.number !== '1')
|
|
||||||
.every((c) => c.estimatedAmount === null),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(result.coupons.find((c) => c.number === '4')?.perBond).toBeNull();
|
expect(result.coupons[3].perBond).toBeNull();
|
||||||
expect(result.positionIsStale).toBe(true);
|
expect(result.positionIsStale).toBe(true);
|
||||||
expect(await income.position('user', 'uid')).toMatchObject({
|
expect(position.quantity).toBe('2');
|
||||||
quantity: '2',
|
});
|
||||||
currentValue: { value: '981.25' },
|
it('preserves amortized nominal, ACI, percent quotes and annualized coupon yield', () => {
|
||||||
|
const values = {
|
||||||
|
instrument: bond,
|
||||||
|
price: { price: { units: '98', nano: 125000000 } },
|
||||||
|
candle: { close: { units: '99' } },
|
||||||
|
coupons: [
|
||||||
|
{
|
||||||
|
couponDate: time('2026-10-10'),
|
||||||
|
payOneBond: money('30'),
|
||||||
|
couponPeriod: 90,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(bondQuote(values, [], 'time')).toMatchObject({
|
||||||
|
pricePercent: '98.125',
|
||||||
|
cleanPrice: { currency: 'RUB', value: '490.625' },
|
||||||
|
dirtyPrice: { currency: 'RUB', value: '502.625' },
|
||||||
|
changePercentagePoints: '-0.875',
|
||||||
|
});
|
||||||
|
expect(bondMetrics(values, [], 'time')).toMatchObject({
|
||||||
|
couponDate: '2026-10-10',
|
||||||
|
couponYieldPercent: '24.7983',
|
||||||
|
creditRating: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it.each(['unknown', 'currency', 'period', 'zero-price'])(
|
||||||
|
'does not invent coupon yield with %s inputs',
|
||||||
|
(scenario) => {
|
||||||
|
const result = bondMetrics(
|
||||||
|
{
|
||||||
|
instrument: bond,
|
||||||
|
price: { price: { units: scenario === 'zero-price' ? '0' : '100' } },
|
||||||
|
coupons: [
|
||||||
|
{
|
||||||
|
couponDate: time('2026-10-10'),
|
||||||
|
payOneBond:
|
||||||
|
scenario === 'unknown'
|
||||||
|
? undefined
|
||||||
|
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||||
|
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
);
|
||||||
|
expect(result.couponYieldPercent).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it('keeps absent and mismatched ACI unknown and never substitutes risk level for a rating', () => {
|
||||||
|
expect(
|
||||||
|
bondQuote(
|
||||||
|
{
|
||||||
|
instrument: { ...bond, aciValue: money('1', 'usd') },
|
||||||
|
price: { price: { units: '100' } },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
).dirtyPrice,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
bondQuote(
|
||||||
|
{
|
||||||
|
instrument: { ...bond, aciValue: undefined },
|
||||||
|
price: { price: { units: '100' } },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
).dirtyPrice,
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
bondMetrics(
|
||||||
|
{
|
||||||
|
instrument: {
|
||||||
|
...bond,
|
||||||
|
riskLevel: 'RISK_LEVEL_LOW',
|
||||||
|
floatingCouponFlag: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
['yield'],
|
||||||
|
'time',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
creditRating: null,
|
||||||
|
couponType: 'COUPON_TYPE_FLOATING',
|
||||||
|
couponYieldPercent: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -14,15 +15,15 @@ import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
|||||||
import { BondIncomeService } from '../application/bond-income.service';
|
import { BondIncomeService } from '../application/bond-income.service';
|
||||||
import { BondMarketService } from '../application/bond-market.service';
|
import { BondMarketService } from '../application/bond-market.service';
|
||||||
import {
|
import {
|
||||||
BondCandlesDto,
|
BondCandlesResourceDto,
|
||||||
BondCandlesQueryDto,
|
BondCandlesQueryDto,
|
||||||
BondCouponsDto,
|
BondCouponsResourceDto,
|
||||||
BondDto,
|
BondResourceDto,
|
||||||
BondMetricsDto,
|
BondMetricsResourceDto,
|
||||||
BondPositionDto,
|
BondPositionResourceDto,
|
||||||
BondQuoteDto,
|
BondQuoteResourceDto,
|
||||||
BondSearchQueryDto,
|
BondSearchQueryDto,
|
||||||
BondSearchResultDto,
|
BondSearchResourceDto,
|
||||||
BondTickerParamsDto,
|
BondTickerParamsDto,
|
||||||
} from './bond.dto';
|
} from './bond.dto';
|
||||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||||
@@ -39,68 +40,91 @@ export class BondController {
|
|||||||
private readonly income: BondIncomeService,
|
private readonly income: BondIncomeService,
|
||||||
) {}
|
) {}
|
||||||
@Get(':uid/metrics')
|
@Get(':uid/metrics')
|
||||||
@ApiOkResponse({ type: BondMetricsDto })
|
@ApiOkResponse({ type: BondMetricsResourceDto })
|
||||||
metrics(
|
metrics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.metrics(request.user.sub, uid);
|
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: [BondSearchResultDto] })
|
@ApiOkResponse({ type: BondSearchResourceDto })
|
||||||
search(
|
search(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: BondSearchQueryDto,
|
@Query() query: BondSearchQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.bonds.search(request.user.sub, query.query);
|
return this.bonds.search(
|
||||||
|
request.user.sub,
|
||||||
|
query.query,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get('by-ticker/:ticker')
|
@Get('by-ticker/:ticker')
|
||||||
@ApiOkResponse({ type: BondDto })
|
@ApiOkResponse({ type: BondResourceDto })
|
||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: BondTickerParamsDto,
|
@Param() params: BondTickerParamsDto,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.bonds.getByTicker(request.user.sub, params.ticker);
|
return this.bonds.getByTicker(
|
||||||
|
request.user.sub,
|
||||||
|
params.ticker,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
@ApiOkResponse({ type: BondDto })
|
@ApiOkResponse({ type: BondResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.bonds.get(request.user.sub, uid);
|
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@ApiOkResponse({ type: BondQuoteDto })
|
@ApiOkResponse({ type: BondQuoteResourceDto })
|
||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid);
|
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@ApiOkResponse({ type: BondCandlesDto })
|
@ApiOkResponse({ type: BondCandlesResourceDto })
|
||||||
candles(
|
candles(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: BondCandlesQueryDto,
|
@Query() query: BondCandlesQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.candles(request.user.sub, uid, query.period);
|
return this.market.candles(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.period,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/position')
|
@Get(':uid/position')
|
||||||
@ApiOkResponse({ type: BondPositionDto })
|
@ApiOkResponse({ type: BondPositionResourceDto })
|
||||||
position(
|
position(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.position(request.user.sub, uid);
|
return this.income.readPosition(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/coupons')
|
@Get(':uid/coupons')
|
||||||
@ApiOkResponse({ type: BondCouponsDto })
|
@ApiOkResponse({ type: BondCouponsResourceDto })
|
||||||
coupons(
|
coupons(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.coupons(request.user.sub, uid);
|
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { MoneyDto } from '../../../../../common/money.dto';
|
import { MoneyDto } from '../../../../../common/money.dto';
|
||||||
|
|
||||||
@@ -175,3 +176,51 @@ export class BondMetricsDto {
|
|||||||
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
@ApiProperty({ type: [String] }) declare unavailable: string[];
|
||||||
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
@ApiProperty({ format: 'date-time' }) declare observedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BondPositionResourceDto {
|
||||||
|
@ApiProperty({ type: BondPositionDto, nullable: true })
|
||||||
|
declare data: BondPositionDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondSearchResourceDto {
|
||||||
|
@ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true })
|
||||||
|
declare data: BondSearchResultDto[] | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondResourceDto {
|
||||||
|
@ApiProperty({ type: BondDto, nullable: true }) declare data: BondDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondQuoteResourceDto {
|
||||||
|
@ApiProperty({ type: BondQuoteDto, nullable: true })
|
||||||
|
declare data: BondQuoteDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondMetricsResourceDto {
|
||||||
|
@ApiProperty({ type: BondMetricsDto, nullable: true })
|
||||||
|
declare data: BondMetricsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondCandlesResourceDto {
|
||||||
|
@ApiProperty({ type: BondCandlesDto, nullable: true })
|
||||||
|
declare data: BondCandlesDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BondCouponsResourceDto {
|
||||||
|
@ApiProperty({ type: BondCouponsDto, nullable: true })
|
||||||
|
declare data: BondCouponsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {
|
||||||
|
candleGaps,
|
||||||
|
candleWindows,
|
||||||
|
confirmedCandleCoverage,
|
||||||
|
} from './candle-coverage';
|
||||||
|
const date = (value: string) => new Date(value);
|
||||||
|
describe('source-confirmed daily candle coverage', () => {
|
||||||
|
it('retains closed empty coverage while yesterday incomplete and open empty ranges require rereading after midnight', () => {
|
||||||
|
const range = { from: date('2026-09-20Z'), to: date('2026-09-26Z') };
|
||||||
|
const now = date('2026-09-25T23:59:50Z');
|
||||||
|
const coverage = confirmedCandleCoverage(
|
||||||
|
range,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
time: '2026-09-24T00:00:00Z',
|
||||||
|
close: '1.000000001',
|
||||||
|
isComplete: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
coverage.map((row) => [
|
||||||
|
row.from.toISOString().slice(0, 10),
|
||||||
|
row.to.toISOString().slice(0, 10),
|
||||||
|
row.isComplete,
|
||||||
|
]),
|
||||||
|
).toEqual([
|
||||||
|
['2026-09-20', '2026-09-24', true],
|
||||||
|
['2026-09-24', '2026-09-25', false],
|
||||||
|
['2026-09-25', '2026-09-26', false],
|
||||||
|
]);
|
||||||
|
expect(candleGaps(range, coverage, date('2026-09-25T23:59:59Z'))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(candleGaps(range, coverage, date('2026-09-26T00:00:01Z'))).toEqual([
|
||||||
|
{ from: date('2026-09-24Z'), to: range.to },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
candleGaps(
|
||||||
|
range,
|
||||||
|
confirmedCandleCoverage(range, [], date('2026-09-27Z')),
|
||||||
|
date('2030-01-01Z'),
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
it('expires the mutable empty current day at exactly one minute', () => {
|
||||||
|
const range = { from: date('2026-09-25Z'), to: date('2026-09-26Z') };
|
||||||
|
const covered = confirmedCandleCoverage(
|
||||||
|
range,
|
||||||
|
[],
|
||||||
|
date('2026-09-25T08:00:00Z'),
|
||||||
|
);
|
||||||
|
expect(candleGaps(range, covered, date('2026-09-25T08:00:59Z'))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(candleGaps(range, covered, date('2026-09-25T08:01:00Z'))).toEqual([
|
||||||
|
range,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
it('splits leap-year history into contiguous windows of at most 365 days', () => {
|
||||||
|
const range = { from: date('2020-01-01Z'), to: date('2026-09-25Z') };
|
||||||
|
const windows = candleWindows(range);
|
||||||
|
expect(windows[0].from).toEqual(range.from);
|
||||||
|
expect(windows.at(-1)?.to).toEqual(range.to);
|
||||||
|
windows.forEach((window, index) => {
|
||||||
|
expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual(
|
||||||
|
365 * 86400000,
|
||||||
|
);
|
||||||
|
if (index) expect(window.from).toEqual(windows[index - 1].to);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
|
|
||||||
|
export type CandleRange = { from: Date; to: Date };
|
||||||
|
export type CandleCoverage = CandleRange & {
|
||||||
|
isComplete: boolean;
|
||||||
|
fetchedAt: Date;
|
||||||
|
};
|
||||||
|
export type SavedCandle = {
|
||||||
|
time: string;
|
||||||
|
close: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
};
|
||||||
|
const windowDays = 365;
|
||||||
|
const tailFreshForMs = 60_000;
|
||||||
|
export function utcDay(value: Date): Date {
|
||||||
|
return new Date(
|
||||||
|
Math.floor(value.valueOf() / MILLISECONDS_PER_DAY) * MILLISECONDS_PER_DAY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clock transition never turns an unconfirmed range into closed coverage.
|
||||||
|
export function candleGaps(
|
||||||
|
range: CandleRange,
|
||||||
|
coverage: CandleCoverage[],
|
||||||
|
now: Date,
|
||||||
|
): CandleRange[] {
|
||||||
|
let gaps = [range];
|
||||||
|
for (const covered of coverage) {
|
||||||
|
if (
|
||||||
|
!covered.isComplete &&
|
||||||
|
(now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs ||
|
||||||
|
utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf())
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
gaps = gaps.flatMap((gap) => {
|
||||||
|
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
|
||||||
|
return [
|
||||||
|
...(covered.from > gap.from
|
||||||
|
? [{ from: gap.from, to: covered.from }]
|
||||||
|
: []),
|
||||||
|
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return gaps.filter((gap) => gap.from < gap.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function candleWindows(range: CandleRange): CandleRange[] {
|
||||||
|
const windows: CandleRange[] = [];
|
||||||
|
for (let start = range.from.valueOf(); start < range.to.valueOf();) {
|
||||||
|
const end = Math.min(
|
||||||
|
start + windowDays * MILLISECONDS_PER_DAY,
|
||||||
|
range.to.valueOf(),
|
||||||
|
);
|
||||||
|
windows.push({ from: new Date(start), to: new Date(end) });
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return windows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function confirmedCandleCoverage(
|
||||||
|
range: CandleRange,
|
||||||
|
candles: SavedCandle[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
): CandleCoverage[] {
|
||||||
|
const boundaries = new Set([range.from.valueOf(), range.to.valueOf()]);
|
||||||
|
const midnight = utcDay(fetchedAt).valueOf();
|
||||||
|
const incompleteDays = new Set(
|
||||||
|
candles
|
||||||
|
.filter((candle) => !candle.isComplete)
|
||||||
|
.map((candle) => utcDay(new Date(candle.time)).valueOf()),
|
||||||
|
);
|
||||||
|
for (const boundary of [
|
||||||
|
midnight,
|
||||||
|
...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]),
|
||||||
|
])
|
||||||
|
if (boundary > range.from.valueOf() && boundary < range.to.valueOf())
|
||||||
|
boundaries.add(boundary);
|
||||||
|
const sorted = [...boundaries].sort((a, b) => a - b);
|
||||||
|
return sorted.slice(1).map((to, index) => {
|
||||||
|
const from = sorted[index];
|
||||||
|
return {
|
||||||
|
from: new Date(from),
|
||||||
|
to: new Date(to),
|
||||||
|
fetchedAt,
|
||||||
|
isComplete:
|
||||||
|
to <= midnight && !incompleteDays.has(utcDay(new Date(from)).valueOf()),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
|
import {
|
||||||
|
confirmedCandleCoverage,
|
||||||
|
type CandleRange,
|
||||||
|
type SavedCandle,
|
||||||
|
} from '../domain/candle-coverage';
|
||||||
|
|
||||||
|
const provider = 'tbank';
|
||||||
|
const interval = 'day';
|
||||||
|
type CandleIntent = {
|
||||||
|
instrumentType?: 'share' | 'bond';
|
||||||
|
uid: string;
|
||||||
|
ranges: CandleRange[];
|
||||||
|
period?: string;
|
||||||
|
now?: string;
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class CandleRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read(uid: string, range: CandleRange) {
|
||||||
|
const identity = { provider, instrumentUid: uid, interval };
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const candles = await tx.tbankCandle.findMany({
|
||||||
|
where: { ...identity, time: { gte: range.from, lt: range.to } },
|
||||||
|
orderBy: { time: 'asc' },
|
||||||
|
});
|
||||||
|
const coverage = await tx.tbankCandleCoverage.findMany({
|
||||||
|
where: { ...identity, from: { lt: range.to }, to: { gt: range.from } },
|
||||||
|
orderBy: [{ from: 'asc' }, { to: 'asc' }],
|
||||||
|
});
|
||||||
|
const data = candles.map((row) => ({
|
||||||
|
time: row.time.toISOString(),
|
||||||
|
close: row.close,
|
||||||
|
isComplete: row.isComplete,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
coverage,
|
||||||
|
dataRevision: createHash('sha256')
|
||||||
|
.update(JSON.stringify(data))
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
coverage(uids: string[]) {
|
||||||
|
return this.prisma.tbankCandleCoverage.findMany({
|
||||||
|
where: { provider, instrumentUid: { in: uids }, interval },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reserve(
|
||||||
|
owner: { userId: string; revision: string },
|
||||||
|
scopeKey: string,
|
||||||
|
intent: CandleIntent,
|
||||||
|
runId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
priority = 1,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
const scope = { ...owner, scopeKey };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === runId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
if (previous)
|
||||||
|
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority,
|
||||||
|
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
work(userId: string, revision: string, scopeKey: string) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: { userId, revision, scopeKey } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stage(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
range: CandleRange,
|
||||||
|
candles: SavedCandle[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
) {
|
||||||
|
// Reject malformed financial input instead of publishing a false empty range.
|
||||||
|
for (const candle of candles)
|
||||||
|
if (
|
||||||
|
!Number.isFinite(Date.parse(candle.time)) ||
|
||||||
|
!/^-?\d+(?:\.\d+)?$/.test(candle.close)
|
||||||
|
)
|
||||||
|
throw new Error('Invalid candle');
|
||||||
|
const key = `${range.from.toISOString()}/${range.to.toISOString()}`;
|
||||||
|
const rows = [
|
||||||
|
...new Map(
|
||||||
|
candles
|
||||||
|
.filter(
|
||||||
|
(candle) =>
|
||||||
|
new Date(candle.time) >= range.from &&
|
||||||
|
new Date(candle.time) < range.to,
|
||||||
|
)
|
||||||
|
.map((candle) => [candle.time, candle]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const valid = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { progress: { lastWindow: key } },
|
||||||
|
});
|
||||||
|
if (!valid.count) return false;
|
||||||
|
const identity = {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey: 'candles',
|
||||||
|
itemKey: key,
|
||||||
|
};
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify({ range, candles: rows, fetchedAt }),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
await tx.tbankSyncStage.upsert({
|
||||||
|
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||||
|
create: { ...identity, payload },
|
||||||
|
update: { payload },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
intent: CandleIntent,
|
||||||
|
completeWork = true,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
...(completeWork ? { state: 'complete' } : {}),
|
||||||
|
errorCode: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const staged = await tx.tbankSyncStage.findMany({
|
||||||
|
where: {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey: 'candles',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
intent.ranges.some(
|
||||||
|
(range) =>
|
||||||
|
!staged.some(
|
||||||
|
(row) =>
|
||||||
|
row.itemKey ===
|
||||||
|
`${range.from.toISOString()}/${range.to.toISOString()}`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new Error('Incomplete candle reconciliation');
|
||||||
|
const identity = { provider, instrumentUid: intent.uid, interval };
|
||||||
|
for (const range of intent.ranges) {
|
||||||
|
const row = staged.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.itemKey ===
|
||||||
|
`${range.from.toISOString()}/${range.to.toISOString()}`,
|
||||||
|
);
|
||||||
|
if (!row) throw new Error('Incomplete candle reconciliation');
|
||||||
|
const payload = row.payload as {
|
||||||
|
candles: SavedCandle[];
|
||||||
|
fetchedAt: string;
|
||||||
|
};
|
||||||
|
const fetchedAt = new Date(payload.fetchedAt);
|
||||||
|
const old = await tx.tbankCandleCoverage.findMany({
|
||||||
|
where: {
|
||||||
|
...identity,
|
||||||
|
from: { lt: range.to },
|
||||||
|
to: { gt: range.from },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.tbankCandleCoverage.deleteMany({
|
||||||
|
where: {
|
||||||
|
...identity,
|
||||||
|
from: { lt: range.to },
|
||||||
|
to: { gt: range.from },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Preserve coverage outside the requested reconciliation boundaries.
|
||||||
|
for (const previous of old) {
|
||||||
|
for (const part of [
|
||||||
|
...(previous.from < range.from
|
||||||
|
? [{ from: previous.from, to: range.from }]
|
||||||
|
: []),
|
||||||
|
...(previous.to > range.to
|
||||||
|
? [{ from: range.to, to: previous.to }]
|
||||||
|
: []),
|
||||||
|
])
|
||||||
|
await tx.tbankCandleCoverage.create({
|
||||||
|
data: { ...previous, ...part },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.tbankCandle.deleteMany({
|
||||||
|
where: { ...identity, time: { gte: range.from, lt: range.to } },
|
||||||
|
});
|
||||||
|
for (const candle of payload.candles)
|
||||||
|
await tx.tbankCandle.create({
|
||||||
|
data: {
|
||||||
|
...identity,
|
||||||
|
time: new Date(candle.time),
|
||||||
|
close: candle.close,
|
||||||
|
isComplete: candle.isComplete,
|
||||||
|
payload: candle,
|
||||||
|
fetchedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const covered of confirmedCandleCoverage(
|
||||||
|
range,
|
||||||
|
payload.candles,
|
||||||
|
fetchedAt,
|
||||||
|
))
|
||||||
|
await tx.tbankCandleCoverage.create({
|
||||||
|
data: { ...identity, ...covered },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.tbankSyncStage.deleteMany({
|
||||||
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
completeObserver(attempt: TbankSyncAttempt, initiator: TbankSyncAttempt) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const origin = await tx.tbankSyncWork.findFirst({
|
||||||
|
where: {
|
||||||
|
...initiator,
|
||||||
|
state: 'complete',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!origin) return false;
|
||||||
|
const result = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', errorCode: null },
|
||||||
|
});
|
||||||
|
return result.count > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode },
|
||||||
|
});
|
||||||
|
await tx.tbankSyncStage.deleteMany({
|
||||||
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,480 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank-access.service';
|
||||||
|
import type {
|
||||||
|
TbankBond,
|
||||||
|
TbankShare,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
export type CatalogQuery = {
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
query: string;
|
||||||
|
exactTicker?: boolean;
|
||||||
|
};
|
||||||
|
export type CatalogSource = {
|
||||||
|
instrumentType: 'share' | 'bond';
|
||||||
|
uid: string;
|
||||||
|
kind:
|
||||||
|
| 'detail'
|
||||||
|
| 'last-price'
|
||||||
|
| 'bond-current'
|
||||||
|
| 'description'
|
||||||
|
| 'fundamentals'
|
||||||
|
| 'trading-status'
|
||||||
|
| 'comparison'
|
||||||
|
| 'coupons'
|
||||||
|
| 'yield'
|
||||||
|
| 'rating'
|
||||||
|
| 'dividends'
|
||||||
|
| 'bond-events';
|
||||||
|
rangeKey?: string;
|
||||||
|
ticker?: string;
|
||||||
|
};
|
||||||
|
export type CatalogWork = CatalogQuery | CatalogSource;
|
||||||
|
export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string };
|
||||||
|
const provider = 'tbank';
|
||||||
|
export function catalogQuery(input: CatalogQuery): CatalogQuery {
|
||||||
|
return {
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
query: input.query.trim().toUpperCase(),
|
||||||
|
...(input.exactTicker ? { exactTicker: true } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function catalogScope(input: CatalogWork) {
|
||||||
|
if ('uid' in input)
|
||||||
|
return `catalog-source:${JSON.stringify([
|
||||||
|
input.kind,
|
||||||
|
input.kind === 'detail' ? input.instrumentType : null,
|
||||||
|
input.rangeKey ?? '',
|
||||||
|
input.uid,
|
||||||
|
])}`;
|
||||||
|
return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogDiscoveryRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read(input: CatalogQuery) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const alias = input.exactTicker
|
||||||
|
? await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_identifierType_identifier: {
|
||||||
|
provider,
|
||||||
|
identifierType: `${input.instrumentType}-ticker`,
|
||||||
|
identifier: input.query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const discovery = input.exactTicker
|
||||||
|
? alias
|
||||||
|
? {
|
||||||
|
instrumentUids: alias.matchedUids,
|
||||||
|
fetchedAt: alias.fetchedAt,
|
||||||
|
dataRevision: alias.dataRevision,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
: await tx.tbankCatalogDiscovery.findUnique({
|
||||||
|
where: {
|
||||||
|
provider_instrumentType_query: {
|
||||||
|
provider,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
query: input.query,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!discovery) return { data: null, fetchedAt: null, dataRevision: '0' };
|
||||||
|
const uids = discovery.instrumentUids as string[];
|
||||||
|
const rows = await tx.tbankInstrumentSource.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
instrumentUid: { in: uids },
|
||||||
|
source: provider,
|
||||||
|
kind: 'catalog',
|
||||||
|
rangeKey: input.instrumentType,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const byUid = new Map(rows.map((row) => [row.instrumentUid, row]));
|
||||||
|
// A damaged/missing dependency is unknown, never a confirmed empty search.
|
||||||
|
if (uids.some((uid) => !byUid.has(uid)))
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
fetchedAt: null,
|
||||||
|
dataRevision: String(discovery.dataRevision),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument),
|
||||||
|
fetchedAt: discovery.fetchedAt,
|
||||||
|
dataRevision: [
|
||||||
|
discovery.dataRevision,
|
||||||
|
...uids.map((uid) => byUid.get(uid)?.dataRevision),
|
||||||
|
].join('/'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
readSources(inputs: CatalogSource[]) {
|
||||||
|
return this.prisma.tbankInstrumentSource.findMany({
|
||||||
|
where: {
|
||||||
|
provider,
|
||||||
|
OR: inputs.map((input) => ({
|
||||||
|
instrumentUid: input.uid,
|
||||||
|
kind: input.kind,
|
||||||
|
source: sourceProvider(input),
|
||||||
|
rangeKey:
|
||||||
|
input.kind === 'detail'
|
||||||
|
? input.instrumentType
|
||||||
|
: (input.rangeKey ?? ''),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publishSource(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
input: CatalogSource,
|
||||||
|
value: object,
|
||||||
|
now: Date,
|
||||||
|
initiator: TbankSyncAttempt = attempt,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const origin = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...initiator,
|
||||||
|
state: { in: ['running', 'complete'] },
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { progress: {} },
|
||||||
|
});
|
||||||
|
if (!origin.count) return false;
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify(value),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
const identity = { provider, uid: input.uid };
|
||||||
|
await tx.tbankInstrument.upsert({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
...identity,
|
||||||
|
metadata: {},
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
fetchedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const instrument = (
|
||||||
|
value as { instrument?: TbankShare | TbankBond | null }
|
||||||
|
).instrument;
|
||||||
|
if (
|
||||||
|
(input.kind === 'detail' || input.kind === 'bond-current') &&
|
||||||
|
instrument &&
|
||||||
|
(instrument.name?.trim() || instrument.ticker?.trim())
|
||||||
|
) {
|
||||||
|
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
});
|
||||||
|
const metadata = JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
...instrument,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
}),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
await tx.tbankInstrument.update({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
data: {
|
||||||
|
metadata,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
figi: instrument.figi ?? null,
|
||||||
|
ticker: instrument.ticker ?? null,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous.metadata, metadata) ? 0 : 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const [identifierType, identifier] of [
|
||||||
|
['uid', input.uid],
|
||||||
|
['figi', instrument.figi],
|
||||||
|
] as const) {
|
||||||
|
if (!identifier) continue;
|
||||||
|
const alias = { provider, identifierType, identifier };
|
||||||
|
const previousAlias = await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: { provider_identifierType_identifier: alias },
|
||||||
|
});
|
||||||
|
const matchedUids = [input.uid];
|
||||||
|
await tx.tbankInstrumentLookup.upsert({
|
||||||
|
where: { provider_identifierType_identifier: alias },
|
||||||
|
create: { ...alias, matchedUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
matchedUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(
|
||||||
|
previousAlias?.matchedUids,
|
||||||
|
matchedUids,
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
instrumentUid: input.uid,
|
||||||
|
source: sourceProvider(input),
|
||||||
|
kind: input.kind,
|
||||||
|
rangeKey:
|
||||||
|
input.kind === 'detail'
|
||||||
|
? input.instrumentType
|
||||||
|
: (input.rangeKey ?? ''),
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankInstrumentSource.findUnique({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentSource.upsert({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
create: { ...key, payload, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
payload,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reserve(
|
||||||
|
owner: { userId: string; revision: string },
|
||||||
|
input: CatalogWork,
|
||||||
|
runId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === runId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority: 1,
|
||||||
|
intent: { source: 'catalog', ...input },
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
workMany(userId: string, revision: string, inputs: CatalogWork[]) {
|
||||||
|
return this.prisma.tbankSyncWork.findMany({
|
||||||
|
where: { userId, revision, scopeKey: { in: inputs.map(catalogScope) } },
|
||||||
|
select: { scopeKey: true, id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
work(userId: string, revision: string, input: CatalogWork) {
|
||||||
|
return this.prisma.tbankSyncWork.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision_scopeKey: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: catalogScope(input),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
input: CatalogQuery,
|
||||||
|
instruments: CatalogInstrument[],
|
||||||
|
now: Date,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
for (const instrument of instruments) {
|
||||||
|
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||||
|
const identity = { provider, uid: instrument.uid };
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
uid: instrument.uid,
|
||||||
|
figi: instrument.figi,
|
||||||
|
ticker: instrument.ticker,
|
||||||
|
name: instrument.name,
|
||||||
|
isin: instrument.isin,
|
||||||
|
classCode: instrument.classCode,
|
||||||
|
instrumentKind: instrument.instrumentKind,
|
||||||
|
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||||
|
currency: instrument.currency,
|
||||||
|
brand: instrument.brand
|
||||||
|
? { logoName: instrument.brand.logoName }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
await tx.tbankInstrument.upsert({
|
||||||
|
where: { provider_uid: identity },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
...identity,
|
||||||
|
metadata: payload,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
figi: instrument.figi,
|
||||||
|
ticker: instrument.ticker,
|
||||||
|
fetchedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
instrumentUid: instrument.uid,
|
||||||
|
source: provider,
|
||||||
|
kind: 'catalog',
|
||||||
|
rangeKey: input.instrumentType,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankInstrumentSource.findUnique({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentSource.upsert({
|
||||||
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
|
create: { ...key, payload, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
payload,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(previous?.payload, payload) ? 0 : 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const instrumentUids = instruments.map((instrument) => instrument.uid);
|
||||||
|
if (input.exactTicker) {
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
identifierType: `${input.instrumentType}-ticker`,
|
||||||
|
identifier: input.query,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankInstrumentLookup.findUnique({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
});
|
||||||
|
await tx.tbankInstrumentLookup.upsert({
|
||||||
|
where: { provider_identifierType_identifier: key },
|
||||||
|
create: { ...key, matchedUids: instrumentUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
matchedUids: instrumentUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(
|
||||||
|
previous?.matchedUids,
|
||||||
|
instrumentUids,
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const key = {
|
||||||
|
provider,
|
||||||
|
instrumentType: input.instrumentType,
|
||||||
|
query: input.query,
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankCatalogDiscovery.findUnique({
|
||||||
|
where: { provider_instrumentType_query: key },
|
||||||
|
});
|
||||||
|
await tx.tbankCatalogDiscovery.upsert({
|
||||||
|
where: { provider_instrumentType_query: key },
|
||||||
|
create: { ...key, instrumentUids, fetchedAt: now },
|
||||||
|
update: {
|
||||||
|
instrumentUids,
|
||||||
|
fetchedAt: now,
|
||||||
|
dataRevision: {
|
||||||
|
increment: isDeepStrictEqual(
|
||||||
|
previous?.instrumentUids,
|
||||||
|
instrumentUids,
|
||||||
|
)
|
||||||
|
? 0
|
||||||
|
: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: true },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||||
|
});
|
||||||
|
await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
state: success ? 'complete' : 'failed',
|
||||||
|
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceProvider(input: CatalogSource) {
|
||||||
|
return input.kind === 'yield' || input.kind === 'bond-events'
|
||||||
|
? 'moex'
|
||||||
|
: input.kind === 'rating'
|
||||||
|
? 'ncr'
|
||||||
|
: 'tbank';
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { StockIncomeService } from './stocks/application/stock-income.service';
|
||||||
|
export { BondIncomeService } from './bonds/application/bond-income.service';
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MoexModule } from '../../../integrations/moex/moex.module';
|
||||||
|
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
||||||
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
|
import { CandleService } from './application/candle.service';
|
||||||
|
import { CandleRepository } from './infrastructure/candle.repository';
|
||||||
|
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||||
|
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||||
|
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TbankModule, MoexModule, NcrModule],
|
||||||
|
providers: [
|
||||||
|
CandleService,
|
||||||
|
CandleRepository,
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
CatalogDiscoveryService,
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
],
|
||||||
|
exports: [CandleService, CatalogEnrichmentService, CatalogDiscoveryService],
|
||||||
|
})
|
||||||
|
export class InstrumentSourcesModule {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { CandleService } from './application/candle.service';
|
||||||
|
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||||
|
export type { CatalogPayload } from './application/catalog-enrichment.service';
|
||||||
|
|
||||||
|
export { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
|
import { InstrumentSourcesModule } from './instrument-sources.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
|
||||||
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
@@ -15,8 +14,9 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, MoexModule, NcrModule, PortfolioModule],
|
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
|
||||||
controllers: [StockController, BondController],
|
controllers: [StockController, BondController],
|
||||||
|
exports: [StockIncomeService, BondIncomeService],
|
||||||
providers: [
|
providers: [
|
||||||
BondIncomeService,
|
BondIncomeService,
|
||||||
BondMarketService,
|
BondMarketService,
|
||||||
|
|||||||
+30
-38
@@ -1,50 +1,42 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||||
import { optional } from '../../application/instrument-market';
|
import {
|
||||||
import { StockCompanyDto, StockMetricDto } from '../presentation/stock.dto';
|
StockCompanyResourceDto,
|
||||||
|
StockMetricDto,
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
} from '../presentation/stock.dto';
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetStockCompanyUseCase {
|
export class GetStockCompanyUseCase {
|
||||||
constructor(
|
constructor(private readonly sources: CatalogEnrichmentService) {}
|
||||||
private readonly catalog: StockCatalogService,
|
async execute(
|
||||||
private readonly tbank: TbankClient,
|
userId: string,
|
||||||
) {}
|
uid: string,
|
||||||
execute(userId: string, uid: string): Promise<StockCompanyDto> {
|
observe = false,
|
||||||
return this.catalog.cached(userId, `company:${uid}`, async (token) => {
|
): Promise<StockCompanyResourceDto> {
|
||||||
const share = await this.catalog.share(token, uid);
|
const result = await this.sources.section(
|
||||||
const unavailable: string[] = [];
|
userId,
|
||||||
const assetUid = share.assetUid;
|
'share',
|
||||||
if (!assetUid)
|
uid,
|
||||||
|
'company',
|
||||||
|
observe,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
description: null,
|
data: result.values.instrument
|
||||||
metrics: [],
|
? {
|
||||||
unavailable,
|
description: result.values.description ?? null,
|
||||||
observedAt: new Date().toISOString(),
|
metrics: metrics(
|
||||||
};
|
result.values.fundamentals ?? null,
|
||||||
const [description, fundamentals] = await Promise.all([
|
result.values.instrument.sector,
|
||||||
optional(
|
|
||||||
() => this.tbank.getAssetDescription(token, assetUid),
|
|
||||||
'description',
|
|
||||||
unavailable,
|
|
||||||
),
|
),
|
||||||
optional(
|
unavailable: result.unavailable,
|
||||||
() => this.tbank.getFundamentals(token, assetUid),
|
observedAt: result.observedAt,
|
||||||
'fundamentals',
|
}
|
||||||
unavailable,
|
: null,
|
||||||
),
|
meta: result.meta,
|
||||||
]);
|
|
||||||
return {
|
|
||||||
description,
|
|
||||||
metrics: metrics(fundamentals, share.sector),
|
|
||||||
unavailable,
|
|
||||||
observedAt: new Date().toISOString(),
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function metrics(
|
export function metrics(
|
||||||
values: TbankFundamentals | null,
|
values: TbankFundamentals | null,
|
||||||
sector?: string,
|
sector?: string,
|
||||||
): StockMetricDto[] {
|
): StockMetricDto[] {
|
||||||
|
|||||||
@@ -1,70 +1,39 @@
|
|||||||
import {
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
ConflictException,
|
import { Injectable } from '@nestjs/common';
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { LRUCache } from 'lru-cache';
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
import { formatDecimal } from '../../../../../common/decimal';
|
||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
|
||||||
TbankAccessService,
|
|
||||||
TbankClient,
|
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { optional, timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import { StockDto, StockSearchResultDto } from '../presentation/stock.dto';
|
import {
|
||||||
|
StockDto,
|
||||||
const SEARCH_RESULTS_LIMIT = 30;
|
StockResourceDto,
|
||||||
|
StockSearchResourceDto,
|
||||||
|
} from '../presentation/stock.dto';
|
||||||
|
|
||||||
type IdentifiedShare = TbankShare & { uid: string };
|
type IdentifiedShare = TbankShare & { uid: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockCatalogService {
|
export class StockCatalogService {
|
||||||
private readonly cache = new LRUCache<string, Promise<object>>({
|
constructor(private readonly discovery: CatalogDiscoveryService) {}
|
||||||
max: 300,
|
async search(
|
||||||
ttl: 30_000,
|
userId: string,
|
||||||
});
|
query: string,
|
||||||
constructor(
|
observe = false,
|
||||||
private readonly access: TbankAccessService,
|
): Promise<StockSearchResourceDto> {
|
||||||
private readonly tbank: TbankClient,
|
const result = await this.discovery.search(
|
||||||
) {}
|
userId,
|
||||||
search(userId: string, query: string): Promise<StockSearchResultDto[]> {
|
{ instrumentType: 'share', query },
|
||||||
return this.cached(userId, `search:${query}`, async (token) => {
|
observe,
|
||||||
const found = await this.tbank.findShares(token, query);
|
|
||||||
const shares = selectShares(found, query).slice(0, SEARCH_RESULTS_LIMIT);
|
|
||||||
if (!shares.length) return [];
|
|
||||||
// One market-data request for the list; failed enrichment must not hide a catalog result.
|
|
||||||
const [details, prices] = await Promise.all([
|
|
||||||
Promise.all(
|
|
||||||
shares.map(
|
|
||||||
async (share) =>
|
|
||||||
(await optional(
|
|
||||||
() =>
|
|
||||||
this.get(userId, share.uid).catch((error: unknown) => {
|
|
||||||
if (error instanceof NotFoundException) return stock(share);
|
|
||||||
throw error;
|
|
||||||
}),
|
|
||||||
'details',
|
|
||||||
[],
|
|
||||||
)) ?? stock(share),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
optional(
|
|
||||||
() =>
|
|
||||||
this.tbank.getLastPrices(
|
|
||||||
token,
|
|
||||||
shares.map((share) => share.uid),
|
|
||||||
),
|
|
||||||
'prices',
|
|
||||||
[],
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
return details.map((detail, index) => {
|
|
||||||
const short = stock(shares[index]);
|
|
||||||
const last = prices?.find(
|
|
||||||
(price) => price.instrumentUid === detail.instrumentUid,
|
|
||||||
);
|
);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data:
|
||||||
|
result.data?.map((item) => {
|
||||||
|
const short = stock(item);
|
||||||
|
const detail = item.detail
|
||||||
|
? stock({ ...item.detail, uid: item.uid })
|
||||||
|
: short;
|
||||||
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
classCode: detail.classCode ?? short.classCode,
|
classCode: detail.classCode ?? short.classCode,
|
||||||
@@ -79,107 +48,40 @@ export class StockCatalogService {
|
|||||||
: null,
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
});
|
}) ?? null,
|
||||||
});
|
|
||||||
}
|
|
||||||
get(userId: string, uid: string): Promise<StockDto> {
|
|
||||||
return this.cached(userId, `share:${uid}`, async (token) => {
|
|
||||||
const result = stock(await this.share(token, 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<StockDto> {
|
|
||||||
const match = await this.tickerMatch(userId, ticker);
|
|
||||||
return {
|
|
||||||
...(await this.get(userId, match.uid)),
|
|
||||||
slug: ticker.trim().toUpperCase(),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
private tickerMatch(
|
async get(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<StockResourceDto> {
|
||||||
|
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
data: result.data
|
||||||
|
? { ...stock(result.data), slug: result.data.slug }
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
): Promise<IdentifiedShare> {
|
observe = false,
|
||||||
const normalized = ticker.trim().toUpperCase();
|
): Promise<StockResourceDto> {
|
||||||
return this.cached(userId, `ticker:${normalized}`, async (token) => {
|
const result = await this.discovery.byTicker(
|
||||||
const found = await this.tbank.findShares(token, normalized);
|
userId,
|
||||||
const matches = selectShares(
|
'share',
|
||||||
found.filter(
|
ticker,
|
||||||
(item) => item.ticker?.trim().toUpperCase() === normalized,
|
observe,
|
||||||
),
|
|
||||||
normalized,
|
|
||||||
);
|
);
|
||||||
if (!matches.length) throw new NotFoundException();
|
return {
|
||||||
if (matches.length > 1)
|
...result,
|
||||||
throw new ConflictException({ code: 'STOCK_TICKER_AMBIGUOUS' });
|
data: result.data
|
||||||
return matches[0];
|
? { ...stock(result.data), slug: result.data.slug }
|
||||||
});
|
: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
async share(token: string, uid: string): Promise<IdentifiedShare> {
|
|
||||||
const share = await this.tbank.getShare(token, uid);
|
|
||||||
if (!share || share.uid !== uid) throw new NotFoundException();
|
|
||||||
return { ...share, uid };
|
|
||||||
}
|
|
||||||
cached<T extends object>(
|
|
||||||
userId: string,
|
|
||||||
key: string,
|
|
||||||
operation: (token: string) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
return this.access.withConnection(userId, ({ token, generation }) => {
|
|
||||||
const cacheKey = JSON.stringify([userId, generation, key]);
|
|
||||||
const cached = this.cache.get(cacheKey);
|
|
||||||
if (cached) return cached as Promise<T>;
|
|
||||||
const pending = operation(token);
|
|
||||||
this.cache.set(cacheKey, pending);
|
|
||||||
return pending.catch((error) => {
|
|
||||||
if (this.cache.get(cacheKey) === pending) this.cache.delete(cacheKey);
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectShares(found: TbankShare[], query: string): IdentifiedShare[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
return found
|
|
||||||
.filter(
|
|
||||||
(share): share is IdentifiedShare =>
|
|
||||||
Boolean(share.uid) && share.instrumentKind === 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
)
|
|
||||||
.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 === 'TQBR') - Number(a.classCode === 'TQBR') ||
|
|
||||||
a.uid.localeCompare(b.uid),
|
|
||||||
)
|
|
||||||
.filter((share) => {
|
|
||||||
// A missing ISIN must not collapse unrelated catalog instruments.
|
|
||||||
const key = share.isin?.trim()
|
|
||||||
? `isin:${share.isin.trim().toUpperCase()}`
|
|
||||||
: `uid:${share.uid}`;
|
|
||||||
if (seen.has(key)) return false;
|
|
||||||
seen.add(key);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stock(share: IdentifiedShare): StockDto {
|
function stock(share: IdentifiedShare): StockDto {
|
||||||
|
|||||||
@@ -1,51 +1,111 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
||||||
import { PortfolioService } from '../../../portfolio/portfolio.api';
|
|
||||||
import {
|
import {
|
||||||
moscowDay,
|
TbankAccessService,
|
||||||
optional,
|
type TbankDividend,
|
||||||
timestamp,
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
} from '../../application/instrument-market';
|
import {
|
||||||
import { stockPosition } from './stock-position';
|
PortfolioService,
|
||||||
|
ForecastService,
|
||||||
|
} from '../../../portfolio/portfolio.api';
|
||||||
|
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||||
|
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||||
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
import {
|
import {
|
||||||
StockDividendDto,
|
|
||||||
StockDividendsDto,
|
StockDividendsDto,
|
||||||
|
StockDividendsResourceDto,
|
||||||
StockPositionDto,
|
StockPositionDto,
|
||||||
|
StockDividendDto,
|
||||||
} from '../presentation/stock.dto';
|
} from '../presentation/stock.dto';
|
||||||
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
|
||||||
|
|
||||||
const ISO_YEAR_LENGTH = 4;
|
const ISO_YEAR_LENGTH = 4;
|
||||||
const INCOME_HISTORY_YEARS = 5;
|
const INCOME_HISTORY_YEARS = 5;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockIncomeService {
|
export class StockIncomeService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: StockCatalogService,
|
|
||||||
private readonly tbank: TbankClient,
|
|
||||||
private readonly portfolios: PortfolioService,
|
private readonly portfolios: PortfolioService,
|
||||||
|
private readonly sources: CatalogEnrichmentService,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly forecasts: ForecastService,
|
||||||
) {}
|
) {}
|
||||||
async position(userId: string, uid: string): Promise<StockPositionDto> {
|
|
||||||
return this.catalog.cached(userId, `position:${uid}`, async () =>
|
async readPosition(userId: string, uid: string, observe = false) {
|
||||||
stockPosition(await this.portfolios.getAll(userId), uid),
|
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||||
);
|
return {
|
||||||
|
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
|
||||||
|
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
|
||||||
|
};
|
||||||
}
|
}
|
||||||
dividends(userId: string, uid: string): Promise<StockDividendsDto> {
|
async dividends(
|
||||||
return this.catalog.cached(userId, `dividends:${uid}`, async (token) => {
|
userId: string,
|
||||||
await this.catalog.share(token, uid);
|
uid: string,
|
||||||
|
observe = false,
|
||||||
|
): Promise<StockDividendsResourceDto> {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||||
const unavailable: string[] = [];
|
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||||
const [events, position] = await Promise.all([
|
const sources = await this.sources.readMany(
|
||||||
this.tbank.getDividends(token, uid, from, to),
|
userId,
|
||||||
optional(() => this.position(userId, uid), 'position', unavailable),
|
'share',
|
||||||
]);
|
[uid],
|
||||||
|
observe,
|
||||||
|
['detail', 'dividends'],
|
||||||
|
{ from, to },
|
||||||
|
);
|
||||||
|
if (sources[0].meta.availability === 'complete' && !sources[0].instrument)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const events = sources[1].payload?.dividends;
|
||||||
|
const position = portfolio.data
|
||||||
|
? instrumentPosition(portfolio.data, uid, 'STOCK')
|
||||||
|
: null;
|
||||||
|
const projected = events
|
||||||
|
? stockDividends(
|
||||||
|
events,
|
||||||
|
position,
|
||||||
|
{ from, to, today },
|
||||||
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
|
!portfolio.data,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
const result = await this.forecasts.result({
|
||||||
|
userId,
|
||||||
|
revision: connection.revision,
|
||||||
|
membershipRevision: connection.membershipRevision,
|
||||||
|
scopeKey: `stock/dividends/${JSON.stringify([uid, from, to])}`,
|
||||||
|
portfolio: portfolio.meta,
|
||||||
|
sources,
|
||||||
|
candidate:
|
||||||
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
|
});
|
||||||
|
return result.data || !projected
|
||||||
|
? result
|
||||||
|
: {
|
||||||
|
data: stockDividends(
|
||||||
|
events ?? [],
|
||||||
|
null,
|
||||||
|
{ from, to, today },
|
||||||
|
projected.observedAt,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
meta: { ...result.meta, availability: 'partial' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stockDividends(
|
||||||
|
events: TbankDividend[],
|
||||||
|
position: StockPositionDto | null,
|
||||||
|
window: { from: string; to: string; today: string },
|
||||||
|
observedAt: string,
|
||||||
|
positionUnavailable: boolean,
|
||||||
|
): StockDividendsDto {
|
||||||
|
const { from, to, today } = window;
|
||||||
const quantity =
|
const quantity =
|
||||||
position &&
|
position &&
|
||||||
position.accounts.length &&
|
position.accounts.length &&
|
||||||
@@ -56,10 +116,8 @@ export class StockIncomeService {
|
|||||||
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
|
const recordDate = timestamp(event.recordDate)?.slice(0, 10) ?? null;
|
||||||
const paymentDate =
|
const paymentDate = timestamp(event.paymentDate)?.slice(0, 10) ?? null;
|
||||||
timestamp(event.paymentDate)?.slice(0, 10) ?? null;
|
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
||||||
const lastBuyDate =
|
|
||||||
timestamp(event.lastBuyDate)?.slice(0, 10) ?? null;
|
|
||||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
const currency = event.dividendNet?.currency?.toUpperCase();
|
||||||
const perShare =
|
const perShare =
|
||||||
currency && event.dividendNet
|
currency && event.dividendNet
|
||||||
@@ -99,11 +157,9 @@ export class StockIncomeService {
|
|||||||
dividends,
|
dividends,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt: observedAt,
|
||||||
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
positionAt: quantity ? (position?.observedAt ?? null) : null,
|
||||||
estimateUnavailable: unavailable.length > 0,
|
estimateUnavailable: positionUnavailable,
|
||||||
positionIsStale: position?.isStale ?? false,
|
positionIsStale: position?.isStale ?? false,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { CandleService } from '../../application/candle.service';
|
||||||
|
import {
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
type CatalogPayload,
|
||||||
|
} from '../../application/catalog-enrichment.service';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
@@ -6,76 +11,67 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
optional,
|
|
||||||
previousClose,
|
|
||||||
timestamp,
|
|
||||||
} from '../../application/instrument-market';
|
|
||||||
import {
|
|
||||||
StockCandlesDto,
|
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
StockQuoteDto,
|
StockQuoteDto,
|
||||||
|
StockQuoteResourceDto,
|
||||||
} from '../presentation/stock.dto';
|
} from '../presentation/stock.dto';
|
||||||
|
|
||||||
const dayMs = 86_400_000;
|
|
||||||
|
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
|
||||||
|
|
||||||
const PREVIOUS_CLOSE_LOOKBACK_DAYS = 20;
|
|
||||||
const HALF_YEAR_MONTHS = 6;
|
|
||||||
const YEAR_MONTHS = 12;
|
|
||||||
const CANDLE_WINDOW_DAYS = 365;
|
|
||||||
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
|
const CHANGE_PERCENT_DECIMAL_PLACES = 6;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockMarketService {
|
export class StockMarketService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly catalog: StockCatalogService,
|
private readonly history: CandleService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
quote(userId: string, uid: string): Promise<StockQuoteDto> {
|
async quote(
|
||||||
return this.catalog.cached(userId, `quote:${uid}`, async (token) => {
|
userId: string,
|
||||||
const share = await this.catalog.share(token, uid);
|
uid: string,
|
||||||
const unavailable: string[] = [];
|
observe = false,
|
||||||
const [last, tradingStatus] = await Promise.all([
|
): Promise<StockQuoteResourceDto> {
|
||||||
optional(
|
const result = await this.sources.section(
|
||||||
() => this.tbank.getLastPrice(token, uid),
|
userId,
|
||||||
'price',
|
'share',
|
||||||
unavailable,
|
uid,
|
||||||
),
|
'quote',
|
||||||
optional(
|
observe,
|
||||||
() => this.tbank.getTradingStatus(token, uid),
|
);
|
||||||
'status',
|
return {
|
||||||
unavailable,
|
data: result.values.instrument
|
||||||
),
|
? stockQuote(result.values, result.unavailable, result.observedAt)
|
||||||
]);
|
: null,
|
||||||
|
meta: result.meta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
candles(
|
||||||
|
userId: string,
|
||||||
|
uid: string,
|
||||||
|
period: StockCandlesQueryDto['period'] = 'month',
|
||||||
|
observe = false,
|
||||||
|
) {
|
||||||
|
return this.history.read(
|
||||||
|
userId,
|
||||||
|
{ uid, instrumentType: 'share', period },
|
||||||
|
observe,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stockQuote(
|
||||||
|
values: CatalogPayload,
|
||||||
|
unavailable: string[],
|
||||||
|
observedAt: string,
|
||||||
|
): StockQuoteDto {
|
||||||
|
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||||
|
const share = values.instrument;
|
||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const currency = share.currency?.toUpperCase();
|
const currency = share?.currency?.toUpperCase();
|
||||||
const price =
|
const price =
|
||||||
last?.price && currency
|
last?.price && currency
|
||||||
? { currency, value: formatDecimal(decimal(last.price)) }
|
? { currency, value: formatDecimal(decimal(last.price)) }
|
||||||
: null;
|
: null;
|
||||||
const previous = priceAt
|
|
||||||
? await optional(
|
|
||||||
async () => {
|
|
||||||
const end = new Date(priceAt);
|
|
||||||
return previousClose(
|
|
||||||
await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
uid,
|
|
||||||
new Date(
|
|
||||||
end.valueOf() - PREVIOUS_CLOSE_LOOKBACK_DAYS * dayMs,
|
|
||||||
),
|
|
||||||
end,
|
|
||||||
),
|
|
||||||
priceAt,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
'comparison',
|
|
||||||
unavailable,
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
const previousPrice =
|
const previousPrice =
|
||||||
previous?.close && currency
|
previous?.close && currency
|
||||||
? { currency, value: formatDecimal(decimal(previous.close)) }
|
? { currency, value: formatDecimal(decimal(previous.close)) }
|
||||||
@@ -110,76 +106,6 @@ export class StockMarketService {
|
|||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt: new Date().toISOString(),
|
observedAt,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
|
||||||
candles(
|
|
||||||
userId: string,
|
|
||||||
uid: string,
|
|
||||||
period: StockCandlesQueryDto['period'] = 'month',
|
|
||||||
): Promise<StockCandlesDto> {
|
|
||||||
return this.catalog.cached(
|
|
||||||
userId,
|
|
||||||
`candles:${uid}:${period}`,
|
|
||||||
async (token) => {
|
|
||||||
const share = await this.catalog.share(token, uid);
|
|
||||||
const historyFrom = timestamp(share.first_1dayCandleDate);
|
|
||||||
const to = new Date();
|
|
||||||
const from = new Date(to);
|
|
||||||
from.setUTCMonth(
|
|
||||||
from.getUTCMonth() -
|
|
||||||
(period === 'month'
|
|
||||||
? 1
|
|
||||||
: period === 'halfYear'
|
|
||||||
? HALF_YEAR_MONTHS
|
|
||||||
: YEAR_MONTHS),
|
|
||||||
);
|
|
||||||
if (period === 'all') {
|
|
||||||
if (!historyFrom)
|
|
||||||
throw new ServiceUnavailableException({
|
|
||||||
code: 'STOCK_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, StockCandlesDto['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 + CANDLE_WINDOW_DAYS * 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: share.currency?.toUpperCase() || null,
|
|
||||||
historyFrom,
|
|
||||||
observedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,375 +1,70 @@
|
|||||||
import {
|
import { metrics } from './get-stock-company.use-case';
|
||||||
ConflictException,
|
import { stockDividends } from './stock-income.service';
|
||||||
NotFoundException,
|
import { stockQuote } from './stock-market.service';
|
||||||
ServiceUnavailableException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { TbankAccessService } from '../../../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
|
||||||
import { PortfolioService } from '../../../portfolio/application/portfolio.service';
|
|
||||||
import { GetStockCompanyUseCase } from './get-stock-company.use-case';
|
|
||||||
import { StockIncomeService } from './stock-income.service';
|
|
||||||
import { StockMarketService } from './stock-market.service';
|
|
||||||
import { StockCatalogService } from './stock-catalog.service';
|
|
||||||
import { previousClose } from '../../application/instrument-market';
|
import { previousClose } from '../../application/instrument-market';
|
||||||
jest.mock('../../../../../integrations/tbank/tbank-access.service', () => ({
|
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||||
TbankAccessService: class TbankAccessService {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
const time = (date: string) => ({ seconds: String(Date.parse(date) / 1000) });
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||||
const share = {
|
describe('stock financial projections', () => {
|
||||||
uid: 'uid',
|
it('keeps raw public payouts when holdings are unavailable and omits cancelled events', () => {
|
||||||
assetUid: 'asset',
|
const result = stockDividends(
|
||||||
currency: 'rub',
|
[
|
||||||
sector: 'financial',
|
|
||||||
first_1dayCandleDate: time('2025-11-20'),
|
|
||||||
};
|
|
||||||
function setup() {
|
|
||||||
let generation = 'a';
|
|
||||||
const tbank = {
|
|
||||||
findShares: jest.fn().mockResolvedValue([]),
|
|
||||||
getLastPrices: jest.fn().mockResolvedValue([]),
|
|
||||||
getShare: jest.fn().mockResolvedValue(share),
|
|
||||||
getCandles: jest.fn().mockResolvedValue([]),
|
|
||||||
getAssetDescription: jest.fn().mockResolvedValue('Company'),
|
|
||||||
getFundamentals: jest.fn().mockResolvedValue({
|
|
||||||
assetUid: 'asset',
|
|
||||||
currency: 'rub',
|
|
||||||
peRatioTtm: 6,
|
|
||||||
netIncomeTtm: 10,
|
|
||||||
}),
|
|
||||||
getDividends: 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 service = new StockCatalogService(
|
|
||||||
access as unknown as TbankAccessService,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
const market = new StockMarketService(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
const income = new StockIncomeService(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
portfolios as unknown as PortfolioService,
|
|
||||||
);
|
|
||||||
const companyInfo = new GetStockCompanyUseCase(
|
|
||||||
service,
|
|
||||||
tbank as unknown as TbankClient,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
market,
|
|
||||||
income,
|
|
||||||
companyInfo,
|
|
||||||
service,
|
|
||||||
tbank,
|
|
||||||
portfolios,
|
|
||||||
changeGeneration: () => {
|
|
||||||
generation = 'b';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
describe('stock market use case', () => {
|
|
||||||
beforeEach(() =>
|
|
||||||
jest.useFakeTimers().setSystemTime(new Date('2026-09-10T10:00:00Z')),
|
|
||||||
);
|
|
||||||
afterEach(() => jest.useRealTimers());
|
|
||||||
it('returns unique ISINs, keeps preferred shares separate and matches prices by selected UID', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = [
|
|
||||||
{
|
|
||||||
uid: 'special',
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'SPEQ',
|
|
||||||
apiTradeAvailableFlag: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'preferred',
|
|
||||||
ticker: 'SBERP',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'common',
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
].map((item) => ({
|
|
||||||
...item,
|
|
||||||
name: 'Сбербанк',
|
|
||||||
isin: item.uid === 'preferred' ? 'ISINP' : 'ISIN',
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
}));
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
...listings,
|
|
||||||
{ uid: 'bond', instrumentKind: 'INSTRUMENT_TYPE_BOND' },
|
|
||||||
{ instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
{ uid: '', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockImplementation((_token: string, uid: string) =>
|
|
||||||
Promise.resolve({
|
|
||||||
...listings.find((item) => item.uid === uid),
|
|
||||||
currency: 'rub',
|
|
||||||
brand: { logoName: 'sber.png' },
|
|
||||||
shareType:
|
|
||||||
uid === 'preferred' ? 'SHARE_TYPE_PREFERRED' : 'SHARE_TYPE_COMMON',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
tbank.getLastPrices.mockResolvedValue([
|
|
||||||
{
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
price: { units: '305' },
|
|
||||||
time: time('2026-09-09'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
instrumentUid: 'common',
|
|
||||||
price: { units: '306', nano: 120000000 },
|
|
||||||
time: time('2026-09-10'),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const result = await service.search('user', 'SBER');
|
|
||||||
expect(result.map((item) => item.instrumentUid)).toEqual([
|
|
||||||
'common',
|
|
||||||
'preferred',
|
|
||||||
]);
|
|
||||||
expect(result[0]).toMatchObject({
|
|
||||||
classCode: 'TQBR',
|
|
||||||
isin: 'ISIN',
|
|
||||||
logoName: 'sber.png',
|
|
||||||
price: { currency: 'RUB', value: '306.12' },
|
|
||||||
priceAt: '2026-09-10T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
expect(result[1]).toMatchObject({
|
|
||||||
shareType: 'SHARE_TYPE_PREFERRED',
|
|
||||||
price: { value: '305' },
|
|
||||||
});
|
|
||||||
expect(tbank.getLastPrices).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
it('prefers TQBR among equally available listings, deduplicates before the limit and keeps missing ISINs separate', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const hit = {
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
ticker: 'SBER',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
};
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
...Array.from({ length: 35 }, (_, i) => ({
|
|
||||||
...hit,
|
|
||||||
uid: `alternate-${i}`,
|
|
||||||
isin: ' isin ',
|
|
||||||
classCode: 'SPEQ',
|
|
||||||
})),
|
|
||||||
{ ...hit, uid: 'common', isin: 'ISIN', classCode: 'TQBR' },
|
|
||||||
{
|
|
||||||
...hit,
|
|
||||||
uid: 'preferred',
|
|
||||||
ticker: 'SBERP',
|
|
||||||
isin: 'ISINP',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
},
|
|
||||||
{ ...hit, uid: 'unknown-a', isin: '' },
|
|
||||||
{ ...hit, uid: 'unknown-b' },
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockResolvedValue(null);
|
|
||||||
const result = await service.search('user', 'SBER');
|
|
||||||
expect(result.map((item) => item.instrumentUid)).toEqual([
|
|
||||||
'common',
|
|
||||||
'unknown-a',
|
|
||||||
'unknown-b',
|
|
||||||
'preferred',
|
|
||||||
]);
|
|
||||||
expect(tbank.getShare).toHaveBeenCalledTimes(4);
|
|
||||||
});
|
|
||||||
it('retains catalog identity when enrichment is unavailable, but propagates auth errors', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
{
|
|
||||||
uid: 'uid',
|
|
||||||
ticker: 'SBER',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
tbank.getLastPrices.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
expect(await service.search('user', 'SBER')).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
price: null,
|
|
||||||
logoName: null,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockResolvedValue(null);
|
|
||||||
expect(await service.search('user', 'sber')).toEqual([
|
|
||||||
expect.objectContaining({ instrumentUid: 'uid' }),
|
|
||||||
]);
|
|
||||||
tbank.getShare.mockRejectedValue(new UnauthorizedException());
|
|
||||||
await expect(service.search('another-user', 'SBER')).rejects.toBeInstanceOf(
|
|
||||||
UnauthorizedException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('skips enrichment for an empty catalog result', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
expect(await service.search('user', 'none')).toEqual([]);
|
|
||||||
expect(tbank.getShare).not.toHaveBeenCalled();
|
|
||||||
expect(tbank.getLastPrices).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
it('resolves an exact ticker, preserves other listing UIDs and does not confuse SBER with SBERP', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = [
|
|
||||||
{
|
|
||||||
uid: 'common',
|
|
||||||
ticker: 'SBER',
|
|
||||||
isin: 'COMMON',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'special',
|
|
||||||
ticker: 'SBER',
|
|
||||||
isin: 'COMMON',
|
|
||||||
classCode: 'SPEQ',
|
|
||||||
apiTradeAvailableFlag: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
uid: 'preferred',
|
|
||||||
ticker: 'SBERP',
|
|
||||||
isin: 'PREFERRED',
|
|
||||||
classCode: 'TQBR',
|
|
||||||
apiTradeAvailableFlag: true,
|
|
||||||
},
|
|
||||||
].map((item) => ({ ...item, instrumentKind: 'INSTRUMENT_TYPE_SHARE' }));
|
|
||||||
tbank.findShares.mockResolvedValue(listings);
|
|
||||||
tbank.getShare.mockImplementation((_token: string, uid: string) =>
|
|
||||||
Promise.resolve(listings.find((item) => item.uid === uid)),
|
|
||||||
);
|
|
||||||
expect(await service.getByTicker('user', 'sberp')).toMatchObject({
|
|
||||||
instrumentUid: 'preferred',
|
|
||||||
slug: 'SBERP',
|
|
||||||
});
|
|
||||||
expect(await service.get('user', 'common')).toMatchObject({
|
|
||||||
instrumentUid: 'common',
|
|
||||||
slug: 'SBER',
|
|
||||||
});
|
|
||||||
expect(await service.get('user', 'special')).toMatchObject({
|
|
||||||
instrumentUid: 'special',
|
|
||||||
slug: 'special',
|
|
||||||
});
|
|
||||||
await expect(service.getByTicker('user', 'SB')).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
it('refuses ambiguous tickers while keeping exact UID pages accessible', async () => {
|
|
||||||
const { service, tbank } = setup();
|
|
||||||
const listings = ['one', 'two'].map((uid) => ({
|
|
||||||
uid,
|
|
||||||
ticker: 'SAME',
|
|
||||||
isin: uid,
|
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
|
||||||
}));
|
|
||||||
tbank.findShares.mockResolvedValue(listings);
|
|
||||||
tbank.getShare.mockResolvedValue(listings[0]);
|
|
||||||
await expect(service.getByTicker('user', 'SAME')).rejects.toBeInstanceOf(
|
|
||||||
ConflictException,
|
|
||||||
);
|
|
||||||
expect(await service.get('user', 'one')).toMatchObject({
|
|
||||||
instrumentUid: 'one',
|
|
||||||
slug: 'one',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
it('keeps exact pages if alias lookup fails and partitions ticker resolution by connection', async () => {
|
|
||||||
const { service, tbank, changeGeneration } = setup();
|
|
||||||
tbank.getShare.mockResolvedValue({ ...share, ticker: 'F' });
|
|
||||||
tbank.findShares.mockRejectedValueOnce(new ServiceUnavailableException());
|
|
||||||
expect(await service.get('user', 'uid')).toMatchObject({ slug: 'uid' });
|
|
||||||
tbank.findShares.mockResolvedValue([
|
|
||||||
{ ...share, ticker: 'F', instrumentKind: 'INSTRUMENT_TYPE_SHARE' },
|
|
||||||
]);
|
|
||||||
expect(await service.getByTicker('user', 'F')).toMatchObject({
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
slug: 'F',
|
|
||||||
});
|
|
||||||
changeGeneration();
|
|
||||||
expect(await service.getByTicker('user', 'F')).toMatchObject({ slug: 'F' });
|
|
||||||
expect(tbank.findShares).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
it('uses a complete previous trading day, never today’s main close', () => {
|
|
||||||
const candles = [
|
|
||||||
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
|
|
||||||
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: true },
|
|
||||||
{ time: time('2026-09-08'), close: { units: '9' }, isComplete: false },
|
|
||||||
];
|
|
||||||
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
|
|
||||||
expect(previousClose(candles, '2026-09-09T22:00:00Z')).toEqual(candles[0]);
|
|
||||||
});
|
|
||||||
it('starts all-history at listing, keeps incomplete candles and deduplicates boundaries', async () => {
|
|
||||||
const { tbank, market } = setup();
|
|
||||||
tbank.getCandles.mockResolvedValue([
|
|
||||||
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: false },
|
|
||||||
]);
|
|
||||||
const result = await market.candles('user', 'uid', 'all');
|
|
||||||
expect(tbank.getCandles).toHaveBeenCalledWith(
|
|
||||||
'user',
|
|
||||||
'uid',
|
|
||||||
new Date('2025-11-20'),
|
|
||||||
expect.any(Date),
|
|
||||||
);
|
|
||||||
expect(result.candles).toHaveLength(1);
|
|
||||||
expect(result.candles[0].isComplete).toBe(false);
|
|
||||||
});
|
|
||||||
it('does not manufacture zero fundamentals and isolates optional failures', async () => {
|
|
||||||
const { tbank, companyInfo } = setup();
|
|
||||||
tbank.getAssetDescription.mockRejectedValue(
|
|
||||||
new ServiceUnavailableException(),
|
|
||||||
);
|
|
||||||
const result = await companyInfo.execute('user', 'uid');
|
|
||||||
expect(result.description).toBeNull();
|
|
||||||
expect(result.unavailable).toEqual(['description']);
|
|
||||||
expect(result.metrics.map((item) => item.key)).toEqual([
|
|
||||||
'peRatioTtm',
|
|
||||||
'netIncomeTtm',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
it('isolates cache by user and connection generation', async () => {
|
|
||||||
const { service, tbank, changeGeneration } = setup();
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
expect(tbank.getShare).toHaveBeenCalledTimes(1);
|
|
||||||
await service.get('b', 'uid');
|
|
||||||
changeGeneration();
|
|
||||||
await service.get('a', 'uid');
|
|
||||||
expect(tbank.getShare).toHaveBeenCalledTimes(3);
|
|
||||||
});
|
|
||||||
it('keeps public dividends when there is no holding or position retrieval fails', async () => {
|
|
||||||
const { tbank, portfolios, income } = setup();
|
|
||||||
portfolios.getAll.mockRejectedValue(new ServiceUnavailableException());
|
|
||||||
tbank.getDividends.mockResolvedValue([
|
|
||||||
{
|
{
|
||||||
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
|
dividendNet: { currency: 'rub', units: '32', nano: 880000000 },
|
||||||
recordDate: time('2026-10-13'),
|
recordDate: time('2026-10-13'),
|
||||||
paymentDate: time('2026-10-27'),
|
paymentDate: time('2026-10-27'),
|
||||||
},
|
},
|
||||||
]);
|
{ dividendType: 'Cancelled', recordDate: time('2026-10-14') },
|
||||||
const result = await income.dividends('user', 'uid');
|
],
|
||||||
expect(result.estimateUnavailable).toBe(true);
|
null,
|
||||||
expect(result.dividends[0]).toMatchObject({
|
{ from: '2021-09-10', to: '2027-09-10', today: '2026-09-10' },
|
||||||
|
'source-time',
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(result.dividends).toEqual([
|
||||||
|
{
|
||||||
perShare: { currency: 'RUB', value: '32.88' },
|
perShare: { currency: 'RUB', value: '32.88' },
|
||||||
estimatedAmount: null,
|
recordDate: '2026-10-13',
|
||||||
|
paymentDate: '2026-10-27',
|
||||||
|
lastBuyDate: null,
|
||||||
timing: 'upcoming',
|
timing: 'upcoming',
|
||||||
|
estimatedAmount: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(result.estimateUnavailable).toBe(true);
|
||||||
|
expect(result.observedAt).toBe('source-time');
|
||||||
});
|
});
|
||||||
|
it('uses the last complete previous trading day', () => {
|
||||||
|
const candles = [
|
||||||
|
{ time: time('2026-09-09'), close: { units: '10' }, isComplete: true },
|
||||||
|
{ time: time('2026-09-10'), close: { units: '11' }, isComplete: false },
|
||||||
|
];
|
||||||
|
expect(previousClose(candles, '2026-09-10T18:00:00Z')).toEqual(candles[0]);
|
||||||
|
});
|
||||||
|
it('retains stock price precision and percent change without inventing unavailable fundamentals', () => {
|
||||||
|
expect(
|
||||||
|
stockQuote(
|
||||||
|
{
|
||||||
|
instrument: { currency: 'rub' },
|
||||||
|
price: { price: { units: '11', nano: 1 } },
|
||||||
|
candle: { close: { units: '10' } },
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
'time',
|
||||||
|
),
|
||||||
|
).toMatchObject({
|
||||||
|
price: { currency: 'RUB', value: '11.000000001' },
|
||||||
|
change: { currency: 'RUB', value: '1.000000001' },
|
||||||
|
changePercent: '10',
|
||||||
|
});
|
||||||
|
expect(metrics(null, 'financial')).toEqual([]);
|
||||||
|
expect(
|
||||||
|
metrics(
|
||||||
|
{ peRatioTtm: 6, netIncomeTtm: 10, revenueTtm: 100, currency: 'rub' },
|
||||||
|
'financial',
|
||||||
|
).map((item) => item.key),
|
||||||
|
).toEqual(['peRatioTtm', 'netIncomeTtm']);
|
||||||
|
expect(metrics({ netIncomeTtm: 10 })).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@@ -15,15 +16,15 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
|
|||||||
import { StockIncomeService } from '../application/stock-income.service';
|
import { StockIncomeService } from '../application/stock-income.service';
|
||||||
import { StockMarketService } from '../application/stock-market.service';
|
import { StockMarketService } from '../application/stock-market.service';
|
||||||
import {
|
import {
|
||||||
StockCandlesDto,
|
StockCandlesResourceDto,
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
StockCompanyDto,
|
StockCompanyResourceDto,
|
||||||
StockDividendsDto,
|
StockDividendsResourceDto,
|
||||||
StockDto,
|
StockResourceDto,
|
||||||
StockPositionDto,
|
StockPositionResourceDto,
|
||||||
StockQuoteDto,
|
StockQuoteResourceDto,
|
||||||
StockSearchQueryDto,
|
StockSearchQueryDto,
|
||||||
StockSearchResultDto,
|
StockSearchResourceDto,
|
||||||
StockTickerParamsDto,
|
StockTickerParamsDto,
|
||||||
} from './stock.dto';
|
} from './stock.dto';
|
||||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||||
@@ -41,68 +42,99 @@ export class StockController {
|
|||||||
private readonly companyInfo: GetStockCompanyUseCase,
|
private readonly companyInfo: GetStockCompanyUseCase,
|
||||||
) {}
|
) {}
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: [StockSearchResultDto] })
|
@ApiOkResponse({ type: StockSearchResourceDto })
|
||||||
search(
|
search(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: StockSearchQueryDto,
|
@Query() query: StockSearchQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.stocks.search(request.user.sub, query.query);
|
return this.stocks.search(
|
||||||
|
request.user.sub,
|
||||||
|
query.query,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get('by-ticker/:ticker')
|
@Get('by-ticker/:ticker')
|
||||||
@ApiOkResponse({ type: StockDto })
|
@ApiOkResponse({ type: StockResourceDto })
|
||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: StockTickerParamsDto,
|
@Param() params: StockTickerParamsDto,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.stocks.getByTicker(request.user.sub, params.ticker);
|
return this.stocks.getByTicker(
|
||||||
|
request.user.sub,
|
||||||
|
params.ticker,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
@ApiOkResponse({ type: StockDto })
|
@ApiOkResponse({ type: StockResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.stocks.get(request.user.sub, uid);
|
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@ApiOkResponse({ type: StockQuoteDto })
|
@ApiOkResponse({ type: StockQuoteResourceDto })
|
||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid);
|
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||||
}
|
}
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@ApiOkResponse({ type: StockCandlesDto })
|
@ApiOkResponse({ type: StockCandlesResourceDto })
|
||||||
candles(
|
candles(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: StockCandlesQueryDto,
|
@Query() query: StockCandlesQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.market.candles(request.user.sub, uid, query.period);
|
return this.market.candles(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.period,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/position')
|
@Get(':uid/position')
|
||||||
@ApiOkResponse({ type: StockPositionDto })
|
@ApiOkResponse({ type: StockPositionResourceDto })
|
||||||
position(
|
position(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.position(request.user.sub, uid);
|
return this.income.readPosition(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/company')
|
@Get(':uid/company')
|
||||||
@ApiOkResponse({ type: StockCompanyDto })
|
@ApiOkResponse({ type: StockCompanyResourceDto })
|
||||||
company(
|
company(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.companyInfo.execute(request.user.sub, uid);
|
return this.companyInfo.execute(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@Get(':uid/dividends')
|
@Get(':uid/dividends')
|
||||||
@ApiOkResponse({ type: StockDividendsDto })
|
@ApiOkResponse({ type: StockDividendsResourceDto })
|
||||||
dividends(
|
dividends(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.income.dividends(request.user.sub, uid);
|
return this.income.dividends(
|
||||||
|
request.user.sub,
|
||||||
|
uid,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
InvestmentReadQueryDto,
|
||||||
|
InvestmentResourceMetaDto,
|
||||||
|
} from '../../../investment-resource.dto';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +17,7 @@ import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
|||||||
|
|
||||||
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
|
const MAX_INSTRUMENT_QUERY_LENGTH = 80;
|
||||||
|
|
||||||
export class StockSearchQueryDto {
|
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
@ApiProperty({ minLength: 2, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||||
@Transform(({ value }: { value: unknown }) =>
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
typeof value === 'string' ? value.trim() : value,
|
typeof value === 'string' ? value.trim() : value,
|
||||||
@@ -35,7 +39,7 @@ export class StockTickerParamsDto {
|
|||||||
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
|
@Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/)
|
||||||
declare ticker: string;
|
declare ticker: string;
|
||||||
}
|
}
|
||||||
export class StockCandlesQueryDto {
|
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ['month', 'halfYear', 'year', 'all'],
|
enum: ['month', 'halfYear', 'year', 'all'],
|
||||||
default: 'month',
|
default: 'month',
|
||||||
@@ -168,3 +172,52 @@ export class StockDividendsDto {
|
|||||||
@ApiProperty() declare estimateUnavailable: boolean;
|
@ApiProperty() declare estimateUnavailable: boolean;
|
||||||
@ApiProperty() declare positionIsStale: boolean;
|
@ApiProperty() declare positionIsStale: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class StockPositionResourceDto {
|
||||||
|
@ApiProperty({ type: StockPositionDto, nullable: true })
|
||||||
|
declare data: StockPositionDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockSearchResourceDto {
|
||||||
|
@ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true })
|
||||||
|
declare data: StockSearchResultDto[] | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockResourceDto {
|
||||||
|
@ApiProperty({ type: StockDto, nullable: true })
|
||||||
|
declare data: StockDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockQuoteResourceDto {
|
||||||
|
@ApiProperty({ type: StockQuoteDto, nullable: true })
|
||||||
|
declare data: StockQuoteDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockCompanyResourceDto {
|
||||||
|
@ApiProperty({ type: StockCompanyDto, nullable: true })
|
||||||
|
declare data: StockCompanyDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockCandlesResourceDto {
|
||||||
|
@ApiProperty({ type: StockCandlesDto, nullable: true })
|
||||||
|
declare data: StockCandlesDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockDividendsResourceDto {
|
||||||
|
@ApiProperty({ type: StockDividendsDto, nullable: true })
|
||||||
|
declare data: StockDividendsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ const MOSCOW_DAY_END_UTC = {
|
|||||||
};
|
};
|
||||||
export type DateOnlyRange = { from: string; to: string };
|
export type DateOnlyRange = { from: string; to: string };
|
||||||
const moscow = 'Europe/Moscow';
|
const moscow = 'Europe/Moscow';
|
||||||
export function moscowDate(value: Date): string {
|
const moscowFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
||||||
timeZone: moscow,
|
timeZone: moscow,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
}).formatToParts(value);
|
});
|
||||||
|
export function moscowDate(value: Date): string {
|
||||||
|
const parts = moscowFormatter.formatToParts(value);
|
||||||
const get = (type: string) => parts.find((part) => part.type === type)?.value;
|
const get = (type: string) => parts.find((part) => part.type === type)?.value;
|
||||||
return `${get('year')}-${get('month')}-${get('day')}`;
|
return `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
|
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
||||||
|
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
||||||
|
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
|
||||||
|
|
||||||
|
export class InvestmentReadQueryDto {
|
||||||
|
@ApiPropertyOptional({ enum: ['observe'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['observe'])
|
||||||
|
declare sync?: 'observe';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvestmentSyncDto {
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
declare id: string | null;
|
||||||
|
@ApiProperty({ enum: syncStateValues })
|
||||||
|
declare state: (typeof syncStateValues)[number];
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
declare errorCode: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvestmentRangeDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
accountId?: string;
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
declare from: string;
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
declare to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvestmentCoverageDto {
|
||||||
|
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
|
||||||
|
declare requested: InvestmentRangeDto[];
|
||||||
|
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
|
||||||
|
declare completeRanges: InvestmentRangeDto[];
|
||||||
|
@ApiProperty({ type: InvestmentRangeDto, isArray: true })
|
||||||
|
declare missingRanges: InvestmentRangeDto[];
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
|
declare asOf: string | null;
|
||||||
|
@ApiProperty({ enum: ['known', 'unknown'] })
|
||||||
|
declare historyBoundary: 'known' | 'unknown';
|
||||||
|
@ApiProperty({
|
||||||
|
enum: ['missing', 'loading', 'partial', 'complete', 'failed'],
|
||||||
|
})
|
||||||
|
declare historyState:
|
||||||
|
'missing' | 'loading' | 'partial' | 'complete' | 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvestmentDependencyDto {
|
||||||
|
@ApiProperty()
|
||||||
|
declare dataRevision: string;
|
||||||
|
@ApiProperty()
|
||||||
|
declare stateVersion: string;
|
||||||
|
@ApiPropertyOptional({ type: String })
|
||||||
|
unavailableReason?: string;
|
||||||
|
@ApiPropertyOptional({ type: () => InvestmentCoverageDto })
|
||||||
|
coverage?: InvestmentCoverageDto;
|
||||||
|
@ApiProperty()
|
||||||
|
declare resourceKey: string;
|
||||||
|
@ApiProperty({ enum: availabilityValues })
|
||||||
|
declare availability: (typeof availabilityValues)[number];
|
||||||
|
@ApiProperty({ enum: freshnessValues })
|
||||||
|
declare freshness: (typeof freshnessValues)[number];
|
||||||
|
@ApiProperty({ type: String, format: 'date-time', nullable: true })
|
||||||
|
declare lastSuccessfulAt: string | null;
|
||||||
|
@ApiProperty({ type: InvestmentSyncDto })
|
||||||
|
declare sync: InvestmentSyncDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class InvestmentResourceMetaDto extends InvestmentDependencyDto {
|
||||||
|
@ApiPropertyOptional({ type: InvestmentDependencyDto, isArray: true })
|
||||||
|
declare dependencies?: InvestmentDependencyDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { investmentResourceMeta } from './investment-resource.mapper';
|
||||||
|
|
||||||
|
describe('investment resource source freshness', () => {
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('uses each required source TTL and oldest successful timestamp', () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-09-25T12:00:00Z'));
|
||||||
|
const membership = investmentResourceMeta({
|
||||||
|
resourceKey: 'membership',
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: new Date('2026-09-25T11:58:00Z'),
|
||||||
|
freshForMs: 300_000,
|
||||||
|
work: null,
|
||||||
|
});
|
||||||
|
const input = {
|
||||||
|
resourceKey: 'portfolio',
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete' as const,
|
||||||
|
fetchedAt: new Date('2026-09-25T11:59:30Z'),
|
||||||
|
freshForMs: 60_000,
|
||||||
|
work: null,
|
||||||
|
dependencies: [membership],
|
||||||
|
};
|
||||||
|
const fresh = investmentResourceMeta(input);
|
||||||
|
expect(fresh.freshness).toBe('fresh');
|
||||||
|
expect(fresh.lastSuccessfulAt).toBe('2026-09-25T11:58:00.000Z');
|
||||||
|
jest.advanceTimersByTime(30_000);
|
||||||
|
const stale = investmentResourceMeta(input);
|
||||||
|
expect(stale.freshness).toBe('stale');
|
||||||
|
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
||||||
|
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
|
||||||
|
expect(
|
||||||
|
investmentResourceMeta({
|
||||||
|
...input,
|
||||||
|
dependencies: [{ ...membership, freshness: 'unknown' }],
|
||||||
|
}).freshness,
|
||||||
|
).toBe('unknown');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dependent resource lifecycle', () => {
|
||||||
|
const input = {
|
||||||
|
resourceKey: 'source',
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete' as const,
|
||||||
|
fetchedAt: new Date(),
|
||||||
|
freshForMs: 60_000,
|
||||||
|
work: null,
|
||||||
|
};
|
||||||
|
it('exposes dependency work without losing saved availability or timestamps', () => {
|
||||||
|
const failed = investmentResourceMeta({
|
||||||
|
...input,
|
||||||
|
work: { id: 'failed', state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
const running = investmentResourceMeta({
|
||||||
|
...input,
|
||||||
|
work: { id: 'running', state: 'running', errorCode: null },
|
||||||
|
});
|
||||||
|
const parent = investmentResourceMeta({
|
||||||
|
...input,
|
||||||
|
dependencies: [failed, running],
|
||||||
|
});
|
||||||
|
expect(parent.sync).toEqual(running.sync);
|
||||||
|
expect(parent.availability).toBe('complete');
|
||||||
|
const terminal = investmentResourceMeta({
|
||||||
|
...input,
|
||||||
|
dependencies: [failed],
|
||||||
|
});
|
||||||
|
expect(terminal.sync).toEqual(failed.sync);
|
||||||
|
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
||||||
|
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
|
||||||
|
expect(
|
||||||
|
investmentResourceMeta({
|
||||||
|
...input,
|
||||||
|
dependencies: [{ ...failed, unavailableReason: 'calendar_boundary' }],
|
||||||
|
}).sync.state,
|
||||||
|
).toBe('idle');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||||
|
|
||||||
|
export function investmentResourceMeta(input: {
|
||||||
|
resourceKey: string;
|
||||||
|
dataRevision: string;
|
||||||
|
availability: InvestmentResourceMetaDto['availability'];
|
||||||
|
fetchedAt: Date | null;
|
||||||
|
freshForMs: number;
|
||||||
|
work: { id: string; state: string; errorCode: string | null } | null;
|
||||||
|
dependencies?: InvestmentResourceMetaDto[];
|
||||||
|
coverage?: InvestmentResourceMetaDto['coverage'];
|
||||||
|
unavailableReason?: string;
|
||||||
|
}): InvestmentResourceMetaDto {
|
||||||
|
const ownSync = {
|
||||||
|
id: input.work?.id ?? null,
|
||||||
|
state:
|
||||||
|
input.work?.state === 'running'
|
||||||
|
? ('running' as const)
|
||||||
|
: input.work?.state === 'failed'
|
||||||
|
? ('failed' as const)
|
||||||
|
: ('idle' as const),
|
||||||
|
errorCode: input.work?.errorCode ?? null,
|
||||||
|
};
|
||||||
|
const states = [
|
||||||
|
ownSync,
|
||||||
|
...(input.dependencies ?? [])
|
||||||
|
.filter((dependency) => !dependency.unavailableReason)
|
||||||
|
.map((dependency) => dependency.sync),
|
||||||
|
];
|
||||||
|
const sync =
|
||||||
|
states.find((state) => state.state === 'running') ??
|
||||||
|
states.find((state) => state.state === 'queued') ??
|
||||||
|
states.find((state) => state.state === 'failed') ??
|
||||||
|
ownSync;
|
||||||
|
const sourceFreshness = input.fetchedAt
|
||||||
|
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
|
||||||
|
? ('fresh' as const)
|
||||||
|
: ('stale' as const)
|
||||||
|
: ('unknown' as const);
|
||||||
|
const freshness = [
|
||||||
|
sourceFreshness,
|
||||||
|
...(input.dependencies ?? [])
|
||||||
|
.filter((dependency) => !dependency.unavailableReason)
|
||||||
|
.map((dependency) => dependency.freshness),
|
||||||
|
];
|
||||||
|
const timestamps = [
|
||||||
|
input.fetchedAt?.toISOString(),
|
||||||
|
...(input.dependencies ?? []).map(
|
||||||
|
(dependency) => dependency.lastSuccessfulAt,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.filter((timestamp): timestamp is string => Boolean(timestamp))
|
||||||
|
.sort();
|
||||||
|
const meta = {
|
||||||
|
resourceKey: input.resourceKey,
|
||||||
|
...(input.unavailableReason
|
||||||
|
? { unavailableReason: input.unavailableReason }
|
||||||
|
: {}),
|
||||||
|
dataRevision: input.dataRevision,
|
||||||
|
availability: input.availability,
|
||||||
|
freshness: freshness.includes('unknown')
|
||||||
|
? ('unknown' as const)
|
||||||
|
: freshness.includes('stale')
|
||||||
|
? ('stale' as const)
|
||||||
|
: ('fresh' as const),
|
||||||
|
lastSuccessfulAt: timestamps[0] ?? null,
|
||||||
|
sync,
|
||||||
|
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||||
|
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
stateVersion: createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify({
|
||||||
|
...meta,
|
||||||
|
// The requested window is identified by resourceKey. Its moving "now"
|
||||||
|
// boundary is not a change to saved coverage between status and GET.
|
||||||
|
coverage: meta.coverage
|
||||||
|
? { ...meta.coverage, requested: undefined }
|
||||||
|
: undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.digest('base64url'),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
jest.mock('../../prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||||
|
|
||||||
|
import {
|
||||||
|
planInvestmentScreen,
|
||||||
|
ledgerRange,
|
||||||
|
accountHistory,
|
||||||
|
} from './investment-sync-plan';
|
||||||
|
|
||||||
|
describe('screen dependency and history planning', () => {
|
||||||
|
const now = new Date('2026-09-25T12:00:00Z');
|
||||||
|
const context = {
|
||||||
|
membershipKnown: true,
|
||||||
|
accounts: [{ accountId: 'a', openedOn: '2020-01-01' }],
|
||||||
|
instruments: [
|
||||||
|
{
|
||||||
|
uid: 'uid',
|
||||||
|
type: 'STOCK' as const,
|
||||||
|
historyFrom: new Date('2020-01-01Z'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => {
|
||||||
|
const plan = planInvestmentScreen(
|
||||||
|
{
|
||||||
|
screen: 'income',
|
||||||
|
accountId: 'a',
|
||||||
|
from: '2024-02-01',
|
||||||
|
to: '2024-02-29',
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(plan.sources[0].kind).toBe('membership');
|
||||||
|
const selected = plan.sources.filter(
|
||||||
|
(source) => source.kind === 'operations' && source.priority === 'screen',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
selected.some(
|
||||||
|
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
selected.some(
|
||||||
|
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(plan.unavailable).toEqual([
|
||||||
|
{ resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier
|
||||||
|
]);
|
||||||
|
const firstHistory = plan.sources.findIndex(
|
||||||
|
(source) => source.priority === 'history',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
plan.sources
|
||||||
|
.slice(firstHistory)
|
||||||
|
.every((source) => source.priority === 'history'),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
it('force contains only shown dependencies and retains instrument reconciliation predicates', () => {
|
||||||
|
const plan = planInvestmentScreen(
|
||||||
|
{
|
||||||
|
screen: 'stock',
|
||||||
|
instrumentUid: 'uid',
|
||||||
|
from: '2026-01-01',
|
||||||
|
to: '2026-02-01',
|
||||||
|
candlePeriod: 'year',
|
||||||
|
},
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(plan.sources.map((source) => source.kind)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'membership',
|
||||||
|
'portfolio',
|
||||||
|
'operations',
|
||||||
|
'metadata',
|
||||||
|
'quote',
|
||||||
|
'company',
|
||||||
|
'calendar',
|
||||||
|
'candles',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(plan.sources.every((source) => source.priority !== 'history')).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
plan.sources
|
||||||
|
.filter((source) => source.kind === 'operations')
|
||||||
|
.every((source) => source.instrumentUid === 'uid'),
|
||||||
|
).toBe(true);
|
||||||
|
const accounts = planInvestmentScreen(
|
||||||
|
{ screen: 'accounts' },
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
accounts.sources.some((source) => source.kind === 'operations'),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
it('never invents an account or candle history boundary; discovery gates unknown membership', () => {
|
||||||
|
const unknown = {
|
||||||
|
...context,
|
||||||
|
accounts: [{ accountId: 'a', openedOn: null }],
|
||||||
|
instruments: [],
|
||||||
|
};
|
||||||
|
expect(accountHistory(unknown.accounts, now)[0].from.toISOString()).toBe(
|
||||||
|
'1970-01-01T00:00:00.000Z',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ledgerRange(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
'uid',
|
||||||
|
unknown.accounts,
|
||||||
|
now,
|
||||||
|
).from.toISOString(),
|
||||||
|
).toBe('1970-01-01T00:00:00.000Z');
|
||||||
|
const plan = planInvestmentScreen(
|
||||||
|
{ screen: 'bond', instrumentUid: 'uid', candlePeriod: 'all' },
|
||||||
|
unknown,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
expect(plan.sources.some((source) => source.kind === 'candles')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(plan.unavailable[0].reason).toBe('history_start_unavailable');
|
||||||
|
expect(
|
||||||
|
planInvestmentScreen(
|
||||||
|
{ screen: 'account' },
|
||||||
|
{ ...context, membershipKnown: false },
|
||||||
|
now,
|
||||||
|
).sources,
|
||||||
|
).toEqual([{ kind: 'membership', priority: 'discovery' }]);
|
||||||
|
});
|
||||||
|
it('keeps UTC event days separate from Moscow instrument calendar days at year rollover', () => {
|
||||||
|
const rollover = new Date('2026-12-31T22:00:00Z');
|
||||||
|
const events = planInvestmentScreen(
|
||||||
|
{ screen: 'events' },
|
||||||
|
context,
|
||||||
|
rollover,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
events.sources.find((source) => source.kind === 'calendar')?.from,
|
||||||
|
).toEqual(new Date('2026-12-31Z'));
|
||||||
|
const stock = planInvestmentScreen(
|
||||||
|
{ screen: 'stock', instrumentUid: 'uid' },
|
||||||
|
context,
|
||||||
|
rollover,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
stock.sources.find((source) => source.kind === 'calendar'),
|
||||||
|
).toMatchObject({
|
||||||
|
from: new Date('2022-01-01Z'),
|
||||||
|
to: new Date('2028-01-01Z'),
|
||||||
|
});
|
||||||
|
expect(rollover.toISOString()).toBe('2026-12-31T22:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps search queries separate and events scoped to their inclusive period', () => {
|
||||||
|
const search = planInvestmentScreen(
|
||||||
|
{ screen: 'stocks', query: ' sber ' },
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
search.sources.find((source) => source.kind === 'catalog')?.query,
|
||||||
|
).toBe('SBER');
|
||||||
|
const events = planInvestmentScreen(
|
||||||
|
{ screen: 'events', from: '2026-10-01', to: '2026-10-31' },
|
||||||
|
context,
|
||||||
|
now,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
events.sources.find((source) => source.kind === 'calendar'),
|
||||||
|
).toMatchObject({
|
||||||
|
from: new Date('2026-10-01Z'),
|
||||||
|
to: new Date('2026-10-31Z'),
|
||||||
|
});
|
||||||
|
expect(events.sources.some((source) => source.kind === 'operations')).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
import { planIncomePeriod } from './income/income.api';
|
||||||
|
import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
||||||
|
import {
|
||||||
|
operationIntervals,
|
||||||
|
type OperationInterval,
|
||||||
|
} from './operations/operations.api';
|
||||||
|
|
||||||
|
export type InvestmentScreen = {
|
||||||
|
screen:
|
||||||
|
| 'accounts'
|
||||||
|
| 'account'
|
||||||
|
| 'operations'
|
||||||
|
| 'income'
|
||||||
|
| 'events'
|
||||||
|
| 'stocks'
|
||||||
|
| 'bonds'
|
||||||
|
| 'stock'
|
||||||
|
| 'bond';
|
||||||
|
accountId?: string;
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
instrumentUid?: string;
|
||||||
|
query?: string;
|
||||||
|
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
|
||||||
|
candleRange?: { from: Date; to: Date };
|
||||||
|
};
|
||||||
|
const defaultEventDays = 30;
|
||||||
|
const incomeHistoryYears = 5;
|
||||||
|
const candleMonths = { month: 1, halfYear: 6, year: 12 };
|
||||||
|
|
||||||
|
type Account = { accountId: string; openedOn: string | null };
|
||||||
|
type Instrument = {
|
||||||
|
uid: string;
|
||||||
|
type: 'STOCK' | 'BOND';
|
||||||
|
historyFrom: Date | null;
|
||||||
|
};
|
||||||
|
type Source = {
|
||||||
|
kind:
|
||||||
|
| 'membership'
|
||||||
|
| 'portfolio'
|
||||||
|
| 'operations'
|
||||||
|
| 'metadata'
|
||||||
|
| 'catalog'
|
||||||
|
| 'quote'
|
||||||
|
| 'company'
|
||||||
|
| 'metrics'
|
||||||
|
| 'calendar'
|
||||||
|
| 'candles';
|
||||||
|
accountId?: string;
|
||||||
|
instrumentUid?: string;
|
||||||
|
instrumentType?: 'STOCK' | 'BOND';
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
query?: string;
|
||||||
|
priority: 'discovery' | 'screen' | 'history';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expands screen scope, never credentials or arbitrary caller-supplied jobs.
|
||||||
|
// Owners execute their own sources and expand instruments after discovery.
|
||||||
|
export function planInvestmentScreen(
|
||||||
|
screen: InvestmentScreen,
|
||||||
|
context: {
|
||||||
|
accounts: Account[];
|
||||||
|
instruments: Instrument[];
|
||||||
|
membershipKnown: boolean;
|
||||||
|
},
|
||||||
|
now: Date,
|
||||||
|
force = false,
|
||||||
|
) {
|
||||||
|
const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }];
|
||||||
|
const accounts = context.accounts.filter(
|
||||||
|
(account) =>
|
||||||
|
!screen.accountId ||
|
||||||
|
screen.accountId === 'all' ||
|
||||||
|
account.accountId === screen.accountId,
|
||||||
|
);
|
||||||
|
const unavailable: { resourceKey: string; reason: string }[] = [];
|
||||||
|
const operations = (from: Date, to: Date, instrumentUid?: string) => {
|
||||||
|
const targets = accounts.flatMap((account) =>
|
||||||
|
from < to
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
accountId: account.accountId,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
...(instrumentUid ? { instrumentUid } : {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
sources.push(
|
||||||
|
...operationIntervals(targets).map((range) => ({
|
||||||
|
...range,
|
||||||
|
kind: 'operations' as const,
|
||||||
|
priority: 'screen' as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const portfolio = () =>
|
||||||
|
sources.push(
|
||||||
|
...accounts.map(({ accountId }) => ({
|
||||||
|
kind: 'portfolio' as const,
|
||||||
|
accountId,
|
||||||
|
priority: 'screen' as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const knownOpening =
|
||||||
|
accounts.length && accounts.every((account) => account.openedOn)
|
||||||
|
? new Date(
|
||||||
|
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
if (!context.membershipKnown) return { sources, unavailable, now };
|
||||||
|
if (
|
||||||
|
['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen)
|
||||||
|
)
|
||||||
|
portfolio();
|
||||||
|
if (
|
||||||
|
screen.screen === 'operations' ||
|
||||||
|
screen.screen === 'stock' ||
|
||||||
|
screen.screen === 'bond'
|
||||||
|
) {
|
||||||
|
const range = ledgerRange(
|
||||||
|
screen.from,
|
||||||
|
screen.to,
|
||||||
|
screen.instrumentUid,
|
||||||
|
accounts,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
operations(range.from, range.to, force ? screen.instrumentUid : undefined);
|
||||||
|
}
|
||||||
|
if (screen.screen === 'account' || screen.screen === 'income') {
|
||||||
|
const period = planIncomePeriod(
|
||||||
|
screen.screen === 'income' ? screen.from : undefined,
|
||||||
|
screen.screen === 'income' ? screen.to : undefined,
|
||||||
|
knownOpening,
|
||||||
|
);
|
||||||
|
for (const range of [
|
||||||
|
period.selected,
|
||||||
|
...(screen.screen === 'income'
|
||||||
|
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
||||||
|
: []),
|
||||||
|
]) {
|
||||||
|
const bounds = dateOnlyBounds(range);
|
||||||
|
operations(
|
||||||
|
bounds.from,
|
||||||
|
new Date(Math.min(bounds.to.valueOf() + 1, now.valueOf())),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (screen.screen === 'income' && !period.yearAgo)
|
||||||
|
unavailable.push({
|
||||||
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
|
reason: 'calendar_or_account_boundary',
|
||||||
|
});
|
||||||
|
if (screen.screen === 'account') {
|
||||||
|
const range = ledgerRange(
|
||||||
|
screen.from,
|
||||||
|
screen.to,
|
||||||
|
undefined,
|
||||||
|
accounts,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
operations(range.from, range.to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (screen.screen === 'stocks' || screen.screen === 'bonds')
|
||||||
|
sources.push({
|
||||||
|
kind: 'catalog',
|
||||||
|
instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND',
|
||||||
|
query: screen.query?.trim().toUpperCase() ?? '',
|
||||||
|
priority: 'screen',
|
||||||
|
});
|
||||||
|
const instruments = context.instruments.filter(
|
||||||
|
(instrument) =>
|
||||||
|
!screen.instrumentUid || instrument.uid === screen.instrumentUid,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
screen.instrumentUid &&
|
||||||
|
!instruments.length &&
|
||||||
|
(screen.screen === 'stock' || screen.screen === 'bond')
|
||||||
|
)
|
||||||
|
instruments.push({
|
||||||
|
uid: screen.instrumentUid,
|
||||||
|
type: screen.screen === 'stock' ? 'STOCK' : 'BOND',
|
||||||
|
historyFrom: null,
|
||||||
|
});
|
||||||
|
for (const instrument of instruments) {
|
||||||
|
const identity = {
|
||||||
|
instrumentUid: instrument.uid,
|
||||||
|
instrumentType: instrument.type,
|
||||||
|
};
|
||||||
|
sources.push({ ...identity, kind: 'metadata', priority: 'screen' });
|
||||||
|
if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen))
|
||||||
|
sources.push({ ...identity, kind: 'quote', priority: 'screen' });
|
||||||
|
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
||||||
|
sources.push({
|
||||||
|
...identity,
|
||||||
|
kind: screen.screen === 'stock' ? 'company' : 'metrics',
|
||||||
|
priority: 'screen',
|
||||||
|
});
|
||||||
|
const from = new Date(now);
|
||||||
|
const period = screen.candlePeriod ?? 'month';
|
||||||
|
if (period !== 'all')
|
||||||
|
from.setUTCMonth(from.getUTCMonth() - candleMonths[period]);
|
||||||
|
const range =
|
||||||
|
screen.candleRange ??
|
||||||
|
(period === 'all'
|
||||||
|
? instrument.historyFrom
|
||||||
|
? { from: instrument.historyFrom, to: now }
|
||||||
|
: null
|
||||||
|
: { from, to: now });
|
||||||
|
if (range)
|
||||||
|
sources.push({
|
||||||
|
...identity,
|
||||||
|
kind: 'candles',
|
||||||
|
...range,
|
||||||
|
priority: 'screen',
|
||||||
|
});
|
||||||
|
else
|
||||||
|
unavailable.push({
|
||||||
|
resourceKey: `candles/${instrument.uid}/all`,
|
||||||
|
reason: 'history_start_unavailable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) {
|
||||||
|
const day =
|
||||||
|
screen.screen === 'stock' || screen.screen === 'bond'
|
||||||
|
? moscowDate(now)
|
||||||
|
: now.toISOString().slice(0, 10);
|
||||||
|
const from =
|
||||||
|
screen.screen === 'events' && screen.from
|
||||||
|
? new Date(`${screen.from}T00:00:00Z`)
|
||||||
|
: new Date(`${day}T00:00:00Z`);
|
||||||
|
const to =
|
||||||
|
screen.screen === 'events' && screen.to
|
||||||
|
? new Date(`${screen.to}T00:00:00Z`)
|
||||||
|
: new Date(from);
|
||||||
|
if (
|
||||||
|
!(screen.screen === 'events' && screen.to) &&
|
||||||
|
screen.screen !== 'stock' &&
|
||||||
|
screen.screen !== 'bond'
|
||||||
|
)
|
||||||
|
to.setUTCDate(to.getUTCDate() + defaultEventDays);
|
||||||
|
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
||||||
|
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
|
||||||
|
to.setUTCFullYear(to.getUTCFullYear() + 1);
|
||||||
|
}
|
||||||
|
sources.push({
|
||||||
|
...identity,
|
||||||
|
kind: 'calendar',
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
priority: 'screen',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
['account', 'operations', 'income', 'events'].includes(screen.screen)
|
||||||
|
) {
|
||||||
|
if (instrument.historyFrom)
|
||||||
|
sources.push({
|
||||||
|
...identity,
|
||||||
|
kind: 'candles',
|
||||||
|
from: instrument.historyFrom,
|
||||||
|
to: now,
|
||||||
|
priority: 'history',
|
||||||
|
});
|
||||||
|
else
|
||||||
|
unavailable.push({
|
||||||
|
resourceKey: `candles/${instrument.uid}/all`,
|
||||||
|
reason: 'history_start_unavailable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!force && !['stocks', 'bonds'].includes(screen.screen))
|
||||||
|
sources.push(
|
||||||
|
...accountHistory(accounts, now).map((range) => ({
|
||||||
|
...range,
|
||||||
|
kind: 'operations' as const,
|
||||||
|
priority: 'history' as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const priorities = { discovery: 0, screen: 1, history: 2 };
|
||||||
|
sources.sort(
|
||||||
|
(left, right) => priorities[left.priority] - priorities[right.priority],
|
||||||
|
);
|
||||||
|
return { sources, unavailable, now };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function accountHistory(
|
||||||
|
accounts: Account[],
|
||||||
|
now: Date,
|
||||||
|
): OperationInterval[] {
|
||||||
|
return accounts.flatMap((account) => {
|
||||||
|
const from = new Date(
|
||||||
|
account.openedOn
|
||||||
|
? `${account.openedOn}T00:00:00Z`
|
||||||
|
: '1970-01-01T00:00:00Z',
|
||||||
|
);
|
||||||
|
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ledgerRange(
|
||||||
|
fromValue: string | undefined,
|
||||||
|
toValue: string | undefined,
|
||||||
|
instrumentUid: string | undefined,
|
||||||
|
accounts: Account[],
|
||||||
|
now: Date,
|
||||||
|
) {
|
||||||
|
const opening = accounts.every((account) => account.openedOn)
|
||||||
|
? accounts.map((account) => account.openedOn ?? '').sort()[0]
|
||||||
|
: undefined;
|
||||||
|
const from = fromValue
|
||||||
|
? new Date(fromValue)
|
||||||
|
: instrumentUid
|
||||||
|
? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
|
||||||
|
: new Date(Date.UTC(now.getUTCFullYear(), 0, 1));
|
||||||
|
const end = toValue
|
||||||
|
? new Date(
|
||||||
|
/^\d{4}-\d{2}-\d{2}$/.test(toValue)
|
||||||
|
? `${toValue}T23:59:59.999Z`
|
||||||
|
: toValue,
|
||||||
|
)
|
||||||
|
: now;
|
||||||
|
return {
|
||||||
|
from,
|
||||||
|
to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { accountHistory } from '../../investment-sync-plan';
|
||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankClient,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import {
|
||||||
|
BrokerageOperationsRepository,
|
||||||
|
type OperationAttempt,
|
||||||
|
} from '../infrastructure/brokerage-operations.repository';
|
||||||
|
import {
|
||||||
|
operationIntervals,
|
||||||
|
planOperationRead,
|
||||||
|
type OperationInterval,
|
||||||
|
uncoveredOperationIntervals,
|
||||||
|
} from '../domain/operations-sync-plan';
|
||||||
|
|
||||||
|
const operationsPageSize = 1000;
|
||||||
|
const priorities = { screen: 1, history: 2 } as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BrokerageOperationsSyncService {
|
||||||
|
private readonly processRunId = randomUUID();
|
||||||
|
private readonly runningAttempts = new Set<string>();
|
||||||
|
private readonly ready = new Map<
|
||||||
|
string,
|
||||||
|
{ attempt: OperationAttempt; priority: number }
|
||||||
|
>();
|
||||||
|
private draining = false;
|
||||||
|
// Only short DB reservations/publications are serialized, never RPCs.
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repository: BrokerageOperationsRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async readPlan(
|
||||||
|
userId: string,
|
||||||
|
intervals: OperationInterval[],
|
||||||
|
options: {
|
||||||
|
movingEnd?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
observe?: boolean;
|
||||||
|
history?: boolean;
|
||||||
|
revision?: string;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
|
if (
|
||||||
|
!connection.membershipAt ||
|
||||||
|
(options.revision && options.revision !== connection.revision)
|
||||||
|
)
|
||||||
|
return { plan: null, handle: null };
|
||||||
|
if (
|
||||||
|
intervals.some(
|
||||||
|
(interval) =>
|
||||||
|
!connection.accounts.some(
|
||||||
|
(account) => account.accountId === interval.accountId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const coverage = await this.repository.coverage(
|
||||||
|
userId,
|
||||||
|
connection.revision,
|
||||||
|
intervals.map((interval) => interval.accountId),
|
||||||
|
);
|
||||||
|
const now = new Date();
|
||||||
|
const movingTo = Math.max(
|
||||||
|
...intervals.map((interval) => interval.to.valueOf()),
|
||||||
|
);
|
||||||
|
const targets = intervals.map((interval) =>
|
||||||
|
options.movingEnd && interval.to.valueOf() === movingTo
|
||||||
|
? { ...interval, to: now }
|
||||||
|
: interval,
|
||||||
|
);
|
||||||
|
const plan = planOperationRead(targets, coverage, now, options);
|
||||||
|
const complete = plan.intervals.every(
|
||||||
|
(interval) => interval.availability === 'complete',
|
||||||
|
);
|
||||||
|
const handle =
|
||||||
|
!options.observe && plan.load.length
|
||||||
|
? await this.request(
|
||||||
|
userId,
|
||||||
|
plan.load,
|
||||||
|
complete || Boolean(options.force),
|
||||||
|
'screen',
|
||||||
|
connection.revision,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
if (!options.observe && !options.force && options.history) {
|
||||||
|
const accountIds = new Set(
|
||||||
|
intervals.map((interval) => interval.accountId),
|
||||||
|
);
|
||||||
|
const history = accountHistory(
|
||||||
|
connection.accounts.filter((account) =>
|
||||||
|
accountIds.has(account.accountId),
|
||||||
|
),
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
const missing = planOperationRead(history, coverage, now, {
|
||||||
|
movingEnd: true,
|
||||||
|
}).load;
|
||||||
|
const remaining = uncoveredOperationIntervals(missing, targets);
|
||||||
|
if (remaining.length)
|
||||||
|
await this.request(
|
||||||
|
userId,
|
||||||
|
remaining,
|
||||||
|
false,
|
||||||
|
'history',
|
||||||
|
connection.revision,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { plan, handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(
|
||||||
|
userId: string,
|
||||||
|
intervals: OperationInterval[],
|
||||||
|
atomic: boolean,
|
||||||
|
priority: keyof typeof priorities = 'screen',
|
||||||
|
revision?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
|
if (
|
||||||
|
connection.membershipAt &&
|
||||||
|
intervals.some(
|
||||||
|
(interval) =>
|
||||||
|
!connection.accounts.some(
|
||||||
|
(account) => account.accountId === interval.accountId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
if (revision && revision !== connection.revision) return null;
|
||||||
|
const cutoff = new Date();
|
||||||
|
const bounded = operationIntervals(intervals)
|
||||||
|
.map((interval) => ({
|
||||||
|
...interval,
|
||||||
|
to: new Date(Math.min(interval.to.valueOf(), cutoff.valueOf())),
|
||||||
|
}))
|
||||||
|
.filter((interval) => interval.from < interval.to);
|
||||||
|
if (!bounded.length) return null;
|
||||||
|
const reserve = this.reservations.then(async () => {
|
||||||
|
const requested = await this.repository.request(
|
||||||
|
userId,
|
||||||
|
connection.revision,
|
||||||
|
operationIntervals(bounded),
|
||||||
|
this.processRunId,
|
||||||
|
atomic,
|
||||||
|
this.runningAttempts,
|
||||||
|
priorities[priority],
|
||||||
|
);
|
||||||
|
if (!requested) return null;
|
||||||
|
const { id, attemptId, revision } = requested.work;
|
||||||
|
this.runningAttempts.add(attemptId);
|
||||||
|
this.ready.set(attemptId, {
|
||||||
|
attempt: { id, attemptId, userId, revision },
|
||||||
|
priority: requested.work.priority,
|
||||||
|
});
|
||||||
|
if (!this.draining) {
|
||||||
|
this.draining = true;
|
||||||
|
// Startup and observation never enter this path.
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
this.reservations = reserve.catch(() => undefined);
|
||||||
|
return reserve;
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(userId: string, id: string) {
|
||||||
|
const connection = await this.access.local(userId);
|
||||||
|
const work = await this.repository.observe(userId, connection.revision, id);
|
||||||
|
if (!work) throw new NotFoundException();
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain(): Promise<void> {
|
||||||
|
try {
|
||||||
|
while (this.ready.size) {
|
||||||
|
const selected = [...this.ready.values()].sort(
|
||||||
|
(a, b) => a.priority - b.priority,
|
||||||
|
)[0];
|
||||||
|
const { attempt } = selected;
|
||||||
|
this.ready.delete(attempt.attemptId);
|
||||||
|
let pending = false;
|
||||||
|
try {
|
||||||
|
pending = await this.step(attempt);
|
||||||
|
} catch {
|
||||||
|
this.logger.event(
|
||||||
|
'error',
|
||||||
|
'tbank.operations.sync.persistence_failed',
|
||||||
|
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const next = pending ? await this.repository.pending(attempt) : null;
|
||||||
|
if (next) {
|
||||||
|
const joined = this.ready.get(attempt.attemptId);
|
||||||
|
this.ready.set(attempt.attemptId, {
|
||||||
|
attempt,
|
||||||
|
priority: Math.min(
|
||||||
|
next.priority,
|
||||||
|
joined?.priority ?? next.priority,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.ready.delete(attempt.attemptId);
|
||||||
|
this.runningAttempts.delete(attempt.attemptId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.draining = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async step(attempt: OperationAttempt): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const plan = await this.repository.pending(attempt);
|
||||||
|
if (!plan) return false;
|
||||||
|
if (!plan.next) {
|
||||||
|
const publish = this.reservations.then(async () => {
|
||||||
|
const current = await this.repository.pending(attempt);
|
||||||
|
if (!current) return false;
|
||||||
|
if (current.next) return true;
|
||||||
|
const published = await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
current.intervals,
|
||||||
|
new Date(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
return published && Boolean(await this.repository.pending(attempt));
|
||||||
|
});
|
||||||
|
this.reservations = publish.catch(() => undefined);
|
||||||
|
return await publish;
|
||||||
|
}
|
||||||
|
const { interval, cursor } = plan.next;
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (!token) return false;
|
||||||
|
const response = await this.tbank.getOperationsByCursor(token, {
|
||||||
|
accountId: interval.accountId,
|
||||||
|
// Upstream inclusivity is not assumed; storage selects exact [from,to).
|
||||||
|
from: {
|
||||||
|
seconds: String(
|
||||||
|
Math.floor(interval.from.valueOf() / MILLISECONDS_PER_SECOND) - 1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
seconds: String(
|
||||||
|
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
...(interval.instrumentUid
|
||||||
|
? { instrumentId: interval.instrumentUid }
|
||||||
|
: {}),
|
||||||
|
cursor,
|
||||||
|
limit: operationsPageSize,
|
||||||
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
if (!plan.atomic && !response.hasNext) {
|
||||||
|
if (
|
||||||
|
!(await this.repository.publish(
|
||||||
|
attempt,
|
||||||
|
[interval],
|
||||||
|
new Date(),
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// One page per turn: newly requested screen work can overtake backfill.
|
||||||
|
// TbankClient alone owns the bounded RPC queue and retries.
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await this.repository.fail(
|
||||||
|
attempt,
|
||||||
|
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
import {
|
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
|
||||||
NotFoundException,
|
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
|
|
||||||
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
|
|
||||||
TbankAccessService: class TbankAccessService {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import type { TbankAccountContext } from '../../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
|
||||||
|
|
||||||
describe('BrokerageOperationsService', () => {
|
|
||||||
const access = { withAccount: jest.fn() };
|
|
||||||
const tbank = {
|
|
||||||
getOperationsByCursor: jest.fn(),
|
|
||||||
};
|
|
||||||
const requestContext = { set: jest.fn() };
|
|
||||||
const metadata = { resolve: jest.fn() };
|
|
||||||
const service = new BrokerageOperationsService(
|
|
||||||
access as never,
|
|
||||||
tbank as never,
|
|
||||||
requestContext as never,
|
|
||||||
metadata as never,
|
|
||||||
);
|
|
||||||
let operationsRequest: unknown;
|
|
||||||
let generation: string;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
access.withAccount.mockReset();
|
|
||||||
tbank.getOperationsByCursor.mockReset();
|
|
||||||
metadata.resolve.mockReset();
|
|
||||||
generation = 'generation-1';
|
|
||||||
access.withAccount.mockImplementation(
|
|
||||||
(
|
|
||||||
_userId: string,
|
|
||||||
_accountId: string,
|
|
||||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
|
||||||
) =>
|
|
||||||
handler({
|
|
||||||
token: 'token',
|
|
||||||
generation,
|
|
||||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
tbank.getOperationsByCursor.mockImplementation(
|
|
||||||
(_token: string, request: unknown) => {
|
|
||||||
operationsRequest = request;
|
|
||||||
return Promise.resolve({ items: [] });
|
|
||||||
},
|
|
||||||
);
|
|
||||||
(service as unknown as { pages: Map<string, unknown> }).pages.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
jest.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the current UTC year by default and asks only for executed operations', async () => {
|
|
||||||
await service.get('user', 'account', {});
|
|
||||||
|
|
||||||
const request = operationsRequest as {
|
|
||||||
state: string;
|
|
||||||
from: { seconds: string };
|
|
||||||
};
|
|
||||||
expect(request.state).toBe('OPERATION_STATE_EXECUTED');
|
|
||||||
expect(request.from.seconds).toBe(
|
|
||||||
String(Math.floor(Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000)),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses inclusive date boundaries, filters categories, and preserves cursors', async () => {
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'next',
|
|
||||||
items: [
|
|
||||||
{ id: 'income', date: { seconds: '1' }, type: 'OPERATION_TYPE_COUPON' },
|
|
||||||
{
|
|
||||||
id: 'fee',
|
|
||||||
date: { seconds: '2' },
|
|
||||||
type: 'OPERATION_TYPE_BROKER_FEE',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.get('user', 'account', {
|
|
||||||
from: '2025-01-01',
|
|
||||||
to: '2025-01-31',
|
|
||||||
categories: ['income'],
|
|
||||||
cursor: 'previous',
|
|
||||||
limit: 10,
|
|
||||||
}),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
items: [{ id: 'income' }],
|
|
||||||
nextCursor: 'next',
|
|
||||||
hasNext: true,
|
|
||||||
});
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
expect.objectContaining({
|
|
||||||
from: { seconds: '1735689600' },
|
|
||||||
to: { seconds: '1738367999', nanos: 999_999_999 },
|
|
||||||
cursor: 'previous',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('includes resolved instrument logo metadata in a ledger entry', async () => {
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'coupon',
|
|
||||||
date: { seconds: '1' },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
instrumentUid: 'instrument-uid',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
metadata.resolve.mockResolvedValue({
|
|
||||||
brand: { logoName: 'instrument-logo.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
|
||||||
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves repeated instrument metadata only once per ledger page', async () => {
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'first',
|
|
||||||
date: { seconds: '1' },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
instrumentUid: 'instrument-uid',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'second',
|
|
||||||
date: { seconds: '2' },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
instrumentUid: 'instrument-uid',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
metadata.resolve.mockResolvedValue({
|
|
||||||
brand: { logoName: 'instrument-logo.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.get('user', 'account', {});
|
|
||||||
|
|
||||||
expect(metadata.resolve).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'first',
|
|
||||||
date: { seconds: '1' },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
instrumentUid: 'instrument-uid',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'second',
|
|
||||||
date: { seconds: '2' },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
instrumentUid: 'instrument-uid',
|
|
||||||
figi: 'BBG000000001',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
|
||||||
brand: { logoName: 'fallback-logo.png' },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
|
||||||
items: [
|
|
||||||
{ id: 'first', logoName: null },
|
|
||||||
{ id: 'second', logoName: 'fallback-logo.png' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
'token',
|
|
||||||
'instrument-uid',
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
'token',
|
|
||||||
'instrument-uid',
|
|
||||||
'BBG000000001',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
|
|
||||||
await expect(
|
|
||||||
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
accountId: 'account',
|
|
||||||
items: [],
|
|
||||||
nextCursor: null,
|
|
||||||
hasNext: false,
|
|
||||||
});
|
|
||||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects inaccessible accounts before reading operations', async () => {
|
|
||||||
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
|
||||||
expect.any(NotFoundException),
|
|
||||||
);
|
|
||||||
expect(tbank.getOperationsByCursor).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains existing T-Bank error semantics', async () => {
|
|
||||||
access.withAccount.mockRejectedValueOnce(
|
|
||||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
|
||||||
);
|
|
||||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
tbank.getOperationsByCursor.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
await expect(service.get('user', 'account', {})).rejects.toEqual(
|
|
||||||
expect.any(ServiceUnavailableException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reuses one fresh page for equivalent categories within the default end-time bucket', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
jest.setSystemTime(new Date('2026-08-23T12:00:00.100Z'));
|
|
||||||
|
|
||||||
await service.get('user', 'account', {
|
|
||||||
categories: ['tax', 'income', 'income'],
|
|
||||||
});
|
|
||||||
jest.advanceTimersByTime(500);
|
|
||||||
await service.get('user', 'account', { categories: ['income', 'tax'] });
|
|
||||||
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not let a cached page bypass account ownership checks', async () => {
|
|
||||||
const query = { from: '2025-01-01', to: '2025-01-31' };
|
|
||||||
await service.get('user', 'account', query);
|
|
||||||
access.withAccount.mockRejectedValueOnce(new NotFoundException());
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
|
||||||
expect.any(NotFoundException),
|
|
||||||
);
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reuse a cached ledger page across connection generations', async () => {
|
|
||||||
const query = { from: '2025-01-01', to: '2025-01-31' };
|
|
||||||
|
|
||||||
await service.get('user', 'account', query);
|
|
||||||
await service.get('user', 'account', query);
|
|
||||||
generation = 'generation-2';
|
|
||||||
await service.get('user', 'account', query);
|
|
||||||
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reuse a cached ledger page after the connection is deleted', async () => {
|
|
||||||
const query = { from: '2025-01-01', to: '2025-01-31' };
|
|
||||||
await service.get('user', 'account', query);
|
|
||||||
access.withAccount.mockRejectedValueOnce(
|
|
||||||
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account', query)).rejects.toEqual(
|
|
||||||
expect.any(ConflictException),
|
|
||||||
);
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a late previous-generation page unavailable to ledger and income collection', async () => {
|
|
||||||
let finishPrevious: ((value: unknown) => void) | undefined;
|
|
||||||
const previous = new Promise((resolve) => {
|
|
||||||
finishPrevious = resolve;
|
|
||||||
});
|
|
||||||
tbank.getOperationsByCursor
|
|
||||||
.mockReturnValueOnce(previous)
|
|
||||||
.mockResolvedValueOnce({
|
|
||||||
items: [operation('current')],
|
|
||||||
});
|
|
||||||
const range = { from: '2025-01-01', to: '2025-01-31' };
|
|
||||||
|
|
||||||
const previousRequest = service.collectRanges('user', 'account', [range]);
|
|
||||||
generation = 'generation-2';
|
|
||||||
const current = await service.collectRanges('user', 'account', [range]);
|
|
||||||
finishPrevious?.({ items: [operation('previous')] });
|
|
||||||
await previousRequest;
|
|
||||||
const reused = await service.collectRanges('user', 'account', [range]);
|
|
||||||
|
|
||||||
expect(current.pages[0][0].items[0].id).toBe('current');
|
|
||||||
expect(reused.pages[0][0].items[0].id).toBe('current');
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collects every cursor page and rejects a malformed continuation cursor', async () => {
|
|
||||||
tbank.getOperationsByCursor
|
|
||||||
.mockResolvedValueOnce({ hasNext: true, nextCursor: 'next', items: [] })
|
|
||||||
.mockResolvedValueOnce({ hasNext: false, items: [] });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
service.collectRanges('user', 'account', [
|
|
||||||
{ from: '2026-01-01', to: '2026-01-31' },
|
|
||||||
]),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
pages: [[{ hasNext: true }, { hasNext: false }]],
|
|
||||||
});
|
|
||||||
|
|
||||||
tbank.getOperationsByCursor.mockReset();
|
|
||||||
tbank.getOperationsByCursor.mockResolvedValue({
|
|
||||||
hasNext: true,
|
|
||||||
nextCursor: 'same',
|
|
||||||
items: [],
|
|
||||||
});
|
|
||||||
await expect(
|
|
||||||
service.collectRanges('user', 'account', [
|
|
||||||
{ from: '2026-02-01', to: '2026-02-28' },
|
|
||||||
]),
|
|
||||||
).rejects.toMatchObject({ response: { code: 'TBANK_UNAVAILABLE' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('collects an in-progress Moscow day only through the current instant', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
jest.setSystemTime(new Date('2026-08-27T21:44:18.510Z'));
|
|
||||||
|
|
||||||
await service.collectRanges('user', 'account', [
|
|
||||||
{ from: '2026-08-01', to: '2026-08-28' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledWith(
|
|
||||||
'token',
|
|
||||||
expect.objectContaining({
|
|
||||||
from: { seconds: '1785531600' },
|
|
||||||
to: { seconds: '1787867058' },
|
|
||||||
limit: 1000,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('evicts the least recently used ledger page after one hundred entries', async () => {
|
|
||||||
for (let index = 0; index < 100; index += 1)
|
|
||||||
await service.get('user', 'account', { cursor: String(index) });
|
|
||||||
|
|
||||||
await service.get('user', 'account', { cursor: '0' });
|
|
||||||
await service.get('user', 'account', { cursor: '100' });
|
|
||||||
await service.get('user', 'account', { cursor: '1' });
|
|
||||||
|
|
||||||
const pages = (service as unknown as { pages: { size: number } }).pages;
|
|
||||||
expect(pages.size).toBe(100);
|
|
||||||
expect(tbank.getOperationsByCursor).toHaveBeenCalledTimes(102);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function operation(id: string) {
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
date: { seconds: '1' },
|
|
||||||
type: 'OPERATION_TYPE_COUPON',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
import { operationIntervals, planOperationRead } from './operations-sync-plan';
|
||||||
|
|
||||||
|
describe('operations calendar windows', () => {
|
||||||
|
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
||||||
|
const result = operationIntervals([
|
||||||
|
{
|
||||||
|
accountId: 'a',
|
||||||
|
from: new Date('2024-02-01T00:00:00+03:00'),
|
||||||
|
to: new Date('2024-03-01T00:00:00+03:00'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accountId: 'a',
|
||||||
|
from: new Date('2024-02-10Z'),
|
||||||
|
to: new Date('2024-04-01Z'),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.map(({ from, to }) => [from.toISOString(), to.toISOString()]),
|
||||||
|
).toEqual([
|
||||||
|
['2024-01-31T21:00:00.000Z', '2024-02-01T00:00:00.000Z'],
|
||||||
|
['2024-02-01T00:00:00.000Z', '2024-03-01T00:00:00.000Z'],
|
||||||
|
['2024-03-01T00:00:00.000Z', '2024-04-01T00:00:00.000Z'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not combine account or instrument predicates and rejects invalid ranges', () => {
|
||||||
|
const range = {
|
||||||
|
from: new Date('2026-12-15Z'),
|
||||||
|
to: new Date('2027-01-15Z'),
|
||||||
|
};
|
||||||
|
const result = operationIntervals([
|
||||||
|
{ accountId: 'a', ...range },
|
||||||
|
{ accountId: 'b', ...range },
|
||||||
|
{ accountId: 'a', instrumentUid: 'uid', ...range },
|
||||||
|
]);
|
||||||
|
expect(result).toHaveLength(6);
|
||||||
|
expect(() =>
|
||||||
|
operationIntervals([{ accountId: 'a', from: range.to, to: range.from }]),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operation completeness and freshness', () => {
|
||||||
|
const from = new Date('2026-09-01Z');
|
||||||
|
const asOf = new Date('2026-09-25T12:00:00Z');
|
||||||
|
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
|
||||||
|
|
||||||
|
it.each([1, 299, 300])(
|
||||||
|
'preserves the observed complete end after %s seconds, separately from TTL',
|
||||||
|
(seconds) => {
|
||||||
|
const now = new Date(asOf.valueOf() + seconds * 1000);
|
||||||
|
const plan = planOperationRead(
|
||||||
|
[{ accountId: 'a', from, to: now }],
|
||||||
|
[covered],
|
||||||
|
now,
|
||||||
|
{ movingEnd: true },
|
||||||
|
);
|
||||||
|
expect(plan.intervals[0]).toMatchObject({
|
||||||
|
availability: 'complete',
|
||||||
|
asOf,
|
||||||
|
to: asOf,
|
||||||
|
stale: seconds >= 300,
|
||||||
|
});
|
||||||
|
expect(plan.load).toEqual(
|
||||||
|
seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('preserves a cursor observed end after the clock and calendar advance', () => {
|
||||||
|
const cursorEnd = new Date(asOf.valueOf() + 1000);
|
||||||
|
const plan = planOperationRead(
|
||||||
|
[{ accountId: 'a', from, to: cursorEnd }],
|
||||||
|
[covered],
|
||||||
|
new Date('2026-10-01Z'),
|
||||||
|
{ movingEnd: true },
|
||||||
|
);
|
||||||
|
expect(plan.intervals[0]).toMatchObject({
|
||||||
|
availability: 'complete',
|
||||||
|
to: asOf,
|
||||||
|
asOf,
|
||||||
|
});
|
||||||
|
expect(plan.load).toEqual([]);
|
||||||
|
});
|
||||||
|
it('does not hide earlier holes or reuse an instrument subset as whole-account completeness', () => {
|
||||||
|
const now = new Date(asOf.valueOf() + 1000);
|
||||||
|
const partial = { ...covered, from: new Date('2026-09-10Z') };
|
||||||
|
const plan = planOperationRead(
|
||||||
|
[{ accountId: 'a', from, to: now }],
|
||||||
|
[partial],
|
||||||
|
now,
|
||||||
|
{ movingEnd: true },
|
||||||
|
);
|
||||||
|
expect(plan.intervals[0]).toMatchObject({
|
||||||
|
availability: 'partial',
|
||||||
|
stale: false,
|
||||||
|
});
|
||||||
|
expect(plan.load).toEqual([{ accountId: 'a', from, to: partial.from }]);
|
||||||
|
expect(
|
||||||
|
planOperationRead(
|
||||||
|
[{ accountId: 'a', from, to: now }],
|
||||||
|
[{ ...covered, instrumentUid: 'uid' }],
|
||||||
|
now,
|
||||||
|
).intervals[0].availability,
|
||||||
|
).toBe('missing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loads a newly explicit tail and closes the previous month after rollover', () => {
|
||||||
|
const now = new Date(asOf.valueOf() + 1000);
|
||||||
|
expect(
|
||||||
|
planOperationRead([{ accountId: 'a', from, to: now }], [covered], now)
|
||||||
|
.load,
|
||||||
|
).toEqual([{ accountId: 'a', from: asOf, to: now }]);
|
||||||
|
const monthEnd = new Date('2026-10-01Z');
|
||||||
|
const rollover = new Date('2026-10-01T00:00:01Z');
|
||||||
|
const plan = planOperationRead(
|
||||||
|
[{ accountId: 'a', from, to: rollover }],
|
||||||
|
[covered],
|
||||||
|
rollover,
|
||||||
|
{ movingEnd: true },
|
||||||
|
);
|
||||||
|
expect(plan.load).toEqual([
|
||||||
|
{ accountId: 'a', from: asOf, to: monthEnd },
|
||||||
|
{ accountId: 'a', from: monthEnd, to: rollover },
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
planOperationRead(
|
||||||
|
[{ accountId: 'a', from, to: monthEnd }],
|
||||||
|
[{ ...covered, to: monthEnd, asOf: monthEnd }],
|
||||||
|
rollover,
|
||||||
|
).load,
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('force refresh reloads only the shown target even when coverage is fresh', () => {
|
||||||
|
const target = { accountId: 'a', from: new Date('2026-09-20Z'), to: asOf };
|
||||||
|
expect(
|
||||||
|
planOperationRead([target], [covered], asOf, { force: true }).load,
|
||||||
|
).toEqual([target]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
export type OperationInterval = {
|
||||||
|
accountId: string;
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
instrumentUid?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Normalize duplicate/overlapping targets before creating a durable identity.
|
||||||
|
// Calendar windows keep each provider walk bounded without changing [from,to).
|
||||||
|
export function operationIntervals(
|
||||||
|
requested: OperationInterval[],
|
||||||
|
): OperationInterval[] {
|
||||||
|
const groups = new Map<string, OperationInterval[]>();
|
||||||
|
for (const interval of requested) {
|
||||||
|
if (!(interval.from < interval.to))
|
||||||
|
throw new Error('Invalid operations interval');
|
||||||
|
const key = JSON.stringify([
|
||||||
|
interval.accountId,
|
||||||
|
interval.instrumentUid ?? '',
|
||||||
|
]);
|
||||||
|
const group = groups.get(key) ?? [];
|
||||||
|
group.push(interval);
|
||||||
|
groups.set(key, group);
|
||||||
|
}
|
||||||
|
const result: OperationInterval[] = [];
|
||||||
|
for (const key of [...groups.keys()].sort()) {
|
||||||
|
const merged: OperationInterval[] = [];
|
||||||
|
for (const interval of (groups.get(key) ?? []).sort(
|
||||||
|
(left, right) => left.from.valueOf() - right.from.valueOf(),
|
||||||
|
)) {
|
||||||
|
const previous = merged.at(-1);
|
||||||
|
if (previous && interval.from <= previous.to)
|
||||||
|
previous.to = new Date(
|
||||||
|
Math.max(previous.to.valueOf(), interval.to.valueOf()),
|
||||||
|
);
|
||||||
|
else merged.push({ ...interval });
|
||||||
|
}
|
||||||
|
for (const interval of merged) {
|
||||||
|
let from = interval.from;
|
||||||
|
while (from < interval.to) {
|
||||||
|
const nextMonth = new Date(
|
||||||
|
Date.UTC(from.getUTCFullYear(), from.getUTCMonth() + 1, 1),
|
||||||
|
);
|
||||||
|
const to = new Date(
|
||||||
|
Math.min(nextMonth.valueOf(), interval.to.valueOf()),
|
||||||
|
);
|
||||||
|
result.push({ ...interval, from, to });
|
||||||
|
from = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationIntervalsOverlap(
|
||||||
|
left: OperationInterval,
|
||||||
|
right: OperationInterval,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left.accountId === right.accountId &&
|
||||||
|
(!left.instrumentUid ||
|
||||||
|
!right.instrumentUid ||
|
||||||
|
left.instrumentUid === right.instrumentUid) &&
|
||||||
|
left.from < right.to &&
|
||||||
|
right.from < left.to
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uncoveredOperationIntervals(
|
||||||
|
requested: OperationInterval[],
|
||||||
|
existing: OperationInterval[],
|
||||||
|
): OperationInterval[] {
|
||||||
|
return requested.flatMap((interval) => {
|
||||||
|
let fragments = [interval];
|
||||||
|
for (const covered of existing) {
|
||||||
|
fragments = fragments.flatMap((fragment) => {
|
||||||
|
if (
|
||||||
|
!operationIntervalsOverlap(fragment, covered) ||
|
||||||
|
(covered.instrumentUid &&
|
||||||
|
covered.instrumentUid !== fragment.instrumentUid)
|
||||||
|
)
|
||||||
|
return [fragment];
|
||||||
|
return [
|
||||||
|
...(fragment.from < covered.from
|
||||||
|
? [{ ...fragment, to: covered.from }]
|
||||||
|
: []),
|
||||||
|
...(covered.to < fragment.to
|
||||||
|
? [{ ...fragment, from: covered.to }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return fragments;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OperationCoverage = OperationInterval & {
|
||||||
|
fetchedAt: Date;
|
||||||
|
asOf: Date;
|
||||||
|
};
|
||||||
|
const operationsFreshForMs = 300_000;
|
||||||
|
|
||||||
|
export function planOperationRead(
|
||||||
|
requested: OperationInterval[],
|
||||||
|
coverage: OperationCoverage[],
|
||||||
|
now: Date,
|
||||||
|
options: { movingEnd?: boolean; force?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const monthStart = new Date(
|
||||||
|
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1),
|
||||||
|
);
|
||||||
|
const observedEnd = Math.max(
|
||||||
|
...requested.map((interval) => interval.to.valueOf()),
|
||||||
|
);
|
||||||
|
const load: OperationInterval[] = [];
|
||||||
|
const intervals = operationIntervals(requested).map((target) => {
|
||||||
|
const sources = coverage.filter(
|
||||||
|
(row) =>
|
||||||
|
row.accountId === target.accountId &&
|
||||||
|
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
|
||||||
|
row.from < target.to &&
|
||||||
|
target.from < row.to,
|
||||||
|
);
|
||||||
|
const ranges = sources.map((row) => ({
|
||||||
|
...row,
|
||||||
|
instrumentUid: target.instrumentUid,
|
||||||
|
}));
|
||||||
|
const current = target.from >= monthStart && target.to <= now;
|
||||||
|
const fresh = sources.filter(
|
||||||
|
(row) => now.valueOf() - row.fetchedAt.valueOf() < operationsFreshForMs,
|
||||||
|
);
|
||||||
|
let effective = target;
|
||||||
|
if (options.movingEnd && target.to.valueOf() === observedEnd) {
|
||||||
|
const latest = sources
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.asOf.valueOf() === row.to.valueOf() && row.to > target.from,
|
||||||
|
)
|
||||||
|
.sort((left, right) => right.to.valueOf() - left.to.valueOf())[0];
|
||||||
|
if (latest && latest.to < target.to)
|
||||||
|
effective = { ...target, to: latest.to };
|
||||||
|
}
|
||||||
|
const missing = uncoveredOperationIntervals([effective], ranges);
|
||||||
|
const complete = missing.length === 0;
|
||||||
|
const stale =
|
||||||
|
current &&
|
||||||
|
sources.length > 0 &&
|
||||||
|
uncoveredOperationIntervals(
|
||||||
|
ranges
|
||||||
|
.map((row) => ({
|
||||||
|
...target,
|
||||||
|
from: new Date(
|
||||||
|
Math.max(effective.from.valueOf(), row.from.valueOf()),
|
||||||
|
),
|
||||||
|
to: new Date(Math.min(effective.to.valueOf(), row.to.valueOf())),
|
||||||
|
}))
|
||||||
|
.filter((row) => row.from < row.to),
|
||||||
|
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
|
||||||
|
).length > 0;
|
||||||
|
if (options.force) load.push(target);
|
||||||
|
else if (stale) load.push({ ...target, from: monthStart, to: now });
|
||||||
|
else load.push(...missing);
|
||||||
|
// Completeness is bounded by the observed end, never by the new wall clock.
|
||||||
|
return {
|
||||||
|
...effective,
|
||||||
|
availability: complete
|
||||||
|
? ('complete' as const)
|
||||||
|
: sources.length
|
||||||
|
? ('partial' as const)
|
||||||
|
: ('missing' as const),
|
||||||
|
stale,
|
||||||
|
asOf: sources.length
|
||||||
|
? new Date(
|
||||||
|
Math.min(
|
||||||
|
effective.to.valueOf(),
|
||||||
|
Math.max(...sources.map((row) => row.asOf.valueOf())),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { intervals, load: operationIntervals(load) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import type {
|
||||||
|
TbankOperationItem,
|
||||||
|
TbankOperationsByCursorResponse,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar';
|
||||||
|
import { operationCategory } from './brokerage-operation.mapper';
|
||||||
|
import {
|
||||||
|
type OperationInterval,
|
||||||
|
operationIntervals,
|
||||||
|
operationIntervalsOverlap,
|
||||||
|
uncoveredOperationIntervals,
|
||||||
|
} from '../domain/operations-sync-plan';
|
||||||
|
export type { OperationInterval } from '../domain/operations-sync-plan';
|
||||||
|
|
||||||
|
export type OperationAttempt = {
|
||||||
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
type OperationsIntent = {
|
||||||
|
intervals: string[];
|
||||||
|
atomic: boolean;
|
||||||
|
atomicIntervals?: string[];
|
||||||
|
screenIntervals?: string[];
|
||||||
|
};
|
||||||
|
type PageProgress = {
|
||||||
|
complete: boolean;
|
||||||
|
cursor: string;
|
||||||
|
seen: string[];
|
||||||
|
pages: number;
|
||||||
|
};
|
||||||
|
const progressItem = '';
|
||||||
|
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class BrokerageOperationsRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async readPage(input: {
|
||||||
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
accountId: string;
|
||||||
|
from: Date;
|
||||||
|
to: Date;
|
||||||
|
instrumentUid?: string;
|
||||||
|
categories: string[];
|
||||||
|
limit?: number;
|
||||||
|
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
||||||
|
filterKey: string;
|
||||||
|
cursor?: { binding: string; at: string; accountId: string; id: string };
|
||||||
|
}) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const connection = await tx.tbankConnection.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision: { userId: input.userId, revision: input.revision },
|
||||||
|
invalidToken: false,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
membershipAt: true,
|
||||||
|
membershipRevision: true,
|
||||||
|
accounts: {
|
||||||
|
where:
|
||||||
|
input.accountId === 'all' ? {} : { accountId: input.accountId },
|
||||||
|
orderBy: { accountId: 'asc' },
|
||||||
|
select: {
|
||||||
|
accountId: true,
|
||||||
|
name: true,
|
||||||
|
openedOn: true,
|
||||||
|
operationsRevision: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!connection) return null;
|
||||||
|
if (
|
||||||
|
connection.membershipAt &&
|
||||||
|
input.accountId !== 'all' &&
|
||||||
|
!connection.accounts.length
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
const accounts = connection.accounts;
|
||||||
|
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
||||||
|
const openedAt =
|
||||||
|
oldest && accounts.every((account) => account.openedOn)
|
||||||
|
? new Date(`${oldest}T00:00:00+03:00`)
|
||||||
|
: undefined;
|
||||||
|
const periods = input.periods?.(openedAt).map((period) => {
|
||||||
|
const bounds = dateOnlyBounds(period);
|
||||||
|
// Date-only end is inclusive; persisted coverage is half-open.
|
||||||
|
const end = new Date(`${period.to}T21:00:00Z`);
|
||||||
|
return {
|
||||||
|
...bounds,
|
||||||
|
key: `${period.from}/${period.to}`,
|
||||||
|
movingEnd: end.valueOf() > Date.now(),
|
||||||
|
to: new Date(Math.min(end.valueOf(), Date.now())),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const binding = createHash('sha256')
|
||||||
|
.update(
|
||||||
|
JSON.stringify([
|
||||||
|
input.userId,
|
||||||
|
input.revision,
|
||||||
|
input.accountId === 'all' ? connection.membershipRevision : null,
|
||||||
|
accounts.map((account) => [
|
||||||
|
account.accountId,
|
||||||
|
account.operationsRevision,
|
||||||
|
]),
|
||||||
|
input.filterKey,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.digest('base64url');
|
||||||
|
if (input.cursor && input.cursor.binding !== binding)
|
||||||
|
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
||||||
|
const scope = {
|
||||||
|
userId: input.userId,
|
||||||
|
revision: input.revision,
|
||||||
|
accountId: { in: accounts.map((account) => account.accountId) },
|
||||||
|
};
|
||||||
|
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||||
|
where: scope,
|
||||||
|
});
|
||||||
|
const work = await tx.tbankSyncWork.findMany({
|
||||||
|
where: {
|
||||||
|
userId: input.userId,
|
||||||
|
revision: input.revision,
|
||||||
|
scopeKey: { startsWith: 'operations:' },
|
||||||
|
state: { in: ['running', 'failed'] },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
state: true,
|
||||||
|
errorCode: true,
|
||||||
|
intent: true,
|
||||||
|
priority: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const membershipWork = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_revision_scopeKey: {
|
||||||
|
userId: input.userId,
|
||||||
|
revision: input.revision,
|
||||||
|
scopeKey: 'membership',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
const after = input.cursor;
|
||||||
|
const rows = await tx.tbankOperation.findMany({
|
||||||
|
where: {
|
||||||
|
...scope,
|
||||||
|
...(periods
|
||||||
|
? {
|
||||||
|
OR: periods.map((period) => ({
|
||||||
|
occurredAt: { gte: period.from, lt: period.to },
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
: { occurredAt: { gte: input.from, lt: input.to } }),
|
||||||
|
...(input.instrumentUid
|
||||||
|
? { instrumentUid: input.instrumentUid }
|
||||||
|
: {}),
|
||||||
|
...(input.categories.length
|
||||||
|
? { category: { in: input.categories } }
|
||||||
|
: {}),
|
||||||
|
...(after
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ occurredAt: { lt: new Date(after.at) } },
|
||||||
|
{
|
||||||
|
occurredAt: new Date(after.at),
|
||||||
|
accountId: { gt: after.accountId },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
occurredAt: new Date(after.at),
|
||||||
|
accountId: after.accountId,
|
||||||
|
brokerOperationId: { gt: after.id },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{ occurredAt: 'desc' },
|
||||||
|
{ accountId: 'asc' },
|
||||||
|
{ brokerOperationId: 'asc' },
|
||||||
|
],
|
||||||
|
...(input.limit === undefined ? {} : { take: input.limit + 1 }),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
connection,
|
||||||
|
periods,
|
||||||
|
membershipWork,
|
||||||
|
binding,
|
||||||
|
rows,
|
||||||
|
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
|
||||||
|
...row,
|
||||||
|
instrumentUid: instrumentPredicate || undefined,
|
||||||
|
})),
|
||||||
|
work: work.map(({ intent, ...row }) => ({
|
||||||
|
...row,
|
||||||
|
intervals: (intent as { intervals: string[] }).intervals.map(
|
||||||
|
decodeInterval,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async coverage(userId: string, revision: string, accountIds: string[]) {
|
||||||
|
const rows = await this.prisma.tbankOperationCoverage.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: { in: accountIds },
|
||||||
|
account: { connection: { invalidToken: false } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return rows.map(({ instrumentPredicate, ...row }) => ({
|
||||||
|
...row,
|
||||||
|
instrumentUid: instrumentPredicate || undefined,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
intervals: OperationInterval[],
|
||||||
|
processRunId: string,
|
||||||
|
atomic: boolean,
|
||||||
|
runningAttempts: ReadonlySet<string>,
|
||||||
|
priority: number,
|
||||||
|
) {
|
||||||
|
if (!intervals.length) throw new Error('Empty operations request');
|
||||||
|
const scopeKey =
|
||||||
|
'operations:' +
|
||||||
|
createHash('sha256')
|
||||||
|
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
||||||
|
.digest('hex');
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const locked = await tx.tbankConnection.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
invalidToken: false,
|
||||||
|
membershipAt: { not: null },
|
||||||
|
},
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!locked.count) return null;
|
||||||
|
const accounts = await tx.tbankAccount.findMany({
|
||||||
|
where: { userId, revision },
|
||||||
|
select: { accountId: true, openedOn: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
intervals.some(
|
||||||
|
(interval) =>
|
||||||
|
!accounts.some(
|
||||||
|
(account) => account.accountId === interval.accountId,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
if (priority > 1) {
|
||||||
|
for (const account of accounts.filter((account) =>
|
||||||
|
intervals.some(
|
||||||
|
(interval) => interval.accountId === account.accountId,
|
||||||
|
),
|
||||||
|
))
|
||||||
|
await tx.tbankAccount.update({
|
||||||
|
where: {
|
||||||
|
userId_revision_accountId: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: account.accountId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
historyFrom: new Date(
|
||||||
|
account.openedOn
|
||||||
|
? `${account.openedOn}T00:00:00Z`
|
||||||
|
: '1970-01-01T00:00:00Z',
|
||||||
|
),
|
||||||
|
historyBoundary: account.openedOn ? 'known' : 'unknown',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
accountId: { in: intervals.map((interval) => interval.accountId) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const publicationAtomic =
|
||||||
|
atomic ||
|
||||||
|
intervals.every(
|
||||||
|
(interval) =>
|
||||||
|
uncoveredOperationIntervals(
|
||||||
|
[interval],
|
||||||
|
coverage
|
||||||
|
.filter(
|
||||||
|
(row) =>
|
||||||
|
row.accountId === interval.accountId &&
|
||||||
|
(!row.instrumentPredicate ||
|
||||||
|
row.instrumentPredicate === interval.instrumentUid),
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
accountId: row.accountId,
|
||||||
|
from: row.from,
|
||||||
|
to: row.to,
|
||||||
|
instrumentUid: interval.instrumentUid,
|
||||||
|
})),
|
||||||
|
).length === 0,
|
||||||
|
);
|
||||||
|
const active = await tx.tbankSyncWork.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
state: 'running',
|
||||||
|
scopeKey: { startsWith: 'operations:' },
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
const touching = active.filter((work) => {
|
||||||
|
const pending = (work.intent as { intervals: string[] }).intervals.map(
|
||||||
|
decodeInterval,
|
||||||
|
);
|
||||||
|
return pending.some((existing) =>
|
||||||
|
intervals.some((incoming) =>
|
||||||
|
operationIntervalsOverlap(existing, incoming),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
const previous =
|
||||||
|
touching[0] ??
|
||||||
|
(await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: { userId, revision, scopeKey } },
|
||||||
|
}));
|
||||||
|
const identity = {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: previous?.scopeKey ?? scopeKey,
|
||||||
|
};
|
||||||
|
const previousIntent = previous?.intent as OperationsIntent | undefined;
|
||||||
|
const upgradeGroup =
|
||||||
|
publicationAtomic && previousIntent && !previousIntent.atomic;
|
||||||
|
if (
|
||||||
|
touching.length <= 1 &&
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === processRunId &&
|
||||||
|
runningAttempts.has(previous.attemptId) &&
|
||||||
|
!upgradeGroup
|
||||||
|
) {
|
||||||
|
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||||
|
const additions =
|
||||||
|
previous.scopeKey === scopeKey
|
||||||
|
? []
|
||||||
|
: uncoveredOperationIntervals(intervals, pending);
|
||||||
|
const work = await tx.tbankSyncWork.update({
|
||||||
|
where: { id: previous.id },
|
||||||
|
data: {
|
||||||
|
intent: {
|
||||||
|
atomic: previousIntent?.atomic ?? publicationAtomic,
|
||||||
|
atomicIntervals: previousIntent?.atomic
|
||||||
|
? [
|
||||||
|
...new Set([
|
||||||
|
...(previousIntent.atomicIntervals ??
|
||||||
|
previousIntent.intervals),
|
||||||
|
...additions.map(intervalKey),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
screenIntervals: [
|
||||||
|
...new Set([
|
||||||
|
...(previousIntent?.screenIntervals ??
|
||||||
|
(previous.priority === 1
|
||||||
|
? (previousIntent?.intervals ?? [])
|
||||||
|
: [])),
|
||||||
|
...(priority === 1
|
||||||
|
? [...pending, ...additions]
|
||||||
|
.filter((item) =>
|
||||||
|
intervals.some((target) =>
|
||||||
|
operationIntervalsOverlap(item, target),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map(intervalKey)
|
||||||
|
: []),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
intervals: (priority < previous.priority
|
||||||
|
? [
|
||||||
|
...pending.filter((item) =>
|
||||||
|
intervals.some((target) =>
|
||||||
|
operationIntervalsOverlap(item, target),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...additions,
|
||||||
|
...pending.filter(
|
||||||
|
(item) =>
|
||||||
|
!intervals.some((target) =>
|
||||||
|
operationIntervalsOverlap(item, target),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: [...pending, ...additions]
|
||||||
|
).map(intervalKey),
|
||||||
|
},
|
||||||
|
priority: Math.min(priority, previous.priority),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { work, launch: false };
|
||||||
|
}
|
||||||
|
const recovering =
|
||||||
|
previous &&
|
||||||
|
previous.state !== 'complete' &&
|
||||||
|
previous.state !== 'superseded';
|
||||||
|
const retained = recovering
|
||||||
|
? (previousIntent?.intervals.map(decodeInterval) ?? [])
|
||||||
|
: [];
|
||||||
|
// A bridge between two active groups must fence both producers before
|
||||||
|
// either can publish a subset of the newly combined result.
|
||||||
|
for (const joined of touching) {
|
||||||
|
if (joined.id === previous?.id) continue;
|
||||||
|
retained.push(
|
||||||
|
...(joined.intent as { intervals: string[] }).intervals.map(
|
||||||
|
decodeInterval,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tx.tbankSyncWork.update({
|
||||||
|
where: { id: joined.id },
|
||||||
|
data: {
|
||||||
|
state: 'superseded',
|
||||||
|
progress: { successorId: previous?.id ?? '' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } });
|
||||||
|
}
|
||||||
|
if (previous)
|
||||||
|
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||||
|
const targets =
|
||||||
|
recovering && !upgradeGroup && touching.length <= 1
|
||||||
|
? previous?.scopeKey === scopeKey
|
||||||
|
? retained
|
||||||
|
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
|
||||||
|
: operationIntervals([...retained, ...intervals]);
|
||||||
|
const atomicTargets = [
|
||||||
|
...(publicationAtomic ? intervals : []),
|
||||||
|
...(recovering && previousIntent?.atomic
|
||||||
|
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
|
||||||
|
decodeInterval,
|
||||||
|
)
|
||||||
|
: []),
|
||||||
|
...touching.flatMap((work) => {
|
||||||
|
const joined = work.intent as OperationsIntent;
|
||||||
|
return joined.atomic
|
||||||
|
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const intent = {
|
||||||
|
intervals: targets.map(intervalKey),
|
||||||
|
atomicIntervals: targets
|
||||||
|
.filter((target) =>
|
||||||
|
atomicTargets.some((atomic) =>
|
||||||
|
operationIntervalsOverlap(target, atomic),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map(intervalKey),
|
||||||
|
screenIntervals: targets
|
||||||
|
.filter(
|
||||||
|
(target) =>
|
||||||
|
(priority === 1 &&
|
||||||
|
intervals.some((incoming) =>
|
||||||
|
operationIntervalsOverlap(target, incoming),
|
||||||
|
)) ||
|
||||||
|
(
|
||||||
|
previousIntent?.screenIntervals ??
|
||||||
|
(previous?.priority === 1
|
||||||
|
? (previousIntent?.intervals ?? [])
|
||||||
|
: [])
|
||||||
|
).some((key) =>
|
||||||
|
operationIntervalsOverlap(target, decodeInterval(key)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map(intervalKey),
|
||||||
|
atomic: atomicTargets.length > 0,
|
||||||
|
};
|
||||||
|
const data = {
|
||||||
|
intent,
|
||||||
|
state: 'running',
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId,
|
||||||
|
priority: Math.min(
|
||||||
|
priority,
|
||||||
|
previous?.priority ?? priority,
|
||||||
|
...touching.map((work) => work.priority),
|
||||||
|
),
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: identity },
|
||||||
|
create: { ...identity, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async pending(attempt: OperationAttempt) {
|
||||||
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
select: { intent: true, priority: true },
|
||||||
|
});
|
||||||
|
if (!work) return null;
|
||||||
|
const intent = work.intent as OperationsIntent;
|
||||||
|
const markers = await this.prisma.tbankSyncStage.findMany({
|
||||||
|
where: {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
itemKey: progressItem,
|
||||||
|
},
|
||||||
|
select: { sourceKey: true, payload: true },
|
||||||
|
});
|
||||||
|
const progress = new Map(
|
||||||
|
markers.map((marker) => [
|
||||||
|
marker.sourceKey,
|
||||||
|
marker.payload as PageProgress,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const group = intent.atomic
|
||||||
|
? (intent.atomicIntervals ?? intent.intervals)
|
||||||
|
: intent.intervals;
|
||||||
|
const nextKey = group.find((key) => !progress.get(key)?.complete);
|
||||||
|
return {
|
||||||
|
priority: work.priority,
|
||||||
|
atomic: intent.atomic,
|
||||||
|
intervals: group.map(decodeInterval),
|
||||||
|
next: nextKey
|
||||||
|
? {
|
||||||
|
interval: decodeInterval(nextKey),
|
||||||
|
cursor: progress.get(nextKey)?.cursor || undefined,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async observe(userId: string, revision: string, id: string) {
|
||||||
|
let target = id;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (!seen.has(target)) {
|
||||||
|
seen.add(target);
|
||||||
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
||||||
|
where: {
|
||||||
|
id: target,
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: { startsWith: 'operations:' },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
state: true,
|
||||||
|
progress: true,
|
||||||
|
errorCode: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!work || work.state !== 'superseded')
|
||||||
|
return work ? { ...work, id } : null;
|
||||||
|
target = (work.progress as { successorId: string }).successorId;
|
||||||
|
}
|
||||||
|
throw new Error('Cyclic operations work successor');
|
||||||
|
}
|
||||||
|
|
||||||
|
async fail(attempt: OperationAttempt, invalidToken: boolean) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
if (!(await claim(tx, attempt))) return false;
|
||||||
|
await tx.tbankSyncWork.update({
|
||||||
|
where: { id: attempt.id },
|
||||||
|
data: {
|
||||||
|
state: 'failed',
|
||||||
|
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (invalidToken)
|
||||||
|
await tx.tbankConnection.update({
|
||||||
|
where: {
|
||||||
|
userId_revision: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { invalidToken: true },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async stagePage(
|
||||||
|
attempt: OperationAttempt,
|
||||||
|
interval: OperationInterval,
|
||||||
|
cursor: string | undefined,
|
||||||
|
response: TbankOperationsByCursorResponse,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const sourceKey = intervalKey(interval);
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
if (!(await claim(tx, attempt))) return false;
|
||||||
|
const stage = {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey,
|
||||||
|
};
|
||||||
|
const marker = await tx.tbankSyncStage.findUnique({
|
||||||
|
where: {
|
||||||
|
workId_attemptId_sourceKey_itemKey: {
|
||||||
|
...stage,
|
||||||
|
itemKey: progressItem,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const progress = marker?.payload as PageProgress | undefined;
|
||||||
|
if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? ''))
|
||||||
|
throw new Error('Unexpected operations page');
|
||||||
|
if (response.hasNext === undefined)
|
||||||
|
throw new Error('Missing operations pagination state');
|
||||||
|
const nextCursor = response.nextCursor ?? '';
|
||||||
|
if (
|
||||||
|
response.hasNext &&
|
||||||
|
(!nextCursor ||
|
||||||
|
nextCursor === cursor ||
|
||||||
|
progress?.seen.includes(nextCursor))
|
||||||
|
)
|
||||||
|
throw new Error('Invalid operations continuation');
|
||||||
|
for (const operation of response.items ?? []) {
|
||||||
|
if (!operation.id?.trim())
|
||||||
|
throw new Error('Missing broker operation identity');
|
||||||
|
const occurredAt = new Date(
|
||||||
|
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||||
|
Math.floor(
|
||||||
|
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!Number.isFinite(occurredAt.valueOf()))
|
||||||
|
throw new Error('Invalid operation timestamp');
|
||||||
|
if (
|
||||||
|
occurredAt < interval.from ||
|
||||||
|
occurredAt >= interval.to ||
|
||||||
|
(interval.instrumentUid &&
|
||||||
|
operation.instrumentUid !== interval.instrumentUid)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const payload = JSON.parse(
|
||||||
|
JSON.stringify({ ...operation, cursor: undefined }),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
// Provider pagination position is not part of a financial row.
|
||||||
|
const identity = { ...stage, itemKey: operation.id };
|
||||||
|
const previous = await tx.tbankSyncStage.findUnique({
|
||||||
|
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||||
|
});
|
||||||
|
if (previous && !isDeepStrictEqual(previous.payload, payload))
|
||||||
|
throw new Error('Conflicting broker operation identity');
|
||||||
|
if (!previous)
|
||||||
|
await tx.tbankSyncStage.create({ data: { ...identity, payload } });
|
||||||
|
}
|
||||||
|
const payload: PageProgress = {
|
||||||
|
complete: !response.hasNext,
|
||||||
|
cursor: nextCursor,
|
||||||
|
seen: [
|
||||||
|
...(progress?.seen ?? []),
|
||||||
|
...(response.hasNext ? [nextCursor] : []),
|
||||||
|
],
|
||||||
|
pages: (progress?.pages ?? 0) + 1,
|
||||||
|
};
|
||||||
|
await tx.tbankSyncStage.upsert({
|
||||||
|
where: {
|
||||||
|
workId_attemptId_sourceKey_itemKey: {
|
||||||
|
...stage,
|
||||||
|
itemKey: progressItem,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: { ...stage, itemKey: progressItem, payload },
|
||||||
|
update: { payload },
|
||||||
|
});
|
||||||
|
await tx.tbankSyncWork.update({
|
||||||
|
where: { id: attempt.id },
|
||||||
|
data: { progress: { sourceKey, ...payload } },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A complete shown scope passes every account/month/comparison here together.
|
||||||
|
// Initial missing history may pass one completed interval at a time.
|
||||||
|
async publish(
|
||||||
|
attempt: OperationAttempt,
|
||||||
|
intervals: OperationInterval[],
|
||||||
|
fetchedAt: Date,
|
||||||
|
advanceWork = false,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!intervals.length)
|
||||||
|
throw new Error('Empty operations publication group');
|
||||||
|
return this.prisma.$transaction(
|
||||||
|
async (tx) => {
|
||||||
|
if (!(await claim(tx, attempt))) return false;
|
||||||
|
const changedAccounts = new Set<string>();
|
||||||
|
const groupRows = new Map<string, Prisma.JsonValue>();
|
||||||
|
for (const interval of intervals) {
|
||||||
|
const sourceKey = intervalKey(interval);
|
||||||
|
const stage = {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey,
|
||||||
|
};
|
||||||
|
const marker = await tx.tbankSyncStage.findUnique({
|
||||||
|
where: {
|
||||||
|
workId_attemptId_sourceKey_itemKey: {
|
||||||
|
...stage,
|
||||||
|
itemKey: progressItem,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!(marker?.payload as PageProgress | undefined)?.complete)
|
||||||
|
throw new Error('Incomplete operations publication group');
|
||||||
|
const staged = await tx.tbankSyncStage.findMany({
|
||||||
|
where: { ...stage, itemKey: { not: progressItem } },
|
||||||
|
});
|
||||||
|
for (const row of staged) {
|
||||||
|
const identity = JSON.stringify([interval.accountId, row.itemKey]);
|
||||||
|
const previous = groupRows.get(identity);
|
||||||
|
if (
|
||||||
|
previous !== undefined &&
|
||||||
|
!isDeepStrictEqual(previous, row.payload)
|
||||||
|
)
|
||||||
|
throw new Error(
|
||||||
|
'Conflicting broker operation identity across intervals',
|
||||||
|
);
|
||||||
|
groupRows.set(identity, row.payload);
|
||||||
|
}
|
||||||
|
const rows = staged.filter((row) => {
|
||||||
|
const state = (row.payload as TbankOperationItem).state;
|
||||||
|
return !state || state === 'OPERATION_STATE_EXECUTED';
|
||||||
|
});
|
||||||
|
const scope = {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
accountId: interval.accountId,
|
||||||
|
};
|
||||||
|
const existing = await tx.tbankOperation.findMany({
|
||||||
|
where: {
|
||||||
|
...scope,
|
||||||
|
occurredAt: { gte: interval.from, lt: interval.to },
|
||||||
|
...(interval.instrumentUid
|
||||||
|
? { instrumentUid: interval.instrumentUid }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const incoming = new Set(rows.map((row) => row.itemKey));
|
||||||
|
const removed = existing.filter(
|
||||||
|
(row) => !incoming.has(row.brokerOperationId),
|
||||||
|
);
|
||||||
|
for (const row of removed)
|
||||||
|
await tx.tbankOperation.delete({
|
||||||
|
where: {
|
||||||
|
userId_revision_accountId_brokerOperationId: {
|
||||||
|
...scope,
|
||||||
|
brokerOperationId: row.brokerOperationId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (removed.length) changedAccounts.add(interval.accountId);
|
||||||
|
for (const row of rows) {
|
||||||
|
const operation = row.payload as TbankOperationItem;
|
||||||
|
const identity = { ...scope, brokerOperationId: row.itemKey };
|
||||||
|
const previous = await tx.tbankOperation.findUnique({
|
||||||
|
where: { userId_revision_accountId_brokerOperationId: identity },
|
||||||
|
});
|
||||||
|
if (previous && isDeepStrictEqual(previous.payload, operation))
|
||||||
|
continue;
|
||||||
|
const data = {
|
||||||
|
occurredAt: new Date(
|
||||||
|
Number(operation.date?.seconds) * MILLISECONDS_PER_SECOND +
|
||||||
|
Math.floor(
|
||||||
|
(operation.date?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
instrumentUid: operation.instrumentUid ?? null,
|
||||||
|
category: operationCategory(operation.type),
|
||||||
|
payload: row.payload as Prisma.InputJsonObject,
|
||||||
|
};
|
||||||
|
await tx.tbankOperation.upsert({
|
||||||
|
where: { userId_revision_accountId_brokerOperationId: identity },
|
||||||
|
create: { ...identity, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
changedAccounts.add(interval.accountId);
|
||||||
|
}
|
||||||
|
const coverage = {
|
||||||
|
...scope,
|
||||||
|
instrumentPredicate: interval.instrumentUid ?? '',
|
||||||
|
from: interval.from,
|
||||||
|
to: interval.to,
|
||||||
|
};
|
||||||
|
await tx.tbankOperationCoverage.upsert({
|
||||||
|
where: {
|
||||||
|
userId_revision_accountId_instrumentPredicate_from_to: coverage,
|
||||||
|
},
|
||||||
|
create: { ...coverage, fetchedAt, asOf: interval.to },
|
||||||
|
update: { fetchedAt, asOf: interval.to },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const accountId of changedAccounts)
|
||||||
|
await tx.tbankAccount.update({
|
||||||
|
where: {
|
||||||
|
userId_revision_accountId: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { operationsRevision: { increment: 1 } },
|
||||||
|
});
|
||||||
|
for (const interval of intervals)
|
||||||
|
await tx.tbankSyncStage.deleteMany({
|
||||||
|
where: {
|
||||||
|
workId: attempt.id,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
sourceKey: intervalKey(interval),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (advanceWork) {
|
||||||
|
const work = await tx.tbankSyncWork.findUniqueOrThrow({
|
||||||
|
where: { id: attempt.id },
|
||||||
|
});
|
||||||
|
const intent = work.intent as OperationsIntent;
|
||||||
|
const published = new Set(intervals.map(intervalKey));
|
||||||
|
const remaining = intent.intervals.filter(
|
||||||
|
(key) => !published.has(key),
|
||||||
|
);
|
||||||
|
await tx.tbankSyncWork.update({
|
||||||
|
where: { id: attempt.id },
|
||||||
|
data: {
|
||||||
|
intent: {
|
||||||
|
...intent,
|
||||||
|
intervals: remaining,
|
||||||
|
atomic:
|
||||||
|
intent.atomic &&
|
||||||
|
(intent.atomicIntervals ?? intent.intervals).some(
|
||||||
|
(key) => !published.has(key),
|
||||||
|
),
|
||||||
|
atomicIntervals: (
|
||||||
|
intent.atomicIntervals ?? intent.intervals
|
||||||
|
).filter((key) => !published.has(key)),
|
||||||
|
},
|
||||||
|
state: remaining.length ? 'running' : 'complete',
|
||||||
|
priority: remaining.some((key) =>
|
||||||
|
(
|
||||||
|
intent.screenIntervals ??
|
||||||
|
(work.priority === 1 ? intent.intervals : [])
|
||||||
|
).includes(key),
|
||||||
|
)
|
||||||
|
? 1
|
||||||
|
: 2,
|
||||||
|
progress: { remainingIntervals: remaining.length },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function intervalKey(interval: OperationInterval): string {
|
||||||
|
if (!(interval.from < interval.to))
|
||||||
|
throw new Error('Invalid operations interval');
|
||||||
|
return JSON.stringify([
|
||||||
|
interval.accountId,
|
||||||
|
interval.from.toISOString(),
|
||||||
|
interval.to.toISOString(),
|
||||||
|
interval.instrumentUid ?? '',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claim(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
attempt: OperationAttempt,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { updatedAt: new Date() },
|
||||||
|
});
|
||||||
|
return result.count === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeInterval(key: string): OperationInterval {
|
||||||
|
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
|
||||||
|
return {
|
||||||
|
accountId,
|
||||||
|
from: new Date(from),
|
||||||
|
to: new Date(to),
|
||||||
|
...(instrumentUid ? { instrumentUid } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||||
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
export { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||||
|
|
||||||
|
export {
|
||||||
|
operationIntervals,
|
||||||
|
type OperationInterval,
|
||||||
|
} from './domain/operations-sync-plan';
|
||||||
|
|
||||||
|
export { operationCategories } from './presentation/brokerage-operation.dto';
|
||||||
|
export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
|||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||||
|
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||||
|
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule],
|
imports: [AuthModule, TbankModule],
|
||||||
controllers: [BrokerageOperationsController],
|
controllers: [BrokerageOperationsController],
|
||||||
providers: [BrokerageOperationsService],
|
providers: [
|
||||||
exports: [BrokerageOperationsService],
|
BrokerageOperationsService,
|
||||||
|
BrokerageOperationsRepository,
|
||||||
|
BrokerageOperationsSyncService,
|
||||||
|
],
|
||||||
|
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
|
||||||
})
|
})
|
||||||
export class OperationsModule {}
|
export class OperationsModule {}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
InvestmentReadQueryDto,
|
||||||
|
InvestmentResourceMetaDto,
|
||||||
|
} from '../../investment-resource.dto';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { Transform, Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +34,7 @@ export const operationCategories = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type OperationCategory = (typeof operationCategories)[number];
|
export type OperationCategory = (typeof operationCategories)[number];
|
||||||
|
|
||||||
export class BrokerageOperationsQueryDto {
|
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description:
|
description:
|
||||||
@@ -144,3 +148,10 @@ export class BrokerageOperationsPageDto {
|
|||||||
@ApiProperty({ format: 'date-time' })
|
@ApiProperty({ format: 'date-time' })
|
||||||
declare observedAt: string;
|
declare observedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class BrokerageOperationsResourceDto {
|
||||||
|
@ApiProperty({ type: BrokerageOperationsPageDto, nullable: true })
|
||||||
|
declare data: BrokerageOperationsPageDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
+2
-2
@@ -6,13 +6,13 @@ import { BrokerageOperationsController } from './brokerage-operations.controller
|
|||||||
|
|
||||||
describe('BrokerageOperationsController', () => {
|
describe('BrokerageOperationsController', () => {
|
||||||
it('passes the authenticated user, account, and validated query to the service', async () => {
|
it('passes the authenticated user, account, and validated query to the service', async () => {
|
||||||
const operations = { get: jest.fn() };
|
const operations = { read: jest.fn() };
|
||||||
const controller = new BrokerageOperationsController(operations as never);
|
const controller = new BrokerageOperationsController(operations as never);
|
||||||
const query = { limit: 10 };
|
const query = { limit: 10 };
|
||||||
const request = { user: { sub: 'user' } };
|
const request = { user: { sub: 'user' } };
|
||||||
|
|
||||||
await controller.get(request as never, 'account', query);
|
await controller.get(request as never, 'account', query);
|
||||||
|
|
||||||
expect(operations.get).toHaveBeenCalledWith('user', 'account', query);
|
expect(operations.read).toHaveBeenCalledWith('user', 'account', query);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Request } from 'express';
|
|||||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationsPageDto,
|
BrokerageOperationsResourceDto,
|
||||||
BrokerageOperationsQueryDto,
|
BrokerageOperationsQueryDto,
|
||||||
} from './brokerage-operation.dto';
|
} from './brokerage-operation.dto';
|
||||||
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
|
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
|
||||||
@@ -18,21 +18,21 @@ export class BrokerageOperationsController {
|
|||||||
constructor(private readonly operations: BrokerageOperationsService) {}
|
constructor(private readonly operations: BrokerageOperationsService) {}
|
||||||
|
|
||||||
@Get('all/operations')
|
@Get('all/operations')
|
||||||
@ApiOkResponse({ type: BrokerageOperationsPageDto })
|
@ApiOkResponse({ type: BrokerageOperationsResourceDto })
|
||||||
all(
|
all(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: BrokerageOperationsQueryDto,
|
@Query() query: BrokerageOperationsQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.operations.getAll(request.user.sub, query);
|
return this.operations.read(request.user.sub, 'all', query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/operations')
|
@Get(':accountId/operations')
|
||||||
@ApiOkResponse({ type: BrokerageOperationsPageDto })
|
@ApiOkResponse({ type: BrokerageOperationsResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: BrokerageOperationsQueryDto,
|
@Query() query: BrokerageOperationsQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.operations.get(request.user.sub, accountId, query);
|
return this.operations.read(request.user.sub, accountId, query);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
|
import {
|
||||||
|
ForecastRepository,
|
||||||
|
type ForecastSourceVersion,
|
||||||
|
} from '../infrastructure/forecast.repository';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ForecastService {
|
||||||
|
constructor(private readonly repository: ForecastRepository) {}
|
||||||
|
|
||||||
|
async result<T extends object>(input: {
|
||||||
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
membershipRevision: number;
|
||||||
|
scopeKey: string;
|
||||||
|
portfolio: InvestmentResourceMetaDto;
|
||||||
|
sources: {
|
||||||
|
version: ForecastSourceVersion | null;
|
||||||
|
meta: InvestmentResourceMetaDto;
|
||||||
|
}[];
|
||||||
|
candidate?: T;
|
||||||
|
}) {
|
||||||
|
const dependencies = [
|
||||||
|
input.portfolio,
|
||||||
|
...input.sources.map((source) => source.meta),
|
||||||
|
];
|
||||||
|
const stored = await this.repository.resolve({
|
||||||
|
...input,
|
||||||
|
portfolioProjection: input.portfolio.dataRevision,
|
||||||
|
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
|
||||||
|
dependency.resourceKey.startsWith('portfolio/')
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
accountId: decodeURIComponent(
|
||||||
|
dependency.resourceKey.slice('portfolio/'.length),
|
||||||
|
),
|
||||||
|
dataRevision: Number(
|
||||||
|
(dependency as InvestmentResourceMetaDto).dataRevision,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
sources: input.sources.flatMap((source) =>
|
||||||
|
source.version ? [source.version] : [],
|
||||||
|
),
|
||||||
|
timestamps: dependencies.flatMap((dependency) =>
|
||||||
|
dependency.lastSuccessfulAt
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
resourceKey: dependency.resourceKey,
|
||||||
|
at: dependency.lastSuccessfulAt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const timestamps = stored?.sourceTimestamps as
|
||||||
|
{ resourceKey: string; at: string }[] | undefined;
|
||||||
|
const first = timestamps?.map((entry) => entry.at).sort()[0];
|
||||||
|
const work =
|
||||||
|
dependencies
|
||||||
|
.map((dependency) => dependency.sync)
|
||||||
|
.find((sync) => sync.state === 'running') ??
|
||||||
|
dependencies
|
||||||
|
.map((dependency) => dependency.sync)
|
||||||
|
.find((sync) => sync.state === 'failed');
|
||||||
|
return {
|
||||||
|
data: stored ? (stored.payload as T) : null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: input.scopeKey,
|
||||||
|
dataRevision: String(stored?.dataRevision ?? 0),
|
||||||
|
availability: stored
|
||||||
|
? 'complete'
|
||||||
|
: dependencies.some(
|
||||||
|
(dependency) => dependency.availability !== 'missing',
|
||||||
|
)
|
||||||
|
? 'partial'
|
||||||
|
: 'missing',
|
||||||
|
fetchedAt: first ? new Date(first) : null,
|
||||||
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
|
dependencies,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,373 +1,197 @@
|
|||||||
import {
|
import { setImmediate as immediate } from 'node:timers/promises';
|
||||||
BadRequestException,
|
import { ServiceUnavailableException } from '@nestjs/common';
|
||||||
ConflictException,
|
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||||
ServiceUnavailableException,
|
PrismaService: class {},
|
||||||
} from '@nestjs/common';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
jest.mock('../../../../integrations/tbank/tbank-access.service', () => ({
|
|
||||||
TbankAccessService: class TbankAccessService {},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { InstrumentMetadataService } from '../../../../integrations/tbank/instrument-metadata.service';
|
|
||||||
import type {
|
|
||||||
TbankAccountContext,
|
|
||||||
TbankConnectionContext,
|
|
||||||
} from '../../../../integrations/tbank/tbank-access.service';
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
describe('PortfolioService', () => {
|
describe('persistent portfolio projection', () => {
|
||||||
const access = {
|
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
|
||||||
withConnection: jest.fn(),
|
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
|
||||||
withAccountForConnection: jest.fn(),
|
const metadata = { savedMany: jest.fn(), request: jest.fn() };
|
||||||
|
const repository = {
|
||||||
|
read: jest.fn(),
|
||||||
|
reserve: jest.fn(),
|
||||||
|
publish: jest.fn(),
|
||||||
|
fail: jest.fn(),
|
||||||
};
|
};
|
||||||
const tbank = {
|
let service: PortfolioService;
|
||||||
getPortfolio: jest.fn(),
|
const membershipAt = new Date();
|
||||||
getPositions: jest.fn(),
|
beforeEach(() => {
|
||||||
getInstrumentByFigi: jest.fn(),
|
jest.resetAllMocks();
|
||||||
getInstrumentByUid: jest.fn(),
|
service = new PortfolioService(
|
||||||
};
|
|
||||||
const requestContext = { set: jest.fn() };
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
const metadata = new InstrumentMetadataService(
|
|
||||||
tbank as never,
|
|
||||||
logger as never,
|
|
||||||
);
|
|
||||||
const service = new PortfolioService(
|
|
||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
requestContext as never,
|
{ set: jest.fn() } as never,
|
||||||
metadata,
|
metadata as never,
|
||||||
|
repository as never,
|
||||||
|
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||||
|
{ event: jest.fn() } as never,
|
||||||
|
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
||||||
);
|
);
|
||||||
let generation: string;
|
access.local.mockResolvedValue({
|
||||||
|
revision: 'private',
|
||||||
beforeEach(() => {
|
membershipAt,
|
||||||
jest.clearAllMocks();
|
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
|
||||||
service.invalidate('user');
|
|
||||||
generation = 'generation-1';
|
|
||||||
access.withConnection.mockImplementation(
|
|
||||||
(
|
|
||||||
_userId: string,
|
|
||||||
handler: (context: TbankConnectionContext) => Promise<unknown>,
|
|
||||||
) => handler({ token: 'token', generation }),
|
|
||||||
);
|
|
||||||
access.withAccountForConnection.mockImplementation(
|
|
||||||
(
|
|
||||||
connection: TbankConnectionContext,
|
|
||||||
_accountId: string,
|
|
||||||
handler: (context: TbankAccountContext) => Promise<unknown>,
|
|
||||||
) =>
|
|
||||||
handler({
|
|
||||||
...connection,
|
|
||||||
account: { id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: '' },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
|
||||||
tbank.getPositions.mockResolvedValue({ money: [] });
|
|
||||||
});
|
});
|
||||||
|
access.tokenForAttempt.mockResolvedValue('synthetic');
|
||||||
it('resolves account-scoped access before loading upstream portfolio', async () => {
|
repository.reserve.mockResolvedValue(null);
|
||||||
await service.get('user', 'account');
|
repository.read.mockResolvedValue({
|
||||||
await service.get('user', 'account');
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
accounts: [],
|
||||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
work: [],
|
||||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
});
|
||||||
expect(tbank.getPositions).toHaveBeenCalledTimes(1);
|
metadata.savedMany.mockResolvedValue(new Map());
|
||||||
|
metadata.request.mockResolvedValue(null);
|
||||||
|
});
|
||||||
|
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes upstream order, exact totals, metadata fallback, and cash', async () => {
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
tbank.getPortfolio.mockResolvedValue({
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
figi: 'ONE',
|
figi: 'A',
|
||||||
|
instrumentUid: 'uid',
|
||||||
instrumentType: 'share',
|
instrumentType: 'share',
|
||||||
quantity: { units: 2 },
|
quantity: { units: '2' },
|
||||||
currentPrice: { currency: 'rub', units: 3 },
|
currentPrice: { currency: 'rub', units: '3', nano: 1 },
|
||||||
averagePositionPrice: { currency: 'RUB', units: 2 },
|
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
figi: 'TWO',
|
figi: 'B',
|
||||||
instrumentType: 'new-kind',
|
instrumentType: 'new-kind',
|
||||||
quantity: { nano: -500000000 },
|
quantity: { nano: -500000000 },
|
||||||
currentPrice: { currency: 'USD', units: 4 },
|
currentPrice: { currency: 'USD', units: '4' },
|
||||||
},
|
},
|
||||||
{ figi: 'ZERO', quantity: {} },
|
{ figi: 'zero', instrumentType: 'bond', quantity: { units: '0' } },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
tbank.getPositions.mockResolvedValue({
|
tbank.getPositions.mockResolvedValue({
|
||||||
money: [
|
money: [
|
||||||
{ currency: 'RUB', units: 1 },
|
{ currency: 'rub', units: '1' },
|
||||||
{ currency: 'rub', nano: 500000000 },
|
{ currency: 'EUR', units: '0' },
|
||||||
{ currency: 'USD' },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
tbank.getInstrumentByFigi
|
metadata.savedMany.mockResolvedValue(
|
||||||
.mockResolvedValueOnce({
|
new Map([
|
||||||
ticker: 'ONE',
|
[
|
||||||
name: 'Первая позиция',
|
JSON.stringify(['uid', 'A']),
|
||||||
isin: 'RU000A0JX0J2',
|
{ data: { name: 'Known', ticker: 'A' } },
|
||||||
brand: { logoName: 'RU000A0JX0J2x160.png' },
|
],
|
||||||
})
|
]),
|
||||||
.mockRejectedValueOnce(new Error('unavailable'));
|
);
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
membershipAt,
|
||||||
positions: [
|
membershipRevision: 1,
|
||||||
|
accounts: [
|
||||||
{
|
{
|
||||||
figi: 'ONE',
|
accountId: 'a',
|
||||||
displayName: 'Первая позиция',
|
name: 'A',
|
||||||
type: 'STOCK',
|
type: 'BROKERAGE',
|
||||||
quantity: '2',
|
openedOn: null,
|
||||||
currentValue: { currency: 'RUB', value: '6' },
|
portfolio: null,
|
||||||
absoluteReturn: { currency: 'RUB', value: '2' },
|
|
||||||
isin: 'RU000A0JX0J2',
|
|
||||||
ticker: 'ONE',
|
|
||||||
logoName: 'RU000A0JX0J2x160.png',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
figi: 'TWO',
|
|
||||||
displayName: 'TWO',
|
|
||||||
type: 'OTHER',
|
|
||||||
quantity: '-0.5',
|
|
||||||
currentValue: { currency: 'USD', value: '-2' },
|
|
||||||
isin: null,
|
|
||||||
ticker: null,
|
|
||||||
logoName: null,
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cashBalances: [{ currency: 'RUB', value: '1.5' }],
|
work: [],
|
||||||
totalsByCurrency: [
|
|
||||||
{ currency: 'RUB', value: '7.5' },
|
|
||||||
{ currency: 'USD', value: '-2' },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
await service.read('user', 'a');
|
||||||
|
for (
|
||||||
it('excludes zero and currency-less values while keeping an empty portfolio available', async () => {
|
let index = 0;
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
index < 10 && !repository.publish.mock.calls.length;
|
||||||
positions: [
|
index += 1
|
||||||
{
|
)
|
||||||
quantity: { units: 1 },
|
await immediate();
|
||||||
currentPrice: { currency: 'RUB' },
|
const snapshot = (
|
||||||
},
|
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
|
||||||
{
|
)[0][2];
|
||||||
quantity: { units: 1 },
|
expect(
|
||||||
currentPrice: { units: 10 },
|
snapshot.positions.map((position) => [
|
||||||
},
|
position.displayName,
|
||||||
],
|
position.type,
|
||||||
});
|
position.quantity,
|
||||||
tbank.getPositions.mockResolvedValue({
|
]),
|
||||||
money: [{ currency: 'USD' }, { units: 2 }],
|
).toEqual([
|
||||||
});
|
['Known', 'STOCK', '2'],
|
||||||
|
['B', 'OTHER', '-0.5'],
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
|
||||||
totalsByCurrency: [],
|
|
||||||
cashBalances: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses instrument UID before FIGI when resolving a name', async () => {
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: [
|
|
||||||
{
|
|
||||||
figi: 'FIGI',
|
|
||||||
instrumentUid: 'UID',
|
|
||||||
instrumentType: 'share',
|
|
||||||
quantity: { units: 1 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
tbank.getInstrumentByUid.mockResolvedValue({ name: 'Имя по UID' });
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
|
||||||
positions: [{ displayName: 'Имя по UID', logoName: null }],
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('loads a large mixed portfolio without overflowing metadata requests or losing names', async () => {
|
|
||||||
const positions = Array.from({ length: 60 }, (_, index) => ({
|
|
||||||
figi: `BATCH-${index}`,
|
|
||||||
instrumentType: index % 2 ? 'bond' : 'share',
|
|
||||||
quantity: { units: 2 },
|
|
||||||
currentPrice: { currency: 'RUB', units: 10 },
|
|
||||||
}));
|
|
||||||
tbank.getPortfolio.mockResolvedValue({ positions });
|
|
||||||
let active = 0;
|
|
||||||
tbank.getInstrumentByFigi.mockImplementation(
|
|
||||||
async (_token: string, figi: string) => {
|
|
||||||
active += 1;
|
|
||||||
try {
|
|
||||||
if (active > 50)
|
|
||||||
throw new ServiceUnavailableException({
|
|
||||||
code: 'TBANK_UNAVAILABLE',
|
|
||||||
});
|
|
||||||
await Promise.resolve();
|
|
||||||
return {
|
|
||||||
name: `Название ${figi}`,
|
|
||||||
ticker: figi,
|
|
||||||
isin: `ISIN-${figi}`,
|
|
||||||
brand: { logoName: `${figi}.png` },
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
active -= 1;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const result = await service.get('user', 'account');
|
|
||||||
expect(result.positions).toMatchObject(
|
|
||||||
positions.map((position, index) => ({
|
|
||||||
displayName: `Название BATCH-${index}`,
|
|
||||||
type: position.instrumentType === 'share' ? 'STOCK' : 'BOND',
|
|
||||||
ticker: `BATCH-${index}`,
|
|
||||||
isin: `ISIN-BATCH-${index}`,
|
|
||||||
logoName: `BATCH-${index}.png`,
|
|
||||||
quantity: '2',
|
|
||||||
currentValue: { currency: 'RUB', value: '20' },
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('bounds total metadata waiting and timestamps the financial response before enrichment', async () => {
|
|
||||||
jest.useFakeTimers({ now: new Date('2026-09-12T12:00:00Z') });
|
|
||||||
try {
|
|
||||||
tbank.getPortfolio.mockResolvedValue({
|
|
||||||
positions: Array.from({ length: 60 }, (_, index) => ({
|
|
||||||
figi: `BUDGET-${index}`,
|
|
||||||
instrumentUid: `budget-uid-${index}`,
|
|
||||||
instrumentType: index % 2 ? 'bond' : 'share',
|
|
||||||
quantity: { units: 2 },
|
|
||||||
currentPrice: { currency: 'RUB', units: 10 },
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
tbank.getInstrumentByUid.mockImplementation(() => new Promise(() => {}));
|
|
||||||
const pending = service.get('user', 'account');
|
|
||||||
await jest.advanceTimersByTimeAsync(0);
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
|
||||||
await jest.advanceTimersByTimeAsync(30_000);
|
|
||||||
const result = await pending;
|
|
||||||
expect(result.lastSuccessfulAt).toBe('2026-09-12T12:00:00Z');
|
|
||||||
expect(result.positions).toHaveLength(60);
|
|
||||||
expect(result.positions[59]).toMatchObject({
|
|
||||||
displayName: 'BUDGET-59',
|
|
||||||
currentValue: { currency: 'RUB', value: '20' },
|
|
||||||
});
|
|
||||||
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(5);
|
|
||||||
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
|
||||||
} finally {
|
|
||||||
jest.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses fresh snapshots, falls back only for temporary errors, and clears them', async () => {
|
|
||||||
await service.get('user', 'account');
|
|
||||||
await service.get('user', 'account');
|
|
||||||
expect(access.withConnection).toHaveBeenCalledTimes(2);
|
|
||||||
expect(access.withAccountForConnection).toHaveBeenCalledTimes(1);
|
|
||||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(1);
|
|
||||||
service.invalidate('user');
|
|
||||||
await service.get('user', 'account');
|
|
||||||
expect(tbank.getPortfolio).toHaveBeenCalledTimes(2);
|
|
||||||
const snapshots = (
|
|
||||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
|
||||||
).snapshots;
|
|
||||||
const snapshot = snapshots.get('user:account');
|
|
||||||
assert.ok(snapshot);
|
|
||||||
snapshot.storedAt = Date.now() - 30_001;
|
|
||||||
tbank.getPortfolio.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
|
||||||
isStale: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not mask an invalid token with a snapshot', async () => {
|
|
||||||
await service.get('user', 'account');
|
|
||||||
const snapshots = (
|
|
||||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
|
||||||
).snapshots;
|
|
||||||
const snapshot = snapshots.get('user:account');
|
|
||||||
assert.ok(snapshot);
|
|
||||||
snapshot.storedAt = Date.now() - 30_001;
|
|
||||||
access.withAccountForConnection.mockRejectedValueOnce(
|
|
||||||
new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }),
|
|
||||||
);
|
|
||||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not reuse a snapshot or fallback across connection generations', async () => {
|
|
||||||
await service.get('user', 'account');
|
|
||||||
const snapshots = (
|
|
||||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
|
||||||
).snapshots;
|
|
||||||
const snapshot = snapshots.get('user:account');
|
|
||||||
assert.ok(snapshot);
|
|
||||||
snapshot.storedAt = Date.now() - 30_001;
|
|
||||||
generation = 'generation-2';
|
|
||||||
tbank.getPortfolio.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
|
||||||
expect.any(ServiceUnavailableException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps current-generation fallback when account resolution is temporarily unavailable', async () => {
|
|
||||||
await service.get('user', 'account');
|
|
||||||
const snapshots = (
|
|
||||||
service as never as { snapshots: Map<string, { storedAt: number }> }
|
|
||||||
).snapshots;
|
|
||||||
const snapshot = snapshots.get('user:account');
|
|
||||||
assert.ok(snapshot);
|
|
||||||
snapshot.storedAt = Date.now() - 30_001;
|
|
||||||
access.withAccountForConnection.mockRejectedValueOnce(
|
|
||||||
new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account')).resolves.toMatchObject({
|
|
||||||
isStale: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not return a snapshot when the current connection is absent', async () => {
|
|
||||||
await service.get('user', 'account');
|
|
||||||
access.withConnection.mockRejectedValueOnce(
|
|
||||||
new ConflictException({ code: 'TBANK_NOT_CONNECTED' }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(service.get('user', 'account')).rejects.toEqual(
|
|
||||||
expect.any(ConflictException),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not expose a late previous-generation portfolio snapshot', async () => {
|
|
||||||
let finishPrevious: ((value: { positions: never[] }) => void) | undefined;
|
|
||||||
const previous = new Promise<{ positions: never[] }>((resolve) => {
|
|
||||||
finishPrevious = resolve;
|
|
||||||
});
|
|
||||||
tbank.getPortfolio
|
|
||||||
.mockReturnValueOnce(previous)
|
|
||||||
.mockResolvedValueOnce({ positions: [] })
|
|
||||||
.mockResolvedValueOnce({ positions: [] });
|
|
||||||
tbank.getPositions
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'RUB', units: 1 }] })
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 2 }] })
|
|
||||||
.mockResolvedValueOnce({ money: [{ currency: 'USD', units: 3 }] });
|
|
||||||
|
|
||||||
const previousRequest = service.get('user', 'account');
|
|
||||||
generation = 'generation-2';
|
|
||||||
const current = await service.get('user', 'account');
|
|
||||||
finishPrevious?.({ positions: [] });
|
|
||||||
await previousRequest;
|
|
||||||
const afterLateCompletion = await service.get('user', 'account');
|
|
||||||
|
|
||||||
expect(current.cashBalances).toEqual([{ currency: 'USD', value: '2' }]);
|
|
||||||
expect(afterLateCompletion.cashBalances).toEqual([
|
|
||||||
{ currency: 'USD', value: '3' },
|
|
||||||
]);
|
]);
|
||||||
|
expect(snapshot.positions[0].absoluteReturn).toEqual({
|
||||||
|
currency: 'RUB',
|
||||||
|
value: '2.000000002',
|
||||||
|
});
|
||||||
|
expect(snapshot.totalsByCurrency).toEqual([
|
||||||
|
{ currency: 'RUB', value: '7.000000002' },
|
||||||
|
{ currency: 'USD', value: '-2' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||||
|
});
|
||||||
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
|
const snapshot: PortfolioDto = {
|
||||||
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
|
isStale: false,
|
||||||
|
positions: [],
|
||||||
|
cashBalances: [],
|
||||||
|
structure: { currencies: [], unvaluedCategories: [] },
|
||||||
|
totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
|
||||||
|
};
|
||||||
|
const account = {
|
||||||
|
accountId: 'a',
|
||||||
|
name: 'A',
|
||||||
|
type: 'BROKERAGE',
|
||||||
|
openedOn: null,
|
||||||
|
portfolio: {
|
||||||
|
payload: snapshot,
|
||||||
|
fetchedAt: membershipAt,
|
||||||
|
dataRevision: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
const partial = await service.read('user', 'all', true);
|
||||||
|
expect(partial.data).toBeNull();
|
||||||
|
expect(partial.meta.availability).toBe('partial');
|
||||||
|
const saved = await service.saved('user', 'all', true);
|
||||||
|
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
||||||
|
snapshot.totalsByCurrency,
|
||||||
|
);
|
||||||
|
expect(saved.accounts[1].snapshot).toBeNull();
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [
|
||||||
|
account,
|
||||||
|
{
|
||||||
|
...account,
|
||||||
|
accountId: 'b',
|
||||||
|
portfolio: { ...account.portfolio, fetchedAt: new Date(0) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
const complete = await service.read('user', 'all', true);
|
||||||
|
expect(complete.data?.totalsByCurrency).toEqual([
|
||||||
|
{ currency: 'RUB', value: '2.000000002' },
|
||||||
|
]);
|
||||||
|
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||||
|
expect(complete.data?.isStale).toBe(true);
|
||||||
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('keeps pending data unavailable to internal financial consumers', async () => {
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt: null,
|
||||||
|
membershipRevision: 0,
|
||||||
|
accounts: [],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,17 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { CandleService } from '../../instruments/instruments.api';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
PortfolioRepository,
|
||||||
|
portfolioFreshForMs,
|
||||||
|
portfolioWorkKey,
|
||||||
|
} from '../infrastructure/portfolio.repository';
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
@@ -10,10 +23,14 @@ import { RequestContextService } from '../../../../common/logging/request-contex
|
|||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
|
metadataKey,
|
||||||
|
type TbankInstrument,
|
||||||
|
type MetadataIdentity,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
TbankConnectionContext,
|
|
||||||
TbankMoney,
|
TbankMoney,
|
||||||
|
TbankMembershipService,
|
||||||
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
@@ -24,10 +41,7 @@ import {
|
|||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
} from '../presentation/portfolio.dto';
|
} from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
const freshForMs = 30_000;
|
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||||
const fallbackForMs = 300_000;
|
|
||||||
const positionsBatchSize = 5;
|
|
||||||
const metadataTimeoutMs = 30_000;
|
|
||||||
const types: Record<string, string> = {
|
const types: Record<string, string> = {
|
||||||
share: 'STOCK',
|
share: 'STOCK',
|
||||||
bond: 'BOND',
|
bond: 'BOND',
|
||||||
@@ -39,48 +53,215 @@ const types: Record<string, string> = {
|
|||||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||||
};
|
};
|
||||||
|
|
||||||
type CachedSnapshot = {
|
|
||||||
generation: string;
|
|
||||||
snapshot: PortfolioDto;
|
|
||||||
storedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
private readonly snapshots = new Map<string, CachedSnapshot>();
|
private readonly processRunId = randomUUID();
|
||||||
|
private readonly active = new Set<string>();
|
||||||
|
private readonly pending = new Map<string, Promise<void>>();
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly requestContext: RequestContextService,
|
private readonly requestContext: RequestContextService,
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
|
private readonly repository: PortfolioRepository,
|
||||||
|
private readonly membership: TbankMembershipService,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
private readonly candles: CandleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
async saved(userId: string, accountId: string, observe = false) {
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
||||||
return this.access.withConnection(userId, (connection) =>
|
const connection = await this.access.local(userId, accountId);
|
||||||
this.snapshot(userId, accountId, connection),
|
if (!observe) {
|
||||||
|
const discovery = await this.membership.request(
|
||||||
|
userId,
|
||||||
|
connection.revision,
|
||||||
|
);
|
||||||
|
if (discovery)
|
||||||
|
void discovery.done
|
||||||
|
.then(async () => {
|
||||||
|
const current = await this.access.local(userId, accountId);
|
||||||
|
if (current.revision !== connection.revision) return;
|
||||||
|
for (const account of current.accounts.filter(
|
||||||
|
(account) =>
|
||||||
|
accountId === 'all' || account.accountId === accountId,
|
||||||
|
))
|
||||||
|
await this.queueSnapshot(
|
||||||
|
userId,
|
||||||
|
current.revision,
|
||||||
|
account.accountId,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
this.logger.event(
|
||||||
|
'warn',
|
||||||
|
'tbank.portfolio.discovery_not_available',
|
||||||
|
{ userId },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (const account of (discovery ? [] : connection.accounts).filter(
|
||||||
|
(account) => accountId === 'all' || account.accountId === accountId,
|
||||||
|
))
|
||||||
|
await this.queueSnapshot(
|
||||||
|
userId,
|
||||||
|
connection.revision,
|
||||||
|
account.accountId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const saved = await this.repository.read(userId, connection.revision);
|
||||||
async getAll(userId: string): Promise<PortfolioDto> {
|
if (
|
||||||
return this.access.withSupportedAccounts(userId, async (context) => {
|
saved?.membershipAt &&
|
||||||
const portfolios: PortfolioDto[] = [];
|
accountId !== 'all' &&
|
||||||
for (const account of context.accounts) {
|
!saved.accounts.some((account) => account.accountId === accountId)
|
||||||
const portfolio = await this.snapshot(userId, account.id, context);
|
)
|
||||||
portfolios.push({
|
throw new NotFoundException();
|
||||||
...portfolio,
|
if (!observe) {
|
||||||
positions: portfolio.positions.map((position) => ({
|
const identities = new Map<string, MetadataIdentity>();
|
||||||
...position,
|
for (const account of (saved?.accounts ?? []).filter(
|
||||||
account: { id: account.id, name: account.name },
|
(account) => accountId === 'all' || account.accountId === accountId,
|
||||||
})),
|
)) {
|
||||||
});
|
const snapshot = account.portfolio?.payload as unknown as
|
||||||
|
PortfolioDto | undefined;
|
||||||
|
for (const position of snapshot?.positions ?? [])
|
||||||
|
identities.set(metadataKey(position), position);
|
||||||
}
|
}
|
||||||
|
const savedMetadata = await this.metadata.savedMany([
|
||||||
|
...identities.values(),
|
||||||
|
]);
|
||||||
|
for (const identity of identities.values())
|
||||||
|
if (!savedMetadata.get(metadataKey(identity))?.fresh)
|
||||||
|
await this.metadata.request(userId, connection.revision, identity);
|
||||||
|
}
|
||||||
|
if (!observe)
|
||||||
|
void this.candles
|
||||||
|
.prefetch(
|
||||||
|
userId,
|
||||||
|
connection.revision,
|
||||||
|
(saved?.accounts ?? [])
|
||||||
|
.filter(
|
||||||
|
(account) =>
|
||||||
|
accountId === 'all' || account.accountId === accountId,
|
||||||
|
)
|
||||||
|
.flatMap(
|
||||||
|
(account) =>
|
||||||
|
(
|
||||||
|
account.portfolio?.payload as unknown as
|
||||||
|
PortfolioDto | undefined
|
||||||
|
)?.positions ?? [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.catch(() =>
|
||||||
|
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
||||||
|
);
|
||||||
|
const membershipMeta = investmentResourceMeta({
|
||||||
|
resourceKey: 'accounts/membership',
|
||||||
|
dataRevision: String(saved?.membershipRevision ?? 0),
|
||||||
|
availability: saved?.membershipAt ? 'complete' : 'missing',
|
||||||
|
fetchedAt: saved?.membershipAt ?? null,
|
||||||
|
freshForMs: membershipFreshForMs,
|
||||||
|
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
lastSuccessfulAt:
|
membership: membershipMeta,
|
||||||
|
accounts: (saved?.accounts ?? [])
|
||||||
|
.filter(
|
||||||
|
(account) => accountId === 'all' || account.accountId === accountId,
|
||||||
|
)
|
||||||
|
.map((account) => ({
|
||||||
|
id: account.accountId,
|
||||||
|
name: account.name,
|
||||||
|
type: account.type,
|
||||||
|
openedOn: account.openedOn,
|
||||||
|
snapshot: account.portfolio
|
||||||
|
? {
|
||||||
|
...(account.portfolio.payload as unknown as PortfolioDto),
|
||||||
|
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
||||||
|
isStale:
|
||||||
|
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
||||||
|
portfolioFreshForMs,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||||
|
dataRevision: String(account.portfolio?.dataRevision ?? 0),
|
||||||
|
availability: account.portfolio ? 'complete' : 'missing',
|
||||||
|
fetchedAt: account.portfolio?.fetchedAt ?? null,
|
||||||
|
freshForMs: portfolioFreshForMs,
|
||||||
|
work:
|
||||||
|
saved?.work.find(
|
||||||
|
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
|
||||||
|
) ?? null,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(userId: string, accountId: string, observe = false) {
|
||||||
|
const saved = await this.saved(userId, accountId, observe);
|
||||||
|
const missing =
|
||||||
|
saved.membership.availability === 'missing' ||
|
||||||
|
saved.accounts.some((account) => !account.snapshot);
|
||||||
|
const metadata = await this.metadata.savedMany(
|
||||||
|
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
|
||||||
|
);
|
||||||
|
const metadataDependencies = [...metadata.entries()].flatMap(
|
||||||
|
([key, source]) =>
|
||||||
|
source.complete && source.data
|
||||||
|
? [
|
||||||
|
investmentResourceMeta({
|
||||||
|
resourceKey: `instrument/metadata/${key}`,
|
||||||
|
dataRevision: source.dataRevision,
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: source.fetchedAt,
|
||||||
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
|
work: null,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const portfolios = saved.accounts.flatMap((account) =>
|
||||||
|
account.snapshot
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
...account.snapshot,
|
||||||
|
positions: account.snapshot.positions.map((position) => {
|
||||||
|
const shared = metadata.get(metadataKey(position))?.data;
|
||||||
|
return {
|
||||||
|
...position,
|
||||||
|
...(shared
|
||||||
|
? {
|
||||||
|
displayName:
|
||||||
|
shared.name?.trim() ||
|
||||||
|
shared.ticker?.trim() ||
|
||||||
|
position.displayName,
|
||||||
|
ticker: shared.ticker?.trim() || position.ticker,
|
||||||
|
isin: shared.isin?.trim() || position.isin,
|
||||||
|
logoName:
|
||||||
|
shared.brand?.logoName?.trim() || position.logoName,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(accountId === 'all'
|
||||||
|
? { account: { id: account.id, name: account.name } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const lastSuccessfulAt =
|
||||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||||
new Date().toISOString(),
|
saved.membership.lastSuccessfulAt;
|
||||||
isStale: portfolios.some((portfolio) => portfolio.isStale),
|
const data =
|
||||||
|
missing || !lastSuccessfulAt
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
lastSuccessfulAt,
|
||||||
|
isStale: portfolios.length
|
||||||
|
? portfolios.some((portfolio) => portfolio.isStale)
|
||||||
|
: saved.membership.freshness !== 'fresh',
|
||||||
totalsByCurrency: sumMoneyByCurrency(
|
totalsByCurrency: sumMoneyByCurrency(
|
||||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||||
),
|
),
|
||||||
@@ -93,62 +274,137 @@ export class PortfolioService {
|
|||||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
const work =
|
||||||
|
saved.accounts
|
||||||
|
.map((account) => account.meta.sync)
|
||||||
|
.find((work) => work.state === 'running') ??
|
||||||
|
saved.accounts
|
||||||
|
.map((account) => account.meta.sync)
|
||||||
|
.find((work) => work.state === 'failed') ??
|
||||||
|
saved.membership.sync;
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
meta: investmentResourceMeta({
|
||||||
|
resourceKey: `portfolio/${encodeURIComponent(accountId)}`,
|
||||||
|
dataRevision: JSON.stringify([
|
||||||
|
saved.accounts.map((account) => [
|
||||||
|
account.id,
|
||||||
|
account.meta.dataRevision,
|
||||||
|
]),
|
||||||
|
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||||
|
]),
|
||||||
|
availability: missing
|
||||||
|
? portfolios.length
|
||||||
|
? 'partial'
|
||||||
|
: 'missing'
|
||||||
|
: 'complete',
|
||||||
|
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||||
|
freshForMs: portfolios.length
|
||||||
|
? portfolioFreshForMs
|
||||||
|
: membershipFreshForMs,
|
||||||
|
work: work.id ? { ...work, id: work.id } : null,
|
||||||
|
dependencies: [
|
||||||
|
saved.membership,
|
||||||
|
...saved.accounts.map((account) => account.meta),
|
||||||
|
...metadataDependencies,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async snapshot(
|
request(userId: string, revision: string, accountId: string, force = false) {
|
||||||
|
return this.queueSnapshot(userId, revision, accountId, force);
|
||||||
|
}
|
||||||
|
|
||||||
|
private queueSnapshot(
|
||||||
userId: string,
|
userId: string,
|
||||||
|
revision: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
{ token, generation }: TbankConnectionContext,
|
force = false,
|
||||||
): Promise<PortfolioDto> {
|
) {
|
||||||
const key = `${userId}:${accountId}`;
|
const reserved = this.reservations.then(async () => {
|
||||||
const cached = this.snapshots.get(key);
|
const requested = await this.repository.reserve(
|
||||||
if (
|
userId,
|
||||||
cached?.generation === generation &&
|
revision,
|
||||||
Date.now() - cached.storedAt <= freshForMs
|
|
||||||
)
|
|
||||||
return cached.snapshot;
|
|
||||||
try {
|
|
||||||
const snapshot = await this.access.withAccountForConnection(
|
|
||||||
{ token, generation },
|
|
||||||
accountId,
|
accountId,
|
||||||
async () => {
|
this.processRunId,
|
||||||
|
this.active,
|
||||||
|
force,
|
||||||
|
);
|
||||||
|
if (!requested) return null;
|
||||||
|
if (!requested.launch)
|
||||||
|
return {
|
||||||
|
id: requested.work.id,
|
||||||
|
done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(),
|
||||||
|
};
|
||||||
|
const { id, attemptId } = requested.work;
|
||||||
|
this.active.add(attemptId);
|
||||||
|
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
|
.then(async () => {
|
||||||
|
const attempt = { id, attemptId, userId, revision };
|
||||||
|
try {
|
||||||
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
|
if (!token) return;
|
||||||
const [portfolio, positions] = await Promise.all([
|
const [portfolio, positions] = await Promise.all([
|
||||||
this.tbank.getPortfolio(token, accountId),
|
this.tbank.getPortfolio(token, accountId),
|
||||||
this.tbank.getPositions(token, accountId),
|
this.tbank.getPositions(token, accountId),
|
||||||
]);
|
]);
|
||||||
return this.normalize(
|
const snapshot = await this.normalize(
|
||||||
token,
|
|
||||||
portfolio.positions ?? [],
|
portfolio.positions ?? [],
|
||||||
positions.money ?? [],
|
positions.money ?? [],
|
||||||
);
|
);
|
||||||
},
|
const published = await this.repository.publish(
|
||||||
);
|
attempt,
|
||||||
this.snapshots.set(key, {
|
accountId,
|
||||||
generation,
|
|
||||||
snapshot,
|
snapshot,
|
||||||
storedAt: Date.parse(snapshot.lastSuccessfulAt),
|
);
|
||||||
});
|
if (published) {
|
||||||
return snapshot;
|
for (const position of snapshot.positions)
|
||||||
|
await this.metadata.request(userId, revision, position);
|
||||||
|
if (!force)
|
||||||
|
await this.candles.prefetch(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
snapshot.positions,
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof ServiceUnavailableException &&
|
error instanceof Error &&
|
||||||
cached?.generation === generation &&
|
error.message === 'INVALID_TBANK_TOKEN'
|
||||||
Date.now() - cached.storedAt <= fallbackForMs
|
)
|
||||||
) {
|
await this.access.rejectMembershipToken(attempt);
|
||||||
return { ...cached.snapshot, isStale: true };
|
else await this.repository.fail(attempt);
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
this.logger.event('error', 'tbank.portfolio.persistence_failed', {
|
||||||
|
userId,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.finally(() => {
|
||||||
|
this.active.delete(attemptId);
|
||||||
|
this.pending.delete(attemptId);
|
||||||
|
});
|
||||||
|
this.pending.set(attemptId, done);
|
||||||
|
return { id, done };
|
||||||
|
});
|
||||||
|
this.reservations = reserved.catch(() => undefined);
|
||||||
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidate(userId: string): void {
|
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||||
for (const key of this.snapshots.keys())
|
const result = await this.read(userId, accountId);
|
||||||
if (key.startsWith(`${userId}:`)) this.snapshots.delete(key);
|
if (!result.data)
|
||||||
|
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAll(userId: string): Promise<PortfolioDto> {
|
||||||
|
return this.get(userId, 'all');
|
||||||
}
|
}
|
||||||
|
|
||||||
private async normalize(
|
private async normalize(
|
||||||
token: string,
|
|
||||||
upstream: {
|
upstream: {
|
||||||
figi?: string;
|
figi?: string;
|
||||||
instrumentUid?: string;
|
instrumentUid?: string;
|
||||||
@@ -160,28 +416,13 @@ export class PortfolioService {
|
|||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
): Promise<PortfolioDto> {
|
): Promise<PortfolioDto> {
|
||||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
const controller = new AbortController();
|
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||||
const timeout = setTimeout(() => controller.abort(), metadataTimeoutMs);
|
const positions = upstream.map((position) =>
|
||||||
const positions: (PortfolioPositionDto | null)[] = [];
|
this.position(
|
||||||
try {
|
position,
|
||||||
for (
|
savedMetadata.get(metadataKey(position))?.data ?? null,
|
||||||
let offset = 0;
|
|
||||||
offset < upstream.length;
|
|
||||||
offset += positionsBatchSize
|
|
||||||
) {
|
|
||||||
positions.push(
|
|
||||||
...(await Promise.all(
|
|
||||||
upstream
|
|
||||||
.slice(offset, offset + positionsBatchSize)
|
|
||||||
.map((position) =>
|
|
||||||
this.position(token, position, controller.signal),
|
|
||||||
),
|
),
|
||||||
)),
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
const cash = new Map<string, ReturnType<typeof decimal>>();
|
||||||
for (const value of money) {
|
for (const value of money) {
|
||||||
const currency = value.currency?.trim().toUpperCase();
|
const currency = value.currency?.trim().toUpperCase();
|
||||||
@@ -206,11 +447,10 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async position(
|
private position(
|
||||||
token: string,
|
position: Parameters<PortfolioService['normalize']>[0][number],
|
||||||
position: Parameters<PortfolioService['normalize']>[1][number],
|
metadata: TbankInstrument | null,
|
||||||
signal: AbortSignal,
|
): PortfolioPositionDto | null {
|
||||||
): Promise<PortfolioPositionDto | null> {
|
|
||||||
const quantity = decimal(position.quantity ?? {});
|
const quantity = decimal(position.quantity ?? {});
|
||||||
if (isZero(quantity)) return null;
|
if (isZero(quantity)) return null;
|
||||||
const figi = position.figi?.trim() || null;
|
const figi = position.figi?.trim() || null;
|
||||||
@@ -235,12 +475,6 @@ export class PortfolioService {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const metadata = await this.metadata.resolve(
|
|
||||||
token,
|
|
||||||
instrumentUid,
|
|
||||||
figi,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
return {
|
return {
|
||||||
figi,
|
figi,
|
||||||
instrumentUid,
|
instrumentUid,
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
|
export type ForecastSourceVersion = {
|
||||||
|
instrumentUid: string;
|
||||||
|
source: string;
|
||||||
|
kind: string;
|
||||||
|
rangeKey: string;
|
||||||
|
dataRevision: number;
|
||||||
|
fetchedAt: Date;
|
||||||
|
};
|
||||||
|
const forecastSourceReadBatchSize = 100;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ForecastRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
resolve(input: {
|
||||||
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
membershipRevision: number;
|
||||||
|
scopeKey: string;
|
||||||
|
candidate?: object;
|
||||||
|
portfolioProjection: string;
|
||||||
|
portfolios: { accountId: string; dataRevision: number }[];
|
||||||
|
sources: ForecastSourceVersion[];
|
||||||
|
timestamps: { resourceKey: string; at: string }[];
|
||||||
|
}) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const owner = { userId: input.userId, revision: input.revision };
|
||||||
|
if (input.candidate) {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: {
|
||||||
|
...owner,
|
||||||
|
membershipRevision: input.membershipRevision,
|
||||||
|
invalidToken: false,
|
||||||
|
},
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
}
|
||||||
|
const connection = await tx.tbankConnection.findFirst({
|
||||||
|
where: {
|
||||||
|
...owner,
|
||||||
|
membershipRevision: input.membershipRevision,
|
||||||
|
invalidToken: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!connection) return null;
|
||||||
|
const key = { ...owner, scopeKey: input.scopeKey };
|
||||||
|
const previous = await tx.tbankForecast.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: key },
|
||||||
|
});
|
||||||
|
const fallback =
|
||||||
|
previous?.membershipRevision === input.membershipRevision
|
||||||
|
? previous
|
||||||
|
: null;
|
||||||
|
if (!input.candidate) return fallback;
|
||||||
|
const portfolios = await tx.tbankPortfolio.findMany({
|
||||||
|
where: {
|
||||||
|
...owner,
|
||||||
|
accountId: { in: input.portfolios.map((row) => row.accountId) },
|
||||||
|
},
|
||||||
|
select: { accountId: true, dataRevision: true },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
input.portfolios.some(
|
||||||
|
(expected) =>
|
||||||
|
!portfolios.some(
|
||||||
|
(row) =>
|
||||||
|
row.accountId === expected.accountId &&
|
||||||
|
row.dataRevision === expected.dataRevision,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return fallback;
|
||||||
|
// Four identity parameters per source: stay below SQLite's parameter limit.
|
||||||
|
const sources: ForecastSourceVersion[] = [];
|
||||||
|
for (
|
||||||
|
let offset = 0;
|
||||||
|
offset < input.sources.length;
|
||||||
|
offset += forecastSourceReadBatchSize
|
||||||
|
)
|
||||||
|
sources.push(
|
||||||
|
...(await tx.tbankInstrumentSource.findMany({
|
||||||
|
where: {
|
||||||
|
provider: 'tbank',
|
||||||
|
OR: input.sources
|
||||||
|
.slice(offset, offset + forecastSourceReadBatchSize)
|
||||||
|
.map(({ instrumentUid, source, kind, rangeKey }) => ({
|
||||||
|
instrumentUid,
|
||||||
|
source,
|
||||||
|
kind,
|
||||||
|
rangeKey,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
instrumentUid: true,
|
||||||
|
source: true,
|
||||||
|
kind: true,
|
||||||
|
rangeKey: true,
|
||||||
|
dataRevision: true,
|
||||||
|
fetchedAt: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
input.sources.some(
|
||||||
|
(expected) =>
|
||||||
|
!sources.some(
|
||||||
|
(row) =>
|
||||||
|
row.instrumentUid === expected.instrumentUid &&
|
||||||
|
row.source === expected.source &&
|
||||||
|
row.kind === expected.kind &&
|
||||||
|
row.rangeKey === expected.rangeKey &&
|
||||||
|
row.dataRevision === expected.dataRevision &&
|
||||||
|
row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return fallback;
|
||||||
|
const revisions = JSON.parse(
|
||||||
|
JSON.stringify({
|
||||||
|
portfolioProjection: input.portfolioProjection,
|
||||||
|
portfolios: input.portfolios,
|
||||||
|
sources: input.sources,
|
||||||
|
}),
|
||||||
|
) as Prisma.InputJsonObject;
|
||||||
|
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
||||||
|
if (
|
||||||
|
fallback &&
|
||||||
|
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
||||||
|
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
||||||
|
)
|
||||||
|
return fallback;
|
||||||
|
const data = {
|
||||||
|
membershipRevision: input.membershipRevision,
|
||||||
|
payload: JSON.parse(
|
||||||
|
JSON.stringify(input.candidate),
|
||||||
|
) as Prisma.InputJsonObject,
|
||||||
|
dependencyRevisions: revisions,
|
||||||
|
sourceTimestamps: timestamps,
|
||||||
|
calculatedAt: new Date(),
|
||||||
|
};
|
||||||
|
return tx.tbankForecast.upsert({
|
||||||
|
where: { userId_revision_scopeKey: key },
|
||||||
|
create: { ...key, ...data },
|
||||||
|
update: { ...data, dataRevision: { increment: 1 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import {
|
||||||
|
tbankSourceTtlMs,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
|
type PortfolioAttempt = {
|
||||||
|
id: string;
|
||||||
|
attemptId: string;
|
||||||
|
userId: string;
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
export const portfolioFreshForMs = tbankSourceTtlMs.portfolio;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PortfolioRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read(userId: string, revision: string) {
|
||||||
|
return this.prisma.$transaction((tx) =>
|
||||||
|
tx.tbankConnection.findUnique({
|
||||||
|
where: { userId_revision: { userId, revision }, invalidToken: false },
|
||||||
|
select: {
|
||||||
|
membershipAt: true,
|
||||||
|
membershipRevision: true,
|
||||||
|
accounts: {
|
||||||
|
orderBy: { upstreamOrder: 'asc' },
|
||||||
|
include: { portfolio: true },
|
||||||
|
},
|
||||||
|
work: {
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ scopeKey: 'membership' },
|
||||||
|
{ scopeKey: { startsWith: 'portfolio:' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { scopeKey: true, id: true, state: true, errorCode: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
reserve(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
accountId: string,
|
||||||
|
processRunId: string,
|
||||||
|
active: ReadonlySet<string>,
|
||||||
|
force: boolean,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.tbankConnection.updateMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
invalidToken: false,
|
||||||
|
accounts: { some: { accountId } },
|
||||||
|
},
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!current.count) return null;
|
||||||
|
const snapshot = await tx.tbankPortfolio.findUnique({
|
||||||
|
where: { userId_revision_accountId: { userId, revision, accountId } },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!force &&
|
||||||
|
snapshot &&
|
||||||
|
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date())
|
||||||
|
)
|
||||||
|
return null;
|
||||||
|
const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === processRunId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
const data = {
|
||||||
|
state: 'running',
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId,
|
||||||
|
priority: 1,
|
||||||
|
intent: { source: 'portfolio', accountId },
|
||||||
|
progress: {},
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
work: await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: scope },
|
||||||
|
create: { ...scope, ...data, id: randomUUID() },
|
||||||
|
update: data,
|
||||||
|
}),
|
||||||
|
launch: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
publish(
|
||||||
|
attempt: PortfolioAttempt,
|
||||||
|
accountId: string,
|
||||||
|
snapshot: PortfolioDto,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: {
|
||||||
|
invalidToken: false,
|
||||||
|
accounts: { some: { accountId } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: { state: 'complete', progress: {} },
|
||||||
|
});
|
||||||
|
if (!claimed.count) return false;
|
||||||
|
const scope = {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
accountId,
|
||||||
|
};
|
||||||
|
const data = {
|
||||||
|
payload: JSON.parse(JSON.stringify(snapshot)) as Prisma.InputJsonObject,
|
||||||
|
fetchedAt: new Date(snapshot.lastSuccessfulAt),
|
||||||
|
};
|
||||||
|
await tx.tbankPortfolio.upsert({
|
||||||
|
where: { userId_revision_accountId: scope },
|
||||||
|
create: { ...scope, ...data },
|
||||||
|
update: { ...data, dataRevision: { increment: 1 } },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fail(attempt: PortfolioAttempt) {
|
||||||
|
const result = await this.prisma.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||||
|
});
|
||||||
|
return result.count === 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function portfolioWorkKey(accountId: string): string {
|
||||||
|
return `portfolio:${JSON.stringify(accountId)}`;
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@ export {
|
|||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
} from './presentation/portfolio.dto';
|
} from './presentation/portfolio.dto';
|
||||||
export { PortfolioService } from './application/portfolio.service';
|
export { PortfolioService } from './application/portfolio.service';
|
||||||
|
|
||||||
|
export { ForecastService } from './application/forecast.service';
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { ForecastService } from './application/forecast.service';
|
||||||
|
import { ForecastRepository } from './infrastructure/forecast.repository';
|
||||||
|
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||||
|
import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
@@ -5,9 +9,14 @@ import { PortfolioController } from './presentation/portfolio.controller';
|
|||||||
import { PortfolioService } from './application/portfolio.service';
|
import { PortfolioService } from './application/portfolio.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule],
|
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [PortfolioService],
|
providers: [
|
||||||
exports: [PortfolioService],
|
PortfolioService,
|
||||||
|
PortfolioRepository,
|
||||||
|
ForecastService,
|
||||||
|
ForecastRepository,
|
||||||
|
],
|
||||||
|
exports: [PortfolioService, ForecastService],
|
||||||
})
|
})
|
||||||
export class PortfolioModule {}
|
export class PortfolioModule {}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
import { InvestmentReadQueryDto } from '../../investment-resource.dto';
|
||||||
|
import { Controller, Get, Param, Req, Query, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||||
import { PortfolioDto } from './portfolio.dto';
|
import { PortfolioResourceDto } from './portfolio.dto';
|
||||||
import { PortfolioService } from '../application/portfolio.service';
|
import { PortfolioService } from '../application/portfolio.service';
|
||||||
|
|
||||||
@ApiTags('brokerage-accounts')
|
@ApiTags('brokerage-accounts')
|
||||||
@@ -15,17 +16,29 @@ export class PortfolioController {
|
|||||||
constructor(private readonly portfolio: PortfolioService) {}
|
constructor(private readonly portfolio: PortfolioService) {}
|
||||||
|
|
||||||
@Get('all/portfolio')
|
@Get('all/portfolio')
|
||||||
@ApiOkResponse({ type: PortfolioDto })
|
@ApiOkResponse({ type: PortfolioResourceDto })
|
||||||
all(@Req() request: Request & { user: AccessPayload }) {
|
all(
|
||||||
return this.portfolio.getAll(request.user.sub);
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
|
) {
|
||||||
|
return this.portfolio.read(
|
||||||
|
request.user.sub,
|
||||||
|
'all',
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/portfolio')
|
@Get(':accountId/portfolio')
|
||||||
@ApiOkResponse({ type: PortfolioDto })
|
@ApiOkResponse({ type: PortfolioResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
|
@Query() query: InvestmentReadQueryDto,
|
||||||
) {
|
) {
|
||||||
return this.portfolio.get(request.user.sub, accountId);
|
return this.portfolio.read(
|
||||||
|
request.user.sub,
|
||||||
|
accountId,
|
||||||
|
query.sync === 'observe',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||||
@@ -123,3 +124,10 @@ export class PortfolioDto {
|
|||||||
@ApiProperty({ type: PortfolioStructureDto })
|
@ApiProperty({ type: PortfolioStructureDto })
|
||||||
declare structure: PortfolioStructureDto;
|
declare structure: PortfolioStructureDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PortfolioResourceDto {
|
||||||
|
@ApiProperty({ type: PortfolioDto, nullable: true })
|
||||||
|
declare data: PortfolioDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
|
||||||
import { TbankConnectionController } from './tbank-connection.controller';
|
import { TbankConnectionController } from './tbank-connection.controller';
|
||||||
import { TbankConnectionService } from './tbank-connection.service';
|
import { TbankConnectionService } from './tbank-connection.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, PortfolioModule],
|
imports: [AuthModule, TbankModule],
|
||||||
controllers: [TbankConnectionController],
|
controllers: [TbankConnectionController],
|
||||||
providers: [TbankConnectionService],
|
providers: [TbankConnectionService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
|
||||||
|
|
||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
|
||||||
PrismaService: class PrismaService {},
|
|
||||||
}));
|
|
||||||
|
|
||||||
import type { TbankClient } from '../../../integrations/tbank/tbank.client';
|
|
||||||
import { TbankConnectionService } from './tbank-connection.service';
|
|
||||||
|
|
||||||
describe('TbankConnectionService', () => {
|
|
||||||
const prisma = {
|
|
||||||
tbankConnection: {
|
|
||||||
findUnique: jest.fn(),
|
|
||||||
upsert: jest.fn(),
|
|
||||||
deleteMany: jest.fn(),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const tokens = {
|
|
||||||
encrypt: jest.fn((value: string) => `encrypted:${value}`),
|
|
||||||
translateInvalidToken: jest.fn(
|
|
||||||
async (operation: () => Promise<unknown>) => {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
};
|
|
||||||
const tbank: jest.Mocked<Pick<TbankClient, 'validateToken'>> = {
|
|
||||||
validateToken: jest.fn(),
|
|
||||||
};
|
|
||||||
const portfolio = { invalidate: jest.fn() };
|
|
||||||
const service = new TbankConnectionService(
|
|
||||||
prisma as never,
|
|
||||||
tokens as never,
|
|
||||||
tbank,
|
|
||||||
portfolio as never,
|
|
||||||
);
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
tbank.validateToken.mockResolvedValue(undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not persist an invalid token', async () => {
|
|
||||||
tbank.validateToken.mockRejectedValueOnce(new Error('INVALID_TBANK_TOKEN'));
|
|
||||||
|
|
||||||
await expect(service.put('user-1', ' token ')).rejects.toEqual(
|
|
||||||
expect.any(BadRequestException),
|
|
||||||
);
|
|
||||||
expect(prisma.tbankConnection.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates and encrypts a candidate token before persisting it', async () => {
|
|
||||||
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
|
|
||||||
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
|
|
||||||
|
|
||||||
await expect(service.put('user-1', ' token ')).resolves.toEqual({
|
|
||||||
connected: true,
|
|
||||||
connectedAt: '2026-08-25T09:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(tokens.translateInvalidToken).toHaveBeenCalledWith(
|
|
||||||
expect.any(Function),
|
|
||||||
);
|
|
||||||
expect(tbank.validateToken).toHaveBeenCalledWith('token');
|
|
||||||
expect(tokens.encrypt).toHaveBeenCalledWith('token');
|
|
||||||
expect(prisma.tbankConnection.upsert).toHaveBeenCalledWith({
|
|
||||||
where: { userId: 'user-1' },
|
|
||||||
create: {
|
|
||||||
userId: 'user-1',
|
|
||||||
encryptedToken: 'encrypted:token',
|
|
||||||
connectedAt: expect.any(Date) as Date,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
encryptedToken: 'encrypted:token',
|
|
||||||
connectedAt: expect.any(Date) as Date,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('re-encrypts the same plaintext token for every successful replacement', async () => {
|
|
||||||
const connectedAt = new Date('2026-08-25T09:00:00.000Z');
|
|
||||||
prisma.tbankConnection.upsert.mockResolvedValue({ connectedAt });
|
|
||||||
tokens.encrypt
|
|
||||||
.mockReturnValueOnce('encrypted:first-nonce')
|
|
||||||
.mockReturnValueOnce('encrypted:second-nonce');
|
|
||||||
|
|
||||||
await service.put('user-1', 'same-token');
|
|
||||||
await service.put('user-1', 'same-token');
|
|
||||||
|
|
||||||
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
|
|
||||||
1,
|
|
||||||
expect.objectContaining({
|
|
||||||
update: expect.objectContaining({
|
|
||||||
encryptedToken: 'encrypted:first-nonce',
|
|
||||||
}) as object,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(prisma.tbankConnection.upsert).toHaveBeenNthCalledWith(
|
|
||||||
2,
|
|
||||||
expect.objectContaining({
|
|
||||||
update: expect.objectContaining({
|
|
||||||
encryptedToken: 'encrypted:second-nonce',
|
|
||||||
}) as object,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes a connection idempotently', async () => {
|
|
||||||
await service.delete('user-1');
|
|
||||||
|
|
||||||
expect(prisma.tbankConnection.deleteMany).toHaveBeenCalledWith({
|
|
||||||
where: { userId: 'user-1' },
|
|
||||||
});
|
|
||||||
expect(portfolio.invalidate).toHaveBeenCalledWith('user-1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,9 +2,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
|||||||
import {
|
import {
|
||||||
TbankClient,
|
TbankClient,
|
||||||
TbankTokenService,
|
TbankTokenService,
|
||||||
|
supportedTbankAccounts,
|
||||||
} from '../../../integrations/tbank/tbank.api';
|
} from '../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
|
||||||
|
|
||||||
type TbankTokenValidator = Pick<TbankClient, 'validateToken'>;
|
type TbankTokenValidator = Pick<TbankClient, 'validateToken'>;
|
||||||
|
|
||||||
@@ -14,7 +14,6 @@ export class TbankConnectionService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly tokens: TbankTokenService,
|
private readonly tokens: TbankTokenService,
|
||||||
@Inject(TbankClient) private readonly tbank: TbankTokenValidator,
|
@Inject(TbankClient) private readonly tbank: TbankTokenValidator,
|
||||||
private readonly portfolio: PortfolioService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async status(userId: string) {
|
async status(userId: string) {
|
||||||
@@ -30,23 +29,34 @@ export class TbankConnectionService {
|
|||||||
async put(userId: string, value: string) {
|
async put(userId: string, value: string) {
|
||||||
const token = value.trim();
|
const token = value.trim();
|
||||||
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
|
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
|
||||||
await this.tokens.translateInvalidToken(() =>
|
const accounts = await this.tokens.translateInvalidToken(() =>
|
||||||
this.tbank.validateToken(token),
|
this.tbank.validateToken(token),
|
||||||
);
|
);
|
||||||
const encryptedToken = this.tokens.encrypt(token);
|
const encryptedToken = this.tokens.encrypt(token);
|
||||||
const connection = await this.prisma.tbankConnection.upsert({
|
const connection = await this.prisma.$transaction(async (tx) => {
|
||||||
where: { userId },
|
// Cascade old private data and attempts in the same transaction that
|
||||||
create: {
|
// publishes the validated membership and the new connection revision.
|
||||||
|
await tx.tbankConnection.deleteMany({ where: { userId } });
|
||||||
|
const now = new Date();
|
||||||
|
return tx.tbankConnection.create({
|
||||||
|
data: {
|
||||||
userId,
|
userId,
|
||||||
encryptedToken,
|
encryptedToken,
|
||||||
connectedAt: new Date(),
|
connectedAt: now,
|
||||||
|
membershipAt: now,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: {
|
||||||
|
create: supportedTbankAccounts(accounts).map(
|
||||||
|
({ id, ...account }, upstreamOrder) => ({
|
||||||
|
...account,
|
||||||
|
accountId: id,
|
||||||
|
upstreamOrder,
|
||||||
|
}),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
update: {
|
|
||||||
encryptedToken,
|
|
||||||
connectedAt: new Date(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.portfolio.invalidate(userId);
|
});
|
||||||
return {
|
return {
|
||||||
connected: true,
|
connected: true,
|
||||||
connectedAt: connection.connectedAt.toISOString(),
|
connectedAt: connection.connectedAt.toISOString(),
|
||||||
@@ -55,6 +65,5 @@ export class TbankConnectionService {
|
|||||||
|
|
||||||
async delete(userId: string): Promise<void> {
|
async delete(userId: string): Promise<void> {
|
||||||
await this.prisma.tbankConnection.deleteMany({ where: { userId } });
|
await this.prisma.tbankConnection.deleteMany({ where: { userId } });
|
||||||
this.portfolio.invalidate(userId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { normalizeScreen } from './tbank-screen';
|
||||||
|
|
||||||
|
describe('manual sync screen boundary', () => {
|
||||||
|
it('normalizes equivalent descriptors to the same scope', () => {
|
||||||
|
expect(normalizeScreen({ screen: 'stocks', query: ' sber ' })).toEqual({
|
||||||
|
screen: 'stocks',
|
||||||
|
query: 'SBER',
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
JSON.stringify(
|
||||||
|
normalizeScreen({
|
||||||
|
screen: 'operations',
|
||||||
|
accountId: 'a',
|
||||||
|
categories: ['tax', 'income', 'tax'],
|
||||||
|
limit: 20,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(
|
||||||
|
JSON.stringify(
|
||||||
|
normalizeScreen({
|
||||||
|
limit: 20,
|
||||||
|
categories: ['income', 'tax'],
|
||||||
|
accountId: 'a',
|
||||||
|
screen: 'operations',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it('rejects missing identities and fields that would expand another screen', () => {
|
||||||
|
for (const scope of [
|
||||||
|
{ screen: 'accounts', accountId: 'a' },
|
||||||
|
{ screen: 'account' },
|
||||||
|
{ screen: 'stock' },
|
||||||
|
{ screen: 'stocks', query: ' ' },
|
||||||
|
{ screen: 'events', accountId: 'a', instrumentUid: 'uid' },
|
||||||
|
{ screen: 'income', accountId: 'a', from: '2026-01-01' },
|
||||||
|
{
|
||||||
|
screen: 'events',
|
||||||
|
accountId: 'a',
|
||||||
|
from: '2026-02-01',
|
||||||
|
to: '2026-01-01',
|
||||||
|
},
|
||||||
|
] as Parameters<typeof normalizeScreen>[0][])
|
||||||
|
expect(() => normalizeScreen(scope)).toThrow();
|
||||||
|
});
|
||||||
|
it('retains inclusive calendar days, rejecting instant values for date-only owners', () => {
|
||||||
|
const scope = {
|
||||||
|
screen: 'events' as const,
|
||||||
|
accountId: 'all',
|
||||||
|
from: '2026-01-01',
|
||||||
|
to: '2026-01-01',
|
||||||
|
};
|
||||||
|
expect(normalizeScreen(scope)).toEqual(scope);
|
||||||
|
expect(() =>
|
||||||
|
normalizeScreen({ ...scope, from: '2026-01-01T00:00:00Z' }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
normalizeScreen({
|
||||||
|
screen: 'stock',
|
||||||
|
instrumentUid: 'uid',
|
||||||
|
candleRange: { from: '2026-01-02', to: '2026-01-01' },
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||||
|
|
||||||
|
// Reject fields from other screens before reserving any source work.
|
||||||
|
export function normalizeScreen(
|
||||||
|
input: InvestmentScreenDto,
|
||||||
|
): InvestmentScreenDto {
|
||||||
|
const commonPeriod = ['from', 'to'];
|
||||||
|
const ledger = [...commonPeriod, 'categories', 'limit'];
|
||||||
|
const allowed: Record<InvestmentScreenDto['screen'], string[]> = {
|
||||||
|
accounts: [],
|
||||||
|
account: ['accountId', ...ledger],
|
||||||
|
operations: ['accountId', 'instrumentUid', ...ledger],
|
||||||
|
income: ['accountId', ...commonPeriod],
|
||||||
|
events: ['accountId', ...commonPeriod],
|
||||||
|
stocks: ['query'],
|
||||||
|
bonds: ['query'],
|
||||||
|
stock: [
|
||||||
|
'accountId',
|
||||||
|
'instrumentUid',
|
||||||
|
...ledger,
|
||||||
|
'candlePeriod',
|
||||||
|
'candleRange',
|
||||||
|
],
|
||||||
|
bond: [
|
||||||
|
'accountId',
|
||||||
|
'instrumentUid',
|
||||||
|
...ledger,
|
||||||
|
'candlePeriod',
|
||||||
|
'candleRange',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const fail = (): never => {
|
||||||
|
throw new BadRequestException({
|
||||||
|
message: [
|
||||||
|
{
|
||||||
|
property: 'scope',
|
||||||
|
constraints: { scope: 'Invalid screen descriptor' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (!allowed[input.screen]) fail();
|
||||||
|
for (const key of Object.keys(input))
|
||||||
|
if (
|
||||||
|
key !== 'screen' &&
|
||||||
|
input[key as keyof InvestmentScreenDto] !== undefined &&
|
||||||
|
!allowed[input.screen].includes(key)
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
const accountId = input.accountId?.trim();
|
||||||
|
const query = input.query?.trim().toUpperCase();
|
||||||
|
if (
|
||||||
|
['account', 'operations', 'income', 'events'].includes(input.screen) &&
|
||||||
|
!accountId
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
if (
|
||||||
|
['stocks', 'bonds'].includes(input.screen) &&
|
||||||
|
(!query || query.length < 2)
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail();
|
||||||
|
if (input.accountId !== undefined && !accountId) fail();
|
||||||
|
if (['income', 'events'].includes(input.screen)) {
|
||||||
|
if (Boolean(input.from) !== Boolean(input.to)) fail();
|
||||||
|
for (const value of [input.from, input.to])
|
||||||
|
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail();
|
||||||
|
}
|
||||||
|
if (input.from && input.to && new Date(input.from) > new Date(input.to))
|
||||||
|
fail();
|
||||||
|
if (
|
||||||
|
input.candleRange &&
|
||||||
|
new Date(input.candleRange.from) >= new Date(input.candleRange.to)
|
||||||
|
)
|
||||||
|
fail();
|
||||||
|
// Fixed field order is part of coalescing, irrespective of JSON input order.
|
||||||
|
return {
|
||||||
|
screen: input.screen,
|
||||||
|
...(accountId ? { accountId } : {}),
|
||||||
|
...(input.from ? { from: input.from } : {}),
|
||||||
|
...(input.to ? { to: input.to } : {}),
|
||||||
|
...(input.instrumentUid
|
||||||
|
? { instrumentUid: input.instrumentUid.toLowerCase() }
|
||||||
|
: {}),
|
||||||
|
...(query ? { query } : {}),
|
||||||
|
...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}),
|
||||||
|
...(input.candleRange
|
||||||
|
? {
|
||||||
|
candleRange: {
|
||||||
|
from: new Date(input.candleRange.from).toISOString(),
|
||||||
|
to: new Date(input.candleRange.to).toISOString(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(input.categories
|
||||||
|
? { categories: [...new Set(input.categories)].sort() }
|
||||||
|
: {}),
|
||||||
|
...(input.limit !== undefined ? { limit: input.limit } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
BadRequestException,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
ValidationPipe,
|
||||||
|
type ValidationError,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiAcceptedResponse,
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
|
import {
|
||||||
|
InvestmentScreenDto,
|
||||||
|
TbankSyncAcceptedDto,
|
||||||
|
TbankSyncStatusDto,
|
||||||
|
} from './tbank-sync.dto';
|
||||||
|
import { TbankSyncService } from './tbank-sync.service';
|
||||||
|
|
||||||
|
@ApiTags('tbank-sync')
|
||||||
|
@ApiProblemResponses()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('tbank-sync')
|
||||||
|
export class TbankSyncController {
|
||||||
|
constructor(private readonly sync: TbankSyncService) {}
|
||||||
|
@Post()
|
||||||
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
|
@ApiBody({ type: InvestmentScreenDto })
|
||||||
|
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
|
||||||
|
request(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Body(
|
||||||
|
new ValidationPipe({
|
||||||
|
expectedType: InvestmentScreenDto,
|
||||||
|
transform: true,
|
||||||
|
whitelist: true,
|
||||||
|
forbidNonWhitelisted: true,
|
||||||
|
exceptionFactory: (message) =>
|
||||||
|
new BadRequestException({ message: descriptorErrors(message) }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
input: unknown,
|
||||||
|
) {
|
||||||
|
return this.sync.request(request.user.sub, input as InvestmentScreenDto);
|
||||||
|
}
|
||||||
|
@Get(':id')
|
||||||
|
@ApiOkResponse({ type: TbankSyncStatusDto })
|
||||||
|
status(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Param('id') id: string,
|
||||||
|
) {
|
||||||
|
return this.sync.status(request.user.sub, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function descriptorErrors(
|
||||||
|
errors: ValidationError[],
|
||||||
|
prefix = '',
|
||||||
|
): ValidationError[] {
|
||||||
|
return errors.flatMap((error) => [
|
||||||
|
{ ...error, property: `${prefix}${error.property}` },
|
||||||
|
...descriptorErrors(error.children ?? [], `${prefix}${error.property}/`),
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsIn,
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Max,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateNested,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
import { operationCategories } from '../operations/operations.api';
|
||||||
|
|
||||||
|
const maxAccountIdLength = 256;
|
||||||
|
const maxQueryLength = 80;
|
||||||
|
const maxPageSize = 1000;
|
||||||
|
|
||||||
|
export const investmentScreens = [
|
||||||
|
'accounts',
|
||||||
|
'account',
|
||||||
|
'operations',
|
||||||
|
'income',
|
||||||
|
'events',
|
||||||
|
'stocks',
|
||||||
|
'bonds',
|
||||||
|
'stock',
|
||||||
|
'bond',
|
||||||
|
] as const;
|
||||||
|
export class SyncCandleRangeDto {
|
||||||
|
@ApiProperty({ format: 'date-time' }) @IsDateString() declare from: string;
|
||||||
|
@ApiProperty({ format: 'date-time' }) @IsDateString() declare to: string;
|
||||||
|
}
|
||||||
|
export class InvestmentScreenDto {
|
||||||
|
@ApiProperty({ enum: investmentScreens })
|
||||||
|
@IsIn(investmentScreens)
|
||||||
|
declare screen: (typeof investmentScreens)[number];
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(maxAccountIdLength)
|
||||||
|
accountId?: string;
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
from?: string;
|
||||||
|
@ApiPropertyOptional({ format: 'date-time' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString({ strict: true })
|
||||||
|
to?: string;
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
instrumentUid?: string;
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(maxQueryLength)
|
||||||
|
query?: string;
|
||||||
|
@ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] })
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['month', 'halfYear', 'year', 'all'])
|
||||||
|
candlePeriod?: 'month' | 'halfYear' | 'year' | 'all';
|
||||||
|
@ApiPropertyOptional({ type: SyncCandleRangeDto })
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => SyncCandleRangeDto)
|
||||||
|
candleRange?: SyncCandleRangeDto;
|
||||||
|
@ApiPropertyOptional({ enum: operationCategories, isArray: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsIn(operationCategories, { each: true })
|
||||||
|
categories?: (typeof operationCategories)[number][];
|
||||||
|
@ApiPropertyOptional({ minimum: 1, maximum: maxPageSize })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(maxPageSize)
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
export class TbankSyncAcceptedDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
|
@ApiProperty({ type: InvestmentScreenDto })
|
||||||
|
declare scope: InvestmentScreenDto;
|
||||||
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
|
}
|
||||||
|
export class TbankSyncProgressDto {
|
||||||
|
@ApiProperty() declare completed: number;
|
||||||
|
@ApiProperty() declare total: number;
|
||||||
|
@ApiProperty() declare discovering: boolean;
|
||||||
|
}
|
||||||
|
export class TbankSyncStatusDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare id: string;
|
||||||
|
@ApiProperty({ type: InvestmentScreenDto, nullable: true })
|
||||||
|
declare scope: InvestmentScreenDto | null;
|
||||||
|
@ApiProperty({ format: 'date-time' }) declare now: string;
|
||||||
|
@ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] })
|
||||||
|
declare state: 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||||
|
@ApiProperty({ type: String, nullable: true }) declare errorCode:
|
||||||
|
string | null;
|
||||||
|
@ApiProperty({ type: TbankSyncProgressDto })
|
||||||
|
declare progress: TbankSyncProgressDto;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true })
|
||||||
|
declare resources: InvestmentResourceMetaDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
|
import { OperationsModule } from '../operations/operations.module';
|
||||||
|
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||||
|
import { InstrumentsModule } from '../instruments/instruments.module';
|
||||||
|
import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module';
|
||||||
|
import { IncomeModule } from '../income/income.module';
|
||||||
|
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
|
||||||
|
import { TbankSyncController } from './tbank-sync.controller';
|
||||||
|
import { TbankSyncService } from './tbank-sync.service';
|
||||||
|
import { TbankSyncRepository } from './tbank-sync.repository';
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
TbankModule,
|
||||||
|
PortfolioModule,
|
||||||
|
OperationsModule,
|
||||||
|
InstrumentSourcesModule,
|
||||||
|
InstrumentsModule,
|
||||||
|
BrokerageAccountsModule,
|
||||||
|
IncomeModule,
|
||||||
|
UpcomingEventsModule,
|
||||||
|
],
|
||||||
|
controllers: [TbankSyncController],
|
||||||
|
providers: [TbankSyncService, TbankSyncRepository],
|
||||||
|
})
|
||||||
|
export class TbankSyncModule {}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import type { Prisma } from '../../../../generated/prisma/client';
|
||||||
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||||
|
import type { InvestmentScreenDto } from './tbank-sync.dto';
|
||||||
|
export type ScreenIntent = { scope: InvestmentScreenDto; now: string };
|
||||||
|
export type ScreenProgress = {
|
||||||
|
children: string[];
|
||||||
|
attempts?: Record<string, string>;
|
||||||
|
dispatched: boolean;
|
||||||
|
};
|
||||||
|
@Injectable()
|
||||||
|
export class TbankSyncRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
reserve(
|
||||||
|
userId: string,
|
||||||
|
revision: string,
|
||||||
|
scope: InvestmentScreenDto,
|
||||||
|
runId: string,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const valid = await tx.tbankConnection.updateMany({
|
||||||
|
where: { userId, revision, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
if (!valid.count) return null;
|
||||||
|
const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`;
|
||||||
|
const key = { userId, revision, scopeKey };
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: key },
|
||||||
|
});
|
||||||
|
if (previous?.state === 'running' && previous.processRunId === runId) {
|
||||||
|
const progress = previous.progress as ScreenProgress;
|
||||||
|
const children = progress.children ?? [];
|
||||||
|
if (
|
||||||
|
!progress.dispatched ||
|
||||||
|
(await tx.tbankSyncWork.count({
|
||||||
|
where: { userId, revision, id: { in: children }, state: 'running' },
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return { work: previous, launch: false };
|
||||||
|
}
|
||||||
|
const data = {
|
||||||
|
attemptId: randomUUID(),
|
||||||
|
processRunId: runId,
|
||||||
|
state: 'running',
|
||||||
|
priority: 0,
|
||||||
|
intent: JSON.parse(
|
||||||
|
JSON.stringify({ scope, now: new Date().toISOString() }),
|
||||||
|
) as Prisma.InputJsonObject,
|
||||||
|
progress: { children: [], dispatched: false },
|
||||||
|
errorCode: null,
|
||||||
|
};
|
||||||
|
const work = await tx.tbankSyncWork.upsert({
|
||||||
|
where: { userId_revision_scopeKey: key },
|
||||||
|
create: { ...key, ...data, id: randomUUID() },
|
||||||
|
update: { ...data, id: randomUUID() },
|
||||||
|
});
|
||||||
|
return { work, launch: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
read(userId: string, revision: string, id: string) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const work = await tx.tbankSyncWork.findFirst({
|
||||||
|
where: { userId, revision, id },
|
||||||
|
});
|
||||||
|
if (!work) return null;
|
||||||
|
const progress = work.progress as ScreenProgress;
|
||||||
|
const children = await tx.tbankSyncWork.findMany({
|
||||||
|
where: { userId, revision, id: { in: progress.children ?? [] } },
|
||||||
|
select: { id: true, attemptId: true, state: true, errorCode: true },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
work,
|
||||||
|
children: children.map((child) =>
|
||||||
|
progress.attempts?.[child.id] &&
|
||||||
|
progress.attempts[child.id] !== child.attemptId
|
||||||
|
? { ...child, state: 'cancelled', errorCode: null }
|
||||||
|
: child,
|
||||||
|
),
|
||||||
|
progress,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async progress(
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
children: string[],
|
||||||
|
dispatched: boolean,
|
||||||
|
errorCode: string | null = null,
|
||||||
|
) {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const previous = await tx.tbankSyncWork.findFirst({ where: attempt });
|
||||||
|
if (!previous) return { count: 0 };
|
||||||
|
const old = previous.progress as ScreenProgress;
|
||||||
|
const rows = await tx.tbankSyncWork.findMany({
|
||||||
|
where: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
revision: attempt.revision,
|
||||||
|
id: { in: children },
|
||||||
|
},
|
||||||
|
select: { id: true, attemptId: true },
|
||||||
|
});
|
||||||
|
const attempts = {
|
||||||
|
...Object.fromEntries(rows.map((row) => [row.id, row.attemptId])),
|
||||||
|
...old.attempts,
|
||||||
|
};
|
||||||
|
return tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: 'running',
|
||||||
|
connection: { invalidToken: false },
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
progress: { children: [...new Set(children)], attempts, dispatched },
|
||||||
|
...(errorCode ? { state: 'failed', errorCode } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
ConflictException,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import {
|
||||||
|
TbankAccessService,
|
||||||
|
TbankMembershipService,
|
||||||
|
InstrumentMetadataService,
|
||||||
|
} from '../../../integrations/tbank/tbank.api';
|
||||||
|
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||||
|
import {
|
||||||
|
BrokerageOperationsService,
|
||||||
|
BrokerageOperationsSyncService,
|
||||||
|
type OperationInterval,
|
||||||
|
} from '../operations/operations.api';
|
||||||
|
import {
|
||||||
|
CandleService,
|
||||||
|
CatalogDiscoveryService,
|
||||||
|
CatalogEnrichmentService,
|
||||||
|
} from '../instruments/instruments.api';
|
||||||
|
import {
|
||||||
|
StockIncomeService,
|
||||||
|
BondIncomeService,
|
||||||
|
} from '../instruments/instrument-income.api';
|
||||||
|
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
|
||||||
|
import { IncomeAnalyticsService } from '../income/income-read.api';
|
||||||
|
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
|
||||||
|
import { planInvestmentScreen } from '../investment-sync-plan';
|
||||||
|
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank-access.service';
|
||||||
|
import {
|
||||||
|
TbankSyncRepository,
|
||||||
|
type ScreenIntent,
|
||||||
|
} from './tbank-sync.repository';
|
||||||
|
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
|
||||||
|
import { normalizeScreen } from './tbank-screen';
|
||||||
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TbankSyncService {
|
||||||
|
private readonly runId = randomUUID();
|
||||||
|
private readonly startedAt = new Date();
|
||||||
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
constructor(
|
||||||
|
private readonly repository: TbankSyncRepository,
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly membership: TbankMembershipService,
|
||||||
|
private readonly portfolio: PortfolioService,
|
||||||
|
private readonly operations: BrokerageOperationsSyncService,
|
||||||
|
private readonly ledger: BrokerageOperationsService,
|
||||||
|
private readonly metadata: InstrumentMetadataService,
|
||||||
|
private readonly discovery: CatalogDiscoveryService,
|
||||||
|
private readonly sources: CatalogEnrichmentService,
|
||||||
|
private readonly candles: CandleService,
|
||||||
|
private readonly accounts: BrokerageAccountsService,
|
||||||
|
private readonly income: IncomeAnalyticsService,
|
||||||
|
private readonly events: UpcomingEventsService,
|
||||||
|
private readonly stockIncome: StockIncomeService,
|
||||||
|
private readonly bondIncome: BondIncomeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async request(userId: string, input: InvestmentScreenDto) {
|
||||||
|
const scope = normalizeScreen(input);
|
||||||
|
const connection = await this.access.local(userId, scope.accountId);
|
||||||
|
const reservation = this.reservations.then(() =>
|
||||||
|
this.repository.reserve(userId, connection.revision, scope, this.runId),
|
||||||
|
);
|
||||||
|
this.reservations = reservation.catch(() => undefined);
|
||||||
|
const reserved = await reservation;
|
||||||
|
if (!reserved) throw new ServiceUnavailableException();
|
||||||
|
const intent = reserved.work.intent as unknown as ScreenIntent;
|
||||||
|
if (reserved.launch) {
|
||||||
|
const attempt = {
|
||||||
|
userId,
|
||||||
|
revision: connection.revision,
|
||||||
|
id: reserved.work.id,
|
||||||
|
attemptId: reserved.work.attemptId,
|
||||||
|
};
|
||||||
|
setImmediate(() => {
|
||||||
|
void this.dispatch(attempt, intent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { id: reserved.work.id, ...intent };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) {
|
||||||
|
const children: string[] = [];
|
||||||
|
const add = async (ids: (string | null | undefined)[]) => {
|
||||||
|
children.push(...ids.filter((id): id is string => Boolean(id)));
|
||||||
|
await this.repository.progress(attempt, children, false);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const { userId, revision } = attempt;
|
||||||
|
const { scope } = intent;
|
||||||
|
const now = new Date(intent.now);
|
||||||
|
const member = await this.membership.request(userId, revision, true);
|
||||||
|
await add([member?.id]);
|
||||||
|
await member?.done;
|
||||||
|
const current = await this.access.local(userId, scope.accountId);
|
||||||
|
if (current.revision !== revision) return;
|
||||||
|
const accountScope = scope.accountId ?? 'all';
|
||||||
|
const selectedAccounts = current.accounts.filter(
|
||||||
|
(account) =>
|
||||||
|
accountScope === 'all' || account.accountId === accountScope,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
['accounts', 'account', 'events', 'stock', 'bond'].includes(
|
||||||
|
scope.screen,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
for (const account of scope.screen === 'stock' ||
|
||||||
|
scope.screen === 'bond'
|
||||||
|
? current.accounts
|
||||||
|
: selectedAccounts) {
|
||||||
|
const work = await this.portfolio.request(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
account.accountId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await add([work?.id]);
|
||||||
|
await work?.done;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const saved = await this.portfolio.read(
|
||||||
|
userId,
|
||||||
|
scope.screen === 'stock' || scope.screen === 'bond'
|
||||||
|
? 'all'
|
||||||
|
: accountScope,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const positions = saved.data?.positions ?? [];
|
||||||
|
const instruments = [
|
||||||
|
...new Map(
|
||||||
|
positions.flatMap((position) =>
|
||||||
|
position.instrumentUid &&
|
||||||
|
(position.type === 'STOCK' || position.type === 'BOND')
|
||||||
|
? [
|
||||||
|
[
|
||||||
|
position.instrumentUid,
|
||||||
|
{
|
||||||
|
uid: position.instrumentUid,
|
||||||
|
type: position.type,
|
||||||
|
historyFrom: null,
|
||||||
|
},
|
||||||
|
] as const,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
const plan = planInvestmentScreen(
|
||||||
|
{
|
||||||
|
...scope,
|
||||||
|
candleRange: scope.candleRange
|
||||||
|
? {
|
||||||
|
from: new Date(scope.candleRange.from),
|
||||||
|
to: new Date(scope.candleRange.to),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accounts: selectedAccounts,
|
||||||
|
instruments:
|
||||||
|
scope.screen === 'stocks' || scope.screen === 'bonds'
|
||||||
|
? []
|
||||||
|
: instruments,
|
||||||
|
membershipKnown: Boolean(current.membershipAt),
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
const intervals: OperationInterval[] = plan.sources.flatMap((source) =>
|
||||||
|
source.kind === 'operations' &&
|
||||||
|
source.accountId &&
|
||||||
|
source.from &&
|
||||||
|
source.to
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
accountId: source.accountId,
|
||||||
|
from: source.from,
|
||||||
|
to: source.to,
|
||||||
|
...(source.instrumentUid
|
||||||
|
? { instrumentUid: source.instrumentUid }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
if (intervals.length)
|
||||||
|
await add([
|
||||||
|
await this.operations.request(
|
||||||
|
userId,
|
||||||
|
intervals,
|
||||||
|
true,
|
||||||
|
'screen',
|
||||||
|
revision,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for (const source of plan.sources) {
|
||||||
|
const uid = source.instrumentUid;
|
||||||
|
const type = source.instrumentType === 'BOND' ? 'bond' : 'share';
|
||||||
|
if (source.kind === 'catalog')
|
||||||
|
await add(
|
||||||
|
await this.discovery.refresh(userId, revision, {
|
||||||
|
instrumentType: type,
|
||||||
|
query: source.query ?? '',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!uid) continue;
|
||||||
|
if (source.kind === 'metadata')
|
||||||
|
await add([
|
||||||
|
await this.metadata.request(userId, revision, {
|
||||||
|
instrumentUid: uid,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (source.kind === 'quote')
|
||||||
|
await add(
|
||||||
|
await this.sources.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
type,
|
||||||
|
[uid],
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
type === 'bond' ? 'bond-current' : 'detail',
|
||||||
|
'last-price',
|
||||||
|
'trading-status',
|
||||||
|
'comparison',
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source.kind === 'company')
|
||||||
|
await add(
|
||||||
|
await this.sources.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
type,
|
||||||
|
[uid],
|
||||||
|
true,
|
||||||
|
['description', 'fundamentals'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source.kind === 'metrics')
|
||||||
|
await add(
|
||||||
|
await this.sources.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
type,
|
||||||
|
[uid],
|
||||||
|
true,
|
||||||
|
['coupons', 'yield', 'rating'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (source.kind === 'calendar' && source.from && source.to)
|
||||||
|
await add(
|
||||||
|
await this.sources.requestMany(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
type,
|
||||||
|
[uid],
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
type === 'share'
|
||||||
|
? 'dividends'
|
||||||
|
: scope.screen === 'bond'
|
||||||
|
? 'coupons'
|
||||||
|
: 'bond-events',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
from: source.from.toISOString().slice(0, 10),
|
||||||
|
to: source.to.toISOString().slice(0, 10),
|
||||||
|
ticker:
|
||||||
|
positions.find((position) => position.instrumentUid === uid)
|
||||||
|
?.ticker ?? undefined,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(scope.screen === 'stock' || scope.screen === 'bond') &&
|
||||||
|
scope.instrumentUid
|
||||||
|
) {
|
||||||
|
await add([
|
||||||
|
await this.candles.request(
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
{
|
||||||
|
uid: scope.instrumentUid,
|
||||||
|
instrumentType: scope.screen === 'bond' ? 'bond' : 'share',
|
||||||
|
period: scope.candlePeriod ?? 'month',
|
||||||
|
...(scope.candleRange
|
||||||
|
? {
|
||||||
|
range: {
|
||||||
|
from: new Date(scope.candleRange.from),
|
||||||
|
to: new Date(scope.candleRange.to),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
await this.repository.progress(attempt, children, true);
|
||||||
|
} catch {
|
||||||
|
await this.repository
|
||||||
|
.progress(attempt, children, true, 'TBANK_UNAVAILABLE')
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(userId: string, id: string): Promise<TbankSyncStatusDto> {
|
||||||
|
const connection = await this.access
|
||||||
|
.local(userId)
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (error instanceof ConflictException) throw new NotFoundException();
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
const saved = await this.repository.read(userId, connection.revision, id);
|
||||||
|
if (!saved) throw new NotFoundException();
|
||||||
|
if (!saved.work.scopeKey.startsWith('screen:'))
|
||||||
|
return this.sourceStatus(userId, saved.work);
|
||||||
|
const intent = saved.work.intent as unknown as ScreenIntent;
|
||||||
|
await this.access.local(userId, intent.scope.accountId);
|
||||||
|
const running = saved.children.some((child) => child.state === 'running');
|
||||||
|
const failed =
|
||||||
|
saved.work.errorCode ??
|
||||||
|
saved.children.find((child) => child.errorCode)?.errorCode ??
|
||||||
|
null;
|
||||||
|
const state =
|
||||||
|
saved.work.processRunId !== this.runId &&
|
||||||
|
(!saved.progress.dispatched || running)
|
||||||
|
? 'cancelled'
|
||||||
|
: !saved.progress.dispatched || running
|
||||||
|
? 'running'
|
||||||
|
: failed
|
||||||
|
? 'failed'
|
||||||
|
: saved.children.some((child) => child.state === 'cancelled')
|
||||||
|
? 'cancelled'
|
||||||
|
: 'succeeded';
|
||||||
|
const resources = await this.observe(userId, intent.scope);
|
||||||
|
if (
|
||||||
|
(await this.access.local(userId, intent.scope.accountId)).revision !==
|
||||||
|
connection.revision
|
||||||
|
)
|
||||||
|
throw new NotFoundException();
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
...intent,
|
||||||
|
state,
|
||||||
|
errorCode: failed,
|
||||||
|
progress: {
|
||||||
|
completed: saved.children.filter((child) => child.state !== 'running')
|
||||||
|
.length,
|
||||||
|
total: saved.children.length,
|
||||||
|
discovering: !saved.progress.dispatched,
|
||||||
|
},
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sourceStatus(
|
||||||
|
userId: string,
|
||||||
|
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
|
||||||
|
): Promise<TbankSyncStatusDto> {
|
||||||
|
const resources: InvestmentResourceMetaDto[] = [];
|
||||||
|
const input = work.intent as Record<string, unknown>;
|
||||||
|
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
||||||
|
resources.push((await read).meta);
|
||||||
|
};
|
||||||
|
if (work.scopeKey === 'membership')
|
||||||
|
await add(this.accounts.list(userId, true));
|
||||||
|
else if (
|
||||||
|
input.source === 'portfolio' &&
|
||||||
|
typeof input.accountId === 'string'
|
||||||
|
)
|
||||||
|
await add(this.portfolio.read(userId, input.accountId, true));
|
||||||
|
else if (input.source === 'catalog') {
|
||||||
|
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
|
||||||
|
if (typeof input.query === 'string')
|
||||||
|
await add(
|
||||||
|
this.discovery.search(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
instrumentType: type,
|
||||||
|
query: input.query,
|
||||||
|
exactTicker: input.exactTicker === true,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
else if (
|
||||||
|
typeof input.uid === 'string' &&
|
||||||
|
typeof input.kind === 'string'
|
||||||
|
) {
|
||||||
|
const range =
|
||||||
|
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
|
||||||
|
? (JSON.parse(input.rangeKey) as string[])
|
||||||
|
: null;
|
||||||
|
const sources = await this.sources.readMany(
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
[input.uid],
|
||||||
|
true,
|
||||||
|
[
|
||||||
|
input.kind as Parameters<
|
||||||
|
CatalogEnrichmentService['readMany']
|
||||||
|
>[4] extends (infer K)[] | undefined
|
||||||
|
? K
|
||||||
|
: never,
|
||||||
|
],
|
||||||
|
range ? { from: range[0], to: range[1] } : undefined,
|
||||||
|
);
|
||||||
|
resources.push(...sources.map((source) => source.meta));
|
||||||
|
}
|
||||||
|
} else if (input.source === 'metadata') {
|
||||||
|
const identity = {
|
||||||
|
instrumentUid:
|
||||||
|
typeof input.instrumentUid === 'string' ? input.instrumentUid : null,
|
||||||
|
figi: typeof input.figi === 'string' ? input.figi : null,
|
||||||
|
};
|
||||||
|
const source = await this.metadata.read(userId, identity, true);
|
||||||
|
resources.push(
|
||||||
|
investmentResourceMeta({
|
||||||
|
resourceKey: work.scopeKey,
|
||||||
|
dataRevision: source?.dataRevision ?? '0',
|
||||||
|
availability: source?.complete ? 'complete' : 'missing',
|
||||||
|
fetchedAt: source?.fetchedAt ?? null,
|
||||||
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
|
work,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
work.scopeKey.startsWith('candles:') &&
|
||||||
|
typeof input.uid === 'string'
|
||||||
|
) {
|
||||||
|
await add(
|
||||||
|
this.candles.read(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
uid: input.uid,
|
||||||
|
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
|
||||||
|
period: input.period as 'month' | 'halfYear' | 'year' | 'all',
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
work.scopeKey.startsWith('operations:') &&
|
||||||
|
Array.isArray(input.intervals)
|
||||||
|
) {
|
||||||
|
for (const key of input.intervals as string[]) {
|
||||||
|
const [accountId, from, end, instrumentUid] = JSON.parse(
|
||||||
|
key,
|
||||||
|
) as string[];
|
||||||
|
await add(
|
||||||
|
this.ledger.read(userId, accountId, {
|
||||||
|
from,
|
||||||
|
to: new Date(Date.parse(end) - 1).toISOString(),
|
||||||
|
...(instrumentUid ? { instrumentUid } : {}),
|
||||||
|
sync: 'observe',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const observed = work.scopeKey.startsWith('operations:')
|
||||||
|
? await this.operations.observe(userId, work.id)
|
||||||
|
: work;
|
||||||
|
const state =
|
||||||
|
observed.state === 'running'
|
||||||
|
? observed.updatedAt < this.startedAt
|
||||||
|
? 'cancelled'
|
||||||
|
: 'running'
|
||||||
|
: observed.state === 'failed'
|
||||||
|
? 'failed'
|
||||||
|
: observed.state === 'cancelled'
|
||||||
|
? 'cancelled'
|
||||||
|
: 'succeeded';
|
||||||
|
if ((await this.access.local(userId)).revision !== work.revision)
|
||||||
|
throw new NotFoundException();
|
||||||
|
return {
|
||||||
|
id: work.id,
|
||||||
|
scope: null,
|
||||||
|
now: work.createdAt.toISOString(),
|
||||||
|
state,
|
||||||
|
errorCode: observed.errorCode,
|
||||||
|
progress: {
|
||||||
|
completed: state === 'running' ? 0 : 1,
|
||||||
|
total: 1,
|
||||||
|
discovering: false,
|
||||||
|
},
|
||||||
|
resources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async observe(userId: string, scope: InvestmentScreenDto) {
|
||||||
|
const resources: InvestmentResourceMetaDto[] = [];
|
||||||
|
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
|
||||||
|
resources.push((await read).meta);
|
||||||
|
};
|
||||||
|
const account = scope.accountId ?? 'all';
|
||||||
|
if (scope.screen === 'accounts')
|
||||||
|
await add(this.accounts.list(userId, true));
|
||||||
|
if (scope.screen === 'account') {
|
||||||
|
await add(this.portfolio.read(userId, account, true));
|
||||||
|
await add(this.income.summary(userId, account, true));
|
||||||
|
await add(this.events.get(userId, account, { sync: 'observe' }));
|
||||||
|
}
|
||||||
|
if (['account', 'operations', 'stock', 'bond'].includes(scope.screen))
|
||||||
|
await add(
|
||||||
|
this.ledger.read(userId, account, {
|
||||||
|
from: scope.from,
|
||||||
|
to: scope.to,
|
||||||
|
instrumentUid: scope.instrumentUid,
|
||||||
|
categories: scope.categories,
|
||||||
|
limit: scope.limit,
|
||||||
|
sync: 'observe',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (scope.screen === 'income')
|
||||||
|
await add(
|
||||||
|
this.income.analytics(userId, account, scope.from, scope.to, true),
|
||||||
|
);
|
||||||
|
if (scope.screen === 'events')
|
||||||
|
await add(
|
||||||
|
this.events.get(userId, account, {
|
||||||
|
from: scope.from,
|
||||||
|
to: scope.to,
|
||||||
|
sync: 'observe',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const type =
|
||||||
|
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
|
||||||
|
if (scope.screen === 'stocks' || scope.screen === 'bonds')
|
||||||
|
await add(
|
||||||
|
this.discovery.search(
|
||||||
|
userId,
|
||||||
|
{ instrumentType: type, query: scope.query ?? '' },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(scope.screen === 'stock' || scope.screen === 'bond') &&
|
||||||
|
scope.instrumentUid
|
||||||
|
) {
|
||||||
|
const uid = scope.instrumentUid;
|
||||||
|
await add(this.discovery.detail(userId, type, uid, true));
|
||||||
|
await add(this.sources.section(userId, type, uid, 'quote', true));
|
||||||
|
await add(
|
||||||
|
this.sources.section(
|
||||||
|
userId,
|
||||||
|
type,
|
||||||
|
uid,
|
||||||
|
type === 'share' ? 'company' : 'metrics',
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await add(
|
||||||
|
this.candles.read(
|
||||||
|
userId,
|
||||||
|
{
|
||||||
|
uid,
|
||||||
|
instrumentType: type,
|
||||||
|
period: scope.candlePeriod ?? 'month',
|
||||||
|
...(scope.candleRange
|
||||||
|
? {
|
||||||
|
range: {
|
||||||
|
from: new Date(scope.candleRange.from),
|
||||||
|
to: new Date(scope.candleRange.to),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await add(
|
||||||
|
type === 'share'
|
||||||
|
? this.stockIncome.readPosition(userId, uid, true)
|
||||||
|
: this.bondIncome.readPosition(userId, uid, true),
|
||||||
|
);
|
||||||
|
await add(
|
||||||
|
type === 'share'
|
||||||
|
? this.stockIncome.dividends(userId, uid, true)
|
||||||
|
: this.bondIncome.coupons(userId, uid, true),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resources;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { UpcomingEventsService } from './upcoming-events.service';
|
||||||
@@ -4,7 +4,7 @@ import type { Request } from 'express';
|
|||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import {
|
import {
|
||||||
UpcomingEventsDto,
|
UpcomingEventsResourceDto,
|
||||||
UpcomingEventsQueryDto,
|
UpcomingEventsQueryDto,
|
||||||
} from './upcoming-events.dto';
|
} from './upcoming-events.dto';
|
||||||
import { UpcomingEventsService } from './upcoming-events.service';
|
import { UpcomingEventsService } from './upcoming-events.service';
|
||||||
@@ -18,7 +18,7 @@ export class UpcomingEventsController {
|
|||||||
constructor(private readonly events: UpcomingEventsService) {}
|
constructor(private readonly events: UpcomingEventsService) {}
|
||||||
|
|
||||||
@Get('all/events')
|
@Get('all/events')
|
||||||
@ApiOkResponse({ type: UpcomingEventsDto })
|
@ApiOkResponse({ type: UpcomingEventsResourceDto })
|
||||||
all(
|
all(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: UpcomingEventsQueryDto,
|
@Query() query: UpcomingEventsQueryDto,
|
||||||
@@ -27,7 +27,7 @@ export class UpcomingEventsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':accountId/events')
|
@Get(':accountId/events')
|
||||||
@ApiOkResponse({ type: UpcomingEventsDto })
|
@ApiOkResponse({ type: UpcomingEventsResourceDto })
|
||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import {
|
||||||
|
InvestmentReadQueryDto,
|
||||||
|
InvestmentResourceMetaDto,
|
||||||
|
} from '../investment-resource.dto';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
import { IsDateString, IsOptional, Matches } from 'class-validator';
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
import { AccountReferenceDto } from '../account-reference.dto';
|
import { AccountReferenceDto } from '../account-reference.dto';
|
||||||
|
|
||||||
export class UpcomingEventsQueryDto {
|
export class UpcomingEventsQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
|
description: 'Inclusive start date (YYYY-MM-DD). Defaults to today in UTC.',
|
||||||
@@ -72,3 +76,10 @@ export class UpcomingEventsDto {
|
|||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
declare incomeTotalsByCurrency: MoneyDto[];
|
declare incomeTotalsByCurrency: MoneyDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class UpcomingEventsResourceDto {
|
||||||
|
@ApiProperty({ type: UpcomingEventsDto, nullable: true })
|
||||||
|
declare data: UpcomingEventsDto | null;
|
||||||
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
||||||
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { UpcomingEventsController } from './upcoming-events.controller';
|
import { UpcomingEventsController } from './upcoming-events.controller';
|
||||||
import { UpcomingEventsService } from './upcoming-events.service';
|
import { UpcomingEventsService } from './upcoming-events.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, MoexModule],
|
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
|
||||||
controllers: [UpcomingEventsController],
|
controllers: [UpcomingEventsController],
|
||||||
providers: [UpcomingEventsService],
|
providers: [UpcomingEventsService],
|
||||||
|
exports: [UpcomingEventsService],
|
||||||
})
|
})
|
||||||
export class UpcomingEventsModule {}
|
export class UpcomingEventsModule {}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user