From 97fc41fa295a7a943a27171fa41cdaf286632956 Mon Sep 17 00:00:00 2001 From: codex-bot Date: Tue, 27 Oct 2026 05:44:07 +0300 Subject: [PATCH] feat: extend account list portfolio summary (#138) --- .../brokerage-account.dto.ts | 41 ++++++++++ .../brokerage-accounts.service.spec.ts | 60 ++++++++++++++- .../brokerage-accounts.service.ts | 76 ++++++++++++++++++- .../investments/portfolio/portfolio.api.ts | 1 + test/tbank-local-reads.e2e-spec.ts | 35 ++++++++- 5 files changed, 207 insertions(+), 6 deletions(-) diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index 31fe0fb..a661d1b 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -2,12 +2,39 @@ import { ApiProperty } from '@nestjs/swagger'; import { MoneyDto } from '../../../common/money.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto'; +import { PortfolioStructureDto } from '../portfolio/portfolio.api'; import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto'; +export class UnrealizedReturnDto { + @ApiProperty() + declare currency: string; + + @ApiProperty({ example: '12.345678901' }) + declare absolute: string; + + @ApiProperty({ example: '100.00' }) + declare costBasis: string; + + @ApiProperty({ type: String, nullable: true, example: '12.34' }) + declare percent: null | string; + + @ApiProperty() + declare partial: boolean; +} + export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) declare totalsByCurrency: MoneyDto[]; + @ApiProperty({ type: MoneyDto, isArray: true }) + declare cashBalances: MoneyDto[]; + + @ApiProperty({ type: PortfolioStructureDto }) + declare structure: PortfolioStructureDto; + + @ApiProperty({ type: UnrealizedReturnDto, isArray: true }) + declare unrealizedReturnByCurrency: UnrealizedReturnDto[]; + @ApiProperty({ type: 'integer', minimum: 0, @@ -21,6 +48,20 @@ export class PortfolioSummaryDto { description: 'Number of non-zero bond positions, not the number of units.', }) declare bondPositionCount: number; + + @ApiProperty({ + type: 'integer', + minimum: 0, + description: 'Number of non-zero fund positions, not the number of units.', + }) + declare fundPositionCount: number; + + @ApiProperty({ + type: 'integer', + minimum: 0, + description: 'Number of all non-zero positions, not the number of units.', + }) + declare totalPositionCount: number; } export class BrokerageAccountDto { diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index bfb01ba..8d51469 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -33,6 +33,7 @@ describe('saved brokerage account projection', () => { }); it('keeps exact saved totals and independent missing summaries', async () => { + const structure = { currencies: [], unvaluedCategories: [] }; portfolio.saved.mockResolvedValue({ membership: meta, accounts: [ @@ -43,10 +44,12 @@ describe('saved brokerage account projection', () => { openedOn: null, meta, snapshot: { + cashBalances: [{ currency: 'RUB', value: '10.000000001' }], + structure, totalsByCurrency: [ { currency: 'RUB', value: '100000000.000000001' }, ], - positions: [{ type: 'STOCK' }, { type: 'BOND' }], + positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }], }, }, { @@ -61,15 +64,70 @@ describe('saved brokerage account projection', () => { }); const response = await service.list('owner'); expect(response.data?.[0].portfolioSummary).toEqual({ + cashBalances: [{ currency: 'RUB', value: '10.000000001' }], + structure, totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], stockPositionCount: 1, bondPositionCount: 1, + fundPositionCount: 0, + totalPositionCount: 2, + unrealizedReturnByCurrency: [], }); expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.meta.availability).toBe('available'); expect(response.meta.completeness).toBe('complete'); }); + it('returns an empty summary from an empty saved portfolio', async () => { + const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] }; + portfolio.saved.mockResolvedValueOnce({ + membership: meta, + accounts: [{ + id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta, + snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] }, + }], + }); + + expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({ + cashBalances: [], structure, totalsByCurrency: [], + stockPositionCount: 0, bondPositionCount: 0, + fundPositionCount: 0, totalPositionCount: 0, + unrealizedReturnByCurrency: [], + }); + }); + + it('calculates exact returns by currency, partial data, and zero cost basis', async () => { + portfolio.saved.mockResolvedValueOnce({ + membership: meta, + accounts: [{ + id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, + snapshot: { + cashBalances: [], structure: {}, totalsByCurrency: [ + { currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' }, + ], + positions: [ + { type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } }, + { type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } }, + { type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null }, + { type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } }, + { type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } }, + { type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null }, + ], + }, + }], + }); + + expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({ + stockPositionCount: 2, bondPositionCount: 2, + fundPositionCount: 1, totalPositionCount: 6, + unrealizedReturnByCurrency: [ + { currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true }, + { currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false }, + { currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true }, + ], + }); + }); + it('observes the latest hourly walk without starting another one', async () => { portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] }); sync.hourly.mockResolvedValue({ diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 8ad93bd..29fefdc 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -1,10 +1,74 @@ import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; +import { + decimalFromString, + divide, + formatDecimal, + isZero, + multiply, +} from '../../../common/decimal'; import { investmentResourceMeta } from '../investment-resource.mapper'; -import { PortfolioService } from '../portfolio/portfolio.api'; +import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals'; +import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api'; import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository'; -import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto'; +import { + BrokerageAccountDto, + BrokerageAccountsResourceDto, + UnrealizedReturnDto, +} from './brokerage-account.dto'; + +const PERCENT_DECIMAL_PLACES = 2; + +function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] { + // Cash-like CURRENCY positions must not duplicate the separately returned cash balances. + const investments = positions.filter((position) => position.type !== 'CURRENCY'); + const absolute = sumMoneyByCurrency( + investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []), + ); + const costs = sumMoneyByCurrency( + investments.flatMap((position) => + position.absoluteReturn && position.averagePurchasePrice + ? [{ + currency: position.absoluteReturn.currency, + value: formatDecimal(multiply( + decimalFromString(position.averagePurchasePrice.value), + decimalFromString(position.quantity), + )), + }] + : [], + ), + ); + const partial = new Set( + investments.flatMap((position) => { + const money = position.absoluteReturn + ? null + : position.averagePurchasePrice ?? position.currentValue; + return money ? [money.currency] : []; + }), + ); + const currencies = new Set([...absolute, ...costs].map((money) => money.currency)); + return [...new Set([...currencies, ...partial])] + .sort((left, right) => left.localeCompare(right)) + .map((currency) => { + const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0'; + const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0'; + const cost = decimalFromString(costBasis); + return { + currency, + absolute: absoluteReturn, + costBasis, + percent: isZero(cost) + ? null + : formatDecimal(divide( + multiply(decimalFromString(absoluteReturn), decimalFromString('100')), + cost, + PERCENT_DECIMAL_PLACES, + )), + partial: partial.has(currency), + }; + }); +} @Injectable() export class BrokerageAccountsService { @@ -26,12 +90,20 @@ export class BrokerageAccountsService { portfolioSummary: account.snapshot ? { totalsByCurrency: account.snapshot.totalsByCurrency, + cashBalances: account.snapshot.cashBalances, + structure: account.snapshot.structure, + unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions), stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', ).length, bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, + fundPositionCount: account.snapshot.positions.filter( + (position) => position.type === 'FUND', + ).length, + // The snapshot mapper already drops zero-quantity positions. + totalPositionCount: account.snapshot.positions.length, } : null, })); diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index fc567da..6deff8a 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -5,4 +5,5 @@ export { PortfolioService } from './application/portfolio.service'; export { PortfolioDto, PortfolioPositionDto, + PortfolioStructureDto, } from './presentation/portfolio.dto'; diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index bb18ca4..58aa495 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -216,7 +216,27 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ).toEqual({ stockPositionCount: 1, bondPositionCount: 0, + fundPositionCount: 0, + totalPositionCount: 1, totalsByCurrency: [{ currency: 'RUB', value: '21.100000002' }], + cashBalances: [{ currency: 'RUB', value: '1.1' }], + structure: { + currencies: [{ + currency: 'RUB', + totalValue: '21.100000002', + hasNegativeValues: false, + categories: [ + { type: 'STOCK', value: '20.000000002', sharePercent: '94.78673' }, + { type: 'BOND', value: '0', sharePercent: '0' }, + { type: 'OTHER', value: '0', sharePercent: '0' }, + { type: 'CASH', value: '1.1', sharePercent: '5.21327' }, + ], + }], + unvaluedCategories: [], + }, + unrealizedReturnByCurrency: [ + { currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true }, + ], }); const portfolio = await get('brokerage-accounts/account/portfolio').expect( 200, @@ -492,9 +512,18 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { } const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) .body as BrokerageAccountsResourceDto; - expect(summaries.data?.[0].portfolioSummary?.totalsByCurrency).toEqual([ - { currency: 'RUB', value: '21.1' }, - ]); + expect(summaries.data?.[0].portfolioSummary).toMatchObject({ + totalsByCurrency: [{ currency: 'RUB', value: '21.1' }], + cashBalances: [{ currency: 'RUB', value: '1.1' }], + structure: { currencies: [{ currency: 'RUB' }] }, + unrealizedReturnByCurrency: [ + { currency: 'RUB', absolute: '0', costBasis: '0', percent: null, partial: true }, + ], + stockPositionCount: 1, + bondPositionCount: 0, + fundPositionCount: 0, + totalPositionCount: 1, + }); expect(summaries.data?.[1].portfolioSummary).toBeNull(); expect(summaries.meta.availability).toBe('available'); expect(summaries.meta.completeness).toBe('complete');