Browse Source

Fix rounding of value per account and platform

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.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bugfix/rounding-of-value-per-account-and-platform
Thomas Kaul 7 days ago
parent
commit
79e2ad5861
  1. 6
      CHANGELOG.md
  2. 38
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 56
      apps/api/src/app/portfolio/portfolio.service.ts

6
CHANGELOG.md

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Fixed
- Fixed the allocation in the accounts tab of the holding detail dialog caused by floating-point rounding
## 3.47.0 - 2026-08-10 ## 3.47.0 - 2026-08-10
### Changed ### Changed

38
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -510,5 +510,43 @@ describe('PortfolioService', () => {
expect(accounts[UNKNOWN_KEY]).toBeUndefined(); expect(accounts[UNKNOWN_KEY]).toBeUndefined();
expect(platforms[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);
});
}); });
}); });

56
apps/api/src/app/portfolio/portfolio.service.ts

@ -2262,40 +2262,56 @@ export class PortfolioService {
} }
} }
for (const { account, assetProfile, quantity, type } of ordersByAccount) { if (ordersByAccount.length === 0) {
const currentValueOfSymbolInBaseCurrency = continue;
getFactor(type) * }
quantity *
(portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? let valueOfAccountInBaseCurrency = new Big(0);
0);
for (const { assetProfile, quantity, type } of ordersByAccount) {
if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus(
accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += new Big(quantity)
currentValueOfSymbolInBaseCurrency; .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 { } else {
accounts[account?.id || UNKNOWN_KEY] = { accounts[currentAccountId] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account?.name, name: account?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
}; };
} }
if ( if (platforms[currentPlatformId]) {
platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency platforms[currentPlatformId].valueInBaseCurrency = new Big(
) { platforms[currentPlatformId].valueInBaseCurrency
platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += )
currentValueOfSymbolInBaseCurrency; .plus(valueOfAccountInBaseCurrency)
.toNumber();
} else { } else {
platforms[account?.platformId || UNKNOWN_KEY] = { platforms[currentPlatformId] = {
balance: 0, balance: 0,
currency: account?.currency, currency: account?.currency,
name: account?.platform?.name, name: account?.platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: valueOfAccountInBaseCurrency.toNumber()
}; };
} }
} }
}
return { accounts, platforms }; return { accounts, platforms };
} }

Loading…
Cancel
Save