Compare commits
4
Commits
main
...
4a17f1fffd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a17f1fffd | ||
|
|
b4c83e7462 | ||
|
|
3b55b611b5 | ||
|
|
fee61537b6 |
@@ -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"
|
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 {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -83,18 +101,17 @@ model TbankPortfolioValueHistory {
|
|||||||
@@id([userId, accountId])
|
@@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 {
|
model TbankPortfolioValuePoint {
|
||||||
userId String
|
userId String
|
||||||
accountId String
|
accountId String
|
||||||
attemptId String
|
attemptId String
|
||||||
currency String
|
|
||||||
value String
|
value String
|
||||||
capturedAt DateTime
|
capturedAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
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])
|
@@index([userId, accountId, capturedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account
|
|||||||
import { IncomeModule } from './modules/investments/income/income.module';
|
import { IncomeModule } from './modules/investments/income/income.module';
|
||||||
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
|
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
|
||||||
import { OperationsModule } from './modules/investments/operations/operations.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 { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
||||||
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
||||||
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||||
@@ -27,6 +28,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
BankCardsModule,
|
BankCardsModule,
|
||||||
BrokerageAccountsModule,
|
BrokerageAccountsModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
|
PerformanceModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
IncomeModule,
|
IncomeModule,
|
||||||
UpcomingEventsModule,
|
UpcomingEventsModule,
|
||||||
|
|||||||
@@ -8,6 +8,73 @@ const emptyEvents = {
|
|||||||
|
|
||||||
describe('MoexClient', () => {
|
describe('MoexClient', () => {
|
||||||
afterEach(() => jest.restoreAllMocks());
|
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 () => {
|
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -273,4 +340,59 @@ describe('MoexClient', () => {
|
|||||||
expect(starts).toEqual(['0', '1']);
|
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 MIN_CURSOR_VALUE = 0;
|
||||||
|
|
||||||
const FALLBACK_PAGE_SIZE = 100;
|
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 baseUrl = 'https://iss.moex.com/iss';
|
||||||
const timeoutMs = 10_000;
|
const timeoutMs = 10_000;
|
||||||
@@ -28,9 +35,64 @@ export type MoexBondYield = {
|
|||||||
tradeAt: string;
|
tradeAt: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
export type MoexIndexClose = { close: string; tradeDate: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MoexClient {
|
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(
|
async bondYield(
|
||||||
secid: string,
|
secid: string,
|
||||||
isin: string,
|
isin: string,
|
||||||
@@ -95,9 +157,9 @@ export class MoexClient {
|
|||||||
for (const row of coupons) {
|
for (const row of coupons) {
|
||||||
const date = text(row.coupondate);
|
const date = text(row.coupondate);
|
||||||
const value = numberText(row.value);
|
const value = numberText(row.value);
|
||||||
const currency = currencyText(row.faceunit);
|
// RUB-only: coupons in other currencies are filtered out at this ingest boundary.
|
||||||
if (date && value && currency) {
|
if (date && value && currencyText(row.faceunit) === 'RUB') {
|
||||||
events.push({ type: 'coupon', date, value, currency });
|
events.push({ type: 'coupon', date, value, currency: 'RUB' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const row of offers) {
|
for (const row of offers) {
|
||||||
@@ -109,9 +171,8 @@ export class MoexClient {
|
|||||||
);
|
);
|
||||||
const date = values.get('MATDATE');
|
const date = values.get('MATDATE');
|
||||||
const value = numberText(values.get('FACEVALUE'));
|
const value = numberText(values.get('FACEVALUE'));
|
||||||
const currency = currencyText(values.get('FACEUNIT'));
|
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') {
|
||||||
if (date && value && currency) {
|
events.push({ type: 'maturity', date, value, currency: 'RUB' });
|
||||||
events.push({ type: 'maturity', date, value, currency });
|
|
||||||
}
|
}
|
||||||
return events;
|
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;
|
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 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 {
|
export function decimal(value: ProtoDecimal): Decimal {
|
||||||
return {
|
return {
|
||||||
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
||||||
|
|||||||
@@ -49,11 +49,17 @@ export enum TbankOperationType {
|
|||||||
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||||
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||||
|
InpMulti = 'OPERATION_TYPE_INP_MULTI',
|
||||||
Input = 'OPERATION_TYPE_INPUT',
|
Input = 'OPERATION_TYPE_INPUT',
|
||||||
|
InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||||
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
InputSwift = 'OPERATION_TYPE_INPUT_SWIFT',
|
||||||
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
OutMulti = 'OPERATION_TYPE_OUT_MULTI',
|
||||||
Output = 'OPERATION_TYPE_OUTPUT',
|
Output = 'OPERATION_TYPE_OUTPUT',
|
||||||
|
OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||||
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||||
Sell = 'OPERATION_TYPE_SELL',
|
Sell = 'OPERATION_TYPE_SELL',
|
||||||
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||||
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
|||||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||||
|
|
||||||
export class UnrealizedReturnDto {
|
export class UnrealizedReturnDto {
|
||||||
@ApiProperty()
|
|
||||||
declare currency: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: '12.345678901' })
|
@ApiProperty({ example: '12.345678901' })
|
||||||
declare absolute: string;
|
declare absolute: string;
|
||||||
|
|
||||||
@@ -24,17 +21,17 @@ export class UnrealizedReturnDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare totalsByCurrency: MoneyDto[];
|
declare totals: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare cashBalances: MoneyDto[];
|
declare cash: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioStructureDto })
|
@ApiProperty({ type: PortfolioStructureDto })
|
||||||
declare structure: PortfolioStructureDto;
|
declare structure: PortfolioStructureDto;
|
||||||
|
|
||||||
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
|
@ApiProperty({ type: UnrealizedReturnDto })
|
||||||
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
|
declare unrealizedReturn: UnrealizedReturnDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ describe('saved brokerage account projection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
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({
|
portfolio.saved.mockResolvedValue({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [
|
accounts: [
|
||||||
@@ -44,11 +44,9 @@ describe('saved brokerage account projection', () => {
|
|||||||
openedOn: null,
|
openedOn: null,
|
||||||
meta,
|
meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
cash: { currency: 'RUB', value: '10.000000001' },
|
||||||
structure,
|
structure,
|
||||||
totalsByCurrency: [
|
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||||
{ currency: 'RUB', value: '100000000.000000001' },
|
|
||||||
],
|
|
||||||
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,14 +62,14 @@ describe('saved brokerage account projection', () => {
|
|||||||
});
|
});
|
||||||
const response = await service.list('owner');
|
const response = await service.list('owner');
|
||||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
cash: { currency: 'RUB', value: '10.000000001' },
|
||||||
structure,
|
structure,
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||||
stockPositionCount: 1,
|
stockPositionCount: 1,
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
fundPositionCount: 0,
|
fundPositionCount: 0,
|
||||||
totalPositionCount: 2,
|
totalPositionCount: 2,
|
||||||
unrealizedReturnByCurrency: [],
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||||
});
|
});
|
||||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||||
expect(response.meta.availability).toBe('available');
|
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 () => {
|
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({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
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({
|
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,
|
stockPositionCount: 0, bondPositionCount: 0,
|
||||||
fundPositionCount: 0, totalPositionCount: 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({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
cashBalances: [], structure: {}, totalsByCurrency: [
|
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
|
||||||
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
|
|
||||||
],
|
|
||||||
positions: [
|
positions: [
|
||||||
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
|
{ 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: '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: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
|
||||||
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
|
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||||
{ 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 },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
||||||
stockPositionCount: 2, bondPositionCount: 2,
|
stockPositionCount: 1, bondPositionCount: 2,
|
||||||
fundPositionCount: 1, totalPositionCount: 6,
|
fundPositionCount: 1, totalPositionCount: 4,
|
||||||
unrealizedReturnByCurrency: [
|
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
|
||||||
{ 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 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits return percent for a currency with short positions', async () => {
|
it('omits return percent for short positions', async () => {
|
||||||
portfolio.saved.mockResolvedValueOnce({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
cashBalances: [], structure: {}, totalsByCurrency: [],
|
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
|
||||||
positions: [
|
positions: [
|
||||||
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
{ 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([
|
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
|
||||||
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
|
{ absolute: '-19', costBasis: '-190', percent: null, partial: false },
|
||||||
{ currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false },
|
);
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('observes the latest hourly walk without starting another one', async () => {
|
it('observes the latest hourly walk without starting another one', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
add,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
} from '#src/common/decimal';
|
} from '#src/common/decimal';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
|
|
||||||
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
||||||
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
||||||
import {
|
import {
|
||||||
@@ -22,61 +22,42 @@ import {
|
|||||||
const PERCENT_DECIMAL_PLACES = 2;
|
const PERCENT_DECIMAL_PLACES = 2;
|
||||||
const ZERO_QUANTITY = 0n;
|
const ZERO_QUANTITY = 0n;
|
||||||
|
|
||||||
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
|
||||||
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
const absoluteReturn = positions.reduce(
|
||||||
const investments = positions.filter((position) => position.type !== 'CURRENCY');
|
(sum, position) => position.absoluteReturn
|
||||||
const absolute = sumMoneyByCurrency(
|
? add(sum, decimalFromString(position.absoluteReturn.value))
|
||||||
investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
|
: sum,
|
||||||
|
decimalFromString('0'),
|
||||||
);
|
);
|
||||||
const costs = sumMoneyByCurrency(
|
const costBasis = positions
|
||||||
investments.flatMap((position) =>
|
.filter((position) => position.absoluteReturn && position.averagePurchasePrice)
|
||||||
position.absoluteReturn && position.averagePurchasePrice
|
.reduce(
|
||||||
? [{
|
(sum, position) =>
|
||||||
currency: position.absoluteReturn.currency,
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above.
|
||||||
value: formatDecimal(multiply(
|
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))),
|
||||||
decimalFromString(position.averagePurchasePrice.value),
|
decimalFromString('0'),
|
||||||
decimalFromString(position.quantity),
|
|
||||||
)),
|
|
||||||
}]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const partial = new Set(
|
const partial = positions.some(
|
||||||
investments.flatMap((position) => {
|
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue),
|
||||||
const money = position.absoluteReturn
|
|
||||||
? null
|
|
||||||
: position.averagePurchasePrice ?? position.currentValue;
|
|
||||||
return money ? [money.currency] : [];
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
// A short position has negative cost basis, so a percent would flip sign or net out.
|
// A short position has negative cost basis, so a percent would flip sign or net out.
|
||||||
const withShorts = new Set(
|
const withShorts = positions.some(
|
||||||
investments.flatMap((position) => {
|
(position) =>
|
||||||
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
|
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
|
||||||
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
|
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 {
|
return {
|
||||||
currency,
|
absolute: formatDecimal(absoluteReturn),
|
||||||
absolute: absoluteReturn,
|
costBasis: formatDecimal(costBasis),
|
||||||
costBasis,
|
percent: isZero(costBasis) || withShorts
|
||||||
percent: isZero(cost) || withShorts.has(currency)
|
|
||||||
? null
|
? null
|
||||||
: formatDecimal(divide(
|
: formatDecimal(divide(
|
||||||
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
multiply(absoluteReturn, decimalFromString('100')),
|
||||||
cost,
|
costBasis,
|
||||||
PERCENT_DECIMAL_PLACES,
|
PERCENT_DECIMAL_PLACES,
|
||||||
)),
|
)),
|
||||||
partial: partial.has(currency),
|
partial,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -98,10 +79,10 @@ export class BrokerageAccountsService {
|
|||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
portfolioSummary: account.snapshot
|
portfolioSummary: account.snapshot
|
||||||
? {
|
? {
|
||||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
totals: account.snapshot.totals,
|
||||||
cashBalances: account.snapshot.cashBalances,
|
cash: account.snapshot.cash,
|
||||||
structure: account.snapshot.structure,
|
structure: account.snapshot.structure,
|
||||||
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
|
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
|
||||||
stockPositionCount: account.snapshot.positions.filter(
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'STOCK',
|
(position) => position.type === 'STOCK',
|
||||||
).length,
|
).length,
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ export class IncomePeriodDto {
|
|||||||
declare to: string;
|
declare to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeCurrencySummaryDto {
|
export class IncomeTotalDto {
|
||||||
@ApiProperty()
|
|
||||||
declare currency: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare coupon: MoneyDto;
|
declare coupon: MoneyDto;
|
||||||
|
|
||||||
@@ -60,8 +57,8 @@ export class IncomeSummaryDto {
|
|||||||
@ApiProperty({ type: IncomePeriodDto })
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
declare period: IncomePeriodDto;
|
declare period: IncomePeriodDto;
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
@ApiProperty({ type: IncomeTotalDto })
|
||||||
declare totalsByCurrency: IncomeCurrencySummaryDto[];
|
declare total: IncomeTotalDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'date-time' })
|
@ApiProperty({ format: 'date-time' })
|
||||||
declare calculatedAt: string;
|
declare calculatedAt: string;
|
||||||
@@ -71,8 +68,8 @@ export class IncomeBucketDto {
|
|||||||
@ApiProperty({ type: IncomePeriodDto })
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
declare period: IncomePeriodDto;
|
declare period: IncomePeriodDto;
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
@ApiProperty({ type: IncomeTotalDto })
|
||||||
declare totalsByCurrency: IncomeCurrencySummaryDto[];
|
declare total: IncomeTotalDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeInstrumentDto {
|
export class IncomeInstrumentDto {
|
||||||
|
|||||||
@@ -57,22 +57,22 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
ticker: 'T',
|
ticker: 'T',
|
||||||
instrumentType: 'bond',
|
instrumentType: 'bond',
|
||||||
},
|
},
|
||||||
key: 'uid:UID:RUB',
|
key: 'uid:UID',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
|
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
|
||||||
key: 'figi:FIGI:RUB',
|
key: 'figi:FIGI',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identity: { ticker: 'T', instrumentType: 'bond' },
|
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' },
|
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',
|
'uses one membership for entry and aggregate: $key',
|
||||||
async ({ identity, 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 = [
|
const selected = [
|
||||||
{
|
{
|
||||||
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
@@ -112,10 +112,6 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
|
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
|
||||||
instrumentUid: 'UID',
|
instrumentUid: 'UID',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
|
|
||||||
instrumentUid: 'UID',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
seed(selected);
|
seed(selected);
|
||||||
|
|
||||||
@@ -131,7 +127,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
expect(result.entries).toEqual(
|
expect(result.entries).toEqual(
|
||||||
selected.map((item) => ({
|
selected.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
instrumentKey: `uid:UID:${item.payment?.currency}`,
|
instrumentKey: 'uid:UID',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -146,14 +142,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
key: 'uid:UID:USD',
|
key: 'uid:UID',
|
||||||
receiptCount: 1,
|
|
||||||
coupon: { currency: 'USD', value: '20' },
|
|
||||||
dividend: { currency: 'USD', value: '0' },
|
|
||||||
total: { currency: 'USD', value: '20' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'uid:UID:RUB',
|
|
||||||
receiptCount: 2,
|
receiptCount: 2,
|
||||||
coupon: { currency: 'RUB', value: '10.25' },
|
coupon: { currency: 'RUB', value: '10.25' },
|
||||||
dividend: { currency: 'RUB', value: '-0.25' },
|
dividend: { currency: 'RUB', value: '-0.25' },
|
||||||
@@ -162,7 +151,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
result.entries
|
result.entries
|
||||||
.filter((item) => item.instrumentKey === 'uid:UID:RUB')
|
.filter((item) => item.instrumentKey === 'uid:UID')
|
||||||
.map((item) => item.payment?.value),
|
.map((item) => item.payment?.value),
|
||||||
).toEqual(['10.25', '-0.25']);
|
).toEqual(['10.25', '-0.25']);
|
||||||
});
|
});
|
||||||
@@ -191,26 +180,32 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
expect(response.data?.buckets).toEqual([
|
expect(response.data?.buckets).toEqual([
|
||||||
{
|
{
|
||||||
period: { from: '2026-01-14', to: '2026-01-14' },
|
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' },
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
})],
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
period: { from: '2026-01-15', to: '2026-01-15' },
|
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||||
totalsByCurrency: [expect.objectContaining({
|
total: {
|
||||||
coupon: { currency: 'RUB', value: '0.2' },
|
coupon: { currency: 'RUB', value: '0.2' },
|
||||||
dividend: { currency: 'RUB', value: '-0.1' },
|
dividend: { currency: 'RUB', value: '-0.1' },
|
||||||
total: { currency: 'RUB', value: '0.1' },
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
})],
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
period: { from: '2026-01-16', to: '2026-01-16' },
|
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(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
_user: string,
|
_user: string,
|
||||||
@@ -223,7 +218,6 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
||||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
|
||||||
],
|
],
|
||||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||||
},
|
},
|
||||||
@@ -238,22 +232,14 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
assert(result);
|
assert(result);
|
||||||
expect(result.totalsByCurrency).toEqual([
|
expect(result.total).toEqual({
|
||||||
expect.objectContaining({
|
|
||||||
currency: 'RUB',
|
|
||||||
coupon: { currency: 'RUB', value: '10.25' },
|
coupon: { currency: 'RUB', value: '10.25' },
|
||||||
dividend: { currency: 'RUB', value: '-2.5' },
|
dividend: { currency: 'RUB', value: '-2.5' },
|
||||||
total: { currency: 'RUB', value: '7.75' },
|
total: { currency: 'RUB', value: '7.75' },
|
||||||
}),
|
});
|
||||||
expect.objectContaining({
|
|
||||||
currency: 'USD',
|
|
||||||
total: { currency: 'USD', value: '1' },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(result.entries.map((entry) => entry.id)).toEqual([
|
expect(result.entries.map((entry) => entry.id)).toEqual([
|
||||||
'coupon',
|
'coupon',
|
||||||
'dividend',
|
'dividend',
|
||||||
'usd',
|
|
||||||
]);
|
]);
|
||||||
expect(result.buckets).toHaveLength(31);
|
expect(result.buckets).toHaveLength(31);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
IncomeAnalyticsEntryDto,
|
IncomeAnalyticsEntryDto,
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
IncomeCurrencySummaryDto,
|
|
||||||
IncomeDependencyDto,
|
IncomeDependencyDto,
|
||||||
IncomeInstrumentDto,
|
IncomeInstrumentDto,
|
||||||
IncomeSummaryDto,
|
IncomeSummaryDto,
|
||||||
IncomeSummaryResourceDto,
|
IncomeSummaryResourceDto,
|
||||||
|
IncomeTotalDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
import {
|
import {
|
||||||
IncomeBucketGranularity,
|
IncomeBucketGranularity,
|
||||||
@@ -105,7 +105,7 @@ export class IncomeAnalyticsService {
|
|||||||
assert(current.meta.lastSuccessfulAt);
|
assert(current.meta.lastSuccessfulAt);
|
||||||
const selected = qualifying(current.items).map((entry) => ({
|
const selected = qualifying(current.items).map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
instrumentKey: instrumentKey(entry),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -139,23 +139,19 @@ function summaryResponse(
|
|||||||
entries: BrokerageOperationDto[],
|
entries: BrokerageOperationDto[],
|
||||||
calculatedAt: string,
|
calculatedAt: string,
|
||||||
): IncomeSummaryDto {
|
): IncomeSummaryDto {
|
||||||
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
|
return { accountId, period, total: totals(entries), calculatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
function totals(entries: BrokerageOperationDto[]): IncomeTotalDto {
|
||||||
const values = new Map<string, Total>();
|
const total = zeroTotal();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.payment) continue;
|
if (!entry.payment) continue;
|
||||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
|
||||||
const value = decimalFromString(entry.payment.value);
|
const value = decimalFromString(entry.payment.value);
|
||||||
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||||
total.coupon = add(total.coupon, value);
|
total.coupon = add(total.coupon, value);
|
||||||
} else total.dividend = add(total.dividend, value);
|
} else total.dividend = add(total.dividend, value);
|
||||||
values.set(entry.payment.currency, total);
|
|
||||||
}
|
}
|
||||||
return [...values]
|
return presentTotal(total);
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([currency, total]) => presentTotal(currency, total));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buckets(
|
function buckets(
|
||||||
@@ -169,7 +165,7 @@ function buckets(
|
|||||||
}));
|
}));
|
||||||
return rangeBuckets(range, granularity).map((period) => ({
|
return rangeBuckets(range, granularity).map((period) => ({
|
||||||
period,
|
period,
|
||||||
totalsByCurrency: totals(
|
total: totals(
|
||||||
datedEntries
|
datedEntries
|
||||||
.filter(({ date }) => date >= period.from && date <= period.to)
|
.filter(({ date }) => date >= period.from && date <= period.to)
|
||||||
.map(({ entry }) => entry),
|
.map(({ entry }) => entry),
|
||||||
@@ -189,7 +185,7 @@ function instruments(
|
|||||||
return [...groups]
|
return [...groups]
|
||||||
.map(([key, group]) => {
|
.map(([key, group]) => {
|
||||||
const first = group[0];
|
const first = group[0];
|
||||||
const [total] = totals(group);
|
const total = totals(group);
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
figi: first.figi,
|
figi: first.figi,
|
||||||
@@ -225,15 +221,10 @@ function zeroTotal(): Total {
|
|||||||
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentTotal(
|
function presentTotal(value: Total): IncomeTotalDto {
|
||||||
currency: string,
|
|
||||||
value: Total,
|
|
||||||
): IncomeCurrencySummaryDto {
|
|
||||||
const total = add(value.coupon, value.dividend);
|
|
||||||
return {
|
return {
|
||||||
currency,
|
coupon: { currency: 'RUB', value: formatDecimal(value.coupon) },
|
||||||
coupon: { currency, value: formatDecimal(value.coupon) },
|
dividend: { currency: 'RUB', value: formatDecimal(value.dividend) },
|
||||||
dividend: { currency, value: formatDecimal(value.dividend) },
|
total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) },
|
||||||
total: { currency, value: formatDecimal(total) },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -49,7 +49,8 @@ export class CatalogDiscoveryService {
|
|||||||
? this.tbank.getShare(token, uid)
|
? this.tbank.getShare(token, uid)
|
||||||
: this.tbank.getBond(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 {
|
return {
|
||||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
@@ -74,10 +75,18 @@ export class CatalogDiscoveryService {
|
|||||||
? this.tbank.findShares(token, query)
|
? this.tbank.findShares(token, query)
|
||||||
: this.tbank.findBonds(token, query),
|
: this.tbank.findBonds(token, query),
|
||||||
);
|
);
|
||||||
const matches = selectCatalogInstruments(
|
const candidates = selectCatalogInstruments(
|
||||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||||
query,
|
query,
|
||||||
instrumentType,
|
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);
|
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||||
if (!matches.length) throw new NotFoundException();
|
if (!matches.length) throw new NotFoundException();
|
||||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||||
@@ -104,7 +113,7 @@ export class CatalogDiscoveryService {
|
|||||||
: found,
|
: found,
|
||||||
input.query,
|
input.query,
|
||||||
input.instrumentType,
|
input.instrumentType,
|
||||||
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
);
|
||||||
const [details, prices] = await Promise.all([
|
const [details, prices] = await Promise.all([
|
||||||
Promise.all(
|
Promise.all(
|
||||||
instruments.map((item) =>
|
instruments.map((item) =>
|
||||||
@@ -122,12 +131,13 @@ export class CatalogDiscoveryService {
|
|||||||
const fetchedAt = new Date();
|
const fetchedAt = new Date();
|
||||||
const data = instruments.flatMap((item, index) => {
|
const data = instruments.flatMap((item, index) => {
|
||||||
const detail = details.at(index);
|
const detail = details.at(index);
|
||||||
|
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
|
||||||
return [{
|
return [{
|
||||||
...item,
|
...item,
|
||||||
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
||||||
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
||||||
}];
|
}];
|
||||||
});
|
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ const row = (
|
|||||||
});
|
});
|
||||||
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||||
positions,
|
positions,
|
||||||
cashBalances: [],
|
cash: { currency: 'RUB', value: '0' },
|
||||||
totalsByCurrency: [],
|
totals: { currency: 'RUB', value: '0' },
|
||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stock position', () => {
|
describe('stock position', () => {
|
||||||
@@ -64,10 +64,10 @@ describe('stock position', () => {
|
|||||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
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 = {
|
const second = {
|
||||||
...row(),
|
...row(),
|
||||||
currentValue: money('2', 'USD'),
|
currentValue: null,
|
||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export function instrumentPosition(
|
|||||||
averagePurchasePrice:
|
averagePurchasePrice:
|
||||||
positive && totalCost
|
positive && totalCost
|
||||||
? {
|
? {
|
||||||
currency: totalCost.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
decimalFromString(totalCost.value),
|
decimalFromString(totalCost.value),
|
||||||
@@ -83,17 +83,12 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||||
const currency = values[0]?.currency;
|
if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null;
|
||||||
if (
|
const present = values;
|
||||||
!currency ||
|
|
||||||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
values.reduce(
|
present.reduce(
|
||||||
(sum, value) => add(sum, decimalFromString(value.value)),
|
(sum, value) => add(sum, decimalFromString(value.value)),
|
||||||
decimalFromString('0'),
|
decimalFromString('0'),
|
||||||
),
|
),
|
||||||
@@ -107,7 +102,6 @@ function percent(
|
|||||||
if (
|
if (
|
||||||
!base ||
|
!base ||
|
||||||
!result ||
|
!result ||
|
||||||
base.currency !== result.currency ||
|
|
||||||
decimalFromString(base.value).value <= ZERO_AMOUNT
|
decimalFromString(base.value).value <= ZERO_AMOUNT
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function bondCoupons(
|
|||||||
recordDate &&
|
recordDate &&
|
||||||
recordDate >= today
|
recordDate >= today
|
||||||
? {
|
? {
|
||||||
currency: perBond.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perBond.value)),
|
multiply(quantity, decimalFromString(perBond.value)),
|
||||||
),
|
),
|
||||||
@@ -227,7 +227,6 @@ export function bondMetrics(
|
|||||||
coupon &&
|
coupon &&
|
||||||
couponPeriodDays &&
|
couponPeriodDays &&
|
||||||
clean &&
|
clean &&
|
||||||
coupon.currency === clean.currency &&
|
|
||||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
||||||
decimalFromString(clean.value).value > ZERO_AMOUNT
|
decimalFromString(clean.value).value > ZERO_AMOUNT
|
||||||
? formatDecimal(
|
? formatDecimal(
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function bondQuote(
|
|||||||
nominal &&
|
nominal &&
|
||||||
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
||||||
? {
|
? {
|
||||||
currency: nominal.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
multiply(
|
multiply(
|
||||||
@@ -96,9 +96,9 @@ export function bondQuote(
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const dirtyPrice =
|
const dirtyPrice =
|
||||||
cleanPrice && aci && aci.currency === cleanPrice.currency
|
cleanPrice && aci
|
||||||
? {
|
? {
|
||||||
currency: cleanPrice.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
add(
|
add(
|
||||||
decimalFromString(cleanPrice.value),
|
decimalFromString(cleanPrice.value),
|
||||||
|
|||||||
@@ -1,10 +1 @@
|
|||||||
import { formatDecimal } from '#src/common/decimal';
|
export { rubMoney as money } from '#src/integrations/tbank/decimal';
|
||||||
import { decimal } from '#src/integrations/tbank/decimal';
|
|
||||||
import { type TbankMoney } from '#src/integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
export function money(value?: TbankMoney) {
|
|
||||||
const currency = value?.currency?.toUpperCase();
|
|
||||||
return value && currency
|
|
||||||
? { currency, value: formatDecimal(decimal(value)) }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|||||||
+3
-2
@@ -70,16 +70,17 @@ export function metrics(
|
|||||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
||||||
const value = values[key];
|
const value = values[key];
|
||||||
const currency = values.currency?.toUpperCase() || null;
|
const currency = values.currency?.toUpperCase() || null;
|
||||||
|
const rub = currency === 'RUB';
|
||||||
return typeof value === 'number' &&
|
return typeof value === 'number' &&
|
||||||
Number.isFinite(value) &&
|
Number.isFinite(value) &&
|
||||||
(unit !== 'money' || currency)
|
(unit !== 'money' || rub)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
unit,
|
unit,
|
||||||
period,
|
period,
|
||||||
currency: unit === 'money' ? currency : null,
|
currency: unit === 'money' ? 'RUB' : null,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export class StockCatalogService {
|
|||||||
price:
|
price:
|
||||||
last?.price && detail.currency
|
last?.price && detail.currency
|
||||||
? {
|
? {
|
||||||
currency: detail.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(decimal(last.price)),
|
value: formatDecimal(decimal(last.price)),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
} from '#src/common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
import { decimal } from '#src/integrations/tbank/decimal';
|
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
@@ -131,11 +131,7 @@ export function stockDividends(
|
|||||||
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
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 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 lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
const perShare = rubMoney(event.dividendNet);
|
||||||
const perShare =
|
|
||||||
currency && event.dividendNet
|
|
||||||
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
|
|
||||||
: null;
|
|
||||||
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
// 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 eventDate = paymentDate ?? recordDate;
|
||||||
const timing: StockDividendDto['timing'] = eventDate
|
const timing: StockDividendDto['timing'] = eventDate
|
||||||
@@ -150,7 +146,7 @@ export function stockDividends(
|
|||||||
recordDate >= today &&
|
recordDate >= today &&
|
||||||
timing === 'upcoming'
|
timing === 'upcoming'
|
||||||
? {
|
? {
|
||||||
currency: perShare.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perShare.value)),
|
multiply(quantity, decimalFromString(perShare.value)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
subtract,
|
subtract,
|
||||||
} from '#src/common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
import { decimal } from '#src/integrations/tbank/decimal';
|
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||||
|
|
||||||
import { CandleService } from '../../application/candle.service';
|
import { CandleService } from '../../application/candle.service';
|
||||||
import {
|
import {
|
||||||
@@ -70,15 +70,8 @@ export function stockQuote(
|
|||||||
const { price: last, candle: previous, tradingStatus = null } = values;
|
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||||
const share = values.instrument;
|
const share = values.instrument;
|
||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const currency = share?.currency?.toUpperCase();
|
const price = last?.price ? rubMoney({ ...last.price, currency: share?.currency }) : null;
|
||||||
const price =
|
const previousPrice = previous?.close ? rubMoney({ ...previous.close, currency: share?.currency }) : null;
|
||||||
last?.price && currency
|
|
||||||
? { currency, value: formatDecimal(decimal(last.price)) }
|
|
||||||
: null;
|
|
||||||
const previousPrice =
|
|
||||||
previous?.close && currency
|
|
||||||
? { currency, value: formatDecimal(decimal(previous.close)) }
|
|
||||||
: null;
|
|
||||||
const difference =
|
const difference =
|
||||||
price && previousPrice
|
price && previousPrice
|
||||||
? subtract(
|
? subtract(
|
||||||
@@ -93,8 +86,8 @@ export function stockQuote(
|
|||||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||||
previousClose: previousPrice,
|
previousClose: previousPrice,
|
||||||
change:
|
change:
|
||||||
difference && currency
|
difference
|
||||||
? { currency, value: formatDecimal(difference) }
|
? { currency: 'RUB', value: formatDecimal(difference) }
|
||||||
: null,
|
: null,
|
||||||
changePercent:
|
changePercent:
|
||||||
difference &&
|
difference &&
|
||||||
|
|||||||
+1
-2
@@ -87,7 +87,6 @@ describe('StockScreenerService', () => {
|
|||||||
client.getAllShares.mockResolvedValueOnce([
|
client.getAllShares.mockResolvedValueOnce([
|
||||||
share(1, {
|
share(1, {
|
||||||
countryOfRisk: undefined,
|
countryOfRisk: undefined,
|
||||||
currency: undefined,
|
|
||||||
isin: undefined,
|
isin: undefined,
|
||||||
realExchange: undefined,
|
realExchange: undefined,
|
||||||
sector: undefined,
|
sector: undefined,
|
||||||
@@ -96,7 +95,7 @@ describe('StockScreenerService', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const result = await service.search('user-1', {});
|
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: ' ' })]);
|
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
||||||
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
|||||||
+9
-2
@@ -29,16 +29,23 @@ describe('selectStockScreenerInstruments', () => {
|
|||||||
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
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' })] });
|
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([
|
it.each([
|
||||||
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
||||||
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
||||||
['currency', { currency: 'USD' }, share({ currency: undefined })],
|
|
||||||
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
||||||
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
||||||
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ export type StockScreenerCatalog = {
|
|||||||
|
|
||||||
export type StockScreenerConditions = {
|
export type StockScreenerConditions = {
|
||||||
countryOfRisk?: string;
|
countryOfRisk?: string;
|
||||||
currency?: string;
|
|
||||||
maxLot?: number;
|
maxLot?: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
realExchange?: string;
|
realExchange?: string;
|
||||||
@@ -76,12 +75,7 @@ function matches(
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (instrument.currency?.toUpperCase() !== 'RUB') return false;
|
||||||
conditions.currency !== undefined &&
|
|
||||||
instrument.currency !== conditions.currency
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
conditions.realExchange !== undefined &&
|
conditions.realExchange !== undefined &&
|
||||||
instrument.realExchange !== conditions.realExchange
|
instrument.realExchange !== conditions.realExchange
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export class StockController {
|
|||||||
criteria: StockScreenerCriteriaDto,
|
criteria: StockScreenerCriteriaDto,
|
||||||
) {
|
) {
|
||||||
// The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them.
|
// 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))) {
|
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export class StockScreenerCriteriaDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_TICKER_LENGTH)
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
currency?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,24 @@ describe('mapBrokerageOperation', () => {
|
|||||||
payment: null,
|
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', () => {
|
describe('executed trade quantity', () => {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { formatDecimal } from '#src/common/decimal';
|
|
||||||
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||||
import { decimal } from '#src/integrations/tbank/decimal';
|
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||||
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||||
import { TbankMoney, TbankOperationItem } from '#src/integrations/tbank/tbank.api';
|
import { TbankOperationItem } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { type OperationCategory } from '../../operation-category';
|
import { type OperationCategory } from '../../operation-category';
|
||||||
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
|
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
|
||||||
@@ -56,7 +55,7 @@ export function mapBrokerageOperation(
|
|||||||
ticker: text(operation.ticker),
|
ticker: text(operation.ticker),
|
||||||
instrumentType: text(operation.instrumentType),
|
instrumentType: text(operation.instrumentType),
|
||||||
logoName: null,
|
logoName: null,
|
||||||
payment: money(operation.payment),
|
payment: rubMoney(operation.payment),
|
||||||
quantity:
|
quantity:
|
||||||
(operationCategory(operation.type) === 'trade'
|
(operationCategory(operation.type) === 'trade'
|
||||||
? operation.quantityDone
|
? operation.quantityDone
|
||||||
@@ -67,9 +66,9 @@ export function mapBrokerageOperation(
|
|||||||
? operation.quantityDone
|
? operation.quantityDone
|
||||||
: operation.quantity,
|
: operation.quantity,
|
||||||
),
|
),
|
||||||
price: money(operation.price),
|
price: rubMoney(operation.price),
|
||||||
accruedInterest: money(operation.accruedInt),
|
accruedInterest: rubMoney(operation.accruedInt),
|
||||||
commission: money(operation.commission),
|
commission: rubMoney(operation.commission),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,13 +76,6 @@ function text(value: string | undefined): null | string {
|
|||||||
return value?.trim() || null;
|
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(
|
export function operationTimestamp(
|
||||||
value: undefined | { nanos?: number; seconds?: number | string },
|
value: undefined | { nanos?: number; seconds?: number | string },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||||
|
|
||||||
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { MarketIndexModule } from '../market-index/market-index.module';
|
||||||
|
import { PerformanceRepository } from './infrastructure/performance.repository';
|
||||||
|
import { PerformanceService } from './performance.service';
|
||||||
|
import { PerformanceController } from './presentation/performance.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule, TbankModule, MarketIndexModule],
|
||||||
|
controllers: [PerformanceController],
|
||||||
|
providers: [PerformanceService, PerformanceRepository],
|
||||||
|
})
|
||||||
|
export class PerformanceModule {}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { PerformanceService } from './performance.service';
|
||||||
|
|
||||||
|
jest.mock('./infrastructure/performance.repository', () => ({ PerformanceRepository: class {} }));
|
||||||
|
jest.mock('../market-index/market-index.service', () => ({ MarketIndexHistoryService: class {} }));
|
||||||
|
jest.mock('#src/integrations/tbank/tbank-access.service', () => ({ TbankAccessService: class {} }));
|
||||||
|
|
||||||
|
const first = new Date('2024-01-01T00:00:00Z');
|
||||||
|
const end = new Date('2025-12-31T15:00:00Z');
|
||||||
|
const coverageEnd = new Date('2026-01-01T00:00:00Z');
|
||||||
|
const operation = (accountId: string, type: string, units: string, date = first) => ({
|
||||||
|
accountId, instrumentUid: null, occurredAt: date,
|
||||||
|
payload: { type, payment: { currency: 'rub', units, nano: 0 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
function account(accountId: string, amount = '121000') {
|
||||||
|
return {
|
||||||
|
accountId, name: accountId, historyFrom: first, historyBoundary: 'known',
|
||||||
|
portfolio: { fetchedAt: end, payload: {
|
||||||
|
positions: [], totals: { currency: 'RUB', value: amount },
|
||||||
|
} },
|
||||||
|
coverage: [{ from: first, to: coverageEnd, asOf: coverageEnd }],
|
||||||
|
operations: [operation(accountId, 'OPERATION_TYPE_INPUT', '100000')],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(accounts: ReturnType<typeof account>[]) {
|
||||||
|
const access = { local: jest.fn().mockResolvedValue({ revision: 'revision' }) };
|
||||||
|
const repository = { read: jest.fn().mockResolvedValue({ membershipAt: first, accounts }) };
|
||||||
|
const indexes = { read: jest.fn().mockImplementation((code: string) => Promise.resolve([
|
||||||
|
{ tradeDate: '2024-01-01', close: code === 'MCFTRR' ? '1000' : '2000' },
|
||||||
|
{ tradeDate: '2025-12-31', close: code === 'MCFTRR' ? '1100' : '2100' },
|
||||||
|
])) };
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
const service = new PerformanceService(access as never, repository as never, indexes as never, logger as never);
|
||||||
|
return { service, indexes };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('investment performance read', () => {
|
||||||
|
it('returns independently verifiable XIRR and same-flow benchmark, including profit', async () => {
|
||||||
|
const { service } = setup([account('A')]);
|
||||||
|
const result = await service.read('user', 'A');
|
||||||
|
expect(result.data).toMatchObject({
|
||||||
|
netInvested: { currency: 'RUB', value: '100000' },
|
||||||
|
currentValue: { currency: 'RUB', value: '121000' },
|
||||||
|
profit: { currency: 'RUB', value: '21000' },
|
||||||
|
return: { basis: 'annual', value: '0.1', unavailableReason: null },
|
||||||
|
benchmark: { code: 'MCFTRR', value: { currency: 'RUB', value: '110000' } },
|
||||||
|
outperformance: { money: { currency: 'RUB', value: '11000' } },
|
||||||
|
});
|
||||||
|
expect(result.data?.outperformance?.percentagePoints).toBe('0.051191');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return numbers for accounts with unconfirmed cash transfer candidates', async () => {
|
||||||
|
const a = account('A');
|
||||||
|
const b = account('B');
|
||||||
|
a.operations.push(operation('A', 'OPERATION_TYPE_OUTPUT', '-50000',
|
||||||
|
new Date('2024-02-01T12:00:00Z')));
|
||||||
|
b.operations.push(operation('B', 'OPERATION_TYPE_INPUT', '50000',
|
||||||
|
new Date('2024-02-01T12:01:00Z')));
|
||||||
|
expect(await setup([a, b]).service.read('user', 'all')).toMatchObject({
|
||||||
|
data: null,
|
||||||
|
meta: { unavailable: [
|
||||||
|
{ accountId: 'A', reason: 'UNCONFIRMED_TRANSFER' },
|
||||||
|
{ accountId: 'B', reason: 'UNCONFIRMED_TRANSFER' },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not sum account valuations observed on different Moscow days', async () => {
|
||||||
|
const b = account('B');
|
||||||
|
b.portfolio.fetchedAt = new Date('2026-01-01T15:00:00Z');
|
||||||
|
b.coverage[0].to = new Date('2026-01-02T00:00:00Z');
|
||||||
|
b.coverage[0].asOf = new Date('2026-01-02T00:00:00Z');
|
||||||
|
expect((await setup([account('A'), b]).service.read('user', 'all')).meta.unavailable)
|
||||||
|
.toEqual(expect.arrayContaining([{ accountId: 'A', name: 'A', reason: 'ACCOUNT_VALUATION_DATES_DIFFER' }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the profit when the index is unavailable', async () => {
|
||||||
|
const { service, indexes } = setup([account('A')]);
|
||||||
|
indexes.read.mockResolvedValue(null);
|
||||||
|
const result = await service.read('user', 'A');
|
||||||
|
expect(result.data?.profit.value).toBe('21000');
|
||||||
|
expect(result.data?.benchmark.unavailableReason).toBe('INDEX_HISTORY_UNAVAILABLE');
|
||||||
|
expect(result.data?.series).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the period at the first deposit, not at account opening', async () => {
|
||||||
|
const late = account('A', '110000');
|
||||||
|
late.historyFrom = new Date('2025-03-07T00:00:00Z');
|
||||||
|
late.coverage[0].from = late.historyFrom;
|
||||||
|
late.operations = [operation('A', 'OPERATION_TYPE_INPUT', '100000', new Date('2025-09-22T09:00:00Z'))];
|
||||||
|
const result = await setup([late]).service.read('user', 'A');
|
||||||
|
expect(result.data?.period).toEqual({ from: '2025-09-22', to: '2025-12-31', days: 100 });
|
||||||
|
expect(result.data?.return).toMatchObject({ basis: 'period', value: '0.1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports incomplete history for a deposit without an amount', async () => {
|
||||||
|
const broken = account('A');
|
||||||
|
broken.operations.push({ ...operation('A', 'OPERATION_TYPE_INPUT', '0'), payload: { type: 'OPERATION_TYPE_INPUT' } } as never);
|
||||||
|
expect((await setup([broken]).service.read('user', 'A')).meta.unavailable)
|
||||||
|
.toEqual([{ accountId: 'A', name: 'A', reason: 'OPERATIONS_HISTORY_INCOMPLETE' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a zero rate available and calculates outperformance', async () => {
|
||||||
|
const { service } = setup([account('A', '100000')]);
|
||||||
|
const result = await service.read('user', 'A');
|
||||||
|
expect(result.data?.return).toMatchObject({ value: '0', unavailableReason: null });
|
||||||
|
expect(result.data?.outperformance?.money.value).toBe('-10000');
|
||||||
|
expect(result.data?.outperformance?.percentagePoints).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { add, type Decimal, decimalFromString, divide, formatDecimal, subtract } from '#src/common/decimal';
|
||||||
|
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||||
|
import { TbankAccessService } from '#src/integrations/tbank/tbank-access.service';
|
||||||
|
|
||||||
|
import { moscowDate } from '../investment-calendar';
|
||||||
|
import { MarketIndexHistoryService } from '../market-index/market-index.service';
|
||||||
|
import { isCompleteValuation } from '../portfolio/domain/complete-valuation';
|
||||||
|
import { hasPerformanceCoverage, PerformanceUnavailableReason } from './domain/performance-availability';
|
||||||
|
import { candidateTransfers, type PerformanceFlow, readPerformanceOperations, sumFlows } from './domain/performance-flows';
|
||||||
|
import { shadowIndex } from './domain/shadow-index';
|
||||||
|
import { xirr } from './domain/xirr';
|
||||||
|
import { PerformanceRepository } from './infrastructure/performance.repository';
|
||||||
|
import {
|
||||||
|
PerformanceDto, PerformanceExcludedAccountDto, PerformanceIndexDto, PerformanceResourceDto,
|
||||||
|
PerformanceReturnDto, PerformanceSeriesPointDto,
|
||||||
|
} from './presentation/performance.dto';
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000;
|
||||||
|
const DAYS_IN_YEAR = 365;
|
||||||
|
const RATE_SCALE = 6;
|
||||||
|
const MONEY_SCALE = 2;
|
||||||
|
const RUB = 'RUB';
|
||||||
|
const ZERO = 0;
|
||||||
|
const ONE = 1;
|
||||||
|
const ZERO_DECIMAL = decimalFromString('0');
|
||||||
|
const LAST_FLOW = -1;
|
||||||
|
const ONE_DECIMAL = decimalFromString('1');
|
||||||
|
|
||||||
|
type Account = NonNullable<Awaited<ReturnType<PerformanceRepository['read']>>>['accounts'][number];
|
||||||
|
type Included = { account: Account; flows: PerformanceFlow[]; to: string; value: Decimal };
|
||||||
|
type IncludedResult = { included: Included | null; reason: null | PerformanceUnavailableReason };
|
||||||
|
|
||||||
|
function money(value: Decimal) {
|
||||||
|
return { currency: RUB, value: formatDecimal(divide(value, ONE_DECIMAL, MONEY_SCALE)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailable(accountId: string, name: string, reason: PerformanceUnavailableReason): PerformanceExcludedAccountDto {
|
||||||
|
return { accountId, name, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(from: string, to: string) {
|
||||||
|
return (Date.parse(`${to}T00:00:00Z`) - Date.parse(`${from}T00:00:00Z`)) / DAY_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Float rate → exact decimal string, rounded by the shared money helper.
|
||||||
|
function roundRate(value: number): string {
|
||||||
|
return formatDecimal(divide(decimalFromString(value.toFixed(RATE_SCALE + ONE)), ONE_DECIMAL, RATE_SCALE));
|
||||||
|
}
|
||||||
|
|
||||||
|
function rate(flows: PerformanceFlow[], endValue: Decimal, to: string, days: number): PerformanceReturnDto {
|
||||||
|
const basis = days >= DAYS_IN_YEAR ? 'annual' : 'period';
|
||||||
|
const result = xirr([
|
||||||
|
...flows.map((flow) => ({ date: flow.date, amount: { value: -flow.amount.value, scale: flow.amount.scale } })),
|
||||||
|
{ date: to, amount: endValue },
|
||||||
|
]);
|
||||||
|
if (result.value === null) {
|
||||||
|
return { value: null, basis, unavailableReason: result.reason === 'AMBIGUOUS_RETURN'
|
||||||
|
? PerformanceUnavailableReason.AmbiguousReturn
|
||||||
|
: PerformanceUnavailableReason.NoSolution };
|
||||||
|
}
|
||||||
|
const raw = Number(result.value);
|
||||||
|
const value = basis === 'annual' ? raw : Math.pow(ONE + raw, days / DAYS_IN_YEAR) - ONE;
|
||||||
|
return { value: roundRate(value), basis, unavailableReason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PerformanceService {
|
||||||
|
constructor(
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
private readonly repository: PerformanceRepository,
|
||||||
|
private readonly indexes: MarketIndexHistoryService,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async read(userId: string, accountId: string): Promise<PerformanceResourceDto> {
|
||||||
|
const started = Date.now();
|
||||||
|
const connection = await this.access.local(userId, accountId);
|
||||||
|
const saved = await this.repository.read(userId, connection.revision, accountId);
|
||||||
|
if (accountId !== 'all' && saved?.membershipAt && !saved.accounts.length) throw new NotFoundException();
|
||||||
|
const accounts = saved?.membershipAt ? saved.accounts : [];
|
||||||
|
const excluded: PerformanceExcludedAccountDto[] = [];
|
||||||
|
let included = accounts.flatMap((account) => {
|
||||||
|
const result = this.includeAccount(account);
|
||||||
|
if (result.reason) {
|
||||||
|
excluded.push(unavailable(account.accountId, account.name, result.reason));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return result.included ? [result.included] : [];
|
||||||
|
});
|
||||||
|
if (accountId === 'all' && included.length > ZERO) {
|
||||||
|
const allOps = included.flatMap((item) => item.account.operations
|
||||||
|
.filter((op) => item.account.portfolio && op.occurredAt <= item.account.portfolio.fetchedAt)
|
||||||
|
.map((op) => ({ ...op, accountId: item.account.accountId })));
|
||||||
|
const candidates = candidateTransfers(readPerformanceOperations(allOps));
|
||||||
|
const affected = new Set(candidates.flatMap((candidate) => candidate.accountIds));
|
||||||
|
if (affected.size) {
|
||||||
|
included = included.filter((item) => {
|
||||||
|
if (!affected.has(item.account.accountId)) return true;
|
||||||
|
excluded.push(unavailable(item.account.accountId, item.account.name, PerformanceUnavailableReason.UnconfirmedTransfer));
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!included.length) return { data: null, meta: { dependencies: [], unavailable: excluded } };
|
||||||
|
const dates = new Set(included.map((item) => item.to));
|
||||||
|
if (dates.size > ONE) {
|
||||||
|
return {
|
||||||
|
data: null,
|
||||||
|
meta: { dependencies: [], unavailable: [
|
||||||
|
...excluded,
|
||||||
|
...included.map((item) => unavailable(item.account.accountId, item.account.name,
|
||||||
|
PerformanceUnavailableReason.AccountValuationDatesDiffer)),
|
||||||
|
] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const [to] = dates;
|
||||||
|
const flows = included.flatMap((item) => item.flows).sort((a, b) => a.at.valueOf() - b.at.valueOf());
|
||||||
|
// The period starts when money was first put in (owner decision), not at account opening.
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Fixed first element of a locally sorted array.
|
||||||
|
const from = flows[ZERO]?.date ?? to;
|
||||||
|
const days = duration(from, to);
|
||||||
|
const invested = sumFlows(flows);
|
||||||
|
const current = included.reduce((total, item) => add(total, item.value), ZERO_DECIMAL);
|
||||||
|
const profit = subtract(current, invested);
|
||||||
|
const ownReturn = rate(flows, current, to, days);
|
||||||
|
const [benchmark, reference] = await Promise.all([
|
||||||
|
this.indexResult('MCFTRR', flows, from, to, days),
|
||||||
|
this.indexResult('IMOEX', flows, from, to, days),
|
||||||
|
]);
|
||||||
|
const outperformance = ownReturn.value !== null && benchmark.index.return.value !== null && benchmark.index.value
|
||||||
|
? {
|
||||||
|
percentagePoints: formatDecimal(subtract(decimalFromString(ownReturn.value),
|
||||||
|
decimalFromString(benchmark.index.return.value))),
|
||||||
|
money: money(subtract(current, decimalFromString(benchmark.index.value.value))),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const referenceByDay = new Map(reference.points.map((point) => [point.date, point.value]));
|
||||||
|
const series: PerformanceSeriesPointDto[] = benchmark.points.map((point) => ({
|
||||||
|
date: point.date,
|
||||||
|
netInvested: point.netInvested,
|
||||||
|
benchmarkValue: point.value,
|
||||||
|
referenceValue: referenceByDay.get(point.date) ?? null,
|
||||||
|
}));
|
||||||
|
const data: PerformanceDto = {
|
||||||
|
accountId, period: { from, to, days }, netInvested: money(invested), currentValue: money(current),
|
||||||
|
profit: money(profit), return: ownReturn, benchmark: benchmark.index, reference: reference.index,
|
||||||
|
outperformance, series, completeness: excluded.length ? 'incomplete' : 'complete',
|
||||||
|
excludedAccounts: excluded, calculatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.logger.event('debug', 'performance.calculated', {
|
||||||
|
count: included.length, durationMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
return { data, meta: { dependencies: [], unavailable: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
|
private includeAccount(account: Account): IncludedResult {
|
||||||
|
const fail = (reason: PerformanceUnavailableReason): IncludedResult => ({ included: null, reason });
|
||||||
|
if (account.historyBoundary !== 'known' || !account.historyFrom) {
|
||||||
|
return fail(PerformanceUnavailableReason.HistoryStartUnknown);
|
||||||
|
}
|
||||||
|
const portfolio = account.portfolio;
|
||||||
|
if (!portfolio) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
|
||||||
|
const snapshot = portfolio.payload as { positions: { currentValue: unknown }[];
|
||||||
|
totals: { value: string }; };
|
||||||
|
if (!isCompleteValuation(snapshot)) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
|
||||||
|
if (!hasPerformanceCoverage(account.accountId, account.historyFrom, portfolio.fetchedAt, account.coverage)) {
|
||||||
|
return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
|
||||||
|
}
|
||||||
|
const operations = account.operations.filter((op) => op.occurredAt <= portfolio.fetchedAt)
|
||||||
|
.map((op) => ({ ...op, accountId: account.accountId }));
|
||||||
|
const parsed = readPerformanceOperations(operations);
|
||||||
|
if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
|
||||||
|
if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer);
|
||||||
|
return {
|
||||||
|
included: { account, flows: parsed.flows, value: decimalFromString(snapshot.totals.value),
|
||||||
|
to: moscowDate(portfolio.fetchedAt) },
|
||||||
|
reason: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async indexResult(code: 'IMOEX' | 'MCFTRR', flows: PerformanceFlow[], from: string, to: string, days: number) {
|
||||||
|
const closes = await this.indexes.read(code, from, to, flows.at(LAST_FLOW)?.date ?? from);
|
||||||
|
const shadow = closes && shadowIndex(flows, closes, from, to);
|
||||||
|
const unavailableReason = shadow ? null : PerformanceUnavailableReason.IndexHistoryUnavailable;
|
||||||
|
const result = shadow && rate(flows, decimalFromString(shadow.value), to, days);
|
||||||
|
const index: PerformanceIndexDto = {
|
||||||
|
code, value: shadow ? money(decimalFromString(shadow.value)) : null,
|
||||||
|
return: result || { basis: days >= DAYS_IN_YEAR ? 'annual' : 'period', value: null, unavailableReason },
|
||||||
|
asOf: shadow?.asOf ?? null, unavailableReason,
|
||||||
|
};
|
||||||
|
return { index, points: shadow?.points ?? [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { Request } from 'express';
|
||||||
|
|
||||||
|
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
|
|
||||||
|
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||||
|
import { PerformanceService } from '../performance.service';
|
||||||
|
import { PerformanceResourceDto } from './performance.dto';
|
||||||
|
|
||||||
|
@ApiTags('brokerage-accounts')
|
||||||
|
@ApiProblemResponses()
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('brokerage-accounts')
|
||||||
|
export class PerformanceController {
|
||||||
|
constructor(private readonly performance: PerformanceService) {}
|
||||||
|
|
||||||
|
@Get('all/performance')
|
||||||
|
@ApiOkResponse({ type: PerformanceResourceDto })
|
||||||
|
all(@Req() request: Request & { user: AccessPayload }) {
|
||||||
|
return this.performance.read(request.user.sub, 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':accountId/performance')
|
||||||
|
@ApiOkResponse({ type: PerformanceResourceDto })
|
||||||
|
account(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Param('accountId') accountId: string,
|
||||||
|
) {
|
||||||
|
return this.performance.read(request.user.sub, accountId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
|
|
||||||
|
import { InvestmentDependencyDto } from '../../investment-resource.dto';
|
||||||
|
import { PerformanceUnavailableReason } from '../domain/performance-availability';
|
||||||
|
|
||||||
|
const rateBasis = ['annual', 'period'] as const;
|
||||||
|
const resultCompleteness = ['complete', 'incomplete'] as const;
|
||||||
|
const indexCodes = ['MCFTRR', 'IMOEX'] as const;
|
||||||
|
|
||||||
|
export class PerformanceReturnDto {
|
||||||
|
@ApiProperty({ type: String, nullable: true, description: 'Decimal fraction: 0.151 = 15.1%' })
|
||||||
|
declare value: null | string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: rateBasis })
|
||||||
|
declare basis: (typeof rateBasis)[number];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PerformanceUnavailableReason, enumName: 'PerformanceUnavailableReason', nullable: true })
|
||||||
|
declare unavailableReason: null | PerformanceUnavailableReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformancePeriodDto {
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
declare from: string;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
declare to: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
declare days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceIndexDto {
|
||||||
|
@ApiProperty({ enum: indexCodes })
|
||||||
|
declare code: (typeof indexCodes)[number];
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||||
|
declare value: MoneyDto | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceReturnDto })
|
||||||
|
declare return: PerformanceReturnDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, format: 'date', nullable: true })
|
||||||
|
declare asOf: null | string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PerformanceUnavailableReason, enumName: 'PerformanceUnavailableReason', nullable: true })
|
||||||
|
declare unavailableReason: null | PerformanceUnavailableReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceOutperformanceDto {
|
||||||
|
@ApiProperty({ type: String, description: 'Difference between rates as decimal fractions' })
|
||||||
|
declare percentagePoints: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
declare money: MoneyDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceSeriesPointDto {
|
||||||
|
@ApiProperty({ format: 'date' })
|
||||||
|
declare date: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
declare netInvested: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String })
|
||||||
|
declare benchmarkValue: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
declare referenceValue: null | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceExcludedAccountDto {
|
||||||
|
@ApiProperty()
|
||||||
|
declare accountId: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
declare name: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: PerformanceUnavailableReason, enumName: 'PerformanceUnavailableReason' })
|
||||||
|
declare reason: PerformanceUnavailableReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceDto {
|
||||||
|
@ApiProperty()
|
||||||
|
declare accountId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformancePeriodDto })
|
||||||
|
declare period: PerformancePeriodDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
declare netInvested: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
declare currentValue: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto })
|
||||||
|
declare profit: MoneyDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceReturnDto })
|
||||||
|
declare return: PerformanceReturnDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceIndexDto })
|
||||||
|
declare benchmark: PerformanceIndexDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceIndexDto })
|
||||||
|
declare reference: PerformanceIndexDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceOutperformanceDto, nullable: true })
|
||||||
|
declare outperformance: null | PerformanceOutperformanceDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceSeriesPointDto, isArray: true })
|
||||||
|
declare series: PerformanceSeriesPointDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: resultCompleteness })
|
||||||
|
declare completeness: (typeof resultCompleteness)[number];
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceExcludedAccountDto, isArray: true })
|
||||||
|
declare excludedAccounts: PerformanceExcludedAccountDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date-time' })
|
||||||
|
declare calculatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceMetaDto {
|
||||||
|
@ApiProperty({ type: InvestmentDependencyDto, isArray: true })
|
||||||
|
declare dependencies: InvestmentDependencyDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceExcludedAccountDto, isArray: true })
|
||||||
|
declare unavailable: PerformanceExcludedAccountDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PerformanceResourceDto {
|
||||||
|
@ApiProperty({ type: PerformanceDto, nullable: true })
|
||||||
|
declare data: null | PerformanceDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PerformanceMetaDto })
|
||||||
|
declare meta: PerformanceMetaDto;
|
||||||
|
}
|
||||||
@@ -103,24 +103,21 @@ function filterEvents<T extends object>(payload: T, scopeKey: string, savedScope
|
|||||||
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
const value = payload as T & {
|
const value = payload as T & {
|
||||||
events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[];
|
events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[];
|
||||||
incomeTotalsByCurrency?: { currency: string; value: string }[];
|
incomeTotal?: { currency: string; value: string };
|
||||||
};
|
};
|
||||||
if (!value.events || !value.incomeTotalsByCurrency) return payload;
|
if (!value.events || !value.incomeTotal) return payload;
|
||||||
const events = value.events.filter((event) => event.date >= from && event.date <= to);
|
const events = value.events.filter((event) => event.date >= from && event.date <= to);
|
||||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
let total = { value: 0n, scale: 0 };
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue;
|
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue;
|
||||||
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
|
total = add(total, decimalFromString(event.projectedAmount.value));
|
||||||
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
|
|
||||||
}
|
}
|
||||||
const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string];
|
const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
return {
|
return {
|
||||||
...value,
|
...value,
|
||||||
window: { from: savedFrom, to: savedTo },
|
window: { from: savedFrom, to: savedTo },
|
||||||
events,
|
events,
|
||||||
incomeTotalsByCurrency: [...totals]
|
incomeTotal: { currency: 'RUB', value: formatDecimal(total) },
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return payload;
|
return payload;
|
||||||
|
|||||||
@@ -145,19 +145,15 @@ describe('persistent portfolio projection', () => {
|
|||||||
]),
|
]),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
['Known', 'STOCK', '2'],
|
['Known', 'STOCK', '2'],
|
||||||
['B', 'OTHER', '-0.5'],
|
|
||||||
]);
|
]);
|
||||||
expect(snapshot.positions[0].absoluteReturn).toEqual({
|
expect(snapshot.positions[0].absoluteReturn).toEqual({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
value: '2.000000002',
|
value: '2.000000002',
|
||||||
});
|
});
|
||||||
expect(snapshot.totalsByCurrency).toEqual([
|
expect(snapshot.totals).toEqual({ currency: 'RUB', value: '7.000000002' });
|
||||||
{ currency: 'RUB', value: '7.000000002' },
|
expect(snapshot.cash).toEqual({ currency: 'RUB', value: '1' });
|
||||||
{ currency: 'USD', value: '-2' },
|
|
||||||
]);
|
|
||||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
|
||||||
});
|
});
|
||||||
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
|
it('filters non-RUB cash and positions out of the RUB-only snapshot', async () => {
|
||||||
repository.reserve.mockResolvedValueOnce({
|
repository.reserve.mockResolvedValueOnce({
|
||||||
launch: true,
|
launch: true,
|
||||||
work: { id: 'work', attemptId: 'attempt' },
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
@@ -180,17 +176,10 @@ describe('persistent portfolio projection', () => {
|
|||||||
assert(handle);
|
assert(handle);
|
||||||
await handle.done;
|
await handle.done;
|
||||||
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
expect(snapshot.cashBalances).toEqual([
|
expect(snapshot.cash).toEqual({ currency: 'RUB', value: '0' });
|
||||||
{ currency: 'RUB', value: '0' },
|
expect(snapshot.totals).toEqual({ currency: 'RUB', value: '0' });
|
||||||
{ currency: 'EUR', value: '1' },
|
|
||||||
]);
|
|
||||||
expect(snapshot.totalsByCurrency).toEqual([
|
|
||||||
{ currency: 'EUR', value: '1' },
|
|
||||||
{ currency: 'USD', value: '3' },
|
|
||||||
]);
|
|
||||||
expect(snapshot.positions[0].currentValue).toBeNull();
|
expect(snapshot.positions[0].currentValue).toBeNull();
|
||||||
expect(snapshot.positions[1].absoluteReturn).toBeNull();
|
expect(snapshot.positions).toHaveLength(1);
|
||||||
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => {
|
it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => {
|
||||||
@@ -213,9 +202,9 @@ describe('persistent portfolio projection', () => {
|
|||||||
assert(handle);
|
assert(handle);
|
||||||
await handle.done;
|
await handle.done;
|
||||||
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]);
|
expect(snapshot.totals).toEqual({ currency: 'RUB', value: '121.49' });
|
||||||
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']);
|
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND']);
|
||||||
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
expect(snapshot.structure.totalValue).toBe('121.49');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response.
|
// Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response.
|
||||||
@@ -235,9 +224,9 @@ describe('persistent portfolio projection', () => {
|
|||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
isStale: false,
|
isStale: false,
|
||||||
positions: [],
|
positions: [],
|
||||||
cashBalances: [],
|
cash: { currency: 'RUB', value: '0' },
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '1.000000001', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
|
totals: { currency: 'RUB', value: '1.000000001' },
|
||||||
};
|
};
|
||||||
const account = {
|
const account = {
|
||||||
accountId: 'a',
|
accountId: 'a',
|
||||||
@@ -261,8 +250,8 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(partial.meta.availability).toBe('temporarily-unavailable');
|
expect(partial.meta.availability).toBe('temporarily-unavailable');
|
||||||
expect(partial.meta.completeness).toBe('incomplete');
|
expect(partial.meta.completeness).toBe('incomplete');
|
||||||
const saved = await service.saved('user', 'all');
|
const saved = await service.saved('user', 'all');
|
||||||
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
expect(saved.accounts[0].snapshot?.totals).toEqual(
|
||||||
snapshot.totalsByCurrency,
|
snapshot.totals,
|
||||||
);
|
);
|
||||||
expect(saved.accounts[1].snapshot).toBeNull();
|
expect(saved.accounts[1].snapshot).toBeNull();
|
||||||
repository.read.mockResolvedValue({
|
repository.read.mockResolvedValue({
|
||||||
@@ -279,9 +268,9 @@ describe('persistent portfolio projection', () => {
|
|||||||
work: [],
|
work: [],
|
||||||
});
|
});
|
||||||
const complete = await service.read('user', 'all');
|
const complete = await service.read('user', 'all');
|
||||||
expect(complete.data?.totalsByCurrency).toEqual([
|
expect(complete.data?.totals).toEqual({
|
||||||
{ currency: 'RUB', value: '2.000000002' },
|
currency: 'RUB', value: '2.000000002',
|
||||||
]);
|
});
|
||||||
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
expect(complete.data?.lastSuccessfulAt).toBe(new Date(0).toISOString());
|
||||||
expect(complete.data?.isStale).toBe(true);
|
expect(complete.data?.isStale).toBe(true);
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
TbankSyncRepository,
|
TbankSyncRepository,
|
||||||
} from '../../tbank-sync/tbank-sync.api';
|
} from '../../tbank-sync/tbank-sync.api';
|
||||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
import { sumMoney } from '../domain/portfolio-totals';
|
||||||
import {
|
import {
|
||||||
portfolioFreshForMs,
|
portfolioFreshForMs,
|
||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
@@ -184,16 +184,16 @@ export class PortfolioService {
|
|||||||
isStale: portfolios.length
|
isStale: portfolios.length
|
||||||
? portfolios.some((portfolio) => portfolio.isStale)
|
? portfolios.some((portfolio) => portfolio.isStale)
|
||||||
: saved.membership.freshness !== 'fresh',
|
: saved.membership.freshness !== 'fresh',
|
||||||
totalsByCurrency: sumMoneyByCurrency(
|
totals: sumMoney(
|
||||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
portfolios.map((portfolio) => portfolio.totals),
|
||||||
),
|
),
|
||||||
cashBalances: sumMoneyByCurrency(
|
cash: sumMoney(
|
||||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
portfolios.map((portfolio) => portfolio.cash),
|
||||||
),
|
),
|
||||||
positions: portfolios.flatMap((portfolio) => portfolio.positions),
|
positions: portfolios.flatMap((portfolio) => portfolio.positions),
|
||||||
structure: portfolioStructure(
|
structure: portfolioStructure(
|
||||||
portfolios.flatMap((portfolio) => portfolio.positions),
|
portfolios.flatMap((portfolio) => portfolio.positions),
|
||||||
portfolios.flatMap((portfolio) => portfolio.cashBalances),
|
portfolios.map((portfolio) => portfolio.cash),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
const work =
|
const work =
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { isCompleteValuation } from './complete-valuation';
|
||||||
|
|
||||||
|
describe('isCompleteValuation', () => {
|
||||||
|
it('requires an actual total and every position value', () => {
|
||||||
|
expect(isCompleteValuation({
|
||||||
|
totals: { value: '100' },
|
||||||
|
positions: [{ currentValue: null }],
|
||||||
|
})).toBe(false);
|
||||||
|
expect(isCompleteValuation({
|
||||||
|
totals: { value: '100' },
|
||||||
|
positions: [{ currentValue: { currency: 'RUB', value: '0' } }],
|
||||||
|
})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
type Valuation = {
|
||||||
|
positions: { currentValue: unknown }[];
|
||||||
|
totals: { value: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isCompleteValuation(
|
||||||
|
snapshot: Valuation,
|
||||||
|
): boolean {
|
||||||
|
return Boolean(snapshot.totals) &&
|
||||||
|
snapshot.positions.every((position) => position.currentValue !== null);
|
||||||
|
}
|
||||||
@@ -4,10 +4,9 @@ import { portfolioStructure } from './portfolio-structure';
|
|||||||
const position = (
|
const position = (
|
||||||
type: string,
|
type: string,
|
||||||
value: null | string,
|
value: null | string,
|
||||||
currency = 'RUB',
|
|
||||||
): PortfolioPositionDto => ({
|
): PortfolioPositionDto => ({
|
||||||
type,
|
type,
|
||||||
currentValue: value === null ? null : { currency, value },
|
currentValue: value === null ? null : { currency: 'RUB', value },
|
||||||
quantity: '1',
|
quantity: '1',
|
||||||
displayName: 'Fixture',
|
displayName: 'Fixture',
|
||||||
figi: null,
|
figi: null,
|
||||||
@@ -20,18 +19,18 @@ const position = (
|
|||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rub = (value: string) => ({ currency: 'RUB', value });
|
||||||
|
|
||||||
describe('portfolio structure', () => {
|
describe('portfolio structure', () => {
|
||||||
it('counts cash once and skips CURRENCY positions that mirror it', () => {
|
it('builds a single RUB composition with cash counted once', () => {
|
||||||
const result = portfolioStructure(
|
const result = portfolioStructure(
|
||||||
[
|
[
|
||||||
position('STOCK', '60'),
|
position('STOCK', '60'),
|
||||||
position('BOND', '20'),
|
position('BOND', '20'),
|
||||||
position('CURRENCY', '10'),
|
|
||||||
],
|
],
|
||||||
[{ currency: 'RUB', value: '10' }],
|
[rub('10')],
|
||||||
);
|
);
|
||||||
expect(result.currencies[0]).toEqual({
|
expect(result).toEqual({
|
||||||
currency: 'RUB',
|
|
||||||
totalValue: '90',
|
totalValue: '90',
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
categories: [
|
categories: [
|
||||||
@@ -40,75 +39,58 @@ describe('portfolio structure', () => {
|
|||||||
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||||
{ type: 'CASH', value: '10', sharePercent: '11.111111' },
|
{ type: 'CASH', value: '10', sharePercent: '11.111111' },
|
||||||
],
|
],
|
||||||
|
unvaluedCategories: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sums beyond Number precision and separates currencies without FX', () => {
|
it('sums beyond Number precision', () => {
|
||||||
const result = portfolioStructure(
|
const result = portfolioStructure(
|
||||||
[
|
[
|
||||||
position('STOCK', '9007199254740993.000000001'),
|
position('STOCK', '9007199254740993.000000001'),
|
||||||
position('STOCK', '0.000000009'),
|
position('STOCK', '0.000000009'),
|
||||||
position('BOND', '2', 'USD'),
|
position('BOND', '3.000000001'),
|
||||||
],
|
],
|
||||||
[{ currency: 'USD', value: '1' }],
|
[rub('1')],
|
||||||
);
|
);
|
||||||
expect(result.currencies[0].totalValue).toBe('9007199254740993.00000001');
|
expect(result.totalValue).toBe('9007199254740997.000000011');
|
||||||
expect(result.currencies[1].totalValue).toBe('3');
|
expect(result.categories[0].sharePercent).toBe('100');
|
||||||
expect(result.currencies[1].categories[1].sharePercent).toBe('66.666667');
|
expect(result.categories[1].sharePercent).toBe('0');
|
||||||
expect(result.currencies[1].categories[3].sharePercent).toBe('33.333333');
|
expect(result.categories[3].sharePercent).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('represents cash-only, empty and zero-valued portfolios', () => {
|
it('represents cash-only, empty and zero-valued portfolios', () => {
|
||||||
expect(portfolioStructure([], []).currencies).toEqual([]);
|
expect(portfolioStructure([], [rub('0')]).totalValue).toBe('0');
|
||||||
|
expect(portfolioStructure([], [rub('10')]).categories[3].sharePercent).toBe('100');
|
||||||
expect(
|
expect(
|
||||||
portfolioStructure([], [{ currency: 'EUR', value: '10' }]).currencies[0]
|
portfolioStructure([position('STOCK', '0')], [rub('0')]).categories.every(
|
||||||
.categories[3].sharePercent,
|
|
||||||
).toBe('100');
|
|
||||||
expect(
|
|
||||||
portfolioStructure(
|
|
||||||
[position('STOCK', '0')],
|
|
||||||
[],
|
|
||||||
).currencies[0].categories.every((item) => item.sharePercent === null),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retains unvalued categories without assigning zero or guessing a currency', () => {
|
|
||||||
const result = portfolioStructure(
|
|
||||||
[position('BOND', null), position('BOND', null), position('STOCK', '50')],
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]);
|
|
||||||
expect(result.currencies[0].totalValue).toBe('50');
|
|
||||||
expect(
|
|
||||||
result.currencies[0].categories.every(
|
|
||||||
(item) => item.sharePercent === null,
|
(item) => item.sharePercent === null,
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains unvalued categories without assigning zero', () => {
|
||||||
|
const result = portfolioStructure(
|
||||||
|
[position('BOND', null), position('BOND', null), position('STOCK', '50')],
|
||||||
|
[rub('0')],
|
||||||
|
);
|
||||||
|
expect(result.unvaluedCategories).toEqual([{ type: 'BOND', count: 2 }]);
|
||||||
|
expect(result.totalValue).toBe('50');
|
||||||
expect(
|
expect(
|
||||||
portfolioStructure([position('OTHER', null)], []).currencies,
|
result.categories.every((item) => item.sharePercent === null),
|
||||||
).toEqual([]);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
[[position('STOCK', '-20'), position('STOCK', '100')], []],
|
[[position('STOCK', '-20'), position('STOCK', '100')], rub('0')],
|
||||||
[
|
[[], rub('-20')],
|
||||||
[],
|
[[position('BOND', '-20'), position('BOND', '20')], rub('0')],
|
||||||
[
|
|
||||||
{ currency: 'RUB', value: '-20' },
|
|
||||||
{ currency: 'RUB', value: '100' },
|
|
||||||
],
|
|
||||||
],
|
|
||||||
[[position('BOND', '-20')], []],
|
|
||||||
[[position('BOND', '-20'), position('BOND', '20')], []],
|
|
||||||
])(
|
])(
|
||||||
'preserves negative constituents even when their category nets positive or zero',
|
'preserves negative constituents even when their category nets positive or zero',
|
||||||
(positions, cash) => {
|
(positions, cash) => {
|
||||||
const result = portfolioStructure(positions, cash);
|
const result = portfolioStructure(positions, [cash]);
|
||||||
expect(result.currencies[0].hasNegativeValues).toBe(true);
|
expect(result.hasNegativeValues).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
result.currencies[0].categories.every(
|
result.categories.every((item) => item.sharePercent === null),
|
||||||
(item) => item.sharePercent === null,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,48 +20,31 @@ const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const;
|
|||||||
// Build from positions and cash, never from totals (which already include cash).
|
// Build from positions and cash, never from totals (which already include cash).
|
||||||
export function portfolioStructure(
|
export function portfolioStructure(
|
||||||
positions: { currentValue: Money | null; type: string }[],
|
positions: { currentValue: Money | null; type: string }[],
|
||||||
cash: Money[],
|
cashAmounts: Money[],
|
||||||
) {
|
) {
|
||||||
const currencies = new Map<
|
const values = categoryTypes.map(() => decimalFromString('0'));
|
||||||
string,
|
let hasNegativeValues = false;
|
||||||
{
|
|
||||||
hasNegativeValues: boolean;
|
|
||||||
values: ReturnType<typeof decimalFromString>[];
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
const unvalued = new Map<string, number>();
|
const unvalued = new Map<string, number>();
|
||||||
const include = (type: string, money: Money) => {
|
const include = (type: string, money: Money) => {
|
||||||
const group = currencies.get(money.currency) ?? {
|
|
||||||
values: categoryTypes.map(() => decimalFromString('0')),
|
|
||||||
hasNegativeValues: false,
|
|
||||||
};
|
|
||||||
const index = categoryTypes.findIndex((category) => category === type);
|
const index = categoryTypes.findIndex((category) => category === type);
|
||||||
const value = decimalFromString(money.value);
|
const value = decimalFromString(money.value);
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
|
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
|
||||||
group.values[index] = add(group.values[index], value);
|
values[index] = add(values[index], value);
|
||||||
group.hasNegativeValues ||= value.value < ZERO_AMOUNT;
|
hasNegativeValues ||= value.value < ZERO_AMOUNT;
|
||||||
currencies.set(money.currency, group);
|
|
||||||
};
|
};
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
// CURRENCY positions mirror cash, which is counted once as CASH below.
|
|
||||||
if (position.type === 'CURRENCY') continue;
|
|
||||||
const type = ['BOND', 'STOCK'].includes(position.type)
|
const type = ['BOND', 'STOCK'].includes(position.type)
|
||||||
? position.type
|
? position.type
|
||||||
: 'OTHER';
|
: 'OTHER';
|
||||||
if (position.currentValue) include(type, position.currentValue);
|
if (position.currentValue) include(type, position.currentValue);
|
||||||
else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
|
else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
|
||||||
}
|
}
|
||||||
for (const amount of cash) include('CASH', amount);
|
for (const amount of cashAmounts) include('CASH', amount);
|
||||||
|
|
||||||
return {
|
const total = values.reduce(add, decimalFromString('0'));
|
||||||
unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })),
|
|
||||||
currencies: [...currencies]
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([currency, group]) => {
|
|
||||||
const total = group.values.reduce(add, decimalFromString('0'));
|
|
||||||
const canCalculateShares =
|
const canCalculateShares =
|
||||||
!unvalued.size && !group.hasNegativeValues && total.value > ZERO_AMOUNT;
|
!unvalued.size && !hasNegativeValues && total.value > ZERO_AMOUNT;
|
||||||
const categories = group.values.map((value, index) => ({
|
const categories = values.map((value, index) => ({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
|
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
|
||||||
type: categoryTypes[index],
|
type: categoryTypes[index],
|
||||||
value: formatDecimal(value),
|
value: formatDecimal(value),
|
||||||
@@ -80,11 +63,9 @@ export function portfolioStructure(
|
|||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
currency,
|
|
||||||
totalValue: formatDecimal(total),
|
totalValue: formatDecimal(total),
|
||||||
hasNegativeValues: group.hasNegativeValues,
|
hasNegativeValues,
|
||||||
categories,
|
categories,
|
||||||
};
|
unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })),
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,12 @@
|
|||||||
import type { Money } from '#src/common/money.types';
|
import type { Money } from '#src/common/money.types';
|
||||||
|
|
||||||
import {
|
import { add, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||||
add,
|
|
||||||
decimalFromString,
|
|
||||||
formatDecimal,
|
|
||||||
} from '#src/common/decimal';
|
|
||||||
|
|
||||||
export function sumMoneyByCurrency(amounts: Money[]): Money[] {
|
// RUB-only: all inputs are expected to be RUB Money.
|
||||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
export function sumMoney(amounts: Money[]): Money {
|
||||||
for (const amount of amounts) {
|
const total = amounts.reduce(
|
||||||
totals.set(
|
(sum, amount) => add(sum, decimalFromString(amount.value)),
|
||||||
amount.currency,
|
decimalFromString('0'),
|
||||||
add(
|
|
||||||
totals.get(amount.currency) ?? decimalFromString('0'),
|
|
||||||
decimalFromString(amount.value),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
return { currency: 'RUB', value: formatDecimal(total) };
|
||||||
return [...totals]
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { toJson } from '#src/prisma/to-json';
|
|||||||
|
|
||||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
|
import { isCompleteValuation } from '../domain/complete-valuation';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
||||||
|
|
||||||
type PortfolioAttempt = {
|
type PortfolioAttempt = {
|
||||||
@@ -140,10 +142,7 @@ export class PortfolioRepository {
|
|||||||
});
|
});
|
||||||
// Value history points require a fully valued snapshot; the attempt
|
// Value history points require a fully valued snapshot; the attempt
|
||||||
// identity deduplicates repeated or late publications of one attempt.
|
// identity deduplicates repeated or late publications of one attempt.
|
||||||
if (
|
if (isCompleteValuation(snapshot)) {
|
||||||
snapshot.totalsByCurrency.length > Number.MIN_VALUE &&
|
|
||||||
snapshot.positions.every((position) => position.currentValue !== null)
|
|
||||||
) {
|
|
||||||
await tx.tbankPortfolioValueHistory.upsert({
|
await tx.tbankPortfolioValueHistory.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_accountId: { userId: attempt.userId, accountId },
|
userId_accountId: { userId: attempt.userId, accountId },
|
||||||
@@ -151,28 +150,24 @@ export class PortfolioRepository {
|
|||||||
create: { userId: attempt.userId, accountId },
|
create: { userId: attempt.userId, accountId },
|
||||||
update: { deletedAt: null },
|
update: { deletedAt: null },
|
||||||
});
|
});
|
||||||
for (const { currency, value } of snapshot.totalsByCurrency) {
|
|
||||||
await tx.tbankPortfolioValuePoint.upsert({
|
await tx.tbankPortfolioValuePoint.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_accountId_attemptId_currency: {
|
userId_accountId_attemptId: {
|
||||||
userId: attempt.userId,
|
userId: attempt.userId,
|
||||||
accountId,
|
accountId,
|
||||||
attemptId: attempt.attemptId,
|
attemptId: attempt.attemptId,
|
||||||
currency,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: attempt.userId,
|
userId: attempt.userId,
|
||||||
accountId,
|
accountId,
|
||||||
attemptId: attempt.attemptId,
|
attemptId: attempt.attemptId,
|
||||||
currency,
|
value: snapshot.totals.value,
|
||||||
value,
|
|
||||||
capturedAt: data.fetchedAt,
|
capturedAt: data.fetchedAt,
|
||||||
},
|
},
|
||||||
update: {},
|
update: {},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -199,8 +194,9 @@ export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto {
|
|||||||
const snapshot = value as null | Partial<PortfolioDto>;
|
const snapshot = value as null | Partial<PortfolioDto>;
|
||||||
if (
|
if (
|
||||||
!Array.isArray(snapshot?.positions) ||
|
!Array.isArray(snapshot?.positions) ||
|
||||||
!Array.isArray(snapshot.totalsByCurrency) ||
|
!snapshot.totals?.currency ||
|
||||||
!Array.isArray(snapshot.cashBalances)
|
!snapshot.cash?.currency ||
|
||||||
|
!Array.isArray(snapshot.structure?.categories)
|
||||||
) {
|
) {
|
||||||
throw new Error('Corrupted portfolio snapshot payload');
|
throw new Error('Corrupted portfolio snapshot payload');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,12 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '#src/common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { decimal } from '#src/integrations/tbank/decimal';
|
import { decimal, rubMoney } from '#src/integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
metadataKey,
|
metadataKey,
|
||||||
type TbankInstrument,
|
type TbankInstrument,
|
||||||
type TbankMoney,
|
type TbankMoney,
|
||||||
TbankPortfolio,
|
TbankPortfolio,
|
||||||
TbankPositions,
|
|
||||||
} from '#src/integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import type { Classification } from '../application/position-classification.service';
|
import type { Classification } from '../application/position-classification.service';
|
||||||
@@ -22,6 +21,8 @@ import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfol
|
|||||||
|
|
||||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
|
|
||||||
|
const RUB = 'RUB';
|
||||||
|
|
||||||
const types: Record<string, string> = {
|
const types: Record<string, string> = {
|
||||||
share: 'STOCK',
|
share: 'STOCK',
|
||||||
bond: 'BOND',
|
bond: 'BOND',
|
||||||
@@ -35,32 +36,36 @@ const types: Record<string, string> = {
|
|||||||
|
|
||||||
function mapPortfolio(
|
function mapPortfolio(
|
||||||
upstream: NonNullable<TbankPortfolio['positions']>,
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
cash: TbankMoney[],
|
cashBalances: TbankMoney[],
|
||||||
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
||||||
lastSuccessfulAt: string,
|
lastSuccessfulAt: string,
|
||||||
classification: ReadonlyMap<string, Classification> = new Map(),
|
classification: ReadonlyMap<string, Classification> = new Map(),
|
||||||
): PortfolioDto {
|
): PortfolioDto {
|
||||||
const positions = upstream
|
const positions = upstream
|
||||||
|
// Currency positions mirror cash balances and are non-RUB holdings by definition.
|
||||||
|
.filter((position) => types[position.instrumentType?.toLowerCase() ?? ''] !== 'CURRENCY' &&
|
||||||
|
(!position.currentPrice?.currency?.trim() || position.currentPrice.currency.trim().toUpperCase() === RUB))
|
||||||
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null, classification.get(metadataKey(position))))
|
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null, classification.get(metadataKey(position))))
|
||||||
.filter((position): position is PortfolioPositionDto => position !== null);
|
.filter((position): position is PortfolioPositionDto => position !== null);
|
||||||
const balances = new Map<string, ReturnType<typeof decimal>>();
|
let cashTotal = decimal({});
|
||||||
for (const value of cash) {
|
for (const value of cashBalances) {
|
||||||
const currency = value.currency?.trim().toUpperCase();
|
if (value.currency?.trim().toUpperCase() !== RUB) continue;
|
||||||
const amount = decimal(value);
|
cashTotal = add(cashTotal, decimal(value));
|
||||||
if (!currency || isZero(amount)) continue;
|
|
||||||
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
|
|
||||||
}
|
}
|
||||||
const cashBalances = [...balances].map(([currency, value]) => ({
|
const cash: Money = { currency: RUB, value: formatDecimal(cashTotal) };
|
||||||
currency,
|
let total = cashTotal;
|
||||||
value: formatDecimal(value),
|
for (const position of positions) {
|
||||||
}));
|
if (!position.currentValue) continue;
|
||||||
|
total = add(total, decimalFromString(position.currentValue.value));
|
||||||
|
}
|
||||||
|
const totals: Money = { currency: RUB, value: formatDecimal(total) };
|
||||||
return {
|
return {
|
||||||
lastSuccessfulAt,
|
lastSuccessfulAt,
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: totalsByCurrency(upstream, cash),
|
totals,
|
||||||
positions,
|
positions,
|
||||||
cashBalances,
|
cash,
|
||||||
structure: portfolioStructure(positions, cashBalances),
|
structure: portfolioStructure(positions, [cash]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,16 +78,16 @@ function mapPosition(
|
|||||||
if (isZero(quantity)) return null;
|
if (isZero(quantity)) return null;
|
||||||
const figi = position.figi?.trim() || null;
|
const figi = position.figi?.trim() || null;
|
||||||
const instrumentUid = position.instrumentUid?.trim() || null;
|
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||||
const currentPrice = money(position.currentPrice);
|
const currentPrice = rubMoney(position.currentPrice);
|
||||||
const averagePurchasePrice = money(position.averagePositionPrice);
|
const averagePurchasePrice = rubMoney(position.averagePositionPrice);
|
||||||
const currentValue = currentPrice && {
|
const currentValue = currentPrice && {
|
||||||
currency: currentPrice.currency,
|
currency: RUB,
|
||||||
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
||||||
};
|
};
|
||||||
const absoluteReturn =
|
const absoluteReturn =
|
||||||
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
|
currentPrice && averagePurchasePrice
|
||||||
? {
|
? {
|
||||||
currency: currentPrice.currency,
|
currency: RUB,
|
||||||
value: formatDecimal(multiply(
|
value: formatDecimal(multiply(
|
||||||
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
|
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
|
||||||
quantity,
|
quantity,
|
||||||
@@ -106,44 +111,4 @@ function mapPosition(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function money(value: TbankMoney | undefined): Money | null {
|
export { mapPortfolio };
|
||||||
const currency = value?.currency?.trim().toUpperCase();
|
|
||||||
return currency && value
|
|
||||||
? { currency, value: formatDecimal(decimal(value)) }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function totalsByCurrency(
|
|
||||||
positions: NonNullable<TbankPortfolio['positions']>,
|
|
||||||
money: NonNullable<TbankPositions['money']>,
|
|
||||||
): Money[] {
|
|
||||||
const totals = new Map<string, ReturnType<typeof decimal>>();
|
|
||||||
const addTotal = (
|
|
||||||
currency: string | undefined,
|
|
||||||
amount: ReturnType<typeof decimal>,
|
|
||||||
) => {
|
|
||||||
const normalizedCurrency = currency?.trim().toUpperCase();
|
|
||||||
if (!normalizedCurrency || isZero(amount)) return;
|
|
||||||
totals.set(
|
|
||||||
normalizedCurrency,
|
|
||||||
add(totals.get(normalizedCurrency) ?? decimal({}), amount),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const value of money) addTotal(value.currency, decimal(value));
|
|
||||||
for (const position of positions) {
|
|
||||||
const price = position.currentPrice;
|
|
||||||
// CURRENCY positions mirror the cash balances already added above.
|
|
||||||
if (!price || types[position.instrumentType?.toLowerCase() ?? ''] === 'CURRENCY') continue;
|
|
||||||
addTotal(
|
|
||||||
price.currency,
|
|
||||||
multiply(decimal(position.quantity ?? {}), decimal(price)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...totals]
|
|
||||||
.filter(([, value]) => !isZero(value))
|
|
||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export { mapPortfolio, totalsByCurrency };
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export { ForecastService } from './application/forecast.service';
|
export { ForecastService } from './application/forecast.service';
|
||||||
export { PortfolioService } from './application/portfolio.service';
|
export { PortfolioService } from './application/portfolio.service';
|
||||||
|
export { isCompleteValuation } from './domain/complete-valuation';
|
||||||
|
|
||||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -85,22 +85,6 @@ export class PortfolioStructureCategoryDto {
|
|||||||
declare sharePercent: null | string;
|
declare sharePercent: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioStructureCurrencyDto {
|
|
||||||
@ApiProperty()
|
|
||||||
declare currency: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
declare totalValue: string;
|
|
||||||
|
|
||||||
@ApiProperty({
|
|
||||||
description: 'Any negative constituent, even if netted positive.',
|
|
||||||
})
|
|
||||||
declare hasNegativeValues: boolean;
|
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true })
|
|
||||||
declare categories: PortfolioStructureCategoryDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PortfolioUnvaluedCategoryDto {
|
export class PortfolioUnvaluedCategoryDto {
|
||||||
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
|
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
|
||||||
declare type: string;
|
declare type: string;
|
||||||
@@ -110,13 +94,21 @@ export class PortfolioUnvaluedCategoryDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioStructureDto {
|
export class PortfolioStructureDto {
|
||||||
@ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true })
|
@ApiProperty({ description: 'Exact known total as a decimal string.' })
|
||||||
declare currencies: PortfolioStructureCurrencyDto[];
|
declare totalValue: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Any negative constituent, even if netted positive.',
|
||||||
|
})
|
||||||
|
declare hasNegativeValues: boolean;
|
||||||
|
|
||||||
|
@ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true })
|
||||||
|
declare categories: PortfolioStructureCategoryDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: PortfolioUnvaluedCategoryDto, isArray: true,
|
type: PortfolioUnvaluedCategoryDto, isArray: true,
|
||||||
description:
|
description:
|
||||||
'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
|
'Positions without current value. Shares are unavailable when nonempty.',
|
||||||
})
|
})
|
||||||
declare unvaluedCategories: PortfolioUnvaluedCategoryDto[];
|
declare unvaluedCategories: PortfolioUnvaluedCategoryDto[];
|
||||||
}
|
}
|
||||||
@@ -128,14 +120,14 @@ export class PortfolioDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
declare isStale: boolean;
|
declare isStale: boolean;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare totalsByCurrency: MoneyDto[];
|
declare totals: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioPositionDto, isArray: true })
|
@ApiProperty({ type: PortfolioPositionDto, isArray: true })
|
||||||
declare positions: PortfolioPositionDto[];
|
declare positions: PortfolioPositionDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare cashBalances: MoneyDto[];
|
declare cash: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioStructureDto })
|
@ApiProperty({ type: PortfolioStructureDto })
|
||||||
declare structure: PortfolioStructureDto;
|
declare structure: PortfolioStructureDto;
|
||||||
|
|||||||
@@ -61,10 +61,10 @@ function projectedMoney(
|
|||||||
value: string,
|
value: string,
|
||||||
currency: string | undefined,
|
currency: string | undefined,
|
||||||
): UpcomingEventDto['projectedAmount'] {
|
): UpcomingEventDto['projectedAmount'] {
|
||||||
const normalizedCurrency = currency?.trim().toUpperCase();
|
// RUB-only: projected income in other currencies is filtered out.
|
||||||
if (!normalizedCurrency) return null;
|
if (currency?.trim().toUpperCase() !== 'RUB') return null;
|
||||||
return {
|
return {
|
||||||
currency: normalizedCurrency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(multiply(quantity, decimalFromString(value))),
|
value: formatDecimal(multiply(quantity, decimalFromString(value))),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ export class UpcomingEventsDto {
|
|||||||
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
@ApiProperty({ type: UpcomingEventDto, isArray: true })
|
||||||
declare events: UpcomingEventDto[];
|
declare events: UpcomingEventDto[];
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare incomeTotalsByCurrency: MoneyDto[];
|
declare incomeTotal: MoneyDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpcomingEventsResourceDto {
|
export class UpcomingEventsResourceDto {
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ const metadata = {
|
|||||||
logoName: null,
|
logoName: null,
|
||||||
};
|
};
|
||||||
describe('forecast financial projections', () => {
|
describe('forecast financial projections', () => {
|
||||||
it('preserves registry dates, source currency and exact signed fractional quantities', () => {
|
it('preserves registry dates and exact signed fractional quantities', () => {
|
||||||
expect(
|
expect(
|
||||||
dividendEvent(metadata, decimalFromString('-2.5'), {
|
dividendEvent(metadata, decimalFromString('-2.5'), {
|
||||||
recordDate: { seconds: String(Date.parse('2026-10-13Z') / 1000) },
|
recordDate: { seconds: String(Date.parse('2026-10-13Z') / 1000) },
|
||||||
dividendNet: { currency: 'usd', units: '10', nano: 1 },
|
dividendNet: { currency: 'rub', units: '10', nano: 1 },
|
||||||
}),
|
}),
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
date: '2026-10-13',
|
date: '2026-10-13',
|
||||||
dateKind: 'registry',
|
dateKind: 'registry',
|
||||||
projectedAmount: { currency: 'USD', value: '-25.0000000025' },
|
projectedAmount: { currency: 'RUB', value: '-25.0000000025' },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it('does not invent an amount for offers or missing currency', () => {
|
it('does not invent an amount for offers, missing or non-RUB currency', () => {
|
||||||
expect(
|
expect(
|
||||||
bondEvent(metadata, decimalFromString('3'), {
|
bondEvent(metadata, decimalFromString('3'), {
|
||||||
type: 'offer',
|
type: 'offer',
|
||||||
@@ -38,6 +38,12 @@ describe('forecast financial projections', () => {
|
|||||||
dividendNet: { units: '10' },
|
dividendNet: { units: '10' },
|
||||||
}).projectedAmount,
|
}).projectedAmount,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
dividendEvent(metadata, decimalFromString('3'), {
|
||||||
|
recordDate: { seconds: '1790812800' },
|
||||||
|
dividendNet: { currency: 'usd', units: '10' },
|
||||||
|
}).projectedAmount,
|
||||||
|
).toBeNull();
|
||||||
});
|
});
|
||||||
it('preserves redemption as a non-income event with its exact amount', () => {
|
it('preserves redemption as a non-income event with its exact amount', () => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export class UpcomingEventsService {
|
|||||||
accountId,
|
accountId,
|
||||||
calculatedAt: new Date().toISOString(),
|
calculatedAt: new Date().toISOString(),
|
||||||
events,
|
events,
|
||||||
incomeTotalsByCurrency: incomeTotals(events),
|
incomeTotal: incomeTotal(events),
|
||||||
}
|
}
|
||||||
: undefined;
|
: undefined;
|
||||||
return this.forecasts.result({
|
return this.forecasts.result({
|
||||||
@@ -286,22 +286,13 @@ function dateWindow(query: UpcomingEventsQueryDto) {
|
|||||||
return { from, to };
|
return { from, to };
|
||||||
}
|
}
|
||||||
|
|
||||||
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
|
function incomeTotal(events: UpcomingEventDto[]): MoneyDto {
|
||||||
const totals = new Map<string, Decimal>();
|
let total: Decimal = { value: 0n, scale: 0 };
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const current = totals.get(event.projectedAmount.currency) ?? {
|
total = add(total, decimalFromString(event.projectedAmount.value));
|
||||||
value: 0n,
|
|
||||||
scale: 0,
|
|
||||||
};
|
|
||||||
totals.set(
|
|
||||||
event.projectedAmount.currency,
|
|
||||||
add(current, decimalFromString(event.projectedAmount.value)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return [...totals]
|
return { currency: 'RUB', value: formatDecimal(total) };
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ function seedTbank(owner) {
|
|||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner);
|
db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner);
|
||||||
insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 });
|
insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 });
|
||||||
insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now });
|
insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cash: { currency: 'RUB', value: '0' }, totals: { currency: 'RUB', value: '0' }, structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] } }), fetchedAt: now });
|
||||||
insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) });
|
insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) });
|
||||||
insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now });
|
insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now });
|
||||||
insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now });
|
insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now });
|
||||||
|
|||||||
@@ -62,12 +62,12 @@ export function failUpstream(rpc: jest.Mock, kind: 'bond' | 'share', code: numbe
|
|||||||
uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13',
|
uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13',
|
||||||
};
|
};
|
||||||
const healthy = new Map<string, unknown>([
|
const healthy = new Map<string, unknown>([
|
||||||
['bondBy', { instrument }],
|
['bondBy', { instrument: { ...instrument, currency: 'rub' } }],
|
||||||
['findInstrument', { instruments: [instrument] }],
|
['findInstrument', { instruments: [instrument] }],
|
||||||
['getAssetBy', { asset: { description: 'description' } }],
|
['getAssetBy', { asset: { description: 'description' } }],
|
||||||
['getAssetFundamentals', { fundamentals: [] }],
|
['getAssetFundamentals', { fundamentals: [] }],
|
||||||
['getLastPrices', { lastPrices: [] }],
|
['getLastPrices', { lastPrices: [] }],
|
||||||
['shareBy', { instrument }],
|
['shareBy', { instrument: { ...instrument, currency: 'rub' } }],
|
||||||
]);
|
]);
|
||||||
const failure = Object.assign(new Error('grpc failure'), {
|
const failure = Object.assign(new Error('grpc failure'), {
|
||||||
code,
|
code,
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ describe('OpenAPI contract (integration)', () => {
|
|||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
'accountId',
|
'accountId',
|
||||||
'period',
|
'period',
|
||||||
'totalsByCurrency',
|
'total',
|
||||||
'calculatedAt',
|
'calculatedAt',
|
||||||
'bucketGranularity',
|
'bucketGranularity',
|
||||||
'buckets',
|
'buckets',
|
||||||
|
|||||||
@@ -111,9 +111,6 @@ describe('Portfolio structure HTTP contract', () => {
|
|||||||
expect((response.body as { data: unknown }).data).toMatchObject({
|
expect((response.body as { data: unknown }).data).toMatchObject({
|
||||||
structure: {
|
structure: {
|
||||||
unvaluedCategories: [],
|
unvaluedCategories: [],
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
currency: 'RUB',
|
|
||||||
totalValue: id === 'all' ? '200' : '100',
|
totalValue: id === 'all' ? '200' : '100',
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
categories: [
|
categories: [
|
||||||
@@ -131,8 +128,6 @@ describe('Portfolio structure HTTP contract', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -149,8 +144,6 @@ describe('Portfolio structure HTTP contract', () => {
|
|||||||
expect((response.body as { data: unknown }).data).toMatchObject({
|
expect((response.body as { data: unknown }).data).toMatchObject({
|
||||||
structure: {
|
structure: {
|
||||||
unvaluedCategories: [{ type: 'BOND', count: 1 }],
|
unvaluedCategories: [{ type: 'BOND', count: 1 }],
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
categories: [
|
categories: [
|
||||||
{ sharePercent: null },
|
{ sharePercent: null },
|
||||||
{ sharePercent: null },
|
{ sharePercent: null },
|
||||||
@@ -158,8 +151,6 @@ describe('Portfolio structure HTTP contract', () => {
|
|||||||
{ value: '40', sharePercent: null },
|
{ value: '40', sharePercent: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -174,8 +165,6 @@ describe('Portfolio structure HTTP contract', () => {
|
|||||||
.expect(200);
|
.expect(200);
|
||||||
expect((response.body as { data: unknown }).data).toMatchObject({
|
expect((response.body as { data: unknown }).data).toMatchObject({
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [
|
|
||||||
{
|
|
||||||
hasNegativeValues: true,
|
hasNegativeValues: true,
|
||||||
totalValue: '150',
|
totalValue: '150',
|
||||||
categories: [
|
categories: [
|
||||||
@@ -185,8 +174,6 @@ describe('Portfolio structure HTTP contract', () => {
|
|||||||
{ value: '30', sharePercent: null },
|
{ value: '30', sharePercent: null },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -103,7 +103,6 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
for (const [id, units, nano, currency, type] of [
|
for (const [id, units, nano, currency, type] of [
|
||||||
['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'],
|
['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'],
|
||||||
['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'],
|
['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'],
|
||||||
['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'],
|
|
||||||
] as const) {
|
] as const) {
|
||||||
await prisma.tbankOperation.create({
|
await prisma.tbankOperation.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -130,11 +129,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
assert(result.data);
|
assert(result.data);
|
||||||
expect(result.meta.availability).toBe('available');
|
expect(result.meta.availability).toBe('available');
|
||||||
expect(result.meta.completeness).toBe('complete');
|
expect(result.meta.completeness).toBe('complete');
|
||||||
expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([
|
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '15.000000002' });
|
||||||
{ currency: 'RUB', value: '15.000000002' },
|
expect(result.data.entries).toHaveLength(4);
|
||||||
{ currency: 'USD', value: '6' },
|
|
||||||
]);
|
|
||||||
expect(result.data.entries).toHaveLength(6);
|
|
||||||
expect(
|
expect(
|
||||||
result.data.entries
|
result.data.entries
|
||||||
.filter((row) => row.id === 'same')
|
.filter((row) => row.id === 'same')
|
||||||
@@ -185,7 +181,7 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
assert(after.data);
|
assert(after.data);
|
||||||
expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png');
|
expect(after.data.entries.find((entry) => entry.account?.id === 'a' && entry.id === 'same')?.logoName).toBe('bond.png');
|
||||||
expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull();
|
expect(after.data.entries.find((entry) => entry.account?.id === 'b' && entry.id === 'same')?.logoName).toBeNull();
|
||||||
expect(after.data.totalsByCurrency).toEqual(base.totalsByCurrency);
|
expect(after.data.total).toEqual(base.total);
|
||||||
expect(after.data.calculatedAt).toBe(base.calculatedAt);
|
expect(after.data.calculatedAt).toBe(base.calculatedAt);
|
||||||
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -205,8 +201,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
new Date('2025-03-31T21:00:00Z'),
|
new Date('2025-03-31T21:00:00Z'),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
|
(await read('a', 'from=2025-03-01&to=2025-03-31')).data?.total.total,
|
||||||
).toEqual([]);
|
).toEqual({ currency: 'RUB', value: '0' });
|
||||||
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
|
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
|
||||||
'synthetic', expect.objectContaining({ accountId: 'b' }),
|
'synthetic', expect.objectContaining({ accountId: 'b' }),
|
||||||
);
|
);
|
||||||
@@ -225,11 +221,8 @@ describe('local income completeness and exact money (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
const result = await read();
|
const result = await read();
|
||||||
assert(result.data);
|
assert(result.data);
|
||||||
expect(result.data.entries).toHaveLength(6);
|
expect(result.data.entries).toHaveLength(4);
|
||||||
expect(
|
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '25.000000002' });
|
||||||
result.data.totalsByCurrency.find(({ currency }) => currency === 'RUB')
|
|
||||||
?.total,
|
|
||||||
).toEqual({ currency: 'RUB', value: '25.000000002' });
|
|
||||||
});
|
});
|
||||||
it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => {
|
it('preserves the complete financial result and timestamp through failed atomic reconciliation', async () => {
|
||||||
const before = await read();
|
const before = await read();
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
|
|||||||
expect(
|
expect(
|
||||||
income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })),
|
income.data?.entries.map(({ id, occurredAt }) => ({ id, occurredAt })),
|
||||||
).toEqual(expected);
|
).toEqual(expected);
|
||||||
expect(income.data?.totalsByCurrency[0].total).toEqual({
|
expect(income.data?.total.total).toEqual({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
value: '2.000000002',
|
value: '2.000000002',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -218,11 +218,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
bondPositionCount: 0,
|
bondPositionCount: 0,
|
||||||
fundPositionCount: 0,
|
fundPositionCount: 0,
|
||||||
totalPositionCount: 1,
|
totalPositionCount: 1,
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }],
|
totals: { currency: 'RUB', value: '21.100000002' },
|
||||||
cashBalances: [{ currency: 'RUB', value: '1.1' }],
|
cash: { currency: 'RUB', value: '1.1' },
|
||||||
structure: {
|
structure: {
|
||||||
currencies: [{
|
|
||||||
currency: 'RUB',
|
|
||||||
totalValue: '21.100000002',
|
totalValue: '21.100000002',
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
categories: [
|
categories: [
|
||||||
@@ -231,19 +229,16 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||||
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' },
|
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' },
|
||||||
],
|
],
|
||||||
}],
|
|
||||||
unvaluedCategories: [],
|
unvaluedCategories: [],
|
||||||
},
|
},
|
||||||
unrealizedReturnByCurrency: [
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||||
{ currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
const portfolio = await get('brokerage-accounts/account/portfolio').expect(
|
const portfolio = await get('brokerage-accounts/account/portfolio').expect(
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
(portfolio.body as PortfolioResourceDto).data?.totalsByCurrency,
|
(portfolio.body as PortfolioResourceDto).data?.totals,
|
||||||
).toEqual([{ currency: 'RUB', value: '21.100000002' }]);
|
).toEqual({ currency: 'RUB', value: '21.100000002' });
|
||||||
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
expect(upstream.getOpenAccounts).not.toHaveBeenCalled();
|
||||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||||
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
|
expect(upstream.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||||
@@ -315,7 +310,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
logoName: 'enriched.png',
|
logoName: 'enriched.png',
|
||||||
quantity: '2',
|
quantity: '2',
|
||||||
});
|
});
|
||||||
expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency);
|
expect(after.data?.totals).toEqual(before.data?.totals);
|
||||||
expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt);
|
expect(after.data?.lastSuccessfulAt).toBe(before.data?.lastSuccessfulAt);
|
||||||
expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision);
|
expect(after.meta.dataRevision).not.toBe(before.meta.dataRevision);
|
||||||
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
expect(upstream.getPortfolio).toHaveBeenCalledTimes(1);
|
||||||
@@ -513,12 +508,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
|
|||||||
const summaries = (await get('brokerage-accounts').expect(200))
|
const summaries = (await get('brokerage-accounts').expect(200))
|
||||||
.body as BrokerageAccountsResourceDto;
|
.body as BrokerageAccountsResourceDto;
|
||||||
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
|
expect(summaries.data?.[0].portfolioSummary).toMatchObject({
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
|
totals: { currency: 'RUB', value: '21.1' },
|
||||||
cashBalances: [{ currency: 'RUB', value: '1.1' }],
|
cash: { currency: 'RUB', value: '1.1' },
|
||||||
structure: { currencies: [{ currency: 'RUB' }] },
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||||
unrealizedReturnByCurrency: [
|
|
||||||
{ currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true },
|
|
||||||
],
|
|
||||||
stockPositionCount: 1,
|
stockPositionCount: 1,
|
||||||
bondPositionCount: 0,
|
bondPositionCount: 0,
|
||||||
fundPositionCount: 0,
|
fundPositionCount: 0,
|
||||||
|
|||||||
@@ -16,18 +16,15 @@ describe('portfolio publication (SQLite)', () => {
|
|||||||
const snapshot = {
|
const snapshot = {
|
||||||
lastSuccessfulAt: '2026-01-01T00:00:00.000Z',
|
lastSuccessfulAt: '2026-01-01T00:00:00.000Z',
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [],
|
totals: { currency: 'RUB', value: '0' },
|
||||||
positions: [],
|
positions: [],
|
||||||
cashBalances: [],
|
cash: { currency: 'RUB', value: '0' },
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
} satisfies PortfolioDto;
|
} satisfies PortfolioDto;
|
||||||
const accountId = 'account';
|
const accountId = 'account';
|
||||||
const valuedSnapshot = {
|
const valuedSnapshot = {
|
||||||
...snapshot,
|
...snapshot,
|
||||||
totalsByCurrency: [
|
totals: { currency: 'RUB', value: '200' },
|
||||||
{ currency: 'RUB', value: '200' },
|
|
||||||
{ currency: 'USD', value: '10' },
|
|
||||||
],
|
|
||||||
} satisfies PortfolioDto;
|
} satisfies PortfolioDto;
|
||||||
const createRunningAttempt = () =>
|
const createRunningAttempt = () =>
|
||||||
prisma.tbankSyncWork.create({
|
prisma.tbankSyncWork.create({
|
||||||
@@ -95,7 +92,7 @@ describe('portfolio publication (SQLite)', () => {
|
|||||||
expect(await prisma.tbankPortfolio.count()).toBe(1);
|
expect(await prisma.tbankPortfolio.count()).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rolls back the snapshot, first value point and claim when the second point fails', async () => {
|
it('rolls back the snapshot, value point and claim when the point write fails', async () => {
|
||||||
const previousFetchedAt = new Date('2025-12-31T00:00:00.000Z');
|
const previousFetchedAt = new Date('2025-12-31T00:00:00.000Z');
|
||||||
const previousCapturedAt = new Date('2025-12-31T01:00:00.000Z');
|
const previousCapturedAt = new Date('2025-12-31T01:00:00.000Z');
|
||||||
await prisma.tbankPortfolio.create({
|
await prisma.tbankPortfolio.create({
|
||||||
@@ -114,7 +111,6 @@ describe('portfolio publication (SQLite)', () => {
|
|||||||
points: {
|
points: {
|
||||||
create: {
|
create: {
|
||||||
attemptId: 'previous-attempt',
|
attemptId: 'previous-attempt',
|
||||||
currency: 'RUB',
|
|
||||||
value: '100',
|
value: '100',
|
||||||
capturedAt: previousCapturedAt,
|
capturedAt: previousCapturedAt,
|
||||||
},
|
},
|
||||||
@@ -123,11 +119,9 @@ describe('portfolio publication (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
await createRunningAttempt();
|
await createRunningAttempt();
|
||||||
interceptTransaction((tx) => {
|
interceptTransaction((tx) => {
|
||||||
const upsert = tx.tbankPortfolioValuePoint.upsert.bind(tx.tbankPortfolioValuePoint);
|
|
||||||
jest
|
jest
|
||||||
.spyOn(tx.tbankPortfolioValuePoint, 'upsert')
|
.spyOn(tx.tbankPortfolioValuePoint, 'upsert')
|
||||||
.mockImplementationOnce((args) => upsert(args))
|
.mockRejectedValueOnce(new Error('injected point failure'));
|
||||||
.mockRejectedValueOnce(new Error('injected second point failure'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -136,7 +130,7 @@ describe('portfolio publication (SQLite)', () => {
|
|||||||
accountId,
|
accountId,
|
||||||
valuedSnapshot,
|
valuedSnapshot,
|
||||||
),
|
),
|
||||||
).rejects.toThrow('injected second point failure');
|
).rejects.toThrow('injected point failure');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
await prisma.tbankPortfolio.findUnique({
|
await prisma.tbankPortfolio.findUnique({
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ describe('T-Bank persisted read budget', () => {
|
|||||||
(item) =>
|
(item) =>
|
||||||
item.instrumentKind === 'INSTRUMENT_TYPE_SHARE' &&
|
item.instrumentKind === 'INSTRUMENT_TYPE_SHARE' &&
|
||||||
item.ticker.includes(query),
|
item.ticker.includes(query),
|
||||||
),
|
).map((item) => ({ uid: item.uid, ticker: item.ticker, name: item.name, isin: item.isin, instrumentKind: item.instrumentKind, classCode: item.classCode, apiTradeAvailableFlag: item.apiTradeAvailableFlag })),
|
||||||
),
|
),
|
||||||
findBonds: (_token: string, query: string) =>
|
findBonds: (_token: string, query: string) =>
|
||||||
counted(
|
counted(
|
||||||
@@ -123,7 +123,7 @@ describe('T-Bank persisted read budget', () => {
|
|||||||
(item) =>
|
(item) =>
|
||||||
item.instrumentKind === 'INSTRUMENT_TYPE_BOND' &&
|
item.instrumentKind === 'INSTRUMENT_TYPE_BOND' &&
|
||||||
item.ticker.includes(query),
|
item.ticker.includes(query),
|
||||||
),
|
).map((item) => ({ uid: item.uid, ticker: item.ticker, name: item.name, isin: item.isin, instrumentKind: item.instrumentKind, classCode: item.classCode, apiTradeAvailableFlag: item.apiTradeAvailableFlag })),
|
||||||
),
|
),
|
||||||
getShare: (_token: string, uid: string) =>
|
getShare: (_token: string, uid: string) =>
|
||||||
counted(
|
counted(
|
||||||
@@ -279,9 +279,9 @@ describe('T-Bank persisted read budget', () => {
|
|||||||
currentPrice: { currency: 'rub', units: '1000' },
|
currentPrice: { currency: 'rub', units: '1000' },
|
||||||
averagePositionPrice: { currency: 'rub', units: '900' },
|
averagePositionPrice: { currency: 'rub', units: '900' },
|
||||||
}],
|
}],
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '1100' }],
|
totals: { currency: 'RUB', value: '1100' },
|
||||||
cashBalances: [{ currency: 'RUB', value: '100' }],
|
cash: { currency: 'RUB', value: '100' },
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '1100', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
},
|
},
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
upstream.getPositions.mockResolvedValue({
|
upstream.getPositions.mockResolvedValue({
|
||||||
money: [{ units: '5', currency: 'usd' }],
|
money: [{ units: '5', currency: 'rub' }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('1.1 Point persistence', () => {
|
describe('1.1 Point persistence', () => {
|
||||||
it('writes points for fully valued snapshot with two currencies', async () => {
|
it('writes a single RUB point for a fully valued snapshot', async () => {
|
||||||
await createUser('owner');
|
await createUser('owner');
|
||||||
await putConnection('owner', 'token-a').expect(200);
|
await putConnection('owner', 'token-a').expect(200);
|
||||||
await publishPortfolio('owner', 'a');
|
await publishPortfolio('owner', 'a');
|
||||||
@@ -133,12 +133,10 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
include: { points: true },
|
include: { points: true },
|
||||||
});
|
});
|
||||||
expect(history?.deletedAt).toBeNull();
|
expect(history?.deletedAt).toBeNull();
|
||||||
expect(history?.points).toHaveLength(2);
|
expect(history?.points).toHaveLength(1);
|
||||||
|
|
||||||
const rubPoint = history?.points.find((p) => p.currency === 'RUB');
|
const rubPoint = history?.points[0];
|
||||||
const usdPoint = history?.points.find((p) => p.currency === 'USD');
|
expect(rubPoint?.value).toBe('26');
|
||||||
expect(rubPoint?.value).toBe('21');
|
|
||||||
expect(usdPoint?.value).toBe('5');
|
|
||||||
|
|
||||||
const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({
|
const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({
|
||||||
where: {
|
where: {
|
||||||
@@ -201,7 +199,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
expect(points).toHaveLength(0);
|
expect(points).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('writes no points for empty account without currency', async () => {
|
it('writes a zero point for an empty fully valued account', async () => {
|
||||||
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
upstream.getPortfolio.mockResolvedValue({ positions: [] });
|
||||||
upstream.getPositions.mockResolvedValue({ money: [] });
|
upstream.getPositions.mockResolvedValue({ money: [] });
|
||||||
|
|
||||||
@@ -212,7 +210,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(points).toHaveLength(0);
|
expect(points).toHaveLength(1);
|
||||||
|
expect(points[0].value).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deduplicates repeated publication of same attempt', async () => {
|
it('deduplicates repeated publication of same attempt', async () => {
|
||||||
@@ -258,7 +257,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(points).toHaveLength(2);
|
expect(points).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('late attempt after token replacement adds no points', async () => {
|
it('late attempt after token replacement adds no points', async () => {
|
||||||
@@ -282,10 +281,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const published = {
|
const published = {
|
||||||
lastSuccessfulAt: new Date().toISOString(),
|
lastSuccessfulAt: new Date().toISOString(),
|
||||||
isStale: false,
|
isStale: false,
|
||||||
totalsByCurrency: [
|
totals: { currency: 'RUB', value: '100' },
|
||||||
{ currency: 'RUB', value: '100' },
|
|
||||||
{ currency: 'USD', value: '50' },
|
|
||||||
],
|
|
||||||
positions: [
|
positions: [
|
||||||
{
|
{
|
||||||
figi: 'stock',
|
figi: 'stock',
|
||||||
@@ -302,8 +298,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
issuer: null,
|
issuer: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cashBalances: [{ currency: 'USD', value: '50' }],
|
cash: { currency: 'RUB', value: '0' },
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '100', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published);
|
const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published);
|
||||||
@@ -323,7 +319,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({
|
const pointsBefore = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(pointsBefore).toHaveLength(2);
|
expect(pointsBefore).toHaveLength(1);
|
||||||
|
|
||||||
upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed'));
|
upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed'));
|
||||||
await publishPortfolio('owner', 'a');
|
await publishPortfolio('owner', 'a');
|
||||||
@@ -331,7 +327,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(pointsAfter).toHaveLength(2);
|
expect(pointsAfter).toHaveLength(1);
|
||||||
expect(pointsAfter).toEqual(pointsBefore);
|
expect(pointsAfter).toEqual(pointsBefore);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -345,7 +341,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({
|
const oldPoints = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(oldPoints).toHaveLength(2);
|
expect(oldPoints).toHaveLength(1);
|
||||||
|
|
||||||
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
upstream.validateToken.mockResolvedValue([accounts[0]]);
|
||||||
await putConnection('owner', 'token-b').expect(200);
|
await putConnection('owner', 'token-b').expect(200);
|
||||||
@@ -361,8 +357,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
});
|
});
|
||||||
expect(newPoints.length).toBeGreaterThan(2);
|
expect(newPoints.length).toBeGreaterThan(1);
|
||||||
expect(newPoints.slice(0, 2)).toEqual(oldPoints);
|
expect(newPoints.slice(0, 1)).toEqual(oldPoints);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hides history when account disappears from membership', async () => {
|
it('hides history when account disappears from membership', async () => {
|
||||||
@@ -392,7 +388,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const pointsB = await prisma.tbankPortfolioValuePoint.findMany({
|
const pointsB = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'b' },
|
where: { userId: 'owner', accountId: 'b' },
|
||||||
});
|
});
|
||||||
expect(pointsB).toHaveLength(2);
|
expect(pointsB).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves visibility unchanged on membership fetch error', async () => {
|
it('leaves visibility unchanged on membership fetch error', async () => {
|
||||||
@@ -424,7 +420,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(points).toHaveLength(2);
|
expect(points).toHaveLength(1);
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
await request(app.getHttpServer())
|
||||||
.delete('/api/v1/tbank-connection')
|
.delete('/api/v1/tbank-connection')
|
||||||
@@ -450,7 +446,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
const pointsAfter = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(pointsAfter.length).toBeGreaterThan(2);
|
expect(pointsAfter.length).toBeGreaterThan(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('unhides when disappeared account returns in membership', async () => {
|
it('unhides when disappeared account returns in membership', async () => {
|
||||||
@@ -530,7 +526,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
|
|||||||
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
const points = await prisma.tbankPortfolioValuePoint.findMany({
|
||||||
where: { userId: 'owner', accountId: 'a' },
|
where: { userId: 'owner', accountId: 'a' },
|
||||||
});
|
});
|
||||||
expect(points).toHaveLength(2);
|
expect(points).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -113,9 +113,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
lastSuccessfulAt: new Date().toISOString(),
|
lastSuccessfulAt: new Date().toISOString(),
|
||||||
isStale: false,
|
isStale: false,
|
||||||
positions,
|
positions,
|
||||||
totalsByCurrency: [],
|
totals: { currency: 'RUB', value: '0' },
|
||||||
cashBalances: [],
|
cash: { currency: 'RUB', value: '0' },
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
};
|
};
|
||||||
await prisma.tbankPortfolio.upsert({
|
await prisma.tbankPortfolio.upsert({
|
||||||
where: {
|
where: {
|
||||||
@@ -273,17 +273,14 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
single.data?.events.find((event) => event.type === 'offer')
|
single.data?.events.find((event) => event.type === 'offer')
|
||||||
?.projectedAmount,
|
?.projectedAmount,
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
expect(single.data?.incomeTotalsByCurrency).toEqual([
|
expect(single.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
|
||||||
{ currency: 'RUB', value: '6' },
|
|
||||||
{ currency: 'USD', value: '20.000000002' },
|
|
||||||
]);
|
|
||||||
const all = (await events('all').expect(200))
|
const all = (await events('all').expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(
|
expect(
|
||||||
all.data?.events.every((event) => event.account?.id === 'account'),
|
all.data?.events.every((event) => event.account?.id === 'account'),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(all.data?.incomeTotalsByCurrency).toEqual(
|
expect(all.data?.incomeTotal).toEqual(
|
||||||
single.data?.incomeTotalsByCurrency,
|
single.data?.incomeTotal,
|
||||||
);
|
);
|
||||||
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
||||||
expect(upstream.getBondCoupons).not.toHaveBeenCalled();
|
expect(upstream.getBondCoupons).not.toHaveBeenCalled();
|
||||||
@@ -334,7 +331,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(newRange.data).toEqual({
|
expect(newRange.data).toEqual({
|
||||||
...saved.data,
|
...saved.data,
|
||||||
events: [],
|
events: [],
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotal: { currency: 'RUB', value: '0' },
|
||||||
});
|
});
|
||||||
expect(newRange.data?.window).toEqual(window);
|
expect(newRange.data?.window).toEqual(window);
|
||||||
await prisma.tbankConnection.update({
|
await prisma.tbankConnection.update({
|
||||||
@@ -372,8 +369,8 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
}
|
}
|
||||||
expect(
|
expect(
|
||||||
((await events().expect(200)).body as UpcomingEventsResourceDto).data
|
((await events().expect(200)).body as UpcomingEventsResourceDto).data
|
||||||
?.incomeTotalsByCurrency,
|
?.incomeTotal,
|
||||||
).toEqual([{ currency: 'USD', value: '90.000000009' }]);
|
).toEqual({ currency: 'RUB', value: '0' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => {
|
it('serves instrument payouts from shared calendars with private exact holdings and retains successful source timestamps on failure', async () => {
|
||||||
@@ -539,9 +536,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
const result = (await events().expect(200))
|
const result = (await events().expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(result.data?.incomeTotalsByCurrency).toEqual([
|
expect(result.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
|
||||||
{ currency: 'RUB', value: '6' },
|
|
||||||
]);
|
|
||||||
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -574,9 +569,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await settled();
|
await settled();
|
||||||
const before = (await events().expect(200))
|
const before = (await events().expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(before.data?.incomeTotalsByCurrency).toEqual([
|
expect(before.data?.incomeTotal).toEqual({ currency: 'RUB', value: '6' });
|
||||||
{ currency: 'RUB', value: '6' },
|
|
||||||
]);
|
|
||||||
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
|
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
|
||||||
where: { kind: 'bond-events' },
|
where: { kind: 'bond-events' },
|
||||||
});
|
});
|
||||||
@@ -675,7 +668,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
const w1 = windowOf(day1, '2099-03-04');
|
const w1 = windowOf(day1, '2099-03-04');
|
||||||
const dividend = (date: string) => ({
|
const dividend = (date: string) => ({
|
||||||
recordDate: time(date),
|
recordDate: time(date),
|
||||||
dividendNet: { units: '10', nano: 1, currency: 'usd' },
|
dividendNet: { units: '10', nano: 1, currency: 'rub' },
|
||||||
});
|
});
|
||||||
const defaultEvents = async () =>
|
const defaultEvents = async () =>
|
||||||
(await get('brokerage-accounts/account/events').expect(200))
|
(await get('brokerage-accounts/account/events').expect(200))
|
||||||
@@ -705,9 +698,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
await refresh({});
|
await refresh({});
|
||||||
const first = await defaultEvents();
|
const first = await defaultEvents();
|
||||||
expect(first.data?.window).toEqual(w0);
|
expect(first.data?.window).toEqual(w0);
|
||||||
expect(first.data?.incomeTotalsByCurrency).toEqual([
|
expect(first.data?.incomeTotal).toEqual({ currency: 'RUB', value: '20.000000002' });
|
||||||
{ currency: 'USD', value: '20.000000002' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
|
jest.setSystemTime(new Date(`${day1}T12:00:00Z`));
|
||||||
await portfolio([position(shareUid, '5')]);
|
await portfolio([position(shareUid, '5')]);
|
||||||
@@ -720,9 +711,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
'2099-02-05',
|
'2099-02-05',
|
||||||
'2099-03-04',
|
'2099-03-04',
|
||||||
]);
|
]);
|
||||||
expect(second.data?.incomeTotalsByCurrency).toEqual([
|
expect(second.data?.incomeTotal).toEqual({ currency: 'RUB', value: '100.00000001' });
|
||||||
{ currency: 'USD', value: '100.00000001' },
|
|
||||||
]);
|
|
||||||
const stale = (await events('account', 'owner', w0).expect(200))
|
const stale = (await events('account', 'owner', w0).expect(200))
|
||||||
.body as UpcomingEventsResourceDto;
|
.body as UpcomingEventsResourceDto;
|
||||||
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
|
expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt);
|
||||||
@@ -736,7 +725,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
expect(disjoint.data).toEqual({
|
expect(disjoint.data).toEqual({
|
||||||
...second.data,
|
...second.data,
|
||||||
events: [],
|
events: [],
|
||||||
incomeTotalsByCurrency: [],
|
incomeTotal: { currency: 'RUB', value: '0' },
|
||||||
});
|
});
|
||||||
expect(await prisma.tbankForecast.count()).toBe(2);
|
expect(await prisma.tbankForecast.count()).toBe(2);
|
||||||
});
|
});
|
||||||
@@ -779,7 +768,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
const dividend = (units: string, dividendType = 'Regular') => ({
|
const dividend = (units: string, dividendType = 'Regular') => ({
|
||||||
recordDate: time(date),
|
recordDate: time(date),
|
||||||
dividendType,
|
dividendType,
|
||||||
dividendNet: { units, currency: 'usd' },
|
dividendNet: { units, currency: 'rub' },
|
||||||
});
|
});
|
||||||
// One hourly walk: the scheduler stamps it once and passes it to every account.
|
// One hourly walk: the scheduler stamps it once and passes it to every account.
|
||||||
const startWalk = async () => {
|
const startWalk = async () => {
|
||||||
@@ -803,16 +792,12 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
|
|||||||
it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => {
|
it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => {
|
||||||
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
upstream.getDividends.mockResolvedValue([dividend('10')]);
|
||||||
await pass(await startWalk());
|
await pass(await startWalk());
|
||||||
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', value: '20' });
|
||||||
{ currency: 'USD', value: '20' },
|
|
||||||
]);
|
|
||||||
const first = await dividendRow();
|
const first = await dividendRow();
|
||||||
|
|
||||||
upstream.getDividends.mockResolvedValue([dividend('30')]);
|
upstream.getDividends.mockResolvedValue([dividend('30')]);
|
||||||
await pass(await startWalk());
|
await pass(await startWalk());
|
||||||
expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
|
expect((await latest()).data?.incomeTotal).toEqual({ currency: 'RUB', value: '60' });
|
||||||
{ currency: 'USD', value: '60' },
|
|
||||||
]);
|
|
||||||
expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1);
|
expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1);
|
||||||
|
|
||||||
upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);
|
upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]);
|
||||||
|
|||||||
Reference in New Issue
Block a user