2 Commits
Author SHA1 Message Date
ksv741 fc0da12032 feat(performance): add investment performance vs Moscow Exchange index endpoint (WealthLedger/main#54)
Backend verify / commits (pull_request) Failing after 1m23s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-11-08 14:20:05 +03:00
ksv741 74dea05113 feat(market-index): store MOEX index closes with confirmed coverage (WealthLedger/main#54) 2026-11-08 14:20:05 +03:00
63 changed files with 735 additions and 647 deletions
@@ -1,32 +0,0 @@
-- 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;
@@ -1,3 +0,0 @@
-- 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';
+3 -2
View File
@@ -101,17 +101,18 @@ model TbankPortfolioValueHistory {
@@id([userId, accountId]) @@id([userId, accountId])
} }
// One row per fully valued successful portfolio attempt (RUB only). // One row per currency of a fully valued successful portfolio attempt.
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]) @@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt]) @@index([userId, accountId, capturedAt])
} }
-55
View File
@@ -340,59 +340,4 @@ 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();
}
});
}); });
+6 -5
View File
@@ -157,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);
// RUB-only: coupons in other currencies are filtered out at this ingest boundary. const currency = currencyText(row.faceunit);
if (date && value && currencyText(row.faceunit) === 'RUB') { if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency: 'RUB' }); events.push({ type: 'coupon', date, value, currency });
} }
} }
for (const row of offers) { for (const row of offers) {
@@ -171,8 +171,9 @@ 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'));
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') { const currency = currencyText(values.get('FACEUNIT'));
events.push({ type: 'maturity', date, value, currency: 'RUB' }); if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency });
} }
return events; return events;
} }
+1 -9
View File
@@ -1,6 +1,4 @@
import type { Money } from '../../common/money.types'; import { Decimal } from '../../common/decimal';
import { Decimal, formatDecimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0; const MISSING_PROTO_COMPONENT = 0;
@@ -8,12 +6,6 @@ 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),
@@ -7,6 +7,9 @@ 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;
@@ -21,17 +24,17 @@ export class UnrealizedReturnDto {
} }
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto, isArray: true })
declare totals: MoneyDto; declare totalsByCurrency: MoneyDto[];
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto, isArray: true })
declare cash: MoneyDto; declare cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto }) @ApiProperty({ type: PortfolioStructureDto })
declare structure: PortfolioStructureDto; declare structure: PortfolioStructureDto;
@ApiProperty({ type: UnrealizedReturnDto }) @ApiProperty({ type: UnrealizedReturnDto, isArray: true })
declare unrealizedReturn: UnrealizedReturnDto; declare unrealizedReturnByCurrency: 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 = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }; const structure = { currencies: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValue({ portfolio.saved.mockResolvedValue({
membership: meta, membership: meta,
accounts: [ accounts: [
@@ -44,9 +44,11 @@ describe('saved brokerage account projection', () => {
openedOn: null, openedOn: null,
meta, meta,
snapshot: { snapshot: {
cash: { currency: 'RUB', value: '10.000000001' }, cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
structure, structure,
totals: { currency: 'RUB', value: '100000000.000000001' }, totalsByCurrency: [
{ currency: 'RUB', value: '100000000.000000001' },
],
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }], positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
}, },
}, },
@@ -62,14 +64,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({
cash: { currency: 'RUB', value: '10.000000001' }, cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
structure, structure,
totals: { currency: 'RUB', value: '100000000.000000001' }, totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 1, bondPositionCount: 1,
fundPositionCount: 0, fundPositionCount: 0,
totalPositionCount: 2, totalPositionCount: 2,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false }, unrealizedReturnByCurrency: [],
}); });
expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.data?.[1].portfolioSummary).toBeNull();
expect(response.meta.availability).toBe('available'); expect(response.meta.availability).toBe('available');
@@ -77,65 +79,74 @@ 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 = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }; const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
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: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] }, snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
}], }],
}); });
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({ expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, cashBalances: [], structure, totalsByCurrency: [],
stockPositionCount: 0, bondPositionCount: 0, stockPositionCount: 0, bondPositionCount: 0,
fundPositionCount: 0, totalPositionCount: 0, fundPositionCount: 0, totalPositionCount: 0,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false }, unrealizedReturnByCurrency: [],
}); });
}); });
it('calculates exact returns, partial data, and zero cost basis', async () => { it('calculates exact returns by currency, 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: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' }, cashBalances: [], structure: {}, totalsByCurrency: [
{ 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: 'RUB', value: '2.5' }, absoluteReturn: null }, { type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null },
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null }, { type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
{ type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } },
{ type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
], ],
}, },
}], }],
}); });
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({ expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
stockPositionCount: 1, bondPositionCount: 2, stockPositionCount: 2, bondPositionCount: 2,
fundPositionCount: 1, totalPositionCount: 4, fundPositionCount: 1, totalPositionCount: 6,
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true }, unrealizedReturnByCurrency: [
{ currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true },
{ currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false },
{ currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true },
],
}); });
}); });
it('omits return percent for short positions', async () => { it('omits return percent for a currency with 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: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' }, cashBalances: [], structure: {}, totalsByCurrency: [],
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: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } }, { type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } },
], ],
}, },
}], }],
}); });
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual( expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
{ absolute: '-19', costBasis: '-190', percent: null, partial: false }, { currency: 'RUB', absolute: '-20', costBasis: '-200', 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,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { import {
add,
decimalFromString, decimalFromString,
divide, divide,
formatDecimal, formatDecimal,
@@ -11,6 +10,7 @@ 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,42 +22,61 @@ import {
const PERCENT_DECIMAL_PLACES = 2; const PERCENT_DECIMAL_PLACES = 2;
const ZERO_QUANTITY = 0n; const ZERO_QUANTITY = 0n;
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto { function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
const absoluteReturn = positions.reduce( // Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
(sum, position) => position.absoluteReturn const investments = positions.filter((position) => position.type !== 'CURRENCY');
? add(sum, decimalFromString(position.absoluteReturn.value)) const absolute = sumMoneyByCurrency(
: sum, investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
decimalFromString('0'),
); );
const costBasis = positions const costs = sumMoneyByCurrency(
.filter((position) => position.absoluteReturn && position.averagePurchasePrice) investments.flatMap((position) =>
.reduce( position.absoluteReturn && position.averagePurchasePrice
(sum, position) => ? [{
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above. currency: position.absoluteReturn.currency,
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))), value: formatDecimal(multiply(
decimalFromString('0'), decimalFromString(position.averagePurchasePrice.value),
); decimalFromString(position.quantity),
const partial = positions.some( )),
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue), }]
: [],
),
);
const partial = new Set(
investments.flatMap((position) => {
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 = positions.some( const withShorts = new Set(
(position) => investments.flatMap((position) => {
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) && const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
decimalFromString(position.quantity).value < ZERO_QUANTITY, return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
}),
); );
return { const currencies = new Set([...absolute, ...costs].map((money) => money.currency));
absolute: formatDecimal(absoluteReturn), return [...new Set([...currencies, ...partial])]
costBasis: formatDecimal(costBasis), .sort((left, right) => left.localeCompare(right))
percent: isZero(costBasis) || withShorts .map((currency) => {
? null const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0';
: formatDecimal(divide( const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0';
multiply(absoluteReturn, decimalFromString('100')), const cost = decimalFromString(costBasis);
costBasis, return {
PERCENT_DECIMAL_PLACES, currency,
)), absolute: absoluteReturn,
partial, costBasis,
}; percent: isZero(cost) || withShorts.has(currency)
? null
: formatDecimal(divide(
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
cost,
PERCENT_DECIMAL_PLACES,
)),
partial: partial.has(currency),
};
});
} }
@Injectable() @Injectable()
@@ -79,10 +98,10 @@ export class BrokerageAccountsService {
openedOn: account.openedOn, openedOn: account.openedOn,
portfolioSummary: account.snapshot portfolioSummary: account.snapshot
? { ? {
totals: account.snapshot.totals, totalsByCurrency: account.snapshot.totalsByCurrency,
cash: account.snapshot.cash, cashBalances: account.snapshot.cashBalances,
structure: account.snapshot.structure, structure: account.snapshot.structure,
unrealizedReturn: unrealizedReturn(account.snapshot.positions), unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
stockPositionCount: account.snapshot.positions.filter( stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK', (position) => position.type === 'STOCK',
).length, ).length,
@@ -39,7 +39,10 @@ export class IncomePeriodDto {
declare to: string; declare to: string;
} }
export class IncomeTotalDto { export class IncomeCurrencySummaryDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto; declare coupon: MoneyDto;
@@ -57,8 +60,8 @@ export class IncomeSummaryDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeTotalDto }) @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare total: IncomeTotalDto; declare totalsByCurrency: IncomeCurrencySummaryDto[];
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
declare calculatedAt: string; declare calculatedAt: string;
@@ -68,8 +71,8 @@ export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeTotalDto }) @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare total: IncomeTotalDto; declare totalsByCurrency: IncomeCurrencySummaryDto[];
} }
export class IncomeInstrumentDto { export class IncomeInstrumentDto {
@@ -57,22 +57,22 @@ describe('IncomeAnalyticsService', () => {
ticker: 'T', ticker: 'T',
instrumentType: 'bond', instrumentType: 'bond',
}, },
key: 'uid:UID', key: 'uid:UID:RUB',
}, },
{ {
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' }, identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
key: 'figi:FIGI', key: 'figi:FIGI:RUB',
}, },
{ {
identity: { ticker: 'T', instrumentType: 'bond' }, identity: { ticker: 'T', instrumentType: 'bond' },
key: 'ticker:T:bond', key: 'ticker:T:bond:RUB',
}, },
{ identity: { ticker: 'T' }, key: 'ticker:T:' }, { identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
{ {
identity: { ticker: null, instrumentType: 'bond' }, identity: { ticker: null, instrumentType: 'bond' },
key: 'ticker::bond', key: 'ticker::bond:RUB',
}, },
{ identity: { ticker: null }, key: 'operation:receipt' }, { identity: { ticker: null }, key: 'operation:receipt:RUB' },
])( ])(
'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 and signed exact totals', async () => { it('preserves entry order, repeated IDs, currency groups 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,6 +112,10 @@ 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);
@@ -127,7 +131,7 @@ describe('IncomeAnalyticsService', () => {
expect(result.entries).toEqual( expect(result.entries).toEqual(
selected.map((item) => ({ selected.map((item) => ({
...item, ...item,
instrumentKey: 'uid:UID', instrumentKey: `uid:UID:${item.payment?.currency}`,
})), })),
); );
expect( expect(
@@ -142,7 +146,14 @@ describe('IncomeAnalyticsService', () => {
), ),
).toEqual([ ).toEqual([
{ {
key: 'uid:UID', key: 'uid:UID:USD',
receiptCount: 1,
coupon: { currency: 'USD', value: '20' },
dividend: { currency: 'USD', value: '0' },
total: { currency: 'USD', value: '20' },
},
{
key: 'uid:UID:RUB',
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' },
@@ -151,7 +162,7 @@ describe('IncomeAnalyticsService', () => {
]); ]);
expect( expect(
result.entries result.entries
.filter((item) => item.instrumentKey === 'uid:UID') .filter((item) => item.instrumentKey === 'uid:UID:RUB')
.map((item) => item.payment?.value), .map((item) => item.payment?.value),
).toEqual(['10.25', '-0.25']); ).toEqual(['10.25', '-0.25']);
}); });
@@ -180,32 +191,26 @@ 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' },
total: { totalsByCurrency: [expect.objectContaining({
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' },
total: { totalsByCurrency: [expect.objectContaining({
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' },
total: { totalsByCurrency: [],
coupon: { currency: 'RUB', value: '0' },
dividend: { currency: 'RUB', value: '0' },
total: { currency: 'RUB', value: '0' },
},
}, },
]); ]);
}); });
it('aggregates only exact coupon and dividend payments', async () => { it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation( operations.collectRanges.mockImplementation(
( (
_user: string, _user: string,
@@ -218,6 +223,7 @@ 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) }));
}, },
@@ -232,14 +238,22 @@ describe('IncomeAnalyticsService', () => {
const result = response.data; const result = response.data;
assert(result); assert(result);
expect(result.total).toEqual({ expect(result.totalsByCurrency).toEqual([
coupon: { currency: 'RUB', value: '10.25' }, expect.objectContaining({
dividend: { currency: 'RUB', value: '-2.5' }, currency: 'RUB',
total: { currency: 'RUB', value: '7.75' }, coupon: { currency: 'RUB', value: '10.25' },
}); dividend: { currency: 'RUB', value: '-2.5' },
total: { currency: 'RUB', value: '7.75' },
}),
expect.objectContaining({
currency: 'USD',
total: { currency: 'USD', value: '1' },
}),
]);
expect(result.entries.map((entry) => entry.id)).toEqual([ 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), instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
})); }));
return { return {
data: { data: {
@@ -139,19 +139,23 @@ function summaryResponse(
entries: BrokerageOperationDto[], entries: BrokerageOperationDto[],
calculatedAt: string, calculatedAt: string,
): IncomeSummaryDto { ): IncomeSummaryDto {
return { accountId, period, total: totals(entries), calculatedAt }; return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
} }
function totals(entries: BrokerageOperationDto[]): IncomeTotalDto { function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
const total = zeroTotal(); const values = new Map<string, Total>();
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 presentTotal(total); return [...values]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, total]) => presentTotal(currency, total));
} }
function buckets( function buckets(
@@ -165,7 +169,7 @@ function buckets(
})); }));
return rangeBuckets(range, granularity).map((period) => ({ return rangeBuckets(range, granularity).map((period) => ({
period, period,
total: totals( totalsByCurrency: 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),
@@ -185,7 +189,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,
@@ -221,10 +225,15 @@ 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(value: Total): IncomeTotalDto { function presentTotal(
currency: string,
value: Total,
): IncomeCurrencySummaryDto {
const total = add(value.coupon, value.dividend);
return { return {
coupon: { currency: 'RUB', value: formatDecimal(value.coupon) }, currency,
dividend: { currency: 'RUB', value: formatDecimal(value.dividend) }, coupon: { currency, value: formatDecimal(value.coupon) },
total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) }, dividend: { currency, value: formatDecimal(value.dividend) },
total: { currency, value: formatDecimal(total) },
}; };
} }
@@ -1,20 +0,0 @@
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,8 +49,7 @@ export class CatalogDiscoveryService {
? this.tbank.getShare(token, uid) ? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid), : this.tbank.getBond(token, uid),
); );
// RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available. if (!instrument) throw new NotFoundException();
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({
@@ -75,18 +74,10 @@ export class CatalogDiscoveryService {
? this.tbank.findShares(token, query) ? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query), : this.tbank.findBonds(token, query),
); );
const candidates = selectCatalogInstruments( const matches = 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) {
@@ -113,7 +104,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) =>
@@ -131,13 +122,12 @@ 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,
cash: { currency: 'RUB', value: '0' }, cashBalances: [],
totals: { currency: 'RUB', value: '0' }, totalsByCurrency: [],
isStale: false, isStale: false,
lastSuccessfulAt: '2026-09-10T00:00:00Z', lastSuccessfulAt: '2026-09-10T00:00:00Z',
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], 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 treat unknown values as zero', () => { it('does not sum currencies or treat unknown values as zero', () => {
const second = { const second = {
...row(), ...row(),
currentValue: null, currentValue: money('2', 'USD'),
averagePurchasePrice: null, averagePurchasePrice: null,
absoluteReturn: null, absoluteReturn: null,
}; };
@@ -46,7 +46,7 @@ export function instrumentPosition(
averagePurchasePrice: averagePurchasePrice:
positive && totalCost positive && totalCost
? { ? {
currency: 'RUB', currency: totalCost.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
decimalFromString(totalCost.value), decimalFromString(totalCost.value),
@@ -83,12 +83,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
: null; : null;
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null; const currency = values[0]?.currency;
const present = values; if (
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
) {
return null;
}
return { return {
currency: 'RUB', currency,
value: formatDecimal( value: formatDecimal(
present.reduce( values.reduce(
(sum, value) => add(sum, decimalFromString(value.value)), (sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'), decimalFromString('0'),
), ),
@@ -102,6 +107,7 @@ 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: 'RUB', currency: perBond.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)), multiply(quantity, decimalFromString(perBond.value)),
), ),
@@ -227,6 +227,7 @@ 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: 'RUB', currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
multiply( multiply(
@@ -96,9 +96,9 @@ export function bondQuote(
} }
: null; : null;
const dirtyPrice = const dirtyPrice =
cleanPrice && aci cleanPrice && aci && aci.currency === cleanPrice.currency
? { ? {
currency: 'RUB', currency: cleanPrice.currency,
value: formatDecimal( value: formatDecimal(
add( add(
decimalFromString(cleanPrice.value), decimalFromString(cleanPrice.value),
@@ -1 +1,10 @@
export { rubMoney as money } from '#src/integrations/tbank/decimal'; import { formatDecimal } from '#src/common/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;
}
@@ -70,17 +70,16 @@ 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' || rub) (unit !== 'money' || currency)
? [ ? [
{ {
key, key,
value, value,
unit, unit,
period, period,
currency: unit === 'money' ? 'RUB' : null, currency: unit === 'money' ? currency : null,
}, },
] ]
: []; : [];
@@ -45,7 +45,7 @@ export class StockCatalogService {
price: price:
last?.price && detail.currency last?.price && detail.currency
? { ? {
currency: 'RUB', currency: detail.currency,
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 { rubMoney } from '#src/integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { import {
TbankAccessService, TbankAccessService,
type TbankDividend, type TbankDividend,
@@ -131,7 +131,11 @@ 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 perShare = rubMoney(event.dividendNet); const currency = event.dividendNet?.currency?.toUpperCase();
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
@@ -146,7 +150,7 @@ export function stockDividends(
recordDate >= today && recordDate >= today &&
timing === 'upcoming' timing === 'upcoming'
? { ? {
currency: 'RUB', currency: perShare.currency,
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 { rubMoney } from '#src/integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
@@ -70,8 +70,15 @@ 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 price = last?.price ? rubMoney({ ...last.price, currency: share?.currency }) : null; const currency = share?.currency?.toUpperCase();
const previousPrice = previous?.close ? rubMoney({ ...previous.close, currency: share?.currency }) : null; const price =
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
const previousPrice =
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
const difference = const difference =
price && previousPrice price && previousPrice
? subtract( ? subtract(
@@ -86,8 +93,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 difference && currency
? { currency: 'RUB', value: formatDecimal(difference) } ? { currency, value: formatDecimal(difference) }
: null, : null,
changePercent: changePercent:
difference && difference &&
@@ -87,6 +87,7 @@ 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,
@@ -95,7 +96,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: 'RUB', isin: null, logoName: null, ticker: null })); expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, 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);
@@ -29,23 +29,16 @@ describe('selectStockScreenerInstruments', () => {
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }), share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
], ],
}, },
{ query: 'star', sector: 'energy' }, { currency: 'RUB', 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,6 +7,7 @@ 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;
@@ -75,7 +76,12 @@ function matches(
) { ) {
return false; return false;
} }
if (instrument.currency?.toUpperCase() !== 'RUB') return false; if (
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', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']); const allowed = new Set(['countryOfRisk', 'currency', '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,6 +52,7 @@ 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()
@@ -56,24 +56,6 @@ 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,7 +1,8 @@
import { formatDecimal } from '#src/common/decimal';
import { MILLISECONDS_PER_SECOND } from '#src/common/time'; import { MILLISECONDS_PER_SECOND } from '#src/common/time';
import { rubMoney } from '#src/integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol'; import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
import { TbankOperationItem } from '#src/integrations/tbank/tbank.api'; import { TbankMoney, 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';
@@ -55,7 +56,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: rubMoney(operation.payment), payment: money(operation.payment),
quantity: quantity:
(operationCategory(operation.type) === 'trade' (operationCategory(operation.type) === 'trade'
? operation.quantityDone ? operation.quantityDone
@@ -66,9 +67,9 @@ export function mapBrokerageOperation(
? operation.quantityDone ? operation.quantityDone
: operation.quantity, : operation.quantity,
), ),
price: rubMoney(operation.price), price: money(operation.price),
accruedInterest: rubMoney(operation.accruedInt), accruedInterest: money(operation.accruedInt),
commission: rubMoney(operation.commission), commission: money(operation.commission),
}; };
} }
@@ -76,6 +77,13 @@ 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 },
) { ) {
@@ -8,6 +8,7 @@ export enum PerformanceUnavailableReason {
AmbiguousReturn = 'AMBIGUOUS_RETURN', AmbiguousReturn = 'AMBIGUOUS_RETURN',
HistoryStartUnknown = 'HISTORY_START_UNKNOWN', HistoryStartUnknown = 'HISTORY_START_UNKNOWN',
IndexHistoryUnavailable = 'INDEX_HISTORY_UNAVAILABLE', IndexHistoryUnavailable = 'INDEX_HISTORY_UNAVAILABLE',
NonRubActivity = 'NON_RUB_ACTIVITY',
NoSolution = 'NO_SOLUTION', NoSolution = 'NO_SOLUTION',
OperationsHistoryIncomplete = 'OPERATIONS_HISTORY_INCOMPLETE', OperationsHistoryIncomplete = 'OPERATIONS_HISTORY_INCOMPLETE',
PortfolioValuationIncomplete = 'PORTFOLIO_VALUATION_INCOMPLETE', PortfolioValuationIncomplete = 'PORTFOLIO_VALUATION_INCOMPLETE',
@@ -30,20 +30,12 @@ describe('performance cash flows', () => {
expect(result.flows).toHaveLength(2); expect(result.flows).toHaveLength(2);
}); });
it('detects unvalued securities transfers', () => { it('detects non-RUB operations and unvalued securities transfers', () => {
const result = readPerformanceOperations([ const result = readPerformanceOperations([
op('A', 'OPERATION_TYPE_INPUT', '2025-01-01T15:00:00Z', '10', 'usd'),
op('B', 'OPERATION_TYPE_INPUT_SECURITIES', '2025-01-01T15:00:00Z', '0'), op('B', 'OPERATION_TYPE_INPUT_SECURITIES', '2025-01-01T15:00:00Z', '0'),
]); ]);
expect([...result.nonRubAccounts]).toEqual(['A']);
expect([...result.unvaluedAccounts]).toEqual(['B']); 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([]);
});
}); });
@@ -68,7 +68,6 @@ function appendCashFlow(
missingAmountAccounts.add(operation.accountId); missingAmountAccounts.add(operation.accountId);
return; return;
} }
// RUB-only: non-RUB cash movements have no rate to RUB and are left out of XIRR.
if (value.currency !== 'RUB') return; if (value.currency !== 'RUB') return;
const amount = magnitude(value.value); const amount = magnitude(value.value);
flows.push({ flows.push({
@@ -80,6 +79,7 @@ function appendCashFlow(
export function readPerformanceOperations(operations: Operation[]) { export function readPerformanceOperations(operations: Operation[]) {
const flows: PerformanceFlow[] = []; const flows: PerformanceFlow[] = [];
const nonRubAccounts = new Set<string>();
const unvaluedAccounts = new Set<string>(); const unvaluedAccounts = new Set<string>();
const missingAmountAccounts = new Set<string>(); const missingAmountAccounts = new Set<string>();
for (const operation of operations) { for (const operation of operations) {
@@ -87,13 +87,14 @@ export function readPerformanceOperations(operations: Operation[]) {
if (typeof payload.type !== 'string') continue; if (typeof payload.type !== 'string') continue;
const value = money(payload.payment); const value = money(payload.payment);
if (deposits.has(payload.type) || withdrawals.has(payload.type)) { if (deposits.has(payload.type) || withdrawals.has(payload.type)) {
if (value && value.currency !== 'RUB') nonRubAccounts.add(operation.accountId);
appendCashFlow(operation, payload, value, flows, missingAmountAccounts); appendCashFlow(operation, payload, value, flows, missingAmountAccounts);
} else if (securities.has(payload.type)) { } else if (securities.has(payload.type)) {
// Securities moved in or out carry no money amount for the flow. // Securities moved in or out carry no money amount for the flow.
unvaluedAccounts.add(operation.accountId); unvaluedAccounts.add(operation.accountId);
} }
} }
return { flows, missingAmountAccounts, unvaluedAccounts }; return { flows, missingAmountAccounts, nonRubAccounts, unvaluedAccounts };
} }
export function candidateTransfers(input: ReturnType<typeof readPerformanceOperations>): Candidate[] { export function candidateTransfers(input: ReturnType<typeof readPerformanceOperations>): Candidate[] {
@@ -16,7 +16,7 @@ function account(accountId: string, amount = '121000') {
return { return {
accountId, name: accountId, historyFrom: first, historyBoundary: 'known', accountId, name: accountId, historyFrom: first, historyBoundary: 'known',
portfolio: { fetchedAt: end, payload: { portfolio: { fetchedAt: end, payload: {
positions: [], totals: { currency: 'RUB', value: amount }, positions: [], totalsByCurrency: [{ currency: 'RUB', value: amount }],
} }, } },
coverage: [{ from: first, to: coverageEnd, asOf: coverageEnd }], coverage: [{ from: first, to: coverageEnd, asOf: coverageEnd }],
operations: [operation(accountId, 'OPERATION_TYPE_INPUT', '100000')], operations: [operation(accountId, 'OPERATION_TYPE_INPUT', '100000')],
@@ -17,13 +17,14 @@ import {
PerformanceReturnDto, PerformanceSeriesPointDto, PerformanceReturnDto, PerformanceSeriesPointDto,
} from './presentation/performance.dto'; } from './presentation/performance.dto';
const RUB = 'RUB';
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const DAYS_IN_YEAR = 365; const DAYS_IN_YEAR = 365;
const RATE_SCALE = 6; const RATE_SCALE = 6;
const MONEY_SCALE = 2; const MONEY_SCALE = 2;
const RUB = 'RUB';
const ZERO = 0; const ZERO = 0;
const ONE = 1; const ONE = 1;
const ZERO_UNITS = 0n;
const ZERO_DECIMAL = decimalFromString('0'); const ZERO_DECIMAL = decimalFromString('0');
const LAST_FLOW = -1; const LAST_FLOW = -1;
const ONE_DECIMAL = decimalFromString('1'); const ONE_DECIMAL = decimalFromString('1');
@@ -163,7 +164,7 @@ export class PerformanceService {
const portfolio = account.portfolio; const portfolio = account.portfolio;
if (!portfolio) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete); if (!portfolio) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
const snapshot = portfolio.payload as { positions: { currentValue: unknown }[]; const snapshot = portfolio.payload as { positions: { currentValue: unknown }[];
totals: { value: string }; }; totalsByCurrency: { currency: string; value: string }[]; };
if (!isCompleteValuation(snapshot)) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete); if (!isCompleteValuation(snapshot)) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
if (!hasPerformanceCoverage(account.accountId, account.historyFrom, portfolio.fetchedAt, account.coverage)) { if (!hasPerformanceCoverage(account.accountId, account.historyFrom, portfolio.fetchedAt, account.coverage)) {
return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete); return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
@@ -171,10 +172,16 @@ export class PerformanceService {
const operations = account.operations.filter((op) => op.occurredAt <= portfolio.fetchedAt) const operations = account.operations.filter((op) => op.occurredAt <= portfolio.fetchedAt)
.map((op) => ({ ...op, accountId: account.accountId })); .map((op) => ({ ...op, accountId: account.accountId }));
const parsed = readPerformanceOperations(operations); const parsed = readPerformanceOperations(operations);
if (parsed.nonRubAccounts.size || snapshot.totalsByCurrency.some((row) =>
row.currency !== RUB && decimalFromString(row.value).value !== ZERO_UNITS)) {
return fail(PerformanceUnavailableReason.NonRubActivity);
}
if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete); if (parsed.missingAmountAccounts.size) return fail(PerformanceUnavailableReason.OperationsHistoryIncomplete);
if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer); if (parsed.unvaluedAccounts.size) return fail(PerformanceUnavailableReason.UnvaluedSecuritiesTransfer);
const value = snapshot.totalsByCurrency.find((row) => row.currency === RUB);
if (!value) return fail(PerformanceUnavailableReason.PortfolioValuationIncomplete);
return { return {
included: { account, flows: parsed.flows, value: decimalFromString(snapshot.totals.value), included: { account, flows: parsed.flows, value: decimalFromString(value.value),
to: moscowDate(portfolio.fetchedAt) }, to: moscowDate(portfolio.fetchedAt) },
reason: null, reason: null,
}; };
@@ -103,21 +103,24 @@ 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 }[];
incomeTotal?: { currency: string; value: string }; incomeTotalsByCurrency?: { currency: string; value: string }[];
}; };
if (!value.events || !value.incomeTotal) return payload; if (!value.events || !value.incomeTotalsByCurrency) 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);
let total = { value: 0n, scale: 0 }; const totals = new Map<string, ReturnType<typeof decimalFromString>>();
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;
total = add(total, decimalFromString(event.projectedAmount.value)); const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
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,
incomeTotal: { currency: 'RUB', value: formatDecimal(total) }, incomeTotalsByCurrency: [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
}; };
} catch { } catch {
return payload; return payload;
@@ -145,15 +145,19 @@ 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.totals).toEqual({ currency: 'RUB', value: '7.000000002' }); expect(snapshot.totalsByCurrency).toEqual([
expect(snapshot.cash).toEqual({ currency: 'RUB', value: '1' }); { currency: 'RUB', value: '7.000000002' },
{ currency: 'USD', value: '-2' },
]);
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
}); });
it('filters non-RUB cash and positions out of the RUB-only snapshot', async () => { it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
repository.reserve.mockResolvedValueOnce({ repository.reserve.mockResolvedValueOnce({
launch: true, launch: true,
work: { id: 'work', attemptId: 'attempt' }, work: { id: 'work', attemptId: 'attempt' },
@@ -176,10 +180,17 @@ 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.cash).toEqual({ currency: 'RUB', value: '0' }); expect(snapshot.cashBalances).toEqual([
expect(snapshot.totals).toEqual({ currency: 'RUB', value: '0' }); { 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).toHaveLength(1); expect(snapshot.positions[1].absoluteReturn).toBeNull();
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 () => {
@@ -202,9 +213,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.totals).toEqual({ currency: 'RUB', value: '121.49' }); expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]);
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND']); expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']);
expect(snapshot.structure.totalValue).toBe('121.49'); expect(snapshot.structure.currencies[0].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.
@@ -224,9 +235,9 @@ describe('persistent portfolio projection', () => {
lastSuccessfulAt: membershipAt.toISOString(), lastSuccessfulAt: membershipAt.toISOString(),
isStale: false, isStale: false,
positions: [], positions: [],
cash: { currency: 'RUB', value: '0' }, cashBalances: [],
structure: { totalValue: '1.000000001', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
totals: { currency: 'RUB', value: '1.000000001' }, totalsByCurrency: [{ currency: 'RUB', value: '1.000000001' }],
}; };
const account = { const account = {
accountId: 'a', accountId: 'a',
@@ -250,8 +261,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?.totals).toEqual( expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
snapshot.totals, snapshot.totalsByCurrency,
); );
expect(saved.accounts[1].snapshot).toBeNull(); expect(saved.accounts[1].snapshot).toBeNull();
repository.read.mockResolvedValue({ repository.read.mockResolvedValue({
@@ -268,9 +279,9 @@ describe('persistent portfolio projection', () => {
work: [], work: [],
}); });
const complete = await service.read('user', 'all'); const complete = await service.read('user', 'all');
expect(complete.data?.totals).toEqual({ expect(complete.data?.totalsByCurrency).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 { sumMoney } from '../domain/portfolio-totals'; import { sumMoneyByCurrency } 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',
totals: sumMoney( totalsByCurrency: sumMoneyByCurrency(
portfolios.map((portfolio) => portfolio.totals), portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
), ),
cash: sumMoney( cashBalances: sumMoneyByCurrency(
portfolios.map((portfolio) => portfolio.cash), portfolios.flatMap((portfolio) => portfolio.cashBalances),
), ),
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.map((portfolio) => portfolio.cash), portfolios.flatMap((portfolio) => portfolio.cashBalances),
), ),
}; };
const work = const work =
@@ -1,13 +1,14 @@
import { isCompleteValuation } from './complete-valuation'; import { isCompleteValuation } from './complete-valuation';
describe('isCompleteValuation', () => { describe('isCompleteValuation', () => {
it('requires an actual total and every position value', () => { it('requires an actual currency total and every position value', () => {
expect(isCompleteValuation({ totalsByCurrency: [], positions: [] })).toBe(false);
expect(isCompleteValuation({ expect(isCompleteValuation({
totals: { value: '100' }, totalsByCurrency: [{ currency: 'RUB', value: '100' }],
positions: [{ currentValue: null }], positions: [{ currentValue: null }],
})).toBe(false); })).toBe(false);
expect(isCompleteValuation({ expect(isCompleteValuation({
totals: { value: '100' }, totalsByCurrency: [{ currency: 'RUB', value: '100' }],
positions: [{ currentValue: { currency: 'RUB', value: '0' } }], positions: [{ currentValue: { currency: 'RUB', value: '0' } }],
})).toBe(true); })).toBe(true);
}); });
@@ -1,11 +1,13 @@
type Valuation = { type Valuation = {
positions: { currentValue: unknown }[]; positions: { currentValue: unknown }[];
totals: { value: string }; totalsByCurrency: unknown[];
}; };
const MIN_TOTALS_COUNT = 0;
export function isCompleteValuation( export function isCompleteValuation(
snapshot: Valuation, snapshot: Valuation,
): boolean { ): boolean {
return Boolean(snapshot.totals) && return snapshot.totalsByCurrency.length > MIN_TOTALS_COUNT &&
snapshot.positions.every((position) => position.currentValue !== null); snapshot.positions.every((position) => position.currentValue !== null);
} }
@@ -4,9 +4,10 @@ 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: 'RUB', value }, currentValue: value === null ? null : { currency, value },
quantity: '1', quantity: '1',
displayName: 'Fixture', displayName: 'Fixture',
figi: null, figi: null,
@@ -19,18 +20,18 @@ const position = (
averagePurchasePrice: null, averagePurchasePrice: null,
}); });
const rub = (value: string) => ({ currency: 'RUB', value });
describe('portfolio structure', () => { describe('portfolio structure', () => {
it('builds a single RUB composition with cash counted once', () => { it('counts cash once and skips CURRENCY positions that mirror it', () => {
const result = portfolioStructure( const result = portfolioStructure(
[ [
position('STOCK', '60'), position('STOCK', '60'),
position('BOND', '20'), position('BOND', '20'),
position('CURRENCY', '10'),
], ],
[rub('10')], [{ currency: 'RUB', value: '10' }],
); );
expect(result).toEqual({ expect(result.currencies[0]).toEqual({
currency: 'RUB',
totalValue: '90', totalValue: '90',
hasNegativeValues: false, hasNegativeValues: false,
categories: [ categories: [
@@ -39,58 +40,75 @@ 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', () => { it('sums beyond Number precision and separates currencies without FX', () => {
const result = portfolioStructure( const result = portfolioStructure(
[ [
position('STOCK', '9007199254740993.000000001'), position('STOCK', '9007199254740993.000000001'),
position('STOCK', '0.000000009'), position('STOCK', '0.000000009'),
position('BOND', '3.000000001'), position('BOND', '2', 'USD'),
], ],
[rub('1')], [{ currency: 'USD', value: '1' }],
); );
expect(result.totalValue).toBe('9007199254740997.000000011'); expect(result.currencies[0].totalValue).toBe('9007199254740993.00000001');
expect(result.categories[0].sharePercent).toBe('100'); expect(result.currencies[1].totalValue).toBe('3');
expect(result.categories[1].sharePercent).toBe('0'); expect(result.currencies[1].categories[1].sharePercent).toBe('66.666667');
expect(result.categories[3].sharePercent).toBe('0'); expect(result.currencies[1].categories[3].sharePercent).toBe('33.333333');
}); });
it('represents cash-only, empty and zero-valued portfolios', () => { it('represents cash-only, empty and zero-valued portfolios', () => {
expect(portfolioStructure([], [rub('0')]).totalValue).toBe('0'); expect(portfolioStructure([], []).currencies).toEqual([]);
expect(portfolioStructure([], [rub('10')]).categories[3].sharePercent).toBe('100');
expect( expect(
portfolioStructure([position('STOCK', '0')], [rub('0')]).categories.every( portfolioStructure([], [{ currency: 'EUR', value: '10' }]).currencies[0]
.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(
result.categories.every((item) => item.sharePercent === null), portfolioStructure([position('OTHER', null)], []).currencies,
).toBe(true); ).toEqual([]);
}); });
it.each([ it.each([
[[position('STOCK', '-20'), position('STOCK', '100')], rub('0')], [[position('STOCK', '-20'), position('STOCK', '100')], []],
[[], 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.hasNegativeValues).toBe(true); expect(result.currencies[0].hasNegativeValues).toBe(true);
expect( expect(
result.categories.every((item) => item.sharePercent === null), result.currencies[0].categories.every(
(item) => item.sharePercent === null,
),
).toBe(true); ).toBe(true);
}, },
); );
@@ -20,52 +20,71 @@ 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 }[],
cashAmounts: Money[], cash: Money[],
) { ) {
const values = categoryTypes.map(() => decimalFromString('0')); const currencies = new Map<
let hasNegativeValues = false; string,
{
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.
values[index] = add(values[index], value); group.values[index] = add(group.values[index], value);
hasNegativeValues ||= value.value < ZERO_AMOUNT; group.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 cashAmounts) include('CASH', amount); for (const amount of cash) include('CASH', amount);
const total = values.reduce(add, decimalFromString('0'));
const canCalculateShares =
!unvalued.size && !hasNegativeValues && total.value > ZERO_AMOUNT;
const categories = values.map((value, index) => ({
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
type: categoryTypes[index],
value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares
? formatDecimal({
value:
(value.value *
DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
PERCENT_SCALED_FACTOR +
total.value / ROUNDING_DIVISOR) /
total.value,
scale: SHARE_PERCENT_DECIMAL_PLACES,
})
: null,
}));
return { return {
totalValue: formatDecimal(total),
hasNegativeValues,
categories,
unvaluedCategories: [...unvalued].map(([type, count]) => ({ type, count })), 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 =
!unvalued.size && !group.hasNegativeValues && total.value > ZERO_AMOUNT;
const categories = group.values.map((value, index) => ({
// eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array.
type: categoryTypes[index],
value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares
? formatDecimal({
value:
(value.value *
DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
PERCENT_SCALED_FACTOR +
total.value / ROUNDING_DIVISOR) /
total.value,
scale: SHARE_PERCENT_DECIMAL_PLACES,
})
: null,
}));
return {
currency,
totalValue: formatDecimal(total),
hasNegativeValues: group.hasNegativeValues,
categories,
};
}),
}; };
} }
@@ -1,12 +1,23 @@
import type { Money } from '#src/common/money.types'; import type { Money } from '#src/common/money.types';
import { add, decimalFromString, formatDecimal } from '#src/common/decimal'; import {
add,
decimalFromString,
formatDecimal,
} from '#src/common/decimal';
// RUB-only: all inputs are expected to be RUB Money. export function sumMoneyByCurrency(amounts: Money[]): Money[] {
export function sumMoney(amounts: Money[]): Money { const totals = new Map<string, ReturnType<typeof decimalFromString>>();
const total = amounts.reduce( for (const amount of amounts) {
(sum, amount) => add(sum, decimalFromString(amount.value)), totals.set(
decimalFromString('0'), amount.currency,
); add(
return { currency: 'RUB', value: formatDecimal(total) }; totals.get(amount.currency) ?? decimalFromString('0'),
decimalFromString(amount.value),
),
);
}
return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
} }
@@ -150,23 +150,27 @@ export class PortfolioRepository {
create: { userId: attempt.userId, accountId }, create: { userId: attempt.userId, accountId },
update: { deletedAt: null }, update: { deletedAt: null },
}); });
await tx.tbankPortfolioValuePoint.upsert({ for (const { currency, value } of snapshot.totalsByCurrency) {
where: { await tx.tbankPortfolioValuePoint.upsert({
userId_accountId_attemptId: { where: {
userId_accountId_attemptId_currency: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
},
},
create: {
userId: attempt.userId, userId: attempt.userId,
accountId, accountId,
attemptId: attempt.attemptId, attemptId: attempt.attemptId,
currency,
value,
capturedAt: data.fetchedAt,
}, },
}, update: {},
create: { });
userId: attempt.userId, }
accountId,
attemptId: attempt.attemptId,
value: snapshot.totals.value,
capturedAt: data.fetchedAt,
},
update: {},
});
} }
return true; return true;
}); });
@@ -194,9 +198,8 @@ 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) ||
!snapshot.totals?.currency || !Array.isArray(snapshot.totalsByCurrency) ||
!snapshot.cash?.currency || !Array.isArray(snapshot.cashBalances)
!Array.isArray(snapshot.structure?.categories)
) { ) {
throw new Error('Corrupted portfolio snapshot payload'); throw new Error('Corrupted portfolio snapshot payload');
} }
@@ -8,12 +8,13 @@ import {
multiply, multiply,
subtract, subtract,
} from '#src/common/decimal'; } from '#src/common/decimal';
import { decimal, rubMoney } from '#src/integrations/tbank/decimal'; import { decimal } 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';
@@ -21,8 +22,6 @@ 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',
@@ -36,36 +35,32 @@ const types: Record<string, string> = {
function mapPortfolio( function mapPortfolio(
upstream: NonNullable<TbankPortfolio['positions']>, upstream: NonNullable<TbankPortfolio['positions']>,
cashBalances: TbankMoney[], cash: 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);
let cashTotal = decimal({}); const balances = new Map<string, ReturnType<typeof decimal>>();
for (const value of cashBalances) { for (const value of cash) {
if (value.currency?.trim().toUpperCase() !== RUB) continue; const currency = value.currency?.trim().toUpperCase();
cashTotal = add(cashTotal, decimal(value)); const amount = decimal(value);
if (!currency || isZero(amount)) continue;
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
} }
const cash: Money = { currency: RUB, value: formatDecimal(cashTotal) }; const cashBalances = [...balances].map(([currency, value]) => ({
let total = cashTotal; currency,
for (const position of positions) { value: formatDecimal(value),
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,
totals, totalsByCurrency: totalsByCurrency(upstream, cash),
positions, positions,
cash, cashBalances,
structure: portfolioStructure(positions, [cash]), structure: portfolioStructure(positions, cashBalances),
}; };
} }
@@ -78,16 +73,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 = rubMoney(position.currentPrice); const currentPrice = money(position.currentPrice);
const averagePurchasePrice = rubMoney(position.averagePositionPrice); const averagePurchasePrice = money(position.averagePositionPrice);
const currentValue = currentPrice && { const currentValue = currentPrice && {
currency: RUB, currency: currentPrice.currency,
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))), value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
}; };
const absoluteReturn = const absoluteReturn =
currentPrice && averagePurchasePrice currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
? { ? {
currency: RUB, currency: currentPrice.currency,
value: formatDecimal(multiply( value: formatDecimal(multiply(
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)), subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
quantity, quantity,
@@ -111,4 +106,44 @@ function mapPosition(
}; };
} }
export { mapPortfolio }; function money(value: TbankMoney | undefined): Money | null {
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 };
@@ -85,16 +85,11 @@ export class PortfolioStructureCategoryDto {
declare sharePercent: null | string; declare sharePercent: null | string;
} }
export class PortfolioUnvaluedCategoryDto { export class PortfolioStructureCurrencyDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] }) @ApiProperty()
declare type: string; declare currency: string;
@ApiProperty({ minimum: 1 }) @ApiProperty()
declare count: number;
}
export class PortfolioStructureDto {
@ApiProperty({ description: 'Exact known total as a decimal string.' })
declare totalValue: string; declare totalValue: string;
@ApiProperty({ @ApiProperty({
@@ -104,11 +99,24 @@ export class PortfolioStructureDto {
@ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true }) @ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true })
declare categories: PortfolioStructureCategoryDto[]; declare categories: PortfolioStructureCategoryDto[];
}
export class PortfolioUnvaluedCategoryDto {
@ApiProperty({ enum: ['STOCK', 'BOND', 'OTHER'] })
declare type: string;
@ApiProperty({ minimum: 1 })
declare count: number;
}
export class PortfolioStructureDto {
@ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true })
declare currencies: PortfolioStructureCurrencyDto[];
@ApiProperty({ @ApiProperty({
type: PortfolioUnvaluedCategoryDto, isArray: true, type: PortfolioUnvaluedCategoryDto, isArray: true,
description: description:
'Positions without current value. Shares are unavailable when nonempty.', 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.',
}) })
declare unvaluedCategories: PortfolioUnvaluedCategoryDto[]; declare unvaluedCategories: PortfolioUnvaluedCategoryDto[];
} }
@@ -120,14 +128,14 @@ export class PortfolioDto {
@ApiProperty() @ApiProperty()
declare isStale: boolean; declare isStale: boolean;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto, isArray: true })
declare totals: MoneyDto; declare totalsByCurrency: MoneyDto[];
@ApiProperty({ type: PortfolioPositionDto, isArray: true }) @ApiProperty({ type: PortfolioPositionDto, isArray: true })
declare positions: PortfolioPositionDto[]; declare positions: PortfolioPositionDto[];
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto, isArray: true })
declare cash: MoneyDto; declare cashBalances: MoneyDto[];
@ApiProperty({ type: PortfolioStructureDto }) @ApiProperty({ type: PortfolioStructureDto })
declare structure: PortfolioStructureDto; declare structure: PortfolioStructureDto;
@@ -244,23 +244,16 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
dispatchProblems: HourlySection[], dispatchProblems: HourlySection[],
walkStartedAt: Date, walkStartedAt: Date,
) { ) {
const portfolios: string[] = [];
for (const account of accounts) { for (const account of accounts) {
await this.dispatchPortfolio( await this.dispatchPortfolio(
userId, userId,
revision, revision,
account.accountId, account.accountId,
async (child) => { dispatch,
portfolios.push(child.id);
await dispatch(child);
},
dispatchProblems, dispatchProblems,
walkStartedAt, walkStartedAt,
); );
} }
// Operations coverage must end after the Portfolio valuation it explains:
// performance requires coverage up to the snapshot fetchedAt.
await this.waitWorks(userId, portfolios);
for (const account of accounts) { for (const account of accounts) {
await this.dispatchOperations( await this.dispatchOperations(
userId, userId,
@@ -61,10 +61,10 @@ function projectedMoney(
value: string, value: string,
currency: string | undefined, currency: string | undefined,
): UpcomingEventDto['projectedAmount'] { ): UpcomingEventDto['projectedAmount'] {
// RUB-only: projected income in other currencies is filtered out. const normalizedCurrency = currency?.trim().toUpperCase();
if (currency?.trim().toUpperCase() !== 'RUB') return null; if (!normalizedCurrency) return null;
return { return {
currency: 'RUB', currency: normalizedCurrency,
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 }) @ApiProperty({ type: MoneyDto, isArray: true })
declare incomeTotal: MoneyDto; declare incomeTotalsByCurrency: 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 and exact signed fractional quantities', () => { it('preserves registry dates, source currency 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: 'rub', units: '10', nano: 1 }, dividendNet: { currency: 'usd', units: '10', nano: 1 },
}), }),
).toMatchObject({ ).toMatchObject({
date: '2026-10-13', date: '2026-10-13',
dateKind: 'registry', dateKind: 'registry',
projectedAmount: { currency: 'RUB', value: '-25.0000000025' }, projectedAmount: { currency: 'USD', value: '-25.0000000025' },
}); });
}); });
it('does not invent an amount for offers, missing or non-RUB currency', () => { it('does not invent an amount for offers or missing currency', () => {
expect( expect(
bondEvent(metadata, decimalFromString('3'), { bondEvent(metadata, decimalFromString('3'), {
type: 'offer', type: 'offer',
@@ -38,12 +38,6 @@ 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,
incomeTotal: incomeTotal(events), incomeTotalsByCurrency: incomeTotals(events),
} }
: undefined; : undefined;
return this.forecasts.result({ return this.forecasts.result({
@@ -286,13 +286,22 @@ function dateWindow(query: UpcomingEventsQueryDto) {
return { from, to }; return { from, to };
} }
function incomeTotal(events: UpcomingEventDto[]): MoneyDto { function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
let total: Decimal = { value: 0n, scale: 0 }; const totals = new Map<string, Decimal>();
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;
} }
total = add(total, decimalFromString(event.projectedAmount.value)); const current = totals.get(event.projectedAmount.currency) ?? {
value: 0n,
scale: 0,
};
totals.set(
event.projectedAmount.currency,
add(current, decimalFromString(event.projectedAmount.value)),
);
} }
return { currency: 'RUB', value: formatDecimal(total) }; return [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
} }
+1 -1
View File
@@ -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: [], cash: { currency: 'RUB', value: '0' }, totals: { currency: 'RUB', value: '0' }, structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] } }), fetchedAt: now }); insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], 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 });
+2 -2
View File
@@ -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: { ...instrument, currency: 'rub' } }], ['bondBy', { instrument }],
['findInstrument', { instruments: [instrument] }], ['findInstrument', { instruments: [instrument] }],
['getAssetBy', { asset: { description: 'description' } }], ['getAssetBy', { asset: { description: 'description' } }],
['getAssetFundamentals', { fundamentals: [] }], ['getAssetFundamentals', { fundamentals: [] }],
['getLastPrices', { lastPrices: [] }], ['getLastPrices', { lastPrices: [] }],
['shareBy', { instrument: { ...instrument, currency: 'rub' } }], ['shareBy', { instrument }],
]); ]);
const failure = Object.assign(new Error('grpc failure'), { const failure = Object.assign(new Error('grpc failure'), {
code, code,
+1 -1
View File
@@ -98,7 +98,7 @@ describe('OpenAPI contract (integration)', () => {
expect.arrayContaining([ expect.arrayContaining([
'accountId', 'accountId',
'period', 'period',
'total', 'totalsByCurrency',
'calculatedAt', 'calculatedAt',
'bucketGranularity', 'bucketGranularity',
'buckets', 'buckets',
+38 -25
View File
@@ -111,20 +111,25 @@ 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: [],
totalValue: id === 'all' ? '200' : '100', currencies: [
hasNegativeValues: false,
categories: [
{ {
type: 'STOCK', currency: 'RUB',
value: id === 'all' ? '120' : '60', totalValue: id === 'all' ? '200' : '100',
sharePercent: '60', hasNegativeValues: false,
}, categories: [
{ type: 'BOND', value: '0', sharePercent: '0' }, {
{ type: 'OTHER', value: '0', sharePercent: '0' }, type: 'STOCK',
{ value: id === 'all' ? '120' : '60',
type: 'CASH', sharePercent: '60',
value: id === 'all' ? '80' : '40', },
sharePercent: '40', { type: 'BOND', value: '0', sharePercent: '0' },
{ type: 'OTHER', value: '0', sharePercent: '0' },
{
type: 'CASH',
value: id === 'all' ? '80' : '40',
sharePercent: '40',
},
],
}, },
], ],
}, },
@@ -144,11 +149,15 @@ 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 }],
categories: [ currencies: [
{ sharePercent: null }, {
{ sharePercent: null }, categories: [
{ sharePercent: null }, { sharePercent: null },
{ value: '40', sharePercent: null }, { sharePercent: null },
{ sharePercent: null },
{ value: '40', sharePercent: null },
],
},
], ],
}, },
}); });
@@ -165,13 +174,17 @@ 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: {
hasNegativeValues: true, currencies: [
totalValue: '150', {
categories: [ hasNegativeValues: true,
{ value: '120', sharePercent: null }, totalValue: '150',
{ sharePercent: null }, categories: [
{ sharePercent: null }, { value: '120', sharePercent: null },
{ value: '30', sharePercent: null }, { sharePercent: null },
{ sharePercent: null },
{ value: '30', sharePercent: null },
],
},
], ],
}, },
}); });
-23
View File
@@ -167,29 +167,6 @@ describe('hourly attempt lifecycle (SQLite)', () => {
expect(read?.state).toBe('succeeded'); expect(read?.state).toBe('succeeded');
}); });
it('starts operations only after every portfolio work finished', async () => {
const portfolios: string[] = [];
portfolio.request.mockImplementation(
async (_userId: string, _revision: string, accountId: string) => {
const row = await child('running', null, `portfolio:${accountId}`);
portfolios.push(row.id);
return { id: row.id, done: Promise.resolve() };
},
);
const scheduler = createScheduler();
await scheduler.tick();
await pump();
expect(portfolios).toHaveLength(2);
expect(operations.request).not.toHaveBeenCalled();
await prisma.tbankSyncWork.updateMany({
where: { id: { in: portfolios } },
data: { state: 'complete' },
});
const row = await runWalk(scheduler);
expect(operations.request).toHaveBeenCalledTimes(2);
expect(row.state).toBe('complete');
});
it('settles partial and keeps successful steps when one account fails', async () => { it('settles partial and keeps successful steps when one account fails', async () => {
portfolio.request.mockImplementation( portfolio.request.mockImplementation(
async (_userId: string, _revision: string, accountId: string) => { async (_userId: string, _revision: string, accountId: string) => {
+14 -7
View File
@@ -103,6 +103,7 @@ 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: {
@@ -129,8 +130,11 @@ 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.total.total).toEqual({ currency: 'RUB', value: '15.000000002' }); expect(result.data.totalsByCurrency.map((row) => row.total)).toEqual([
expect(result.data.entries).toHaveLength(4); { currency: 'RUB', value: '15.000000002' },
{ 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')
@@ -181,7 +185,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.total).toEqual(base.total); expect(after.data.totalsByCurrency).toEqual(base.totalsByCurrency);
expect(after.data.calculatedAt).toBe(base.calculatedAt); expect(after.data.calculatedAt).toBe(base.calculatedAt);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
}); });
@@ -201,8 +205,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?.total.total, (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency,
).toEqual({ currency: 'RUB', value: '0' }); ).toEqual([]);
expect(upstream.getOperationsByCursor).toHaveBeenCalledWith( expect(upstream.getOperationsByCursor).toHaveBeenCalledWith(
'synthetic', expect.objectContaining({ accountId: 'b' }), 'synthetic', expect.objectContaining({ accountId: 'b' }),
); );
@@ -221,8 +225,11 @@ 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(4); expect(result.data.entries).toHaveLength(6);
expect(result.data.total.total).toEqual({ currency: 'RUB', value: '25.000000002' }); expect(
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();
+1 -1
View File
@@ -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?.total.total).toEqual({ expect(income.data?.totalsByCurrency[0].total).toEqual({
currency: 'RUB', currency: 'RUB',
value: '2.000000002', value: '2.000000002',
}); });
+25 -17
View File
@@ -218,27 +218,32 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
bondPositionCount: 0, bondPositionCount: 0,
fundPositionCount: 0, fundPositionCount: 0,
totalPositionCount: 1, totalPositionCount: 1,
totals: { currency: 'RUB', value: '21.100000002' }, totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }],
cash: { currency: 'RUB', value: '1.1' }, cashBalances: [{ currency: 'RUB', value: '1.1' }],
structure: { structure: {
totalValue: '21.100000002', currencies: [{
hasNegativeValues: false, currency: 'RUB',
categories: [ totalValue: '21.100000002',
{ type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' }, hasNegativeValues: false,
{ type: 'BOND', value: '0', sharePercent: '0' }, categories: [
{ type: 'OTHER', value: '0', sharePercent: '0' }, { type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' },
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' }, { type: 'BOND', value: '0', sharePercent: '0' },
], { type: 'OTHER', value: '0', sharePercent: '0' },
{ type: 'CASH', value: '1.1', sharePercent: '5.21327' },
],
}],
unvaluedCategories: [], unvaluedCategories: [],
}, },
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true }, unrealizedReturnByCurrency: [
{ 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?.totals, (portfolio.body as PortfolioResourceDto).data?.totalsByCurrency,
).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();
@@ -310,7 +315,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => {
logoName: 'enriched.png', logoName: 'enriched.png',
quantity: '2', quantity: '2',
}); });
expect(after.data?.totals).toEqual(before.data?.totals); expect(after.data?.totalsByCurrency).toEqual(before.data?.totalsByCurrency);
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);
@@ -508,9 +513,12 @@ 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({
totals: { currency: 'RUB', value: '21.1' }, totalsByCurrency: [{ currency: 'RUB', value: '21.1' }],
cash: { currency: 'RUB', value: '1.1' }, cashBalances: [{ currency: 'RUB', value: '1.1' }],
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: true }, structure: { currencies: [{ currency: 'RUB' }] },
unrealizedReturnByCurrency: [
{ currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true },
],
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 0, bondPositionCount: 0,
fundPositionCount: 0, fundPositionCount: 0,
+13 -7
View File
@@ -16,15 +16,18 @@ 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,
totals: { currency: 'RUB', value: '0' }, totalsByCurrency: [],
positions: [], positions: [],
cash: { currency: 'RUB', value: '0' }, cashBalances: [],
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
} satisfies PortfolioDto; } satisfies PortfolioDto;
const accountId = 'account'; const accountId = 'account';
const valuedSnapshot = { const valuedSnapshot = {
...snapshot, ...snapshot,
totals: { currency: 'RUB', value: '200' }, totalsByCurrency: [
{ currency: 'RUB', value: '200' },
{ currency: 'USD', value: '10' },
],
} satisfies PortfolioDto; } satisfies PortfolioDto;
const createRunningAttempt = () => const createRunningAttempt = () =>
prisma.tbankSyncWork.create({ prisma.tbankSyncWork.create({
@@ -92,7 +95,7 @@ describe('portfolio publication (SQLite)', () => {
expect(await prisma.tbankPortfolio.count()).toBe(1); expect(await prisma.tbankPortfolio.count()).toBe(1);
}); });
it('rolls back the snapshot, value point and claim when the point write fails', async () => { it('rolls back the snapshot, first value point and claim when the second point 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({
@@ -111,6 +114,7 @@ describe('portfolio publication (SQLite)', () => {
points: { points: {
create: { create: {
attemptId: 'previous-attempt', attemptId: 'previous-attempt',
currency: 'RUB',
value: '100', value: '100',
capturedAt: previousCapturedAt, capturedAt: previousCapturedAt,
}, },
@@ -119,9 +123,11 @@ 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')
.mockRejectedValueOnce(new Error('injected point failure')); .mockImplementationOnce((args) => upsert(args))
.mockRejectedValueOnce(new Error('injected second point failure'));
}); });
await expect( await expect(
@@ -130,7 +136,7 @@ describe('portfolio publication (SQLite)', () => {
accountId, accountId,
valuedSnapshot, valuedSnapshot,
), ),
).rejects.toThrow('injected point failure'); ).rejects.toThrow('injected second point failure');
expect( expect(
await prisma.tbankPortfolio.findUnique({ await prisma.tbankPortfolio.findUnique({
+5 -5
View File
@@ -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' },
}], }],
totals: { currency: 'RUB', value: '1100' }, totalsByCurrency: [{ currency: 'RUB', value: '1100' }],
cash: { currency: 'RUB', value: '100' }, cashBalances: [{ currency: 'RUB', value: '100' }],
structure: { totalValue: '1100', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
}, },
fetchedAt: now, fetchedAt: now,
}, },
+25 -21
View File
@@ -80,7 +80,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
], ],
}); });
upstream.getPositions.mockResolvedValue({ upstream.getPositions.mockResolvedValue({
money: [{ units: '5', currency: 'rub' }], money: [{ units: '5', currency: 'usd' }],
}); });
}); });
@@ -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 a single RUB point for a fully valued snapshot', async () => { it('writes points for fully valued snapshot with two currencies', 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,10 +133,12 @@ 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(1); expect(history?.points).toHaveLength(2);
const rubPoint = history?.points[0]; const rubPoint = history?.points.find((p) => p.currency === 'RUB');
expect(rubPoint?.value).toBe('26'); const usdPoint = history?.points.find((p) => p.currency === 'USD');
expect(rubPoint?.value).toBe('21');
expect(usdPoint?.value).toBe('5');
const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({ const snapshot = await prisma.tbankPortfolio.findUniqueOrThrow({
where: { where: {
@@ -199,7 +201,7 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
expect(points).toHaveLength(0); expect(points).toHaveLength(0);
}); });
it('writes a zero point for an empty fully valued account', async () => { it('writes no points for empty account without currency', async () => {
upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPortfolio.mockResolvedValue({ positions: [] });
upstream.getPositions.mockResolvedValue({ money: [] }); upstream.getPositions.mockResolvedValue({ money: [] });
@@ -210,8 +212,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(1); expect(points).toHaveLength(0);
expect(points[0].value).toBe('0');
}); });
it('deduplicates repeated publication of same attempt', async () => { it('deduplicates repeated publication of same attempt', async () => {
@@ -257,7 +258,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(1); expect(points).toHaveLength(2);
}); });
it('late attempt after token replacement adds no points', async () => { it('late attempt after token replacement adds no points', async () => {
@@ -281,7 +282,10 @@ 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,
totals: { currency: 'RUB', value: '100' }, totalsByCurrency: [
{ currency: 'RUB', value: '100' },
{ currency: 'USD', value: '50' },
],
positions: [ positions: [
{ {
figi: 'stock', figi: 'stock',
@@ -298,8 +302,8 @@ describe('T-Bank value history persistence and visibility (SQLite)', () => {
issuer: null, issuer: null,
}, },
], ],
cash: { currency: 'RUB', value: '0' }, cashBalances: [{ currency: 'USD', value: '50' }],
structure: { totalValue: '100', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
}; };
const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published); const result = await app.get(PortfolioService)['repository'].publish(oldWork, 'a', published);
@@ -319,7 +323,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(1); expect(pointsBefore).toHaveLength(2);
upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed')); upstream.getPortfolio.mockRejectedValue(new Error('Upstream failed'));
await publishPortfolio('owner', 'a'); await publishPortfolio('owner', 'a');
@@ -327,7 +331,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(1); expect(pointsAfter).toHaveLength(2);
expect(pointsAfter).toEqual(pointsBefore); expect(pointsAfter).toEqual(pointsBefore);
}); });
}); });
@@ -341,7 +345,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(1); expect(oldPoints).toHaveLength(2);
upstream.validateToken.mockResolvedValue([accounts[0]]); upstream.validateToken.mockResolvedValue([accounts[0]]);
await putConnection('owner', 'token-b').expect(200); await putConnection('owner', 'token-b').expect(200);
@@ -357,8 +361,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(1); expect(newPoints.length).toBeGreaterThan(2);
expect(newPoints.slice(0, 1)).toEqual(oldPoints); expect(newPoints.slice(0, 2)).toEqual(oldPoints);
}); });
it('hides history when account disappears from membership', async () => { it('hides history when account disappears from membership', async () => {
@@ -388,7 +392,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(1); expect(pointsB).toHaveLength(2);
}); });
it('leaves visibility unchanged on membership fetch error', async () => { it('leaves visibility unchanged on membership fetch error', async () => {
@@ -420,7 +424,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(1); expect(points).toHaveLength(2);
await request(app.getHttpServer()) await request(app.getHttpServer())
.delete('/api/v1/tbank-connection') .delete('/api/v1/tbank-connection')
@@ -446,7 +450,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(1); expect(pointsAfter.length).toBeGreaterThan(2);
}); });
it('unhides when disappeared account returns in membership', async () => { it('unhides when disappeared account returns in membership', async () => {
@@ -526,7 +530,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(1); expect(points).toHaveLength(2);
}); });
}); });
}); });
+33 -18
View File
@@ -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,
totals: { currency: 'RUB', value: '0' }, totalsByCurrency: [],
cash: { currency: 'RUB', value: '0' }, cashBalances: [],
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
}; };
await prisma.tbankPortfolio.upsert({ await prisma.tbankPortfolio.upsert({
where: { where: {
@@ -273,14 +273,17 @@ 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?.incomeTotal).toEqual({ currency: 'RUB', value: '6' }); expect(single.data?.incomeTotalsByCurrency).toEqual([
{ 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?.incomeTotal).toEqual( expect(all.data?.incomeTotalsByCurrency).toEqual(
single.data?.incomeTotal, single.data?.incomeTotalsByCurrency,
); );
expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
expect(upstream.getBondCoupons).not.toHaveBeenCalled(); expect(upstream.getBondCoupons).not.toHaveBeenCalled();
@@ -331,7 +334,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
expect(newRange.data).toEqual({ expect(newRange.data).toEqual({
...saved.data, ...saved.data,
events: [], events: [],
incomeTotal: { currency: 'RUB', value: '0' }, incomeTotalsByCurrency: [],
}); });
expect(newRange.data?.window).toEqual(window); expect(newRange.data?.window).toEqual(window);
await prisma.tbankConnection.update({ await prisma.tbankConnection.update({
@@ -369,8 +372,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
?.incomeTotal, ?.incomeTotalsByCurrency,
).toEqual({ currency: 'RUB', value: '0' }); ).toEqual([{ currency: 'USD', value: '90.000000009' }]);
}); });
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 () => {
@@ -536,7 +539,9 @@ 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?.incomeTotal).toEqual({ currency: 'RUB', value: '6' }); expect(result.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '6' },
]);
expect(moex.bondEvents).toHaveBeenCalledWith('BOND'); expect(moex.bondEvents).toHaveBeenCalledWith('BOND');
}); });
@@ -569,7 +574,9 @@ 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?.incomeTotal).toEqual({ currency: 'RUB', value: '6' }); expect(before.data?.incomeTotalsByCurrency).toEqual([
{ currency: 'RUB', value: '6' },
]);
const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({ const calendar = await prisma.tbankInstrumentSource.findFirstOrThrow({
where: { kind: 'bond-events' }, where: { kind: 'bond-events' },
}); });
@@ -668,7 +675,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: 'rub' }, dividendNet: { units: '10', nano: 1, currency: 'usd' },
}); });
const defaultEvents = async () => const defaultEvents = async () =>
(await get('brokerage-accounts/account/events').expect(200)) (await get('brokerage-accounts/account/events').expect(200))
@@ -698,7 +705,9 @@ 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?.incomeTotal).toEqual({ currency: 'RUB', value: '20.000000002' }); expect(first.data?.incomeTotalsByCurrency).toEqual([
{ 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')]);
@@ -711,7 +720,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
'2099-02-05', '2099-02-05',
'2099-03-04', '2099-03-04',
]); ]);
expect(second.data?.incomeTotal).toEqual({ currency: 'RUB', value: '100.00000001' }); expect(second.data?.incomeTotalsByCurrency).toEqual([
{ 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);
@@ -725,7 +736,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => {
expect(disjoint.data).toEqual({ expect(disjoint.data).toEqual({
...second.data, ...second.data,
events: [], events: [],
incomeTotal: { currency: 'RUB', value: '0' }, incomeTotalsByCurrency: [],
}); });
expect(await prisma.tbankForecast.count()).toBe(2); expect(await prisma.tbankForecast.count()).toBe(2);
}); });
@@ -768,7 +779,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: 'rub' }, dividendNet: { units, currency: 'usd' },
}); });
// 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 () => {
@@ -792,12 +803,16 @@ 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?.incomeTotal).toEqual({ currency: 'RUB', value: '20' }); expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
{ 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?.incomeTotal).toEqual({ currency: 'RUB', value: '60' }); expect((await latest()).data?.incomeTotalsByCurrency).toEqual([
{ 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')]);