From 7b74cf5cea8abb6ceef5c878d4dafce6ec6b199f Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 11 Aug 2026 21:03:42 +0200 Subject: [PATCH] Fix rounding of value per account and platform (#7594) Aggregate the value of accounts and platforms with Big.js, so that activities cancelling each other out result in exactly zero instead of a floating-point residue. Such a residue distorted the allocation per account in the holding detail dialog, e.g. -8.5277e-16 for a closed position and 1.0000000000000009 for the remaining one. --- CHANGELOG.md | 1 + .../app/portfolio/portfolio.service.spec.ts | 38 +++++++++ .../src/app/portfolio/portfolio.service.ts | 78 +++++++++++-------- 3 files changed, 86 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c571fdae7..d98e93424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding - Fixed the account aggregations in impersonation mode to be based on the impersonated user - Fixed the base currency of the activities in impersonation mode to be based on the impersonated user - Fixed the base currency of the dividends in impersonation mode to be based on the impersonated user diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index eed3a27cb..c5e0a8332 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -510,5 +510,43 @@ describe('PortfolioService', () => { expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(platforms[UNKNOWN_KEY]).toBeUndefined(); }); + + it('should not accumulate rounding errors of activities cancelling each other out', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.1, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.2, + type: 'BUY' + }, + { + account, + accountId: account.id, + assetProfile: { symbol: 'AAPL' }, + quantity: 0.3, + type: 'SELL' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 1234.5678 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 0 (activities) + expect(accounts[account.id].valueInBaseCurrency).toBe(100); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(100); + }); }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 88f675008..2c7701784 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -2270,38 +2270,54 @@ export class PortfolioService { } } - for (const { account, assetProfile, quantity, type } of ordersByAccount) { - const currentValueOfSymbolInBaseCurrency = - getFactor(type) * - quantity * - (portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? - 0); - - if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { - accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += - currentValueOfSymbolInBaseCurrency; - } else { - accounts[account?.id || UNKNOWN_KEY] = { - balance: 0, - currency: account?.currency, - name: account?.name, - valueInBaseCurrency: currentValueOfSymbolInBaseCurrency - }; - } + if (ordersByAccount.length === 0) { + continue; + } - if ( - platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency - ) { - platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += - currentValueOfSymbolInBaseCurrency; - } else { - platforms[account?.platformId || UNKNOWN_KEY] = { - balance: 0, - currency: account?.currency, - name: account?.platform?.name, - valueInBaseCurrency: currentValueOfSymbolInBaseCurrency - }; - } + let valueOfAccountInBaseCurrency = new Big(0); + + for (const { assetProfile, quantity, type } of ordersByAccount) { + valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus( + new Big(quantity) + .mul(getFactor(type)) + .mul( + portfolioItemsNow[assetProfile.symbol] + ?.marketPriceInBaseCurrency ?? 0 + ) + ); + } + + const currentAccountId = account?.id || UNKNOWN_KEY; + const currentPlatformId = account?.platformId || UNKNOWN_KEY; + + if (accounts[currentAccountId]) { + accounts[currentAccountId].valueInBaseCurrency = new Big( + accounts[currentAccountId].valueInBaseCurrency + ) + .plus(valueOfAccountInBaseCurrency) + .toNumber(); + } else { + accounts[currentAccountId] = { + balance: 0, + currency: account?.currency, + name: account?.name, + valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() + }; + } + + if (platforms[currentPlatformId]) { + platforms[currentPlatformId].valueInBaseCurrency = new Big( + platforms[currentPlatformId].valueInBaseCurrency + ) + .plus(valueOfAccountInBaseCurrency) + .toNumber(); + } else { + platforms[currentPlatformId] = { + balance: 0, + currency: account?.currency, + name: account?.platform?.name, + valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber() + }; } }