From 24655251fcf36626c75ac9e80a0476c9a35d68bc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:21:57 +0200 Subject: [PATCH] Fix portfolio calculation for holdings with same symbol --- .../calculator/portfolio-calculator.ts | 46 ++++++++------- .../src/app/portfolio/portfolio.service.ts | 14 +++-- apps/api/src/helper/object.helper.spec.ts | 56 +++++++++---------- .../allocations/allocations-page.component.ts | 33 +++++++---- .../app/pages/public/public-page.component.ts | 27 ++++++--- 5 files changed, 106 insertions(+), 70 deletions(-) diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 029e71c5a..3a0b063bb 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -527,48 +527,51 @@ export abstract class PortfolioCalculator { } } + const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); + for (const dateString of chartDates) { - for (const assetProfileIdentifier of Object.keys( - valuesByAssetProfileIdentifier - )) { - const symbolValues = + for (const assetProfileIdentifier of assetProfileIdentifiers) { + const assetProfileValues = valuesByAssetProfileIdentifier[assetProfileIdentifier]; const currentValue = - symbolValues.currentValues?.[dateString] ?? new Big(0); + assetProfileValues.currentValues?.[dateString] ?? new Big(0); const currentValueWithCurrencyEffect = - symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? + assetProfileValues.currentValuesWithCurrencyEffect?.[dateString] ?? new Big(0); const investmentValueAccumulated = - symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); + assetProfileValues.investmentValuesAccumulated?.[dateString] ?? + new Big(0); const investmentValueAccumulatedWithCurrencyEffect = - symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ + assetProfileValues.investmentValuesAccumulatedWithCurrencyEffect?.[ dateString ] ?? new Big(0); const investmentValueWithCurrencyEffect = - symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? + assetProfileValues.investmentValuesWithCurrencyEffect?.[dateString] ?? new Big(0); const netPerformanceValue = - symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); + assetProfileValues.netPerformanceValues?.[dateString] ?? new Big(0); const netPerformanceValueWithCurrencyEffect = - symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? - new Big(0); + assetProfileValues.netPerformanceValuesWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); const netWorthValueWithCurrencyEffect = - symbolValues.netWorthValuesWithCurrencyEffect?.[dateString] ?? + assetProfileValues.netWorthValuesWithCurrencyEffect?.[dateString] ?? new Big(0); const timeWeightedInvestmentValue = - symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); + assetProfileValues.timeWeightedInvestmentValues?.[dateString] ?? + new Big(0); const timeWeightedInvestmentValueWithCurrencyEffect = - symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ + assetProfileValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ dateString ] ?? new Big(0); @@ -989,7 +992,7 @@ export abstract class PortfolioCalculator { @LogPerformance private computeTransactionPoints() { this.transactionPoints = []; - const symbols: { + const transactionPointSymbols: { [assetProfileIdentifier: string]: TransactionPointSymbol; } = {}; @@ -1017,7 +1020,8 @@ export abstract class PortfolioCalculator { const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile); - const oldAccumulatedSymbol = symbols[assetProfileIdentifier]; + const oldAccumulatedSymbol = + transactionPointSymbols[assetProfileIdentifier]; if (oldAccumulatedSymbol) { let investment = oldAccumulatedSymbol.investment; @@ -1099,7 +1103,8 @@ export abstract class PortfolioCalculator { 'id' ); - symbols[assetProfileIdentifier] = currentTransactionPointItem; + transactionPointSymbols[assetProfileIdentifier] = + currentTransactionPointItem; const items = lastTransactionPoint?.items ?? []; @@ -1110,7 +1115,10 @@ export abstract class PortfolioCalculator { newItems.push(currentTransactionPointItem); newItems.sort((a, b) => { - return a.symbol?.localeCompare(b.symbol); + return ( + a.symbol?.localeCompare(b.symbol) || + a.dataSource?.localeCompare(b.dataSource) + ); }); let fees = new Big(0); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 6ee390c58..5e602dda2 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -737,14 +737,20 @@ export class PortfolioService { valueInBaseCurrency: emergencyFundInCash }; - const indexOfHoldingInBaseCurrency = holdings.findIndex( + const emergencyFundCashHoldingAssetProfileIdentifier = + getAssetProfileIdentifier(emergencyFundCashHolding.assetProfile); + + const indexOfEmergencyFundCashHolding = holdings.findIndex( ({ assetProfile }) => { - return assetProfile.symbol === userCurrency; + return ( + getAssetProfileIdentifier(assetProfile) === + emergencyFundCashHoldingAssetProfileIdentifier + ); } ); - if (indexOfHoldingInBaseCurrency >= 0) { - holdings[indexOfHoldingInBaseCurrency] = emergencyFundCashHolding; + if (indexOfEmergencyFundCashHolding >= 0) { + holdings[indexOfEmergencyFundCashHolding] = emergencyFundCashHolding; } else { holdings.push(emergencyFundCashHolding); } diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts index 5a442ca9e..a28afb15c 100644 --- a/apps/api/src/helper/object.helper.spec.ts +++ b/apps/api/src/helper/object.helper.spec.ts @@ -109,8 +109,8 @@ describe('redactAttributes', () => { } }, hasError: false, - holdings: { - 'AAPL.US': { + holdings: [ + { activitiesCount: 1, currency: 'USD', markets: { @@ -162,7 +162,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 11039.5, valueInPercentage: 0.0694356974830054 }, - 'ALV.DE': { + { activitiesCount: 2, currency: 'EUR', markets: { @@ -209,7 +209,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 6616.826601205088, valueInPercentage: 0.04161818652826481 }, - AMZN: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -261,7 +261,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 18799, valueInPercentage: 0.11824101426541227 }, - bitcoin: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -312,7 +312,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 36985.0332704, valueInPercentage: 0.232626620912395 }, - BONDORA_GO_AND_GROW: { + { activitiesCount: 5, currency: 'EUR', markets: { @@ -363,7 +363,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 2231.644722160232, valueInPercentage: 0.014036487867880205 }, - FRANKLY95P: { + { activitiesCount: 6, currency: 'CHF', markets: { @@ -487,7 +487,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 22363.19795483481, valueInPercentage: 0.14065892911313693 }, - MSFT: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -539,7 +539,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 12840.6, valueInPercentage: 0.08076416659271518 }, - TSLA: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -591,7 +591,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 39069, valueInPercentage: 0.2457342510950259 }, - VTI: { + { activitiesCount: 5, currency: 'USD', markets: { @@ -763,7 +763,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 14102.5, valueInPercentage: 0.08870120238725339 }, - 'VWRL.SW': { + { activitiesCount: 5, currency: 'CHF', markets: { @@ -1171,7 +1171,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 23079.20085622547, valueInPercentage: 0.145162408515095 }, - 'XDWD.DE': { + { activitiesCount: 1, currency: 'EUR', markets: { @@ -1449,7 +1449,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 8847.35550100424, valueInPercentage: 0.055647656152211074 }, - USD: { + { activitiesCount: 0, currency: 'USD', allocationInPercentage: 0.20291717628620132, @@ -1476,7 +1476,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: 49890, valueInPercentage: 0.3137956381563603 } - }, + ], platforms: { 'a5b14588-49a0-48e4-b9f7-e186b27860b7': { balance: 0, @@ -1613,8 +1613,8 @@ describe('redactAttributes', () => { } }, hasError: false, - holdings: { - 'AAPL.US': { + holdings: [ + { activitiesCount: 1, currency: 'USD', markets: { @@ -1666,7 +1666,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.0694356974830054 }, - 'ALV.DE': { + { activitiesCount: 2, currency: 'EUR', markets: { @@ -1713,7 +1713,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.04161818652826481 }, - AMZN: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -1765,7 +1765,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.11824101426541227 }, - bitcoin: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -1816,7 +1816,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.232626620912395 }, - BONDORA_GO_AND_GROW: { + { activitiesCount: 5, currency: 'EUR', markets: { @@ -1867,7 +1867,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.014036487867880205 }, - FRANKLY95P: { + { activitiesCount: 6, currency: 'CHF', markets: { @@ -1971,7 +1971,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.14065892911313693 }, - MSFT: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -2023,7 +2023,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.08076416659271518 }, - TSLA: { + { activitiesCount: 1, currency: 'USD', markets: { @@ -2075,7 +2075,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.2457342510950259 }, - VTI: { + { activitiesCount: 5, currency: 'USD', markets: { @@ -2247,7 +2247,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.08870120238725339 }, - 'VWRL.SW': { + { activitiesCount: 5, currency: 'CHF', markets: { @@ -2647,7 +2647,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.145162408515095 }, - 'XDWD.DE': { + { activitiesCount: 1, currency: 'EUR', markets: { @@ -2925,7 +2925,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.055647656152211074 }, - USD: { + { activitiesCount: 0, currency: 'USD', allocationInPercentage: 0.20291717628620132, @@ -2952,7 +2952,7 @@ describe('redactAttributes', () => { valueInBaseCurrency: null, valueInPercentage: 0.3137956381563603 } - }, + ], platforms: { 'a5b14588-49a0-48e4-b9f7-e186b27860b7': { balance: null, diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index 4ff746651..e5960515c 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -119,7 +119,7 @@ export class GfAllocationsPageComponent implements OnInit { [name: string]: { name: string; value: number }; }; protected symbols: { - [name: string]: { + [symbol: string]: { dataSource?: DataSource; isClickable?: boolean; name: string; @@ -505,16 +505,27 @@ export class GfAllocationsPageComponent implements OnInit { this.totalValueInEtf += this.holdings[assetProfileIdentifier].value; } - this.symbols[assetProfileIdentifier] = { - dataSource: position.assetProfile.dataSource, - isClickable: canOpenHoldingDetail(position), - name: position.assetProfile.name ?? '', - symbol: position.assetProfile.symbol, - value: - (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage) ?? 0 - }; + const symbol = position.assetProfile.symbol; + + const value = + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage) ?? 0; + + const symbolData = this.symbols[symbol]; + + if (symbolData) { + // Aggregate holdings with the same symbol from different data sources + symbolData.value += value; + } else { + this.symbols[symbol] = { + symbol, + value, + dataSource: position.assetProfile.dataSource, + isClickable: canOpenHoldingDetail(position), + name: position.assetProfile.name ?? '' + }; + } } this.markets = this.portfolioDetails.markets; diff --git a/apps/client/src/app/pages/public/public-page.component.ts b/apps/client/src/app/pages/public/public-page.component.ts index cc2baa092..e76154ce0 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -93,7 +93,7 @@ export class GfPublicPageComponent implements OnInit { [name: string]: { name: string; value: number }; }; protected symbols: { - [name: string]: { name: string; symbol: string; value: number }; + [symbol: string]: { name: string; symbol: string; value: number }; }; protected readonly UNKNOWN_KEY = UNKNOWN_KEY; @@ -245,13 +245,24 @@ export class GfPublicPageComponent implements OnInit { } } - this.symbols[assetProfileIdentifier] = { - name: position.assetProfile.name ?? position.assetProfile.symbol, - symbol: position.assetProfile.symbol, - value: isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : (position.valueInPercentage ?? 0) - }; + const symbol = position.assetProfile.symbol; + + const value = isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : (position.valueInPercentage ?? 0); + + const symbolData = this.symbols[symbol]; + + if (symbolData) { + // Aggregate holdings with the same symbol from different data sources + symbolData.value += value; + } else { + this.symbols[symbol] = { + symbol, + value, + name: position.assetProfile.name ?? symbol + }; + } } } }