Compare commits
7
Commits
e3ecb8c15e
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
809936e62d | ||
|
|
85d65f8997 | ||
|
|
fee61537b6 | ||
|
|
84433ee207 | ||
|
|
d792baaf59 | ||
|
|
83b515a453 | ||
|
|
07cfd08df9 |
@@ -1,5 +1,41 @@
|
||||
# Changelog
|
||||
|
||||
## [2.4.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.4.0...v2.4.1) (2026-11-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **events:** refresh saved payout calendars once per hourly pass (WealthLedger/backend[#53](https://git.personal.ksv741.ru/WealthLedger/backend/issues/53)) ([#157](https://git.personal.ksv741.ru/WealthLedger/backend/issues/157)) ([aeb4647](https://git.personal.ksv741.ru/WealthLedger/backend/commit/aeb4647fda3806a419ff3578834c34fff97ae3e8))
|
||||
* **portfolio:** publish new complete forecast regardless of request window (WealthLedger/backend[#55](https://git.personal.ksv741.ru/WealthLedger/backend/issues/55)) ([#156](https://git.personal.ksv741.ru/WealthLedger/backend/issues/156)) ([169bee1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/169bee1da5731a1330e995a4a508da544f791387))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **api:** describe bank operation create body as oneOf by kind (WealthLedger/backend[#172](https://git.personal.ksv741.ru/WealthLedger/backend/issues/172)) ([#173](https://git.personal.ksv741.ru/WealthLedger/backend/issues/173)) ([02e0d56](https://git.personal.ksv741.ru/WealthLedger/backend/commit/02e0d560c84606d6372cbac001dc94edc348995e))
|
||||
* check persisted json payload shape instead of double casts ([#152](https://git.personal.ksv741.ru/WealthLedger/backend/issues/152)) ([#154](https://git.personal.ksv741.ru/WealthLedger/backend/issues/154)) ([a80bbbf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a80bbbf1e28954812d2d32c6e8f9f67f5a5d42a4))
|
||||
* enforce repository boundary in application (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([e3ecb8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e3ecb8c15e61ccd8de9acb4954a38932099eefc3))
|
||||
* expose upcoming-events public api and guard its boundary (WealthLedger/backend[#163](https://git.personal.ksv741.ru/WealthLedger/backend/issues/163)) ([#195](https://git.personal.ksv741.ru/WealthLedger/backend/issues/195)) ([83b515a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/83b515a4536a6c534bcdec82f9f43f9005a44c35))
|
||||
* extract catalog enrichment source loader (WealthLedger/backend[#169](https://git.personal.ksv741.ru/WealthLedger/backend/issues/169)) ([#186](https://git.personal.ksv741.ru/WealthLedger/backend/issues/186)) ([c5ccfea](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5ccfeadea7a9235ebdd6171d9b6f7b89ed2e1e7))
|
||||
* inject TbankAccessService directly into stock screener ([#151](https://git.personal.ksv741.ru/WealthLedger/backend/issues/151)) ([#155](https://git.personal.ksv741.ru/WealthLedger/backend/issues/155)) ([554410a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/554410a2716ce13d7202fd27a621c72734b5e6c8))
|
||||
* move auth persistence into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([61c5525](https://git.personal.ksv741.ru/WealthLedger/backend/commit/61c55256e756b9187e1087215d35db3fd30ac148))
|
||||
* move bank card queries into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3d4801](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3d4801cfe67ecf301c58c894676b13b8833c4a6))
|
||||
* move category persistence into bank repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([efac720](https://git.personal.ksv741.ru/WealthLedger/backend/commit/efac7201e06f49b264854566b2e3f73931f3f6cc))
|
||||
* replace deep imports with native node aliases (WealthLedger/backend[#165](https://git.personal.ksv741.ru/WealthLedger/backend/issues/165)) ([#193](https://git.personal.ksv741.ru/WealthLedger/backend/issues/193)) ([07cfd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/07cfd08df9b13ac3fd13da153b12acf159329cea))
|
||||
* retire legacy tbank-sync status and sync=observe (WealthLedger/backend[#196](https://git.personal.ksv741.ru/WealthLedger/backend/issues/196)) ([d792baa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d792baaf59a1c204458ec249ee3ce907f3564455))
|
||||
* route bank card reads through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3ca11d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3ca11d1410899bb9c6707113ba9fd1f3b30e4bf))
|
||||
* route bank operation queries through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([cea77f6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cea77f6d568809d989462c121f50b4e07e7efca5))
|
||||
* share prisma json codec and check progress shape (WealthLedger/backend[#170](https://git.personal.ksv741.ru/WealthLedger/backend/issues/170)) ([#191](https://git.personal.ksv741.ru/WealthLedger/backend/issues/191)) ([85a7196](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85a71967fa4e85427a991c973c6528206332cf5e))
|
||||
* share sync reservation join rule (WealthLedger/backend[#119](https://git.personal.ksv741.ru/WealthLedger/backend/issues/119)) ([#162](https://git.personal.ksv741.ru/WealthLedger/backend/issues/162)) ([fa0a6e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa0a6e56030bb1c0302cd7588d30e7fa91e19c11))
|
||||
* share tbank-sync claim and generation lock (WealthLedger/backend[#166](https://git.personal.ksv741.ru/WealthLedger/backend/issues/166)) ([#188](https://git.personal.ksv741.ru/WealthLedger/backend/issues/188)) ([5d31b3e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5d31b3e6dd37d1c79b61c43ed820947b5b38aaa1))
|
||||
* single bank operation projection and domain rules (WealthLedger/backend[#168](https://git.personal.ksv741.ru/WealthLedger/backend/issues/168)) ([#189](https://git.personal.ksv741.ru/WealthLedger/backend/issues/189)) ([deda7ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/deda7ffc2cc8185eba48a18c37272c404a3e9983))
|
||||
* split brokerage operations repository and cursor codec (WealthLedger/backend[#167](https://git.personal.ksv741.ru/WealthLedger/backend/issues/167)) ([#190](https://git.personal.ksv741.ru/WealthLedger/backend/issues/190)) ([cd78b43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cd78b43783cb7469e44b9531d165603df258b55e))
|
||||
|
||||
### CI
|
||||
|
||||
* drop stale npm audit advisory ignore (WealthLedger/backend[#158](https://git.personal.ksv741.ru/WealthLedger/backend/issues/158)) ([#159](https://git.personal.ksv741.ru/WealthLedger/backend/issues/159)) ([1a5de9b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a5de9bebd060a5ff2ca188ad862c4ec5aa06ffd))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6)) ([#160](https://git.personal.ksv741.ru/WealthLedger/backend/issues/160)) ([bade5fa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bade5fae1c3170b38717b26eda56ca20183f4ec8))
|
||||
|
||||
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -53,11 +53,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
@@ -135,11 +130,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"test/bank-cards-durability.cjs": {
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 1
|
||||
@@ -158,9 +148,6 @@
|
||||
"test/tbank-ledger-local.e2e-spec.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-15
@@ -15,6 +15,18 @@ import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||
const guardedModules = [
|
||||
{
|
||||
name: 'tbank-sync',
|
||||
except: [
|
||||
'./tbank-sync.api.ts',
|
||||
'./tbank-sync.dto.ts',
|
||||
'./tbank-sync.module.ts',
|
||||
'./tbank-sync-repository.module.ts',
|
||||
],
|
||||
},
|
||||
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
|
||||
];
|
||||
const prismaImports = [
|
||||
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||
];
|
||||
@@ -213,24 +225,20 @@ export default defineConfig(
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
ignores: [...tests, 'src/modules/investments/tbank-sync/**'],
|
||||
rules: {
|
||||
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
|
||||
// A module may import its own internals, so each guarded module gets a block without its zone.
|
||||
...[undefined, ...guardedModules].map((own) => ({
|
||||
files: own ? [`src/modules/investments/${own.name}/**/*.ts`] : ['src/**/*.ts'],
|
||||
ignores: own ? tests : [...tests, ...guardedModules.map(({ name }) => `src/modules/investments/${name}/**`)],
|
||||
rules: {
|
||||
'import-x/no-restricted-paths': ['error', {
|
||||
zones: [{
|
||||
zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
|
||||
target: './src',
|
||||
from: './src/modules/investments/tbank-sync',
|
||||
except: [
|
||||
'./tbank-sync.api.ts',
|
||||
'./tbank-sync.dto.ts',
|
||||
'./tbank-sync.module.ts',
|
||||
'./tbank-sync-repository.module.ts',
|
||||
],
|
||||
message: 'Use tbank-sync.api for cross-module access.',
|
||||
}],
|
||||
from: `./src/modules/investments/${name}`,
|
||||
except,
|
||||
message: `Use ${name}.api for cross-module access.`,
|
||||
})),
|
||||
}],
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.6",
|
||||
|
||||
+17
-3
@@ -1,9 +1,19 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#generated/*": {
|
||||
"development": "./dist-dev/generated/*",
|
||||
"default": "./dist/generated/*"
|
||||
},
|
||||
"#src/*": {
|
||||
"development": "./dist-dev/src/*",
|
||||
"default": "./dist/src/*"
|
||||
}
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": "v24.22.1"
|
||||
@@ -14,8 +24,8 @@
|
||||
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||
"format": "npm run lint:fix",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint . --max-warnings=0",
|
||||
@@ -131,6 +141,10 @@
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"moduleNameMapper": {
|
||||
"^#generated/(.*)$": "<rootDir>/../generated/$1",
|
||||
"^#src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
CREATE TABLE "MarketIndexDailyClose" (
|
||||
"secid" TEXT NOT NULL,
|
||||
"tradeDate" TEXT NOT NULL,
|
||||
"close" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "MarketIndexDailyClose_pkey" PRIMARY KEY ("secid","tradeDate")
|
||||
);
|
||||
|
||||
CREATE TABLE "MarketIndexSyncState" (
|
||||
"secid" TEXT NOT NULL PRIMARY KEY,
|
||||
"lastAttemptAt" DATETIME,
|
||||
"lastSuccessAt" DATETIME,
|
||||
"coveredFrom" TEXT,
|
||||
"coveredTo" TEXT
|
||||
);
|
||||
@@ -0,0 +1,32 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_TbankPortfolioValuePoint" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"attemptId" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"capturedAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("userId", "accountId", "attemptId"),
|
||||
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
-- RUB-only: keep the RUB row per key; non-RUB rows are dropped.
|
||||
INSERT INTO "new_TbankPortfolioValuePoint" ("accountId", "attemptId", "capturedAt", "createdAt", "userId", "value")
|
||||
SELECT "accountId", "attemptId", "capturedAt", "createdAt", "userId", "value"
|
||||
FROM "TbankPortfolioValuePoint"
|
||||
WHERE "currency" = 'RUB';
|
||||
DROP TABLE "TbankPortfolioValuePoint";
|
||||
ALTER TABLE "new_TbankPortfolioValuePoint" RENAME TO "TbankPortfolioValuePoint";
|
||||
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
|
||||
|
||||
-- RUB-only: persisted JSON payloads written in the old multi-currency shape
|
||||
-- are stale. A missing snapshot/forecast is the app's normal "not synced yet"
|
||||
-- state: reads report availability 'missing' and the hourly walk rebuilds them
|
||||
-- on the next pass. Delete the stale rows instead of failing reads with 500.
|
||||
DELETE FROM "TbankPortfolio";
|
||||
DELETE FROM "TbankForecast";
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- RUB-only: snapshots were dropped by the previous migration; clearing the hourly
|
||||
-- schedule makes the next scheduler tick rebuild them instead of waiting for the term.
|
||||
DELETE FROM "TbankSyncWork" WHERE "scopeKey" = 'hourly';
|
||||
+20
-3
@@ -8,6 +8,24 @@ datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
// Public MOEX index closes contain no user holdings or credentials.
|
||||
model MarketIndexDailyClose {
|
||||
secid String
|
||||
tradeDate String
|
||||
close String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@id([secid, tradeDate])
|
||||
}
|
||||
|
||||
model MarketIndexSyncState {
|
||||
secid String @id
|
||||
lastAttemptAt DateTime?
|
||||
lastSuccessAt DateTime?
|
||||
coveredFrom String?
|
||||
coveredTo String?
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
email String @unique
|
||||
@@ -83,18 +101,17 @@ model TbankPortfolioValueHistory {
|
||||
@@id([userId, accountId])
|
||||
}
|
||||
|
||||
// One row per currency of a fully valued successful portfolio attempt.
|
||||
// One row per fully valued successful portfolio attempt (RUB only).
|
||||
model TbankPortfolioValuePoint {
|
||||
userId String
|
||||
accountId String
|
||||
attemptId String
|
||||
currency String
|
||||
value String
|
||||
capturedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||
|
||||
@@id([userId, accountId, attemptId, currency])
|
||||
@@id([userId, accountId, attemptId])
|
||||
@@index([userId, accountId, capturedAt])
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account
|
||||
import { IncomeModule } from './modules/investments/income/income.module';
|
||||
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
|
||||
import { OperationsModule } from './modules/investments/operations/operations.module';
|
||||
import { PerformanceModule } from './modules/investments/performance/performance.module';
|
||||
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
||||
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
||||
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||
@@ -27,6 +28,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
||||
BankCardsModule,
|
||||
BrokerageAccountsModule,
|
||||
PortfolioModule,
|
||||
PerformanceModule,
|
||||
OperationsModule,
|
||||
IncomeModule,
|
||||
UpcomingEventsModule,
|
||||
|
||||
@@ -8,6 +8,73 @@ const emptyEvents = {
|
||||
|
||||
describe('MoexClient', () => {
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
it('loads all pages of index closes without dropping a page', async () => {
|
||||
const fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||
const start = Number(new URL(input as string).searchParams.get('start'));
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({
|
||||
'history': {
|
||||
columns: ['TRADEDATE', 'CLOSE'],
|
||||
data: start === 0 ? [['2026-10-01', 5752.22]] : [['2026-10-02', 5745.61]],
|
||||
},
|
||||
'history.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[start, 2, 1]],
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
});
|
||||
expect(await new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')).toEqual([
|
||||
{ tradeDate: '2026-10-01', close: '5752.22' },
|
||||
{ tradeDate: '2026-10-02', close: '5745.61' },
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('rejects a later malformed page instead of returning partial index history', async () => {
|
||||
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||
const start = Number(new URL(input as string).searchParams.get('start'));
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({
|
||||
'history': {
|
||||
columns: ['TRADEDATE', 'CLOSE'],
|
||||
data: [[start ? '2026-10-02' : '2026-10-01', start ? null : 100]],
|
||||
},
|
||||
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[start, 2, 1]] },
|
||||
}),
|
||||
} as Response);
|
||||
});
|
||||
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an unsuccessful index history response', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 503 } as Response);
|
||||
await expect(new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects a timed-out index history request', async () => {
|
||||
jest.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Timed out', 'TimeoutError'));
|
||||
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
|
||||
it('rejects duplicate or out-of-order trading dates', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true, status: 200,
|
||||
json: () => Promise.resolve({
|
||||
'history': { columns: ['TRADEDATE', 'CLOSE'], data: [['2026-10-02', 100], ['2026-10-01', 101]] },
|
||||
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 2, 100]] },
|
||||
}),
|
||||
} as Response);
|
||||
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
|
||||
.rejects.toThrow();
|
||||
});
|
||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
@@ -273,4 +340,59 @@ describe('MoexClient', () => {
|
||||
expect(starts).toEqual(['0', '1']);
|
||||
},
|
||||
);
|
||||
|
||||
it('filters non-RUB coupons and maturity out of bond events', async () => {
|
||||
const pages: Record<string, unknown> = {
|
||||
coupons: Response.json({
|
||||
'coupons': {
|
||||
columns: ['coupondate', 'value', 'faceunit'],
|
||||
data: [
|
||||
['2099-02-01', '2', 'SUR'],
|
||||
['2099-02-02', '3', 'USD'],
|
||||
],
|
||||
},
|
||||
'coupons.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[0, 2, 100]],
|
||||
},
|
||||
}),
|
||||
offers: Response.json({
|
||||
'offers': {
|
||||
columns: ['offerdate'],
|
||||
data: [['2099-02-28']],
|
||||
},
|
||||
'offers.cursor': {
|
||||
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||
data: [[0, 1, 100]],
|
||||
},
|
||||
}),
|
||||
description: Response.json({
|
||||
description: {
|
||||
columns: ['name', 'value'],
|
||||
data: [
|
||||
['MATDATE', '2099-02-28'],
|
||||
['FACEVALUE', '500'],
|
||||
['FACEUNIT', 'USD'],
|
||||
],
|
||||
},
|
||||
}),
|
||||
};
|
||||
const fetchMock = jest
|
||||
.spyOn(global, 'fetch')
|
||||
.mockImplementation((input) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||
const block = url.searchParams.get('iss.only')?.split(',')[0] ?? 'description';
|
||||
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names of this test's page map.
|
||||
const page = pages[block] ?? pages.description;
|
||||
return Promise.resolve(page as Response);
|
||||
});
|
||||
try {
|
||||
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([
|
||||
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
|
||||
{ type: 'offer', date: '2099-02-28' },
|
||||
]);
|
||||
} finally {
|
||||
fetchMock.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,13 @@ const EMPTY_PAGE_SIZE = 0;
|
||||
const MIN_CURSOR_VALUE = 0;
|
||||
|
||||
const FALLBACK_PAGE_SIZE = 100;
|
||||
const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE'];
|
||||
const ISO_DATE_LENGTH = 10;
|
||||
const ZERO_CLOSE = 0;
|
||||
const ISO_DATE_START = 0;
|
||||
const LAST_HISTORY_ROW = -1;
|
||||
|
||||
const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
const baseUrl = 'https://iss.moex.com/iss';
|
||||
const timeoutMs = 10_000;
|
||||
@@ -28,9 +35,64 @@ export type MoexBondYield = {
|
||||
tradeAt: string;
|
||||
value: string;
|
||||
};
|
||||
export type MoexIndexClose = { close: string; tradeDate: string };
|
||||
|
||||
@Injectable()
|
||||
export class MoexClient {
|
||||
async indexHistory(
|
||||
secid: 'IMOEX' | 'MCFTRR',
|
||||
from: string,
|
||||
till: string,
|
||||
): Promise<MoexIndexClose[]> {
|
||||
const path = `history/engines/stock/markets/index/securities/${secid}.json`;
|
||||
const rows: MoexIndexClose[] = [];
|
||||
let start = FIRST_PAGE_OFFSET;
|
||||
for (;;) {
|
||||
const query = new URLSearchParams({
|
||||
from,
|
||||
till,
|
||||
'start': String(start),
|
||||
'iss.meta': 'off',
|
||||
'history.columns': 'TRADEDATE,CLOSE',
|
||||
});
|
||||
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
||||
if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
const block = response.history;
|
||||
if (
|
||||
!block || !Array.isArray(block.columns) ||
|
||||
block.columns.length !== INDEX_COLUMNS.length ||
|
||||
// eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array.
|
||||
block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset])
|
||||
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
const current = parseRows(block);
|
||||
let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate;
|
||||
for (const row of current) {
|
||||
const tradeDate = text(row.tradedate);
|
||||
const close = numberText(row.close);
|
||||
if (
|
||||
!tradeDate || !isoDayPattern.test(tradeDate) ||
|
||||
Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) ||
|
||||
new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate ||
|
||||
!close || Number(close) <= ZERO_CLOSE ||
|
||||
(previousDate !== undefined && tradeDate <= previousDate)
|
||||
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
rows.push({ tradeDate, close });
|
||||
previousDate = tradeDate;
|
||||
}
|
||||
const cursor = parseRows(response['history.cursor']);
|
||||
const index = integer(cursor[0]?.index);
|
||||
const total = integer(cursor[0]?.total);
|
||||
const pageSize = integer(cursor[0]?.pagesize);
|
||||
if (
|
||||
cursor.length !== EXPECTED_CURSOR_ROWS || index !== start ||
|
||||
total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE ||
|
||||
current.length !== Math.min(pageSize, total - start)
|
||||
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
if (index + pageSize >= total) return rows;
|
||||
start = index + pageSize;
|
||||
}
|
||||
}
|
||||
|
||||
async bondYield(
|
||||
secid: string,
|
||||
isin: string,
|
||||
@@ -95,9 +157,9 @@ export class MoexClient {
|
||||
for (const row of coupons) {
|
||||
const date = text(row.coupondate);
|
||||
const value = numberText(row.value);
|
||||
const currency = currencyText(row.faceunit);
|
||||
if (date && value && currency) {
|
||||
events.push({ type: 'coupon', date, value, currency });
|
||||
// RUB-only: coupons in other currencies are filtered out at this ingest boundary.
|
||||
if (date && value && currencyText(row.faceunit) === 'RUB') {
|
||||
events.push({ type: 'coupon', date, value, currency: 'RUB' });
|
||||
}
|
||||
}
|
||||
for (const row of offers) {
|
||||
@@ -109,9 +171,8 @@ export class MoexClient {
|
||||
);
|
||||
const date = values.get('MATDATE');
|
||||
const value = numberText(values.get('FACEVALUE'));
|
||||
const currency = currencyText(values.get('FACEUNIT'));
|
||||
if (date && value && currency) {
|
||||
events.push({ type: 'maturity', date, value, currency });
|
||||
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') {
|
||||
events.push({ type: 'maturity', date, value, currency: 'RUB' });
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { Decimal } from '../../common/decimal';
|
||||
import type { Money } from '../../common/money.types';
|
||||
|
||||
import { Decimal, formatDecimal } from '../../common/decimal';
|
||||
|
||||
const MISSING_PROTO_COMPONENT = 0;
|
||||
|
||||
@@ -6,6 +8,12 @@ const NANO = 1_000_000_000n;
|
||||
|
||||
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
||||
|
||||
export function rubMoney(value?: ProtoDecimal & { currency?: string }): Money | null {
|
||||
return value?.currency?.trim().toUpperCase() === 'RUB'
|
||||
? { currency: 'RUB', value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function decimal(value: ProtoDecimal): Decimal {
|
||||
return {
|
||||
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
||||
|
||||
@@ -5,7 +5,8 @@ import { isDeepStrictEqual } from 'node:util';
|
||||
import type {
|
||||
TbankInstrumentLookup as MetadataLookupRow,
|
||||
TbankInstrument as MetadataRow,
|
||||
} from '../../../generated/prisma/client';
|
||||
} from '#generated/prisma/client';
|
||||
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@@ -49,11 +49,17 @@ export enum TbankOperationType {
|
||||
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||
InpMulti = 'OPERATION_TYPE_INP_MULTI',
|
||||
Input = 'OPERATION_TYPE_INPUT',
|
||||
InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||
InputSwift = 'OPERATION_TYPE_INPUT_SWIFT',
|
||||
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||
OutMulti = 'OPERATION_TYPE_OUT_MULTI',
|
||||
Output = 'OPERATION_TYPE_OUTPUT',
|
||||
OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||
OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||
Sell = 'OPERATION_TYPE_SELL',
|
||||
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Prisma } from '../../../generated/prisma/client';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
|
||||
const NO_UPDATED_ROWS = 0;
|
||||
|
||||
@@ -9,7 +9,7 @@ jest.mock('bcrypt', () => ({
|
||||
compare: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
import type { Session, User } from '../../../../generated/prisma/client';
|
||||
import type { Session, User } from '#generated/prisma/client';
|
||||
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||
import { AuthErrorCode } from './auth-error-code';
|
||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { Environment } from '../../../config/env.validation';
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
import { Environment } from '#src/config/env.validation';
|
||||
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
|
||||
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||
import { LoginDto, RegisterDto } from './auth.dto';
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { BankCard, Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { bankBalance } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { GoneException, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
BankCardOperation,
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
} from '#generated/prisma/client';
|
||||
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
import { ZERO_KOPECKS } from './rub-money';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import type { Money } from '#src/common/money.types';
|
||||
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||
|
||||
export const ZERO_KOPECKS = 0n;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import {
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
FIRST_PAGE,
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
|
||||
@@ -29,8 +29,9 @@ import {
|
||||
import {
|
||||
ApiProblemResponses,
|
||||
ProblemDetailsDto,
|
||||
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
} from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BankCardsService } from '../application/bank-cards.service';
|
||||
import { BankOperationsService } from '../application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||
|
||||
export class UnrealizedReturnDto {
|
||||
@ApiProperty()
|
||||
declare currency: string;
|
||||
|
||||
@ApiProperty({ example: '12.345678901' })
|
||||
declare absolute: string;
|
||||
|
||||
@@ -23,17 +21,17 @@ export class UnrealizedReturnDto {
|
||||
}
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
declare totalsByCurrency: MoneyDto[];
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
declare totals: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
declare cashBalances: MoneyDto[];
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
declare cash: MoneyDto;
|
||||
|
||||
@ApiProperty({ type: PortfolioStructureDto })
|
||||
declare structure: PortfolioStructureDto;
|
||||
|
||||
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
|
||||
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
|
||||
@ApiProperty({ type: UnrealizedReturnDto })
|
||||
declare unrealizedReturn: UnrealizedReturnDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: 'integer',
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
@@ -33,7 +33,7 @@ describe('saved brokerage account projection', () => {
|
||||
});
|
||||
|
||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||
const structure = { currencies: [], unvaluedCategories: [] };
|
||||
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
||||
portfolio.saved.mockResolvedValue({
|
||||
membership: meta,
|
||||
accounts: [
|
||||
@@ -44,11 +44,9 @@ describe('saved brokerage account projection', () => {
|
||||
openedOn: null,
|
||||
meta,
|
||||
snapshot: {
|
||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||
cash: { currency: 'RUB', value: '10.000000001' },
|
||||
structure,
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100000000.000000001' },
|
||||
],
|
||||
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
||||
},
|
||||
},
|
||||
@@ -64,14 +62,14 @@ describe('saved brokerage account projection', () => {
|
||||
});
|
||||
const response = await service.list('owner');
|
||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||
cash: { currency: 'RUB', value: '10.000000001' },
|
||||
structure,
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||
stockPositionCount: 1,
|
||||
bondPositionCount: 1,
|
||||
fundPositionCount: 0,
|
||||
totalPositionCount: 2,
|
||||
unrealizedReturnByCurrency: [],
|
||||
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||
});
|
||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||
expect(response.meta.availability).toBe('available');
|
||||
@@ -79,74 +77,65 @@ describe('saved brokerage account projection', () => {
|
||||
});
|
||||
|
||||
it('returns an empty summary from an empty saved portfolio', async () => {
|
||||
const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
|
||||
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
|
||||
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
||||
cashBalances: [], structure, totalsByCurrency: [],
|
||||
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
|
||||
stockPositionCount: 0, bondPositionCount: 0,
|
||||
fundPositionCount: 0, totalPositionCount: 0,
|
||||
unrealizedReturnByCurrency: [],
|
||||
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates exact returns by currency, partial data, and zero cost basis', async () => {
|
||||
it('calculates exact returns, partial data, and zero cost basis', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: {
|
||||
cashBalances: [], structure: {}, totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
|
||||
],
|
||||
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
|
||||
positions: [
|
||||
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
|
||||
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
|
||||
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null },
|
||||
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
|
||||
{ type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } },
|
||||
{ type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
|
||||
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
||||
stockPositionCount: 2, bondPositionCount: 2,
|
||||
fundPositionCount: 1, totalPositionCount: 6,
|
||||
unrealizedReturnByCurrency: [
|
||||
{ currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
{ currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false },
|
||||
{ currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true },
|
||||
],
|
||||
stockPositionCount: 1, bondPositionCount: 2,
|
||||
fundPositionCount: 1, totalPositionCount: 4,
|
||||
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('omits return percent for a currency with short positions', async () => {
|
||||
it('omits return percent for short positions', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: {
|
||||
cashBalances: [], structure: {}, totalsByCurrency: [],
|
||||
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
|
||||
positions: [
|
||||
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
||||
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } },
|
||||
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
|
||||
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
|
||||
{ currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false },
|
||||
]);
|
||||
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
|
||||
{ absolute: '-19', costBasis: '-190', percent: null, partial: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('observes the latest hourly walk without starting another one', async () => {
|
||||
|
||||
@@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from '../../../common/decimal';
|
||||
} from '#src/common/decimal';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
|
||||
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
||||
import {
|
||||
@@ -21,61 +22,42 @@ import {
|
||||
const PERCENT_DECIMAL_PLACES = 2;
|
||||
const ZERO_QUANTITY = 0n;
|
||||
|
||||
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
||||
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
||||
const investments = positions.filter((position) => position.type !== 'CURRENCY');
|
||||
const absolute = sumMoneyByCurrency(
|
||||
investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
|
||||
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
|
||||
const absoluteReturn = positions.reduce(
|
||||
(sum, position) => position.absoluteReturn
|
||||
? add(sum, decimalFromString(position.absoluteReturn.value))
|
||||
: sum,
|
||||
decimalFromString('0'),
|
||||
);
|
||||
const costs = sumMoneyByCurrency(
|
||||
investments.flatMap((position) =>
|
||||
position.absoluteReturn && position.averagePurchasePrice
|
||||
? [{
|
||||
currency: position.absoluteReturn.currency,
|
||||
value: formatDecimal(multiply(
|
||||
decimalFromString(position.averagePurchasePrice.value),
|
||||
decimalFromString(position.quantity),
|
||||
)),
|
||||
}]
|
||||
: [],
|
||||
),
|
||||
const costBasis = positions
|
||||
.filter((position) => position.absoluteReturn && position.averagePurchasePrice)
|
||||
.reduce(
|
||||
(sum, position) =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above.
|
||||
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))),
|
||||
decimalFromString('0'),
|
||||
);
|
||||
const partial = new Set(
|
||||
investments.flatMap((position) => {
|
||||
const money = position.absoluteReturn
|
||||
? null
|
||||
: position.averagePurchasePrice ?? position.currentValue;
|
||||
return money ? [money.currency] : [];
|
||||
}),
|
||||
const partial = positions.some(
|
||||
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue),
|
||||
);
|
||||
// A short position has negative cost basis, so a percent would flip sign or net out.
|
||||
const withShorts = new Set(
|
||||
investments.flatMap((position) => {
|
||||
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
|
||||
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
|
||||
}),
|
||||
const withShorts = positions.some(
|
||||
(position) =>
|
||||
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
|
||||
decimalFromString(position.quantity).value < ZERO_QUANTITY,
|
||||
);
|
||||
const currencies = new Set([...absolute, ...costs].map((money) => money.currency));
|
||||
return [...new Set([...currencies, ...partial])]
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((currency) => {
|
||||
const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0';
|
||||
const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0';
|
||||
const cost = decimalFromString(costBasis);
|
||||
return {
|
||||
currency,
|
||||
absolute: absoluteReturn,
|
||||
costBasis,
|
||||
percent: isZero(cost) || withShorts.has(currency)
|
||||
absolute: formatDecimal(absoluteReturn),
|
||||
costBasis: formatDecimal(costBasis),
|
||||
percent: isZero(costBasis) || withShorts
|
||||
? null
|
||||
: formatDecimal(divide(
|
||||
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
||||
cost,
|
||||
multiply(absoluteReturn, decimalFromString('100')),
|
||||
costBasis,
|
||||
PERCENT_DECIMAL_PLACES,
|
||||
)),
|
||||
partial: partial.has(currency),
|
||||
partial,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -97,10 +79,10 @@ export class BrokerageAccountsService {
|
||||
openedOn: account.openedOn,
|
||||
portfolioSummary: account.snapshot
|
||||
? {
|
||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||
cashBalances: account.snapshot.cashBalances,
|
||||
totals: account.snapshot.totals,
|
||||
cash: account.snapshot.cash,
|
||||
structure: account.snapshot.structure,
|
||||
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
|
||||
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
|
||||
stockPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'STOCK',
|
||||
).length,
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import {
|
||||
IncomeAnalyticsQueryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
@@ -25,12 +25,10 @@ export class IncomeAnalyticsController {
|
||||
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||
allSummary(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.summary(
|
||||
request.user.sub,
|
||||
'all',
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +43,6 @@ export class IncomeAnalyticsController {
|
||||
'all',
|
||||
query.from,
|
||||
query.to,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,12 +51,10 @@ export class IncomeAnalyticsController {
|
||||
summary(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.summary(
|
||||
request.user.sub,
|
||||
accountId,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +70,6 @@ export class IncomeAnalyticsController {
|
||||
accountId,
|
||||
query.from,
|
||||
query.to,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
InvestmentDependencyDto,
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../investment-resource.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||
export class IncomeAnalyticsQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description:
|
||||
@@ -39,10 +39,7 @@ export class IncomePeriodDto {
|
||||
declare to: string;
|
||||
}
|
||||
|
||||
export class IncomeCurrencySummaryDto {
|
||||
@ApiProperty()
|
||||
declare currency: string;
|
||||
|
||||
export class IncomeTotalDto {
|
||||
@ApiProperty({ type: MoneyDto })
|
||||
declare coupon: MoneyDto;
|
||||
|
||||
@@ -60,8 +57,8 @@ export class IncomeSummaryDto {
|
||||
@ApiProperty({ type: IncomePeriodDto })
|
||||
declare period: IncomePeriodDto;
|
||||
|
||||
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
||||
declare totalsByCurrency: IncomeCurrencySummaryDto[];
|
||||
@ApiProperty({ type: IncomeTotalDto })
|
||||
declare total: IncomeTotalDto;
|
||||
|
||||
@ApiProperty({ format: 'date-time' })
|
||||
declare calculatedAt: string;
|
||||
@@ -71,8 +68,8 @@ export class IncomeBucketDto {
|
||||
@ApiProperty({ type: IncomePeriodDto })
|
||||
declare period: IncomePeriodDto;
|
||||
|
||||
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
||||
declare totalsByCurrency: IncomeCurrencySummaryDto[];
|
||||
@ApiProperty({ type: IncomeTotalDto })
|
||||
declare total: IncomeTotalDto;
|
||||
}
|
||||
|
||||
export class IncomeInstrumentDto {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||
@@ -57,22 +57,22 @@ describe('IncomeAnalyticsService', () => {
|
||||
ticker: 'T',
|
||||
instrumentType: 'bond',
|
||||
},
|
||||
key: 'uid:UID:RUB',
|
||||
key: 'uid:UID',
|
||||
},
|
||||
{
|
||||
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
|
||||
key: 'figi:FIGI:RUB',
|
||||
key: 'figi:FIGI',
|
||||
},
|
||||
{
|
||||
identity: { ticker: 'T', instrumentType: 'bond' },
|
||||
key: 'ticker:T:bond:RUB',
|
||||
key: 'ticker:T:bond',
|
||||
},
|
||||
{ identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
|
||||
{ identity: { ticker: 'T' }, key: 'ticker:T:' },
|
||||
{
|
||||
identity: { ticker: null, instrumentType: 'bond' },
|
||||
key: 'ticker::bond:RUB',
|
||||
key: 'ticker::bond',
|
||||
},
|
||||
{ identity: { ticker: null }, key: 'operation:receipt:RUB' },
|
||||
{ identity: { ticker: null }, key: 'operation:receipt' },
|
||||
])(
|
||||
'uses one membership for entry and aggregate: $key',
|
||||
async ({ identity, key }) => {
|
||||
@@ -102,7 +102,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => {
|
||||
it('preserves entry order, repeated IDs and signed exact totals', async () => {
|
||||
const selected = [
|
||||
{
|
||||
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||
@@ -112,10 +112,6 @@ describe('IncomeAnalyticsService', () => {
|
||||
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
|
||||
instrumentUid: 'UID',
|
||||
},
|
||||
{
|
||||
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
|
||||
instrumentUid: 'UID',
|
||||
},
|
||||
];
|
||||
seed(selected);
|
||||
|
||||
@@ -131,7 +127,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
expect(result.entries).toEqual(
|
||||
selected.map((item) => ({
|
||||
...item,
|
||||
instrumentKey: `uid:UID:${item.payment?.currency}`,
|
||||
instrumentKey: 'uid:UID',
|
||||
})),
|
||||
);
|
||||
expect(
|
||||
@@ -146,14 +142,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
key: 'uid:UID:USD',
|
||||
receiptCount: 1,
|
||||
coupon: { currency: 'USD', value: '20' },
|
||||
dividend: { currency: 'USD', value: '0' },
|
||||
total: { currency: 'USD', value: '20' },
|
||||
},
|
||||
{
|
||||
key: 'uid:UID:RUB',
|
||||
key: 'uid:UID',
|
||||
receiptCount: 2,
|
||||
coupon: { currency: 'RUB', value: '10.25' },
|
||||
dividend: { currency: 'RUB', value: '-0.25' },
|
||||
@@ -162,7 +151,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
]);
|
||||
expect(
|
||||
result.entries
|
||||
.filter((item) => item.instrumentKey === 'uid:UID:RUB')
|
||||
.filter((item) => item.instrumentKey === 'uid:UID')
|
||||
.map((item) => item.payment?.value),
|
||||
).toEqual(['10.25', '-0.25']);
|
||||
});
|
||||
@@ -191,26 +180,32 @@ describe('IncomeAnalyticsService', () => {
|
||||
expect(response.data?.buckets).toEqual([
|
||||
{
|
||||
period: { from: '2026-01-14', to: '2026-01-14' },
|
||||
totalsByCurrency: [expect.objectContaining({
|
||||
total: {
|
||||
coupon: { currency: 'RUB', value: '0.1' },
|
||||
dividend: { currency: 'RUB', value: '0' },
|
||||
total: { currency: 'RUB', value: '0.1' },
|
||||
})],
|
||||
},
|
||||
},
|
||||
{
|
||||
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||
totalsByCurrency: [expect.objectContaining({
|
||||
total: {
|
||||
coupon: { currency: 'RUB', value: '0.2' },
|
||||
dividend: { currency: 'RUB', value: '-0.1' },
|
||||
total: { currency: 'RUB', value: '0.1' },
|
||||
})],
|
||||
},
|
||||
},
|
||||
{
|
||||
period: { from: '2026-01-16', to: '2026-01-16' },
|
||||
totalsByCurrency: [],
|
||||
total: {
|
||||
coupon: { currency: 'RUB', value: '0' },
|
||||
dividend: { currency: 'RUB', value: '0' },
|
||||
total: { currency: 'RUB', value: '0' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
||||
it('aggregates only exact coupon and dividend payments', async () => {
|
||||
operations.collectRanges.mockImplementation(
|
||||
(
|
||||
_user: string,
|
||||
@@ -223,7 +218,6 @@ describe('IncomeAnalyticsService', () => {
|
||||
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||
],
|
||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||
},
|
||||
@@ -238,22 +232,14 @@ describe('IncomeAnalyticsService', () => {
|
||||
|
||||
const result = response.data;
|
||||
assert(result);
|
||||
expect(result.totalsByCurrency).toEqual([
|
||||
expect.objectContaining({
|
||||
currency: 'RUB',
|
||||
expect(result.total).toEqual({
|
||||
coupon: { currency: 'RUB', value: '10.25' },
|
||||
dividend: { currency: 'RUB', value: '-2.5' },
|
||||
total: { currency: 'RUB', value: '7.75' },
|
||||
}),
|
||||
expect.objectContaining({
|
||||
currency: 'USD',
|
||||
total: { currency: 'USD', value: '1' },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
expect(result.entries.map((entry) => entry.id)).toEqual([
|
||||
'coupon',
|
||||
'dividend',
|
||||
'usd',
|
||||
]);
|
||||
expect(result.buckets).toHaveLength(31);
|
||||
});
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
compare,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
} from '../../../common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||
} from '#src/common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
@@ -17,11 +18,11 @@ import {
|
||||
import {
|
||||
IncomeAnalyticsEntryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeCurrencySummaryDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeInstrumentDto,
|
||||
IncomeSummaryDto,
|
||||
IncomeSummaryResourceDto,
|
||||
IncomeTotalDto,
|
||||
} from './income-analytics.dto';
|
||||
import {
|
||||
IncomeBucketGranularity,
|
||||
@@ -43,14 +44,12 @@ export class IncomeAnalyticsService {
|
||||
async summary(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
observe = false,
|
||||
): Promise<IncomeSummaryResourceDto> {
|
||||
const plan = planIncomePeriod(undefined, undefined);
|
||||
const [selected] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||
assert(selected.meta.lastSuccessfulAt);
|
||||
@@ -70,14 +69,12 @@ export class IncomeAnalyticsService {
|
||||
accountId: string,
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
observe = false,
|
||||
): Promise<IncomeAnalyticsResourceDto> {
|
||||
const plan = planIncomePeriod(from, to);
|
||||
const [current] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
const dependencies: IncomeDependencyDto[] = [
|
||||
{ ...current.meta, role: 'selected' },
|
||||
@@ -108,7 +105,7 @@ export class IncomeAnalyticsService {
|
||||
assert(current.meta.lastSuccessfulAt);
|
||||
const selected = qualifying(current.items).map((entry) => ({
|
||||
...entry,
|
||||
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
||||
instrumentKey: instrumentKey(entry),
|
||||
}));
|
||||
return {
|
||||
data: {
|
||||
@@ -142,23 +139,19 @@ function summaryResponse(
|
||||
entries: BrokerageOperationDto[],
|
||||
calculatedAt: string,
|
||||
): IncomeSummaryDto {
|
||||
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
|
||||
return { accountId, period, total: totals(entries), calculatedAt };
|
||||
}
|
||||
|
||||
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
const values = new Map<string, Total>();
|
||||
function totals(entries: BrokerageOperationDto[]): IncomeTotalDto {
|
||||
const total = zeroTotal();
|
||||
for (const entry of entries) {
|
||||
if (!entry.payment) continue;
|
||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||
const value = decimalFromString(entry.payment.value);
|
||||
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||
total.coupon = add(total.coupon, value);
|
||||
} else total.dividend = add(total.dividend, value);
|
||||
values.set(entry.payment.currency, total);
|
||||
}
|
||||
return [...values]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([currency, total]) => presentTotal(currency, total));
|
||||
return presentTotal(total);
|
||||
}
|
||||
|
||||
function buckets(
|
||||
@@ -172,7 +165,7 @@ function buckets(
|
||||
}));
|
||||
return rangeBuckets(range, granularity).map((period) => ({
|
||||
period,
|
||||
totalsByCurrency: totals(
|
||||
total: totals(
|
||||
datedEntries
|
||||
.filter(({ date }) => date >= period.from && date <= period.to)
|
||||
.map(({ entry }) => entry),
|
||||
@@ -192,7 +185,7 @@ function instruments(
|
||||
return [...groups]
|
||||
.map(([key, group]) => {
|
||||
const first = group[0];
|
||||
const [total] = totals(group);
|
||||
const total = totals(group);
|
||||
return {
|
||||
key,
|
||||
figi: first.figi,
|
||||
@@ -228,15 +221,10 @@ function zeroTotal(): Total {
|
||||
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
||||
}
|
||||
|
||||
function presentTotal(
|
||||
currency: string,
|
||||
value: Total,
|
||||
): IncomeCurrencySummaryDto {
|
||||
const total = add(value.coupon, value.dividend);
|
||||
function presentTotal(value: Total): IncomeTotalDto {
|
||||
return {
|
||||
currency,
|
||||
coupon: { currency, value: formatDecimal(value.coupon) },
|
||||
dividend: { currency, value: formatDecimal(value.dividend) },
|
||||
total: { currency, value: formatDecimal(total) },
|
||||
coupon: { currency: 'RUB', value: formatDecimal(value.coupon) },
|
||||
dividend: { currency: 'RUB', value: formatDecimal(value.dividend) },
|
||||
total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
|
||||
import {
|
||||
DateOnlyRange,
|
||||
dateParts,
|
||||
|
||||
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
|
||||
jest.mock('./catalog-enrichment.service', () => ({
|
||||
CatalogEnrichmentService: class {},
|
||||
}));
|
||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
||||
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
TbankClient: class {},
|
||||
}));
|
||||
|
||||
@@ -5,15 +5,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
candleGaps,
|
||||
@@ -98,7 +99,7 @@ export class CandleService {
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CandleInput, observe = false) {
|
||||
async read(userId: string, input: CandleInput) {
|
||||
const { revision } = await this.access.local(userId);
|
||||
const [detail] = await this.sources.readMany(
|
||||
userId,
|
||||
@@ -118,7 +119,7 @@ export class CandleService {
|
||||
const hasCompleteRange = Boolean(
|
||||
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||
);
|
||||
if (!observe && (!saved || !hasCompleteRange)) {
|
||||
if (!saved || !hasCompleteRange) {
|
||||
await this.request(userId, revision, input, now);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CatalogDiscoveryService } from './catalog-discovery.service';
|
||||
|
||||
jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||
|
||||
describe('catalog discovery currency filtering', () => {
|
||||
it('uses details to filter InstrumentShort results before resolving ticker ambiguity', async () => {
|
||||
const short = (uid: string) => ({ uid, ticker: 'SBER', instrumentKind: 'INSTRUMENT_TYPE_SHARE' });
|
||||
const client = {
|
||||
findShares: jest.fn().mockResolvedValue([short('a'), short('b')]),
|
||||
getShare: jest.fn().mockImplementation((_token: string, uid: string) =>
|
||||
Promise.resolve({ uid, ticker: 'SBER', currency: uid === 'a' ? 'USD' : 'RUB' })),
|
||||
getLastPrices: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const access = { online: jest.fn().mockImplementation((_user: string, run: (connection: { token: string }) => unknown) => run({ token: 'fixture' })) };
|
||||
const service = new CatalogDiscoveryService(access as never, client as never);
|
||||
const search = await service.search('user', { instrumentType: 'share', query: 'SBER' });
|
||||
expect(search.data.map((item) => item.uid)).toEqual(['b']);
|
||||
expect((await service.byTicker('user', 'share', 'SBER')).data?.uid).toBe('b');
|
||||
});
|
||||
});
|
||||
@@ -8,16 +8,15 @@ import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogQuery,
|
||||
type CatalogQuery,
|
||||
catalogScope,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import {
|
||||
catalogSearchLimit,
|
||||
selectCatalogInstruments,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||
const NO_MATCHES = 0;
|
||||
const FIRST_RESULT_OFFSET = 0;
|
||||
|
||||
const tickerAmbiguityLimit = 2;
|
||||
@@ -33,101 +31,26 @@ const tickerAmbiguityLimit = 2;
|
||||
@Injectable()
|
||||
export class CatalogDiscoveryService {
|
||||
constructor(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly enrichment: CatalogEnrichmentService,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CatalogQuery) {
|
||||
input = catalogQuery(input);
|
||||
const connection = await this.access.local(userId);
|
||||
const saved = await this.repository.read(input);
|
||||
return {
|
||||
data: saved.data,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: catalogScope(input),
|
||||
dataRevision: saved.dataRevision,
|
||||
availability: saved.data === null ? 'missing' : 'complete',
|
||||
fetchedAt: saved.fetchedAt,
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: await this.repository.work(userId, connection.revision, input),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
|
||||
const discovery = await this.read(userId, input);
|
||||
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.completeness === 'complete')
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||
? new Date(discovery.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: discovery.meta.sync.id
|
||||
? {
|
||||
id: discovery.meta.sync.id,
|
||||
state: discovery.meta.sync.state,
|
||||
errorCode: discovery.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
async search(userId: string, input: CatalogQuery) {
|
||||
return this.onlineSearch(userId, catalogQuery(input));
|
||||
}
|
||||
|
||||
async detail(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
observe = false,
|
||||
) {
|
||||
if (!observe) {
|
||||
const instrument = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.getShare(token, uid)
|
||||
: this.tbank.getBond(token, uid),
|
||||
);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
// RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available.
|
||||
if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') throw new NotFoundException();
|
||||
return {
|
||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||
meta: investmentResourceMeta({
|
||||
@@ -140,76 +63,30 @@ export class CatalogDiscoveryService {
|
||||
}),
|
||||
};
|
||||
}
|
||||
const sources = await this.enrichment.readMany(
|
||||
userId,
|
||||
instrumentType,
|
||||
[uid],
|
||||
observe,
|
||||
['detail'],
|
||||
);
|
||||
const source = sources[0];
|
||||
if (source.meta.completeness === 'complete' && !source.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const instrument = source.instrument;
|
||||
let slug = uid;
|
||||
const dependencies = [source.meta];
|
||||
if (
|
||||
instrument?.ticker &&
|
||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
||||
) {
|
||||
const alias = await this.read(userId, {
|
||||
instrumentType,
|
||||
query: instrument.ticker,
|
||||
exactTicker: true,
|
||||
});
|
||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && 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.portfolio,
|
||||
work: source.meta.sync.id
|
||||
? {
|
||||
id: source.meta.sync.id,
|
||||
state: source.meta.sync.state,
|
||||
errorCode: source.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
||||
async byTicker(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
ticker: string,
|
||||
observe = false,
|
||||
) {
|
||||
if (!observe) {
|
||||
const query = ticker.trim().toUpperCase();
|
||||
const found = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.findShares(token, query)
|
||||
: this.tbank.findBonds(token, query),
|
||||
);
|
||||
const matches = selectCatalogInstruments(
|
||||
const candidates = selectCatalogInstruments(
|
||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||
query,
|
||||
instrumentType,
|
||||
);
|
||||
const details = await this.access.online(userId, ({ token }) => Promise.all(
|
||||
candidates.map((item) => instrumentType === 'share'
|
||||
? this.tbank.getShare(token, item.uid)
|
||||
: this.tbank.getBond(token, item.uid)),
|
||||
));
|
||||
const matches = candidates.filter((_item, index) =>
|
||||
details.at(index)?.currency?.trim().toUpperCase() === 'RUB',
|
||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||
if (!matches.length) throw new NotFoundException();
|
||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
@@ -223,49 +100,6 @@ export class CatalogDiscoveryService {
|
||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||
}
|
||||
const lookup = await this.read(userId, {
|
||||
instrumentType,
|
||||
query: ticker,
|
||||
exactTicker: true,
|
||||
});
|
||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
? InstrumentErrorCode.StockTickerAmbiguous
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
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.portfolio,
|
||||
work: lookup.meta.sync.id
|
||||
? {
|
||||
id: lookup.meta.sync.id,
|
||||
state: lookup.meta.sync.state,
|
||||
errorCode: lookup.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
|
||||
@@ -279,7 +113,7 @@ export class CatalogDiscoveryService {
|
||||
: found,
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||
);
|
||||
const [details, prices] = await Promise.all([
|
||||
Promise.all(
|
||||
instruments.map((item) =>
|
||||
@@ -297,12 +131,13 @@ export class CatalogDiscoveryService {
|
||||
const fetchedAt = new Date();
|
||||
const data = instruments.flatMap((item, index) => {
|
||||
const detail = details.at(index);
|
||||
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
|
||||
return [{
|
||||
...item,
|
||||
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
||||
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
||||
}];
|
||||
});
|
||||
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
@@ -221,9 +222,8 @@ export class CatalogEnrichmentService {
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
section: 'company' | 'metrics' | 'quote',
|
||||
observe = false,
|
||||
) {
|
||||
if (section === 'company' && !observe) {
|
||||
if (section === 'company') {
|
||||
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
||||
const instrument =
|
||||
instrumentType === 'share'
|
||||
@@ -253,10 +253,7 @@ export class CatalogEnrichmentService {
|
||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const kinds: CatalogSource['kind'][] =
|
||||
section === 'company'
|
||||
? ['detail', 'description', 'fundamentals']
|
||||
: [
|
||||
const kinds: CatalogSource['kind'][] = [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
@@ -271,7 +268,7 @@ export class CatalogEnrichmentService {
|
||||
userId,
|
||||
instrumentType,
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
kinds,
|
||||
);
|
||||
const first = sources[0];
|
||||
@@ -299,7 +296,7 @@ export class CatalogEnrichmentService {
|
||||
fetchedAt: first.meta.lastSuccessfulAt
|
||||
? new Date(first.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
||||
freshForMs: tbankSourceTtlMs.quote,
|
||||
work: null,
|
||||
dependencies,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
export const catalogSearchLimit = 30;
|
||||
|
||||
|
||||
@@ -3,13 +3,13 @@ import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
} from '../../../../common/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import {
|
||||
MILLISECONDS_PER_DAY,
|
||||
MILLISECONDS_PER_SECOND,
|
||||
} from '../../../../common/time';
|
||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
} from '#src/common/time';
|
||||
import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
|
||||
import {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankTimestamp,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
|
||||
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
||||
|
||||
|
||||
@@ -27,11 +27,11 @@ const row = (
|
||||
});
|
||||
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||
positions,
|
||||
cashBalances: [],
|
||||
totalsByCurrency: [],
|
||||
cash: { currency: 'RUB', value: '0' },
|
||||
totals: { currency: 'RUB', value: '0' },
|
||||
isStale: false,
|
||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||
structure: { currencies: [], unvaluedCategories: [] },
|
||||
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||
});
|
||||
|
||||
describe('stock position', () => {
|
||||
@@ -64,10 +64,10 @@ describe('stock position', () => {
|
||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||
},
|
||||
);
|
||||
it('does not sum currencies or treat unknown values as zero', () => {
|
||||
it('does not treat unknown values as zero', () => {
|
||||
const second = {
|
||||
...row(),
|
||||
currentValue: money('2', 'USD'),
|
||||
currentValue: null,
|
||||
averagePurchasePrice: null,
|
||||
absoluteReturn: null,
|
||||
};
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../common/decimal';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
} from '#src/common/decimal';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
@@ -45,7 +46,7 @@ export function instrumentPosition(
|
||||
averagePurchasePrice:
|
||||
positive && totalCost
|
||||
? {
|
||||
currency: totalCost.currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(
|
||||
divide(
|
||||
decimalFromString(totalCost.value),
|
||||
@@ -82,17 +83,12 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||
: null;
|
||||
}
|
||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||
const currency = values[0]?.currency;
|
||||
if (
|
||||
!currency ||
|
||||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null;
|
||||
const present = values;
|
||||
return {
|
||||
currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(
|
||||
values.reduce(
|
||||
present.reduce(
|
||||
(sum, value) => add(sum, decimalFromString(value.value)),
|
||||
decimalFromString('0'),
|
||||
),
|
||||
@@ -106,7 +102,6 @@ function percent(
|
||||
if (
|
||||
!base ||
|
||||
!result ||
|
||||
base.currency !== result.currency ||
|
||||
decimalFromString(base.value).value <= ZERO_AMOUNT
|
||||
) {
|
||||
return null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
export function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
|
||||
type IdentifiedBond = TbankBond & { uid: string };
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
|
||||
const MIN_LOT_EXCLUSIVE = 0;
|
||||
@@ -25,12 +27,10 @@ export class BondCatalogService {
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<BondSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'bond', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
@@ -58,9 +58,8 @@ export class BondCatalogService {
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||
const result = await this.discovery.detail(userId, 'bond', uid);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
@@ -72,13 +71,11 @@ export class BondCatalogService {
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
'bond',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -5,19 +5,20 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCoupon,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
@@ -54,14 +55,12 @@ export class BondIncomeService {
|
||||
async metrics(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondMetricsResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'metrics',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -82,7 +81,6 @@ export class BondIncomeService {
|
||||
async coupons(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondCouponsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
@@ -93,7 +91,7 @@ export class BondIncomeService {
|
||||
userId,
|
||||
'bond',
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
@@ -180,7 +178,7 @@ export function bondCoupons(
|
||||
recordDate &&
|
||||
recordDate >= today
|
||||
? {
|
||||
currency: perBond.currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(
|
||||
multiply(quantity, decimalFromString(perBond.value)),
|
||||
),
|
||||
@@ -229,7 +227,6 @@ export function bondMetrics(
|
||||
coupon &&
|
||||
couponPeriodDays &&
|
||||
clean &&
|
||||
coupon.currency === clean.currency &&
|
||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
||||
decimalFromString(clean.value).value > ZERO_AMOUNT
|
||||
? formatDecimal(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
add,
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
@@ -38,14 +39,12 @@ export class BondMarketService {
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondQuoteResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'quote',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -59,12 +58,10 @@ export class BondMarketService {
|
||||
userId: string,
|
||||
uid: string,
|
||||
period: BondCandlesQueryDto['period'] = 'month',
|
||||
observe = false,
|
||||
) {
|
||||
return this.history.read(
|
||||
userId,
|
||||
{ uid, instrumentType: 'bond', period },
|
||||
observe,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +82,7 @@ export function bondQuote(
|
||||
nominal &&
|
||||
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
||||
? {
|
||||
currency: nominal.currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(
|
||||
divide(
|
||||
multiply(
|
||||
@@ -99,9 +96,9 @@ export function bondQuote(
|
||||
}
|
||||
: null;
|
||||
const dirtyPrice =
|
||||
cleanPrice && aci && aci.currency === cleanPrice.currency
|
||||
cleanPrice && aci
|
||||
? {
|
||||
currency: cleanPrice.currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(
|
||||
add(
|
||||
decimalFromString(cleanPrice.value),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export function money(value?: TbankMoney) {
|
||||
const currency = value?.currency?.toUpperCase();
|
||||
return value && currency
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
export { rubMoney as money } from '#src/integrations/tbank/decimal';
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||
import { BondIncomeService } from '../application/bond-income.service';
|
||||
import { BondMarketService } from '../application/bond-market.service';
|
||||
@@ -47,9 +47,8 @@ export class BondController {
|
||||
metrics(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.income.metrics(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -61,7 +60,6 @@ export class BondController {
|
||||
return this.bonds.search(
|
||||
request.user.sub,
|
||||
query.query,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,12 +68,10 @@ export class BondController {
|
||||
getByTicker(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param() params: BondTickerParamsDto,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.getByTicker(
|
||||
request.user.sub,
|
||||
params.ticker,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,9 +80,8 @@ export class BondController {
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.bonds.get(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/quote')
|
||||
@@ -94,9 +89,8 @@ export class BondController {
|
||||
quote(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.market.quote(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@@ -110,7 +104,6 @@ export class BondController {
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.period,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,8 +121,7 @@ export class BondController {
|
||||
coupons(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.income.coupons(request.user.sub, uid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
|
||||
|
||||
import {
|
||||
StockCandlesDto,
|
||||
StockCandlesQueryDto,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
|
||||
const FIRST_RANGE_END_INDEX = 1;
|
||||
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import {
|
||||
claimSyncWork,
|
||||
lockConnectionGeneration,
|
||||
} from '../../../../integrations/tbank/tbank-sync-claim';
|
||||
} from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import {
|
||||
isJoinableRunningAttempt,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { toJson } from '../../../../prisma/to-json';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
import {
|
||||
type CandleRange,
|
||||
confirmedCandleCoverage,
|
||||
|
||||
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { toJson } from '../../../../prisma/to-json';
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
const REVISION_INCREMENT = 1;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankCandle } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import type { SavedCandle } from '../domain/candle-coverage';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { timestamp } from '../application/instrument-market';
|
||||
|
||||
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { MoexModule } from '#src/integrations/moex/moex.module';
|
||||
import { NcrModule } from '#src/integrations/ncr/ncr.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { CandleService } from './application/candle.service';
|
||||
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
||||
|
||||
+4
-5
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankFundamentals } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import {
|
||||
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
|
||||
async execute(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockCompanyResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'share',
|
||||
uid,
|
||||
'company',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -72,16 +70,17 @@ export function metrics(
|
||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
||||
const value = values[key];
|
||||
const currency = values.currency?.toUpperCase() || null;
|
||||
const rub = currency === 'RUB';
|
||||
return typeof value === 'number' &&
|
||||
Number.isFinite(value) &&
|
||||
(unit !== 'money' || currency)
|
||||
(unit !== 'money' || rub)
|
||||
? [
|
||||
{
|
||||
key,
|
||||
value,
|
||||
unit,
|
||||
period,
|
||||
currency: unit === 'money' ? currency : null,
|
||||
currency: unit === 'money' ? 'RUB' : null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
@@ -22,12 +23,10 @@ export class StockCatalogService {
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<StockSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'share', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
@@ -46,7 +45,7 @@ export class StockCatalogService {
|
||||
price:
|
||||
last?.price && detail.currency
|
||||
? {
|
||||
currency: detail.currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(decimal(last.price)),
|
||||
}
|
||||
: null,
|
||||
@@ -59,9 +58,8 @@ export class StockCatalogService {
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||
const result = await this.discovery.detail(userId, 'share', uid);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
@@ -73,13 +71,11 @@ export class StockCatalogService {
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
observe = false,
|
||||
): Promise<StockResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
'share',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -4,18 +4,19 @@ import {
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankDividend,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
@@ -51,7 +52,6 @@ export class StockIncomeService {
|
||||
async dividends(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockDividendsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
@@ -62,7 +62,7 @@ export class StockIncomeService {
|
||||
userId,
|
||||
'share',
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
['detail', 'dividends'],
|
||||
{ from, to },
|
||||
);
|
||||
@@ -131,11 +131,7 @@ export function stockDividends(
|
||||
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
||||
const perShare =
|
||||
currency && event.dividendNet
|
||||
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
|
||||
: null;
|
||||
const perShare = rubMoney(event.dividendNet);
|
||||
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
||||
const eventDate = paymentDate ?? recordDate;
|
||||
const timing: StockDividendDto['timing'] = eventDate
|
||||
@@ -150,7 +146,7 @@ export function stockDividends(
|
||||
recordDate >= today &&
|
||||
timing === 'upcoming'
|
||||
? {
|
||||
currency: perShare.currency,
|
||||
currency: 'RUB',
|
||||
value: formatDecimal(
|
||||
multiply(quantity, decimalFromString(perShare.value)),
|
||||
),
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
@@ -34,14 +35,12 @@ export class StockMarketService {
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockQuoteResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'share',
|
||||
uid,
|
||||
'quote',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -55,12 +54,10 @@ export class StockMarketService {
|
||||
userId: string,
|
||||
uid: string,
|
||||
period: StockCandlesQueryDto['period'] = 'month',
|
||||
observe = false,
|
||||
) {
|
||||
return this.history.read(
|
||||
userId,
|
||||
{ uid, instrumentType: 'share', period },
|
||||
observe,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,15 +70,8 @@ export function stockQuote(
|
||||
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||
const share = values.instrument;
|
||||
const priceAt = timestamp(last?.time);
|
||||
const currency = share?.currency?.toUpperCase();
|
||||
const price =
|
||||
last?.price && currency
|
||||
? { currency, value: formatDecimal(decimal(last.price)) }
|
||||
: null;
|
||||
const previousPrice =
|
||||
previous?.close && currency
|
||||
? { currency, value: formatDecimal(decimal(previous.close)) }
|
||||
: null;
|
||||
const price = last?.price ? rubMoney({ ...last.price, currency: share?.currency }) : null;
|
||||
const previousPrice = previous?.close ? rubMoney({ ...previous.close, currency: share?.currency }) : null;
|
||||
const difference =
|
||||
price && previousPrice
|
||||
? subtract(
|
||||
@@ -96,8 +86,8 @@ export function stockQuote(
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
previousClose: previousPrice,
|
||||
change:
|
||||
difference && currency
|
||||
? { currency, value: formatDecimal(difference) }
|
||||
difference
|
||||
? { currency: 'RUB', value: formatDecimal(difference) }
|
||||
: null,
|
||||
changePercent:
|
||||
difference &&
|
||||
|
||||
+3
-4
@@ -1,10 +1,10 @@
|
||||
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({
|
||||
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
}));
|
||||
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { StockScreenerService } from './stock-screener.service';
|
||||
|
||||
@@ -87,7 +87,6 @@ describe('StockScreenerService', () => {
|
||||
client.getAllShares.mockResolvedValueOnce([
|
||||
share(1, {
|
||||
countryOfRisk: undefined,
|
||||
currency: undefined,
|
||||
isin: undefined,
|
||||
realExchange: undefined,
|
||||
sector: undefined,
|
||||
@@ -96,7 +95,7 @@ describe('StockScreenerService', () => {
|
||||
}),
|
||||
]);
|
||||
const result = await service.search('user-1', {});
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: 'RUB', isin: null, logoName: null, ticker: null }));
|
||||
|
||||
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
||||
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank-market.types';
|
||||
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { TbankAccessService } from '#src/integrations/tbank/tbank.api';
|
||||
import { TbankClient } from '#src/integrations/tbank/tbank.client';
|
||||
|
||||
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
||||
import { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
|
||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||
import {
|
||||
selectStockScreenerInstruments,
|
||||
type StockScreenerConditions,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { previousClose } from '../../application/instrument-market';
|
||||
import { metrics } from './get-stock-company.use-case';
|
||||
import { stockDividends } from './stock-income.service';
|
||||
import { stockQuote } from './stock-market.service';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||
|
||||
+10
-3
@@ -1,4 +1,4 @@
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
|
||||
|
||||
@@ -29,16 +29,23 @@ describe('selectStockScreenerInstruments', () => {
|
||||
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
||||
],
|
||||
},
|
||||
{ currency: 'RUB', query: 'star', sector: 'energy' },
|
||||
{ query: 'star', sector: 'energy' },
|
||||
);
|
||||
|
||||
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
|
||||
});
|
||||
|
||||
it('filters out non-RUB instruments', () => {
|
||||
const result = selectStockScreenerInstruments(
|
||||
{ asOf, instruments: [share({ currency: 'USD' }), share({ currency: undefined })] },
|
||||
{},
|
||||
);
|
||||
expect(result.instruments).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
||||
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
||||
['currency', { currency: 'USD' }, share({ currency: undefined })],
|
||||
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
||||
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
||||
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
export type StockScreenerCatalog = {
|
||||
asOf: Date;
|
||||
@@ -7,7 +7,6 @@ export type StockScreenerCatalog = {
|
||||
|
||||
export type StockScreenerConditions = {
|
||||
countryOfRisk?: string;
|
||||
currency?: string;
|
||||
maxLot?: number;
|
||||
query?: string;
|
||||
realExchange?: string;
|
||||
@@ -76,12 +75,7 @@ function matches(
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
conditions.currency !== undefined &&
|
||||
instrument.currency !== conditions.currency
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (instrument.currency?.toUpperCase() !== 'RUB') return false;
|
||||
if (
|
||||
conditions.realExchange !== undefined &&
|
||||
instrument.realExchange !== conditions.realExchange
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||
import { StockIncomeService } from '../application/stock-income.service';
|
||||
@@ -64,7 +64,6 @@ export class StockController {
|
||||
return this.stocks.search(
|
||||
request.user.sub,
|
||||
query.query,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,12 +72,10 @@ export class StockController {
|
||||
getByTicker(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param() params: StockTickerParamsDto,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.stocks.getByTicker(
|
||||
request.user.sub,
|
||||
params.ticker,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,7 +89,7 @@ export class StockController {
|
||||
criteria: StockScreenerCriteriaDto,
|
||||
) {
|
||||
// The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them.
|
||||
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||
const allowed = new Set(['countryOfRisk', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||
throw new BadRequestException();
|
||||
}
|
||||
@@ -115,9 +112,8 @@ export class StockController {
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.stocks.get(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/quote')
|
||||
@@ -125,9 +121,8 @@ export class StockController {
|
||||
quote(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.market.quote(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@@ -141,7 +136,6 @@ export class StockController {
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.period,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,12 +153,10 @@ export class StockController {
|
||||
company(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.companyInfo.execute(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,12 +165,10 @@ export class StockController {
|
||||
dividends(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.dividends(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,15 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
import {
|
||||
MAX_INSTRUMENT_QUERY_LENGTH,
|
||||
MIN_INSTRUMENT_QUERY_LENGTH,
|
||||
} from '../../../instrument-query';
|
||||
} from '#src/modules/investments/instrument-query';
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../../investment-resource.dto';
|
||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/investment-resource.dto';
|
||||
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
const MIN_TICKER_LENGTH = 1;
|
||||
const MAX_SCREENER_QUERY_LENGTH = 80;
|
||||
@@ -53,7 +52,6 @@ export class StockScreenerCriteriaDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(MIN_TICKER_LENGTH)
|
||||
currency?: string;
|
||||
|
||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||
@IsOptional()
|
||||
@@ -104,7 +102,7 @@ export class StockScreenerPageDto {
|
||||
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
||||
}
|
||||
|
||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
||||
export class StockSearchQueryDto {
|
||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
@@ -126,7 +124,7 @@ export class StockTickerParamsDto {
|
||||
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
||||
declare ticker: string;
|
||||
}
|
||||
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
||||
export class StockCandlesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: ['month', 'halfYear', 'year', 'all'],
|
||||
default: 'month',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export const availabilityValues = [
|
||||
'available',
|
||||
@@ -17,13 +16,6 @@ export const attemptStateValues = [
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export class InvestmentReadQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['observe'] })
|
||||
@IsOptional()
|
||||
@IsIn(['observe'])
|
||||
declare sync?: 'observe';
|
||||
}
|
||||
|
||||
export class InvestmentAttemptProblemDto {
|
||||
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
||||
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MoexModule } from '#src/integrations/moex/moex.module';
|
||||
|
||||
import { MarketIndexRepository } from './market-index.repository';
|
||||
import { MarketIndexHistoryService } from './market-index.service';
|
||||
|
||||
@Module({
|
||||
imports: [MoexModule],
|
||||
providers: [MarketIndexHistoryService, MarketIndexRepository],
|
||||
exports: [MarketIndexHistoryService],
|
||||
})
|
||||
export class MarketIndexModule {}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { MoexIndexClose } from '#src/integrations/moex/moex.client';
|
||||
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class MarketIndexRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async read(secid: 'IMOEX' | 'MCFTRR', from: string, to: string) {
|
||||
const [closes, state] = await Promise.all([
|
||||
this.prisma.marketIndexDailyClose.findMany({
|
||||
where: { secid, tradeDate: { gte: from, lte: to } },
|
||||
orderBy: { tradeDate: 'asc' },
|
||||
}),
|
||||
this.prisma.marketIndexSyncState.findUnique({ where: { secid } }),
|
||||
]);
|
||||
return { closes, state };
|
||||
}
|
||||
|
||||
async attempt(secid: 'IMOEX' | 'MCFTRR', at: Date) {
|
||||
await this.prisma.marketIndexSyncState.upsert({
|
||||
where: { secid }, create: { secid, lastAttemptAt: at }, update: { lastAttemptAt: at },
|
||||
});
|
||||
}
|
||||
|
||||
// coveredTo is the `till` MOEX answered for: a close for today that is not
|
||||
// published yet is re-checked by the service while the last close < to.
|
||||
async save(secid: 'IMOEX' | 'MCFTRR', closes: MoexIndexClose[], from: string, to: string, at: Date) {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
for (const close of closes) {
|
||||
await tx.marketIndexDailyClose.upsert({
|
||||
where: { secid_tradeDate: { secid, tradeDate: close.tradeDate } },
|
||||
create: { secid, ...close },
|
||||
update: { close: close.close },
|
||||
});
|
||||
}
|
||||
const state = await tx.marketIndexSyncState.findUniqueOrThrow({ where: { secid } });
|
||||
const joined = Boolean(state.coveredFrom && state.coveredTo &&
|
||||
from <= state.coveredTo && to >= state.coveredFrom);
|
||||
await tx.marketIndexSyncState.update({
|
||||
where: { secid },
|
||||
data: {
|
||||
lastSuccessAt: at,
|
||||
coveredFrom: joined && state.coveredFrom && state.coveredFrom < from ? state.coveredFrom : from,
|
||||
coveredTo: joined && state.coveredTo && state.coveredTo > to ? state.coveredTo : to,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MarketIndexHistoryService } from './market-index.service';
|
||||
|
||||
jest.mock('./market-index.repository', () => ({ MarketIndexRepository: class {} }));
|
||||
|
||||
type State = {
|
||||
coveredFrom: null | string;
|
||||
coveredTo: null | string;
|
||||
lastAttemptAt: Date | null;
|
||||
lastSuccessAt: Date | null;
|
||||
};
|
||||
type Close = { close: string; tradeDate: string };
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
// In-memory stand-in mirroring the repository's merge rules.
|
||||
function setup(history: Close[]) {
|
||||
let state: null | State = null;
|
||||
const stored = new Map<string, string>();
|
||||
const repository = {
|
||||
read: jest.fn((_: string, from: string, to: string) => Promise.resolve({
|
||||
closes: [...stored.entries()]
|
||||
.filter(([date]) => date >= from && date <= to)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([tradeDate, close]) => ({ tradeDate, close })),
|
||||
state: state && { ...state },
|
||||
})),
|
||||
attempt: jest.fn((_: string, at: Date) => {
|
||||
state = { coveredFrom: null, coveredTo: null, lastSuccessAt: null, ...state, lastAttemptAt: at };
|
||||
return Promise.resolve();
|
||||
}),
|
||||
save: jest.fn((_: string, closes: Close[], from: string, to: string, at: Date) => {
|
||||
for (const close of closes) stored.set(close.tradeDate, close.close);
|
||||
const current = state as State;
|
||||
const joined = Boolean(current.coveredFrom && current.coveredTo &&
|
||||
from <= current.coveredTo && to >= current.coveredFrom);
|
||||
state = {
|
||||
...current,
|
||||
lastSuccessAt: at,
|
||||
coveredFrom: joined && current.coveredFrom && current.coveredFrom < from ? current.coveredFrom : from,
|
||||
coveredTo: joined && current.coveredTo && current.coveredTo > to ? current.coveredTo : to,
|
||||
};
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
const moex = {
|
||||
indexHistory: jest.fn((_: string, from: string, till: string) => Promise.resolve(
|
||||
history.filter((row) => row.tradeDate >= from && row.tradeDate <= till),
|
||||
)),
|
||||
};
|
||||
const service = new MarketIndexHistoryService(
|
||||
repository as never, moex as never, { event: jest.fn() } as never,
|
||||
);
|
||||
return { service, moex, setHistory: (rows: Close[]) => history.splice(0, history.length, ...rows) };
|
||||
}
|
||||
|
||||
const closes = (...dates: string[]) => dates.map((tradeDate) => ({ tradeDate, close: '100' }));
|
||||
|
||||
describe('MarketIndexHistoryService', () => {
|
||||
afterEach(() => jest.useRealTimers());
|
||||
|
||||
it('loads a ten-day lookback once and reuses the confirmed range', async () => {
|
||||
const { service, moex } = setup(closes('2026-09-30', '2026-10-02', '2026-10-05'));
|
||||
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
|
||||
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
|
||||
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
|
||||
expect(moex.indexHistory).toHaveBeenCalledWith('MCFTRR', '2026-09-22', '2026-10-05');
|
||||
});
|
||||
|
||||
it('extends history backward at once, even right after a successful load', async () => {
|
||||
const { service, moex } = setup(closes('2023-01-03', '2026-09-30', '2026-10-05'));
|
||||
await service.read('MCFTRR', '2026-10-01', '2026-10-05');
|
||||
expect(await service.read('MCFTRR', '2023-01-05', '2026-10-05')).toHaveLength(3);
|
||||
expect(moex.indexHistory).toHaveBeenLastCalledWith('MCFTRR', '2022-12-26', '2026-10-05');
|
||||
});
|
||||
|
||||
it('re-checks a missing tail close after an hour, but not sooner', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
|
||||
const { service, moex, setHistory } = setup(closes('2026-09-30', '2026-10-02'));
|
||||
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
|
||||
setHistory(closes('2026-09-30', '2026-10-02', '2026-10-05'));
|
||||
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
|
||||
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
|
||||
jest.setSystemTime(Date.now() + HOUR_MS + 1);
|
||||
const rows = await service.read('MCFTRR', '2026-10-02', '2026-10-05');
|
||||
expect(rows?.at(-1)?.tradeDate).toBe('2026-10-05');
|
||||
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps saved history usable when a later refresh fails and throttles retries', async () => {
|
||||
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
|
||||
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
|
||||
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
|
||||
jest.setSystemTime(Date.now() + 1000);
|
||||
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
|
||||
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
|
||||
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
|
||||
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns no history when nothing was ever loaded and MOEX fails', async () => {
|
||||
const { service, moex } = setup([]);
|
||||
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
|
||||
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses a range that does not reach the last cash flow', async () => {
|
||||
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
|
||||
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
|
||||
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
|
||||
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-09', '2026-10-08')).toBeNull();
|
||||
});
|
||||
|
||||
it('shares one request for parallel reads', async () => {
|
||||
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
|
||||
await Promise.all([
|
||||
service.read('IMOEX', '2026-10-02', '2026-10-05'),
|
||||
service.read('IMOEX', '2026-10-02', '2026-10-05'),
|
||||
]);
|
||||
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MoexClient, type MoexIndexClose } from '#src/integrations/moex/moex.client';
|
||||
|
||||
import { MarketIndexRepository } from './market-index.repository';
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
const LOOKBACK_DAYS = 10;
|
||||
const ISO_DATE_LENGTH = 10;
|
||||
const ISO_DATE_START = 0;
|
||||
const LAST_CLOSE = -1;
|
||||
const MAX_REFRESH_ROUNDS = 2;
|
||||
const FIRST_ROUND = 0;
|
||||
|
||||
type IndexId = 'IMOEX' | 'MCFTRR';
|
||||
type Saved = Awaited<ReturnType<MarketIndexRepository['read']>>;
|
||||
|
||||
function shiftDays(date: string, days: number): string {
|
||||
const value = new Date(`${date}T00:00:00Z`);
|
||||
value.setUTCDate(value.getUTCDate() + days);
|
||||
return value.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
}
|
||||
|
||||
function within(at: Date | null | undefined, ms: number) {
|
||||
return Boolean(at && Date.now() - at.valueOf() < ms);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MarketIndexHistoryService {
|
||||
private readonly pending = new Map<IndexId, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: MarketIndexRepository,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Closes covering [from − 10 days, to]. `requiredThrough` is the latest date
|
||||
* that must have a close on or after it (the last cash flow); null when the
|
||||
* confirmed history does not cover the period.
|
||||
*/
|
||||
async read(
|
||||
secid: IndexId,
|
||||
from: string,
|
||||
to: string,
|
||||
requiredThrough = from,
|
||||
): Promise<MoexIndexClose[] | null> {
|
||||
const rangeFrom = shiftDays(from, -LOOKBACK_DAYS);
|
||||
let saved = await this.repository.read(secid, rangeFrom, to);
|
||||
for (let round = FIRST_ROUND; round < MAX_REFRESH_ROUNDS; round++) {
|
||||
const range = this.refreshRange(saved, rangeFrom, to);
|
||||
if (!range) break;
|
||||
const job = this.pending.get(secid) ?? this.start(secid, range.from, to);
|
||||
await job;
|
||||
saved = await this.repository.read(secid, rangeFrom, to);
|
||||
}
|
||||
return this.covered(saved, from, rangeFrom, to, requiredThrough)
|
||||
? saved.closes.map(({ tradeDate, close }) => ({ tradeDate, close }))
|
||||
: null;
|
||||
}
|
||||
|
||||
// coveredFrom..coveredTo is the range MOEX was successfully asked about.
|
||||
// Extending it backward is fetched at once; an unpublished tail close is
|
||||
// re-checked at most hourly; a failed attempt is retried at most hourly.
|
||||
private refreshRange(saved: Saved, rangeFrom: string, to: string) {
|
||||
const state = saved.state;
|
||||
const lastAttempt = state?.lastAttemptAt ?? null;
|
||||
const lastSuccess = state?.lastSuccessAt ?? null;
|
||||
// Success is stamped with its own attempt time, so equal means success.
|
||||
const failed = lastAttempt !== null && (lastSuccess === null || lastSuccess.valueOf() !== lastAttempt.valueOf());
|
||||
if (failed && within(lastAttempt, HOUR_MS)) return null;
|
||||
if (!state?.coveredFrom || !state.coveredTo || state.coveredFrom > rangeFrom) return { from: rangeFrom };
|
||||
if (state.coveredTo < to) return { from: state.coveredTo };
|
||||
const last = saved.closes.at(LAST_CLOSE)?.tradeDate;
|
||||
if ((!last || last < to) && !within(lastSuccess, HOUR_MS)) return { from: last ?? rangeFrom };
|
||||
return null;
|
||||
}
|
||||
|
||||
private covered(saved: Saved, from: string, rangeFrom: string, to: string, requiredThrough: string) {
|
||||
const last = saved.closes.at(LAST_CLOSE)?.tradeDate;
|
||||
return Boolean(
|
||||
saved.state?.coveredFrom && saved.state.coveredFrom <= rangeFrom &&
|
||||
saved.closes.some((row) => row.tradeDate <= from) &&
|
||||
last && last >= shiftDays(to, -LOOKBACK_DAYS) &&
|
||||
// The last cash flow must lie inside the checked range, otherwise it
|
||||
// would be priced by a stale close.
|
||||
saved.state.coveredTo && saved.state.coveredTo >= requiredThrough,
|
||||
);
|
||||
}
|
||||
|
||||
private start(secid: IndexId, from: string, to: string): Promise<void> {
|
||||
const job = this.refresh(secid, from, to).finally(() => this.pending.delete(secid));
|
||||
this.pending.set(secid, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
private async refresh(secid: IndexId, from: string, to: string): Promise<void> {
|
||||
const at = new Date();
|
||||
const started = Date.now();
|
||||
await this.repository.attempt(secid, at);
|
||||
try {
|
||||
const closes = await this.moex.indexHistory(secid, from, to);
|
||||
await this.repository.save(secid, closes, from, to, at);
|
||||
this.logger.event('info', 'integration.moex.index_history.succeeded', {
|
||||
operation: secid, count: closes.length, durationMs: Date.now() - started,
|
||||
});
|
||||
} catch {
|
||||
this.logger.event('warn', 'integration.moex.index_history.failed', {
|
||||
operation: secid, durationMs: Date.now() - started,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { TbankAccessService, TbankClient } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankOperationItem,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { ledgerRange } from '../../investment-sync-plan';
|
||||
@@ -237,7 +238,6 @@ export class BrokerageOperationsService {
|
||||
userId: string,
|
||||
accountId: string,
|
||||
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
||||
observe = false,
|
||||
) {
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
const input = {
|
||||
@@ -251,7 +251,6 @@ export class BrokerageOperationsService {
|
||||
periods: ranges,
|
||||
};
|
||||
let saved = await this.repository.readPage(input);
|
||||
if (!observe) {
|
||||
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
||||
(saved?.periods ?? []).map((period) => ({
|
||||
accountId: account.accountId,
|
||||
@@ -265,7 +264,6 @@ export class BrokerageOperationsService {
|
||||
});
|
||||
}
|
||||
saved = await this.repository.readPage(input);
|
||||
}
|
||||
const now = new Date();
|
||||
const hourly = await this.hourlySync.hourly(userId);
|
||||
const membership = investmentResourceMeta({
|
||||
|
||||
@@ -56,6 +56,24 @@ describe('mapBrokerageOperation', () => {
|
||||
payment: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('filters non-RUB money out of every money field at ingest', () => {
|
||||
expect(
|
||||
mapBrokerageOperation({
|
||||
id: 'usd',
|
||||
date: { seconds: '1' },
|
||||
type: 'OPERATION_TYPE_DIVIDEND',
|
||||
payment: { currency: 'usd', units: '21' },
|
||||
price: { currency: 'USD', units: '10' },
|
||||
commission: { currency: 'EUR', units: '1' },
|
||||
}),
|
||||
).toMatchObject({
|
||||
id: 'usd',
|
||||
payment: null,
|
||||
price: null,
|
||||
commission: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('executed trade quantity', () => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
||||
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { TbankOperationItem } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { type OperationCategory } from '../../operation-category';
|
||||
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
|
||||
|
||||
@@ -55,7 +55,7 @@ export function mapBrokerageOperation(
|
||||
ticker: text(operation.ticker),
|
||||
instrumentType: text(operation.instrumentType),
|
||||
logoName: null,
|
||||
payment: money(operation.payment),
|
||||
payment: rubMoney(operation.payment),
|
||||
quantity:
|
||||
(operationCategory(operation.type) === 'trade'
|
||||
? operation.quantityDone
|
||||
@@ -66,9 +66,9 @@ export function mapBrokerageOperation(
|
||||
? operation.quantityDone
|
||||
: operation.quantity,
|
||||
),
|
||||
price: money(operation.price),
|
||||
accruedInterest: money(operation.accruedInt),
|
||||
commission: money(operation.commission),
|
||||
price: rubMoney(operation.price),
|
||||
accruedInterest: rubMoney(operation.accruedInt),
|
||||
commission: rubMoney(operation.commission),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,13 +76,6 @@ function text(value: string | undefined): null | string {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function money(value: TbankMoney | undefined) {
|
||||
const currency = value?.currency?.trim().toUpperCase();
|
||||
return currency && value
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
|
||||
export function operationTimestamp(
|
||||
value: undefined | { nanos?: number; seconds?: number | string },
|
||||
) {
|
||||
|
||||
+2
-1
@@ -5,7 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import {
|
||||
dateOnlyBounds,
|
||||
DateOnlyRange,
|
||||
|
||||
+8
-7
@@ -2,17 +2,18 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { isTbankProtocolValue, TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import {
|
||||
isJoinableRunningAttempt,
|
||||
type TbankOperationItem, type TbankOperationsByCursorResponse,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { toJson } from '../../../../prisma/to-json';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
import {
|
||||
decodeInterval,
|
||||
extendRunningIntent,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import { operationCategories, type OperationCategory } from '../../operation-category';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
|
||||
import {
|
||||
BrokerageOperationsQueryDto,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { hasPerformanceCoverage } from './performance-availability';
|
||||
|
||||
const start = new Date('2024-01-01T00:00:00Z');
|
||||
const valuation = new Date('2024-02-01T15:00:00Z');
|
||||
|
||||
describe('performance operations coverage', () => {
|
||||
it('requires contiguous history through the exact valuation instant', () => {
|
||||
expect(hasPerformanceCoverage('A', start, valuation, [{
|
||||
from: start,
|
||||
to: new Date('2024-02-01T15:00:00Z'),
|
||||
asOf: valuation,
|
||||
}])).toBe(false);
|
||||
const end = new Date('2024-02-02T00:00:00Z');
|
||||
expect(hasPerformanceCoverage('A', start, valuation, [
|
||||
{ from: start, to: new Date('2024-01-15T00:00:00Z'), asOf: new Date('2024-01-15T00:00:00Z') },
|
||||
{ from: new Date('2024-01-15T00:00:00Z'), to: end, asOf: end },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects missing history in the middle', () => {
|
||||
const end = new Date('2024-02-02T00:00:00Z');
|
||||
expect(hasPerformanceCoverage('A', start, valuation, [
|
||||
{ from: start, to: new Date('2024-01-15T00:00:00Z'), asOf: end },
|
||||
{ from: new Date('2024-01-16T00:00:00Z'), to: end, asOf: end },
|
||||
])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { uncoveredOperationIntervals } from '../../operations/domain/operations-sync-plan';
|
||||
|
||||
const ZERO_MILLIS = 0;
|
||||
const NEXT_MILLISECOND = 1;
|
||||
|
||||
export enum PerformanceUnavailableReason {
|
||||
AccountValuationDatesDiffer = 'ACCOUNT_VALUATION_DATES_DIFFER',
|
||||
AmbiguousReturn = 'AMBIGUOUS_RETURN',
|
||||
HistoryStartUnknown = 'HISTORY_START_UNKNOWN',
|
||||
IndexHistoryUnavailable = 'INDEX_HISTORY_UNAVAILABLE',
|
||||
NoSolution = 'NO_SOLUTION',
|
||||
OperationsHistoryIncomplete = 'OPERATIONS_HISTORY_INCOMPLETE',
|
||||
PortfolioValuationIncomplete = 'PORTFOLIO_VALUATION_INCOMPLETE',
|
||||
UnconfirmedTransfer = 'UNCONFIRMED_TRANSFER',
|
||||
UnvaluedSecuritiesTransfer = 'UNVALUED_SECURITIES_TRANSFER',
|
||||
}
|
||||
|
||||
export const performanceUnavailableReasons = Object.values(PerformanceUnavailableReason);
|
||||
|
||||
type Coverage = { asOf: Date; from: Date; to: Date };
|
||||
|
||||
/** Coverage is half-open and must include the instant of the last valuation. */
|
||||
export function hasPerformanceCoverage(
|
||||
accountId: string,
|
||||
from: Date,
|
||||
valuationAt: Date,
|
||||
coverage: Coverage[],
|
||||
): boolean {
|
||||
if (from.valueOf() >= valuationAt.valueOf()) return false;
|
||||
const end = new Date(valuationAt.valueOf() + NEXT_MILLISECOND);
|
||||
if (!coverage.some((item) => item.asOf >= valuationAt && item.to > valuationAt)) return false;
|
||||
const confirmed = coverage.map((item) => ({ accountId, from: item.from, to: item.to }));
|
||||
return uncoveredOperationIntervals([{ accountId, from, to: end }], confirmed).length === ZERO_MILLIS;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
|
||||
import { candidateTransfers, readPerformanceOperations, sumFlows } from './performance-flows';
|
||||
|
||||
const at = (date: string) => new Date(date);
|
||||
const op = (accountId: string, type: string, date: string, units: string, currency = 'rub') => ({
|
||||
accountId, occurredAt: at(date), instrumentUid: null,
|
||||
payload: { type, payment: { units, nano: 0, currency } },
|
||||
});
|
||||
|
||||
describe('performance cash flows', () => {
|
||||
it('includes only external cash and preserves dividends within the portfolio', () => {
|
||||
const operations = [
|
||||
op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '100000'),
|
||||
op('A', 'OPERATION_TYPE_INPUT_ACQUIRING', '2025-02-01T15:00:00Z', '50000'),
|
||||
op('A', 'OPERATION_TYPE_OUTPUT', '2025-03-01T15:00:00Z', '-20000'),
|
||||
op('A', 'OPERATION_TYPE_DIVIDEND', '2025-04-01T15:00:00Z', '3000'),
|
||||
];
|
||||
const result = readPerformanceOperations(operations);
|
||||
expect(formatDecimal(sumFlows(result.flows))).toBe('130000');
|
||||
expect(result.flows).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('treats equal opposite cash movements as unconfirmed transfer candidates, not a proven pair', () => {
|
||||
const result = readPerformanceOperations([
|
||||
op('A', 'OPERATION_TYPE_OUTPUT', '2025-03-01T15:00:00Z', '-50000'),
|
||||
op('B', 'OPERATION_TYPE_INPUT', '2025-03-01T15:01:00Z', '50000'),
|
||||
]);
|
||||
expect(candidateTransfers(result)).toEqual([{ accountIds: ['A', 'B'], type: 'cash' }]);
|
||||
expect(result.flows).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('detects unvalued securities transfers', () => {
|
||||
const result = readPerformanceOperations([
|
||||
op('B', 'OPERATION_TYPE_INPUT_SECURITIES', '2025-01-01T15:00:00Z', '0'),
|
||||
]);
|
||||
expect([...result.unvaluedAccounts]).toEqual(['B']);
|
||||
});
|
||||
|
||||
it('skips non-RUB cash movements read back from persisted payloads', () => {
|
||||
const result = readPerformanceOperations([
|
||||
op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '100'),
|
||||
op('A', 'OPERATION_TYPE_INPUT', '2025-02-01T15:00:00Z', '21', 'usd'),
|
||||
]);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(formatDecimal(sumFlows(result.flows))).toBe('100');
|
||||
expect([...result.missingAmountAccounts]).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { add, type Decimal, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
import { moscowDate } from '../../investment-calendar';
|
||||
|
||||
const ZERO: Decimal = { value: 0n, scale: 0 };
|
||||
const ZERO_UNITS = 0n;
|
||||
const NANO_SCALE = 9;
|
||||
const TRANSFER_WINDOW_MS = 300_000;
|
||||
|
||||
export type PerformanceFlow = { accountId: string; amount: Decimal; at: Date; date: string };
|
||||
export type Candidate = { accountIds: [string, string]; type: 'cash' };
|
||||
|
||||
type Operation = {
|
||||
accountId: string;
|
||||
instrumentUid: null | string;
|
||||
occurredAt: Date;
|
||||
payload: unknown;
|
||||
};
|
||||
|
||||
const deposits = new Set<string>([
|
||||
TbankOperationType.InpMulti, TbankOperationType.Input,
|
||||
TbankOperationType.InputAcquiring, TbankOperationType.InputSwift,
|
||||
]);
|
||||
const withdrawals = new Set<string>([
|
||||
TbankOperationType.OutMulti, TbankOperationType.Output,
|
||||
TbankOperationType.OutputAcquiring, TbankOperationType.OutputSwift,
|
||||
]);
|
||||
const securities = new Set<string>([
|
||||
TbankOperationType.InputSecurities, TbankOperationType.OutputSecurities,
|
||||
TbankOperationType.TransBsBs, TbankOperationType.TransIisBs,
|
||||
]);
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
function money(value: unknown): null | { currency: string; value: Decimal } {
|
||||
const source = record(value);
|
||||
if (typeof source.currency !== 'string') return null;
|
||||
const units = typeof source.units === 'string' || typeof source.units === 'number'
|
||||
? String(source.units)
|
||||
: '0';
|
||||
let nano = ZERO_UNITS;
|
||||
if (typeof source.nano === 'number' && Number.isInteger(source.nano)) nano = BigInt(source.nano);
|
||||
if (typeof source.nano === 'string' && /^-?\d+$/.test(source.nano)) nano = BigInt(source.nano);
|
||||
const whole = decimalFromString(units);
|
||||
return {
|
||||
currency: source.currency.toUpperCase(),
|
||||
value: add(whole, { value: nano, scale: NANO_SCALE }),
|
||||
};
|
||||
}
|
||||
|
||||
function magnitude(value: Decimal): Decimal {
|
||||
return { value: value.value < ZERO_UNITS ? -value.value : value.value, scale: value.scale };
|
||||
}
|
||||
|
||||
function appendCashFlow(
|
||||
operation: Operation,
|
||||
payload: Record<string, unknown>,
|
||||
value: null | { currency: string; value: Decimal },
|
||||
flows: PerformanceFlow[],
|
||||
missingAmountAccounts: Set<string>,
|
||||
) {
|
||||
if (!value) {
|
||||
missingAmountAccounts.add(operation.accountId);
|
||||
return;
|
||||
}
|
||||
// RUB-only: non-RUB cash movements have no rate to RUB and are left out of XIRR.
|
||||
if (value.currency !== 'RUB') return;
|
||||
const amount = magnitude(value.value);
|
||||
flows.push({
|
||||
accountId: operation.accountId,
|
||||
amount: deposits.has(payload.type as string) ? amount : { value: -amount.value, scale: amount.scale },
|
||||
date: moscowDate(operation.occurredAt), at: operation.occurredAt,
|
||||
});
|
||||
}
|
||||
|
||||
export function readPerformanceOperations(operations: Operation[]) {
|
||||
const flows: PerformanceFlow[] = [];
|
||||
const unvaluedAccounts = new Set<string>();
|
||||
const missingAmountAccounts = new Set<string>();
|
||||
for (const operation of operations) {
|
||||
const payload = record(operation.payload);
|
||||
if (typeof payload.type !== 'string') continue;
|
||||
const value = money(payload.payment);
|
||||
if (deposits.has(payload.type) || withdrawals.has(payload.type)) {
|
||||
appendCashFlow(operation, payload, value, flows, missingAmountAccounts);
|
||||
} else if (securities.has(payload.type)) {
|
||||
// Securities moved in or out carry no money amount for the flow.
|
||||
unvaluedAccounts.add(operation.accountId);
|
||||
}
|
||||
}
|
||||
return { flows, missingAmountAccounts, unvaluedAccounts };
|
||||
}
|
||||
|
||||
export function candidateTransfers(input: ReturnType<typeof readPerformanceOperations>): Candidate[] {
|
||||
const candidates: Candidate[] = [];
|
||||
for (const out of input.flows.filter((flow) => flow.amount.value < ZERO_UNITS)) {
|
||||
for (const incoming of input.flows.filter((flow) => flow.amount.value > ZERO_UNITS)) {
|
||||
if (out.accountId !== incoming.accountId &&
|
||||
Math.abs(out.at.valueOf() - incoming.at.valueOf()) <= TRANSFER_WINDOW_MS &&
|
||||
formatDecimal(magnitude(out.amount)) === formatDecimal(incoming.amount)) {
|
||||
candidates.push({ accountIds: [out.accountId, incoming.accountId], type: 'cash' });
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function sumFlows(flows: PerformanceFlow[]): Decimal {
|
||||
return flows.reduce((sum, flow) => add(sum, flow.amount), ZERO);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { decimalFromString } from '#src/common/decimal';
|
||||
|
||||
import { shadowIndex } from './shadow-index';
|
||||
|
||||
const flow = (date: string, amount: string) => ({
|
||||
accountId: 'A', amount: decimalFromString(amount),
|
||||
date, at: new Date(`${date}T12:00:00Z`),
|
||||
});
|
||||
|
||||
describe('shadowIndex', () => {
|
||||
it('values identical deposits at the index close and links later index growth', () => {
|
||||
const result = shadowIndex([flow('2026-11-06', '100000')], [
|
||||
{ tradeDate: '2026-11-06', close: '1000' },
|
||||
{ tradeDate: '2026-11-09', close: '1100' },
|
||||
], '2026-11-06', '2026-11-09');
|
||||
expect(result?.value).toBe('110000');
|
||||
expect(result?.points.map((point) => point.value)).toEqual(['100000', '110000']);
|
||||
});
|
||||
|
||||
it('uses Friday close for a Saturday deposit, not Monday close', () => {
|
||||
const result = shadowIndex([flow('2026-11-07', '100000')], [
|
||||
{ tradeDate: '2026-11-06', close: '1000' },
|
||||
{ tradeDate: '2026-11-09', close: '1100' },
|
||||
], '2026-11-07', '2026-11-09');
|
||||
expect(result?.value).toBe('110000');
|
||||
expect(result?.points).toEqual([{ date: '2026-11-09', netInvested: '100000', value: '110000' }]);
|
||||
});
|
||||
|
||||
it('refuses withdrawals larger than the index position', () => {
|
||||
expect(shadowIndex([flow('2026-11-06', '100000'), flow('2026-11-09', '-120000')], [
|
||||
{ tradeDate: '2026-11-06', close: '1000' },
|
||||
{ tradeDate: '2026-11-09', close: '900' },
|
||||
], '2026-11-06', '2026-11-09')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not invent a price for a flow preceding the first close', () => {
|
||||
expect(shadowIndex([flow('2026-11-05', '100000')], [
|
||||
{ tradeDate: '2026-11-06', close: '1000' },
|
||||
], '2026-11-05', '2026-11-06')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
add, type Decimal, decimalFromString, divide, formatDecimal, multiply,
|
||||
} from '#src/common/decimal';
|
||||
|
||||
import type { PerformanceFlow } from './performance-flows';
|
||||
|
||||
const UNIT_SCALE = 12;
|
||||
const CENT_SCALE = 2;
|
||||
const ZERO: Decimal = { scale: 0, value: 0n };
|
||||
const FIRST = 0;
|
||||
const NEXT = 1;
|
||||
const LAST = -1;
|
||||
const CENT = decimalFromString('1');
|
||||
|
||||
export type IndexClose = { close: string; tradeDate: string };
|
||||
export type IndexPoint = { date: string; netInvested: string; value: string };
|
||||
|
||||
function valueAt(units: Decimal, close: string): string {
|
||||
return formatDecimal(divide(multiply(units, decimalFromString(close)), CENT, CENT_SCALE));
|
||||
}
|
||||
|
||||
/** A closed price on or before every cash flow is mandatory; no invented prices. */
|
||||
export function shadowIndex(
|
||||
flows: PerformanceFlow[],
|
||||
closes: IndexClose[],
|
||||
from: string,
|
||||
to: string,
|
||||
): null | { asOf: string; points: IndexPoint[]; value: string } {
|
||||
const prices = closes.filter((close) => close.tradeDate <= to)
|
||||
.sort((a, b) => a.tradeDate.localeCompare(b.tradeDate));
|
||||
if (!prices.length) return null;
|
||||
const events = [...flows].sort((a, b) => a.at.valueOf() - b.at.valueOf());
|
||||
let closeIndex = FIRST;
|
||||
let eventIndex = FIRST;
|
||||
let units = ZERO;
|
||||
let invested = ZERO;
|
||||
const points: IndexPoint[] = [];
|
||||
|
||||
// Advance prices only up to the date of the event, never to the next trading day.
|
||||
const applyEvent = (event: PerformanceFlow): boolean => {
|
||||
while (prices[closeIndex + NEXT]?.tradeDate <= event.date) closeIndex++;
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric index advances only through this validated sorted local array.
|
||||
const price = prices[closeIndex];
|
||||
if (!price || price.tradeDate > event.date) return false;
|
||||
units = add(units, divide(event.amount, decimalFromString(price.close), UNIT_SCALE));
|
||||
// Withdrawals larger than the index position cannot be replayed honestly.
|
||||
if (units.value < ZERO.value) return false;
|
||||
invested = add(invested, event.amount);
|
||||
return true;
|
||||
};
|
||||
|
||||
for (const close of prices) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- Sequential local iteration bounded by array length.
|
||||
while (events[eventIndex]?.date <= close.tradeDate) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- While condition ensures an event exists at this index.
|
||||
const event = events[eventIndex];
|
||||
if (!event || !applyEvent(event)) return null;
|
||||
eventIndex++;
|
||||
}
|
||||
if (close.tradeDate >= from) {
|
||||
points.push({
|
||||
date: close.tradeDate,
|
||||
netInvested: formatDecimal(invested),
|
||||
value: valueAt(units, close.close),
|
||||
});
|
||||
}
|
||||
}
|
||||
// A flow on a market holiday after the last close still buys at that close.
|
||||
// eslint-disable-next-line security/detect-object-injection -- Sequential local iteration bounded by array length.
|
||||
while (events[eventIndex]?.date <= to) {
|
||||
// eslint-disable-next-line security/detect-object-injection -- While condition ensures an event exists at this index.
|
||||
const event = events[eventIndex];
|
||||
if (!event || !applyEvent(event)) return null;
|
||||
eventIndex++;
|
||||
}
|
||||
if (eventIndex !== events.length || !points.length) return null;
|
||||
const last = points.at(LAST);
|
||||
const finalPrice = prices.at(LAST);
|
||||
if (!last || !finalPrice) return null;
|
||||
return { asOf: last.date, points, value: valueAt(units, finalPrice.close) };
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { decimalFromString } from '#src/common/decimal';
|
||||
|
||||
import { xirr } from './xirr';
|
||||
|
||||
const cash = (date: string, value: string) => ({ date, amount: decimalFromString(value) });
|
||||
|
||||
describe('XIRR of actual dated external flows', () => {
|
||||
it('finds 10% annual on 100000 invested for exactly 730 days', () => {
|
||||
expect(xirr([cash('2024-01-01', '-100000'), cash('2025-12-31', '121000')]))
|
||||
.toEqual({ value: '0.1', reason: null });
|
||||
});
|
||||
|
||||
it('returns no rate when an investment is worth zero at the end', () => {
|
||||
expect(xirr([cash('2024-01-01', '-100000'), cash('2025-01-01', '0')]))
|
||||
.toEqual({ value: null, reason: 'NO_SOLUTION' });
|
||||
});
|
||||
|
||||
it('never selects one of two mathematically valid IRR roots', () => {
|
||||
// NPV = -100 + 230/(1+r) - 132/(1+r)^2: roots 10% and 20%.
|
||||
expect(xirr([
|
||||
cash('2023-01-01', '-100'),
|
||||
cash('2024-01-01', '230'),
|
||||
cash('2024-12-31', '-132'),
|
||||
])).toEqual({ value: null, reason: 'AMBIGUOUS_RETURN' });
|
||||
});
|
||||
|
||||
it('combines flows on the same day before choosing a rate', () => {
|
||||
expect(xirr([
|
||||
cash('2024-01-01', '-100000'), cash('2024-01-01', '25000'),
|
||||
cash('2024-12-31', '82500'),
|
||||
])).toEqual({ value: '0.1', reason: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { add, type Decimal, formatDecimal } from '#src/common/decimal';
|
||||
|
||||
const DAYS_IN_YEAR = 365;
|
||||
const DAY_MS = 86_400_000;
|
||||
const MIN_RATE = -0.9999;
|
||||
const MAX_RATE = 100;
|
||||
const NPV_TOLERANCE_RUB = 0.01;
|
||||
const MAX_ITERATIONS = 200;
|
||||
const MIDPOINT = 2;
|
||||
const ROOT_COUNT_NONE = 0;
|
||||
const ROOT_COUNT_ONE = 1;
|
||||
const ZERO = 0;
|
||||
const ZERO_UNITS = 0n;
|
||||
const RATE_SCALE = 6;
|
||||
const RATE_BASE = 1;
|
||||
|
||||
export type DatedCashFlow = { amount: Decimal; date: string };
|
||||
export type XirrResult =
|
||||
| { reason: 'AMBIGUOUS_RETURN' | 'NO_SOLUTION'; value: null } |
|
||||
{ reason: null; value: string };
|
||||
|
||||
// With x=(1+r)^(-1/365) the dated NPV is a polynomial with terms at integer
|
||||
// day exponents. Descartes' rule proves uniqueness when its nonzero
|
||||
// coefficients have exactly one sign change. More sign changes may still
|
||||
// yield a unique root: conservatively report AMBIGUOUS_RETURN until all
|
||||
// candidate roots can be isolated, never silently choose one.
|
||||
export function xirr(flows: DatedCashFlow[]): XirrResult {
|
||||
if (!flows.length) return { reason: 'NO_SOLUTION', value: null };
|
||||
const byDate = new Map<string, Decimal>();
|
||||
for (const flow of flows) {
|
||||
byDate.set(flow.date, add(byDate.get(flow.date) ?? { value: ZERO_UNITS, scale: ZERO }, flow.amount));
|
||||
}
|
||||
const ordered = [...byDate.entries()].sort(([left], [right]) => left.localeCompare(right))
|
||||
.filter(([, amount]) => amount.value !== ZERO_UNITS);
|
||||
const changes = signChanges(ordered);
|
||||
if (changes > ROOT_COUNT_ONE) return { reason: 'AMBIGUOUS_RETURN', value: null };
|
||||
if (changes < ROOT_COUNT_ONE) return { reason: 'NO_SOLUTION', value: null };
|
||||
|
||||
const firstDay = ordered[0]?.[0];
|
||||
if (!firstDay) return { reason: 'NO_SOLUTION', value: null };
|
||||
const first = Date.parse(`${firstDay}T00:00:00Z`);
|
||||
if (!Number.isFinite(first)) return { reason: 'NO_SOLUTION', value: null };
|
||||
const terms = ordered.map(([date, amount]) => ({
|
||||
days: (Date.parse(`${date}T00:00:00Z`) - first) / DAY_MS,
|
||||
amount: Number(formatDecimal(amount)),
|
||||
}));
|
||||
if (terms.some((term) => !Number.isFinite(term.days) || !Number.isFinite(term.amount))) {
|
||||
return { reason: 'NO_SOLUTION', value: null };
|
||||
}
|
||||
const npv = (rate: number) => terms.reduce((sum, term) =>
|
||||
sum + term.amount / Math.pow(RATE_BASE + rate, term.days / DAYS_IN_YEAR), ZERO);
|
||||
return bisect(npv);
|
||||
}
|
||||
|
||||
function bisect(npv: (rate: number) => number): XirrResult {
|
||||
let low = MIN_RATE;
|
||||
let high = MAX_RATE;
|
||||
let lower = npv(low);
|
||||
const upper = npv(high);
|
||||
if (!Number.isFinite(lower) || !Number.isFinite(upper) || lower * upper > ZERO) {
|
||||
return { reason: 'NO_SOLUTION', value: null };
|
||||
}
|
||||
if (Math.abs(lower) <= NPV_TOLERANCE_RUB) {
|
||||
return { reason: null, value: String(Number(low.toFixed(RATE_SCALE))) };
|
||||
}
|
||||
if (Math.abs(upper) <= NPV_TOLERANCE_RUB) {
|
||||
return { reason: null, value: String(Number(high.toFixed(RATE_SCALE))) };
|
||||
}
|
||||
for (let i = ROOT_COUNT_NONE; i < MAX_ITERATIONS; i++) {
|
||||
const mid = (low + high) / MIDPOINT;
|
||||
const value = npv(mid);
|
||||
if (!Number.isFinite(value)) return { reason: 'NO_SOLUTION', value: null };
|
||||
if (Math.abs(value) <= NPV_TOLERANCE_RUB) {
|
||||
return { reason: null, value: String(Number(mid.toFixed(RATE_SCALE))) };
|
||||
}
|
||||
if ((lower < ZERO) === (value < ZERO)) {
|
||||
low = mid;
|
||||
lower = value;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
return { reason: 'NO_SOLUTION', value: null };
|
||||
}
|
||||
|
||||
function signChanges(ordered: [string, Decimal][]): number {
|
||||
let changes = ROOT_COUNT_NONE;
|
||||
let previous = ZERO_UNITS;
|
||||
for (const [, amount] of ordered) {
|
||||
if (previous * amount.value < ZERO_UNITS) changes++;
|
||||
previous = amount.value;
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
read(userId: string, revision: string, accountId: string) {
|
||||
return this.prisma.tbankConnection.findUnique({
|
||||
where: { userId_revision: { userId, revision }, invalidToken: false },
|
||||
select: {
|
||||
membershipAt: true,
|
||||
accounts: {
|
||||
where: accountId === 'all' ? {} : { accountId },
|
||||
orderBy: { upstreamOrder: 'asc' },
|
||||
select: {
|
||||
accountId: true, name: true, historyBoundary: true, historyFrom: true,
|
||||
portfolio: { select: { payload: true, fetchedAt: true } },
|
||||
operations: {
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
select: { occurredAt: true, payload: true, instrumentUid: true },
|
||||
},
|
||||
coverage: {
|
||||
where: { instrumentPredicate: '' },
|
||||
select: { from: true, to: true, asOf: true },
|
||||
orderBy: { from: 'asc' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user