From f2843e30bda5442158ec145cc63a5491506bb60f Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 28 Oct 2026 08:00:20 +0300 Subject: [PATCH 1/2] fix: count brokerage cash once and skip short-position return percent (WealthLedger/backend#140) --- .../brokerage-accounts.service.spec.ts | 21 ++++++++++++++++ .../brokerage-accounts.service.ts | 10 +++++++- .../application/portfolio.service.spec.ts | 25 +++++++++++++++++++ .../domain/portfolio-structure.spec.ts | 12 ++++----- .../portfolio/domain/portfolio-structure.ts | 2 ++ .../infrastructure/tbank-portfolio.mapper.ts | 3 ++- 6 files changed, 65 insertions(+), 8 deletions(-) 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 8d51469..44c54a9 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -128,6 +128,27 @@ describe('saved brokerage account projection', () => { }); }); + it('omits return percent for a currency with short positions', async () => { + portfolio.saved.mockResolvedValueOnce({ + membership: meta, + accounts: [{ + id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta, + snapshot: { + cashBalances: [], structure: {}, totalsByCurrency: [], + positions: [ + { type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } }, + { type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } }, + ], + }, + }], + }); + + expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([ + { currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false }, + { currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false }, + ]); + }); + 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 29fefdc..96845cc 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -19,6 +19,7 @@ import { } from './brokerage-account.dto'; const PERCENT_DECIMAL_PLACES = 2; +const ZERO_QUANTITY = 0n; function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] { // Cash-like CURRENCY positions must not duplicate the separately returned cash balances. @@ -47,6 +48,13 @@ function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnD return money ? [money.currency] : []; }), ); + // A short position has negative cost basis, so a percent would flip sign or net out. + const withShorts = new Set( + investments.flatMap((position) => { + const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue; + return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : []; + }), + ); const currencies = new Set([...absolute, ...costs].map((money) => money.currency)); return [...new Set([...currencies, ...partial])] .sort((left, right) => left.localeCompare(right)) @@ -58,7 +66,7 @@ function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnD currency, absolute: absoluteReturn, costBasis, - percent: isZero(cost) + percent: isZero(cost) || withShorts.has(currency) ? null : formatDecimal(divide( multiply(decimalFromString(absoluteReturn), decimalFromString('100')), diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 008c160..81c0ed3 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -159,6 +159,31 @@ describe('persistent portfolio projection', () => { 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 () => { + repository.reserve.mockResolvedValueOnce({ + launch: true, + work: { id: 'work', attemptId: 'attempt' }, + }); + tbank.getPortfolio.mockResolvedValue({ positions: [ + { figi: 'FUND', instrumentType: 'etf', quantity: { units: '1' }, currentPrice: { currency: 'RUB', units: '120', nano: 240000000 } }, + { figi: 'RUB000UTSTOM', instrumentType: 'currency', quantity: { units: '1', nano: 250000000 }, currentPrice: { currency: 'RUB', units: '1' } }, + ] }); + tbank.getPositions.mockResolvedValue({ money: [{ currency: 'RUB', units: '1', nano: 250000000 }] }); + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null }], + work: [], + }); + const handle = await service.request('user', 'private', 'a'); + assert(handle); + await handle.done; + const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2]; + expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]); + expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']); + expect(snapshot.structure.currencies[0].totalValue).toBe('121.49'); + }); + it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts index 4264872..14559fd 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts @@ -19,7 +19,7 @@ const position = ( }); describe('portfolio structure', () => { - it('includes cash once and keeps currency instruments among other assets', () => { + it('counts cash once and skips CURRENCY positions that mirror it', () => { const result = portfolioStructure( [ position('STOCK', '60'), @@ -30,13 +30,13 @@ describe('portfolio structure', () => { ); expect(result.currencies[0]).toEqual({ currency: 'RUB', - totalValue: '100', + totalValue: '90', hasNegativeValues: false, categories: [ - { type: 'STOCK', value: '60', sharePercent: '60' }, - { type: 'BOND', value: '20', sharePercent: '20' }, - { type: 'OTHER', value: '10', sharePercent: '10' }, - { type: 'CASH', value: '10', sharePercent: '10' }, + { type: 'STOCK', value: '60', sharePercent: '66.666667' }, + { type: 'BOND', value: '20', sharePercent: '22.222222' }, + { type: 'OTHER', value: '0', sharePercent: '0' }, + { type: 'CASH', value: '10', sharePercent: '11.111111' }, ], }); }); diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index 6b01a9a..7498ff6 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -43,6 +43,8 @@ export function portfolioStructure( currencies.set(money.currency, group); }; 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) ? position.type : 'OTHER'; diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index 08c8873..11c0d8c 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -126,7 +126,8 @@ function totalsByCurrency( for (const value of money) addTotal(value.currency, decimal(value)); for (const position of positions) { const price = position.currentPrice; - if (!price) continue; + // 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)), -- 2.54.0 From 2325b638839240bf5d6d4f7e6e1f8eaa82d4d7fd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 28 Oct 2026 08:00:20 +0300 Subject: [PATCH 2/2] fix: reset portfolio value points inflated by double-counted cash (WealthLedger/backend#140) --- .../migration.sql | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 prisma/migrations/20261028080000_reset_inflated_portfolio_value_points/migration.sql diff --git a/prisma/migrations/20261028080000_reset_inflated_portfolio_value_points/migration.sql b/prisma/migrations/20261028080000_reset_inflated_portfolio_value_points/migration.sql new file mode 100644 index 0000000..74c1101 --- /dev/null +++ b/prisma/migrations/20261028080000_reset_inflated_portfolio_value_points/migration.sql @@ -0,0 +1,4 @@ +-- Points written before WealthLedger/backend#140 counted cash twice (cash balance +-- plus the mirroring CURRENCY position). Historical compositions and FX rates are +-- not stored, so the points cannot be recomputed exactly; history restarts here. +DELETE FROM "TbankPortfolioValuePoint"; -- 2.54.0