diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d7d053e3..845b15d3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Improved the loading state of the symbol autocomplete component - Consolidated the duplicated translations of the asset classes and asset sub classes +- Changed the holdings in the portfolio endpoints from a map keyed by the symbol to an array ### Fixed - Improved the handling of indices in the _Financial Modeling Prep_ service +- Fixed the portfolio calculation for holdings with the same symbol from different data sources ## 3.62.0 - 2026-08-27 diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 4c6e096b8..3284d2a15 100644 --- a/apps/api/src/app/endpoints/ai/ai.service.ts +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -118,7 +118,7 @@ export class AiService { values: Object.values(AssetSubClass) }); - const holdingsTableRows = Object.values(holdings) + const holdingsTableRows = [...holdings] .sort((a, b) => { return b.allocationInPercentage - a.allocationInPercentage; }) diff --git a/apps/api/src/app/endpoints/public/public.service.ts b/apps/api/src/app/endpoints/public/public.service.ts index 0b709cd01..246f437f3 100644 --- a/apps/api/src/app/endpoints/public/public.service.ts +++ b/apps/api/src/app/endpoints/public/public.service.ts @@ -133,7 +133,7 @@ export class PublicService { latestActivities, markets, alias: access.alias, - holdings: {}, + holdings: [], performance: { '1d': { relativeChange: @@ -151,7 +151,7 @@ export class PublicService { }; const totalValue = getSum( - Object.values(holdings).map(({ assetProfile, marketPrice, quantity }) => { + holdings.map(({ assetProfile, marketPrice, quantity }) => { return new Big( this.exchangeRateDataService.toCurrency( quantity * marketPrice, @@ -162,33 +162,32 @@ export class PublicService { }) ).toNumber(); - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { - publicPortfolioResponse.holdings[symbol] = { - allocationInPercentage: - portfolioPosition.valueInBaseCurrency / totalValue, + for (const holding of holdings) { + publicPortfolioResponse.holdings.push({ + allocationInPercentage: holding.valueInBaseCurrency / totalValue, assetProfile: { - ...portfolioPosition.assetProfile, + ...holding.assetProfile, assetClass: hasDetails || - portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetProfile.assetClass + holding.assetProfile.assetClass === AssetClass.LIQUIDITY + ? holding.assetProfile.assetClass : undefined, assetClassLabel: hasDetails || - portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY - ? portfolioPosition.assetProfile.assetClassLabel + holding.assetProfile.assetClass === AssetClass.LIQUIDITY + ? holding.assetProfile.assetClassLabel : undefined, assetSubClass: hasDetails || - portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetProfile.assetSubClass + holding.assetProfile.assetSubClass === AssetSubClass.CASH + ? holding.assetProfile.assetSubClass : undefined, assetSubClassLabel: hasDetails || - portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH - ? portfolioPosition.assetProfile.assetSubClassLabel + holding.assetProfile.assetSubClass === AssetSubClass.CASH + ? holding.assetProfile.assetSubClassLabel : undefined, - holdings: portfolioPosition.assetProfile.holdings?.map( + holdings: holding.assetProfile.holdings?.map( ({ allocationInPercentage, name }) => { return { allocationInPercentage, name }; } @@ -202,12 +201,12 @@ export class PublicService { sectors: [] }) }, - dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, - markets: hasDetails ? portfolioPosition.markets : undefined, + dateOfFirstActivity: holding.dateOfFirstActivity, + markets: hasDetails ? holding.markets : undefined, netPerformancePercentWithCurrencyEffect: - portfolioPosition.netPerformancePercentWithCurrencyEffect, - valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue - }; + holding.netPerformancePercentWithCurrencyEffect, + valueInPercentage: holding.valueInBaseCurrency / totalValue + }); } return publicPortfolioResponse; diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts index 1460892fa..a57ac5d5b 100644 --- a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -19,7 +19,7 @@ export class MwrPortfolioCalculator extends PortfolioCalculator { end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; + [date: string]: { [assetProfileIdentifier: string]: Big }; }; start: Date; step?: number; diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 4ec03083d..3a0b063bb 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -22,6 +22,7 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT, + getAssetProfileIdentifier, getSum, parseDate, resetHours @@ -72,8 +73,8 @@ export abstract class PortfolioCalculator { protected accountBalanceItems: HistoricalDataItem[]; protected activities: PortfolioOrder[]; - protected activitiesBySymbol: { - [symbol: string]: PortfolioOrder[]; + protected activitiesByAssetProfileIdentifier: { + [assetProfileIdentifier: string]: PortfolioOrder[]; }; private configurationService: ConfigurationService; @@ -165,9 +166,12 @@ export abstract class PortfolioCalculator { return a.date?.localeCompare(b.date); }); - this.activitiesBySymbol = groupBy(this.activities, ({ assetProfile }) => { - return assetProfile.symbol; - }); + this.activitiesByAssetProfileIdentifier = groupBy( + this.activities, + ({ assetProfile }) => { + return getAssetProfileIdentifier(assetProfile); + } + ); this.portfolioSnapshotService = portfolioSnapshotService; this.redisCacheService = redisCacheService; @@ -221,8 +225,8 @@ export abstract class PortfolioCalculator { }; } - const cashSymbols = new Set(); - const currencies: { [symbol: string]: string } = {}; + const cashAssetProfileIdentifiers = new Set(); + const currencies: { [assetProfileIdentifier: string]: string } = {}; const dataGatheringItems: DataGatheringItem[] = []; let firstIndex = transactionPoints.length; let firstTransactionPoint: TransactionPoint = null; @@ -244,7 +248,7 @@ export abstract class PortfolioCalculator { }); } - currencies[symbol] = currency; + currencies[getAssetProfileIdentifier({ dataSource, symbol })] = currency; } for (let i = 0; i < transactionPoints.length; i++) { @@ -280,7 +284,7 @@ export abstract class PortfolioCalculator { this.dataProviderInfos = dataProviderInfos; const marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; + [date: string]: { [assetProfileIdentifier: string]: Big }; } = {}; for (const marketSymbol of marketSymbols) { @@ -291,9 +295,8 @@ export abstract class PortfolioCalculator { } if (marketSymbol.marketPrice) { - marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPrice - ); + marketSymbolMap[date][getAssetProfileIdentifier(marketSymbol)] = + new Big(marketSymbol.marketPrice); } } @@ -346,8 +349,8 @@ export abstract class PortfolioCalculator { }; } = {}; - const valuesBySymbol: { - [symbol: string]: { + const valuesByAssetProfileIdentifier: { + [assetProfileIdentifier: string]: { currentValues: { [date: string]: Big }; currentValuesWithCurrencyEffect: { [date: string]: Big }; investmentValuesAccumulated: { [date: string]: Big }; @@ -362,8 +365,11 @@ export abstract class PortfolioCalculator { } = {}; for (const item of lastTransactionPoint.items) { + const assetProfileIdentifier = getAssetProfileIdentifier(item); + const marketPriceInBaseCurrency = ( - marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + marketSymbolMap[endDateString]?.[assetProfileIdentifier] ?? + item.averagePrice ).mul( exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ endDateString @@ -421,31 +427,32 @@ export abstract class PortfolioCalculator { // contributes nothing but its balance to the performance calculation. It // is therefore excluded from the value and the investment, while still // contributing to the net worth. - valuesBySymbol[item.symbol] = isCashInBaseCurrency - ? { - currentValues: {}, - currentValuesWithCurrencyEffect: {}, - investmentValuesAccumulated: {}, - investmentValuesAccumulatedWithCurrencyEffect: {}, - investmentValuesWithCurrencyEffect: {}, - netPerformanceValues: {}, - netPerformanceValuesWithCurrencyEffect: {}, - netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect, - timeWeightedInvestmentValues: {}, - timeWeightedInvestmentValuesWithCurrencyEffect: {} - } - : { - currentValues, - currentValuesWithCurrencyEffect, - investmentValuesAccumulated, - investmentValuesAccumulatedWithCurrencyEffect, - investmentValuesWithCurrencyEffect, - netPerformanceValues, - netPerformanceValuesWithCurrencyEffect, - timeWeightedInvestmentValues, - timeWeightedInvestmentValuesWithCurrencyEffect, - netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect - }; + valuesByAssetProfileIdentifier[assetProfileIdentifier] = + isCashInBaseCurrency + ? { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect, + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {} + } + : { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + netWorthValuesWithCurrencyEffect: currentValuesWithCurrencyEffect + }; positions.push({ timeWeightedInvestment, @@ -474,7 +481,9 @@ export abstract class PortfolioCalculator { investment: totalInvestment, investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, marketPrice: - marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1, + marketSymbolMap[endDateString]?.[ + assetProfileIdentifier + ]?.toNumber() ?? 1, marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1, netPerformance: !hasErrors ? (netPerformance ?? null) : null, netPerformancePercentage: !hasErrors @@ -493,7 +502,7 @@ export abstract class PortfolioCalculator { }); if (item.assetSubClass === AssetSubClass.CASH) { - cashSymbols.add(item.symbol); + cashAssetProfileIdentifiers.add(assetProfileIdentifier); totalCashInBaseCurrency = totalCashInBaseCurrency.plus(valueInBaseCurrency); @@ -518,45 +527,51 @@ export abstract class PortfolioCalculator { } } + const assetProfileIdentifiers = Object.keys(valuesByAssetProfileIdentifier); + for (const dateString of chartDates) { - for (const symbol of Object.keys(valuesBySymbol)) { - const symbolValues = valuesBySymbol[symbol]; + 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); @@ -569,7 +584,7 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalCashValueWithCurrencyEffect ?? new Big(0) ).add( - cashSymbols.has(symbol) + cashAssetProfileIdentifiers.has(assetProfileIdentifier) ? netWorthValueWithCurrencyEffect : new Big(0) ), @@ -879,7 +894,7 @@ export abstract class PortfolioCalculator { end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; + [date: string]: { [assetProfileIdentifier: string]: Big }; }; start: Date; } & AssetProfileIdentifier): SymbolMetrics; @@ -977,7 +992,9 @@ export abstract class PortfolioCalculator { @LogPerformance private computeTransactionPoints() { this.transactionPoints = []; - const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + const transactionPointSymbols: { + [assetProfileIdentifier: string]: TransactionPointSymbol; + } = {}; let lastDate: string = null; let lastTransactionPoint: TransactionPoint = null; @@ -1001,7 +1018,10 @@ export abstract class PortfolioCalculator { const skipErrors = !!assetProfile.userId; // Skip errors for custom asset profiles const symbol = assetProfile.symbol; - const oldAccumulatedSymbol = symbols[symbol]; + const assetProfileIdentifier = getAssetProfileIdentifier(assetProfile); + + const oldAccumulatedSymbol = + transactionPointSymbols[assetProfileIdentifier]; if (oldAccumulatedSymbol) { let investment = oldAccumulatedSymbol.investment; @@ -1083,18 +1103,22 @@ export abstract class PortfolioCalculator { 'id' ); - symbols[symbol] = currentTransactionPointItem; + transactionPointSymbols[assetProfileIdentifier] = + currentTransactionPointItem; const items = lastTransactionPoint?.items ?? []; - const newItems = items.filter(({ symbol }) => { - return symbol !== assetProfile.symbol; + const newItems = items.filter((item) => { + return getAssetProfileIdentifier(item) !== assetProfileIdentifier; }); 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/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts new file mode 100644 index 000000000..0aa3a4be3 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-from-two-data-sources.spec.ts @@ -0,0 +1,175 @@ +import { + activityDummyData, + assetProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + PortfolioSnapshotServiceMock.reset(); + RedisCacheServiceMock.reset(); + + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null, null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy from two data sources', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + type: 'BUY', + unitPriceInAssetProfileCurrency: 339.51 + }, + { + ...activityDummyData, + assetProfile: { + ...assetProfileDummyData, + currency: 'USD', + dataSource: 'EOD_HISTORICAL_DATA', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + type: 'BUY', + unitPriceInAssetProfileCurrency: 339.51 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + // The holdings must not be aggregated, because they belong to two + // different asset profiles. Each holding must be valuated with the + // market price of its own data source. + expect(portfolioSnapshot.positions).toEqual([ + expect.objectContaining({ + activitiesCount: 1, + dataSource: 'EOD_HISTORICAL_DATA', + investment: new Big('679.02'), + marketPrice: 332.47, + quantity: new Big('2'), + symbol: 'MSFT', + valueInBaseCurrency: new Big('664.94') + }), + expect.objectContaining({ + activitiesCount: 1, + dataSource: 'YAHOO', + investment: new Big('339.51'), + marketPrice: 331.83, + quantity: new Big('1'), + symbol: 'MSFT', + valueInBaseCurrency: new Big('331.83') + }) + ]); + + expect(portfolioSnapshot.currentValueInBaseCurrency).toEqual( + new Big('996.77') + ); + + expect(portfolioSnapshot.totalInvestment).toEqual(new Big('1018.53')); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index e49b3fd25..ed9c264c1 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -3,7 +3,11 @@ import { PortfolioCalculatorPosition } from '@ghostfolio/api/app/portfolio/inter import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; -import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + parseDate +} from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, SymbolMetrics @@ -140,7 +144,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; + [date: string]: { [assetProfileIdentifier: string]: Big }; }; start: Date; } & AssetProfileIdentifier): SymbolMetrics { @@ -192,10 +196,15 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { let valueAtStartDate: Big; let valueAtStartDateWithCurrencyEffect: Big; + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + // Copy the items as they are enriched below. A shallow copy is sufficient // because only top-level properties are written. let orders: PortfolioOrderItem[] = ( - this.activitiesBySymbol[symbol] ?? [] + this.activitiesByAssetProfileIdentifier[assetProfileIdentifier] ?? [] ).map((activity) => { return { ...activity }; }); @@ -275,8 +284,11 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { const endDateString = format(end, DATE_FORMAT); const startDateString = format(start, DATE_FORMAT); - const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol]; - let unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol]; + const unitPriceAtStartDate = + marketSymbolMap[startDateString]?.[assetProfileIdentifier]; + + let unitPriceAtEndDate = + marketSymbolMap[endDateString]?.[assetProfileIdentifier]; const latestActivity = orders.at(-1); @@ -391,7 +403,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { break; } - const unitPrice = marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; + const unitPrice = + marketSymbolMap[dateString]?.[assetProfileIdentifier] ?? lastUnitPrice; if (ordersByDate[dateString]?.length > 0) { for (const order of ordersByDate[dateString]) { diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts index b4929c570..3625ab96c 100644 --- a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -19,7 +19,7 @@ export class RoiPortfolioCalculator extends PortfolioCalculator { end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; + [date: string]: { [assetProfileIdentifier: string]: Big }; }; start: Date; step?: number; diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts index 8a58f816a..ad54ff34c 100644 --- a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -19,7 +19,7 @@ export class TwrPortfolioCalculator extends PortfolioCalculator { end: Date; exchangeRates: { [dateString: string]: number }; marketSymbolMap: { - [date: string]: { [symbol: string]: Big }; + [date: string]: { [assetProfileIdentifier: string]: Big }; }; start: Date; step?: number; diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 8e027f971..7fcacad61 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -1,5 +1,6 @@ import { parseDate, resetHours } from '@ghostfolio/common/helper'; +import { DataSource } from '@prisma/client'; import { addDays, eachDayOfInterval, @@ -12,7 +13,15 @@ import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValuesObject } from './interfaces/get-values-object.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface'; -function mockGetValue(symbol: string, date: Date) { +function mockGetValue({ + dataSource, + date, + symbol +}: { + dataSource: DataSource; + date: Date; + symbol: string; +}) { switch (symbol) { case '55196015-1365-4560-aa60-8751ae6d18f8': if (isSameDay(parseDate('2022-01-31'), date)) { @@ -83,7 +92,12 @@ function mockGetValue(symbol: string, date: Date) { } else if (isSameDay(parseDate('2023-07-09'), date)) { return { marketPrice: 337.22 }; } else if (isSameDay(parseDate('2023-07-10'), date)) { - return { marketPrice: 331.83 }; + // Deviating market prices per data source to verify that the market + // price is resolved by the asset profile identifier + return { + marketPrice: + dataSource === DataSource.EOD_HISTORICAL_DATA ? 332.47 : 331.83 + }; } return { marketPrice: 0 }; @@ -117,8 +131,11 @@ export const CurrentRateServiceMock = { values.push({ date, dataSource: dataGatheringItem.dataSource, - marketPrice: mockGetValue(dataGatheringItem.symbol, date) - .marketPrice, + marketPrice: mockGetValue({ + date, + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + }).marketPrice, symbol: dataGatheringItem.symbol }); } @@ -132,8 +149,11 @@ export const CurrentRateServiceMock = { values.push({ date, dataSource: dataGatheringItem.dataSource, - marketPrice: mockGetValue(dataGatheringItem.symbol, date) - .marketPrice, + marketPrice: mockGetValue({ + date, + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + }).marketPrice, symbol: dataGatheringItem.symbol }); } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5b277dee5..02b847932 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -128,13 +128,13 @@ export class PortfolioController { !hasScope(impersonationScopes, scopes.portfolioReadValues) || isRestrictedView(this.request.user) ) { - const totalInvestment = Object.values(holdings) + const totalInvestment = holdings .map(({ investment }) => { return investment; }) .reduce((a, b) => a + b, 0); - const totalValue = Object.values(holdings) + const totalValue = holdings .filter(({ assetProfile }) => { return ( assetProfile.assetClass !== AssetClass.LIQUIDITY && @@ -148,11 +148,9 @@ export class PortfolioController { return a + b; }, 0); - for (const [, portfolioPosition] of Object.entries(holdings)) { - portfolioPosition.investment = - portfolioPosition.investment / totalInvestment; - portfolioPosition.valueInPercentage = - portfolioPosition.valueInBaseCurrency / totalValue; + for (const holding of holdings) { + holding.investment = holding.investment / totalInvestment; + holding.valueInPercentage = holding.valueInBaseCurrency / totalValue; } for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { @@ -204,8 +202,8 @@ export class PortfolioController { ]); } - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { - holdings[symbol] = { + for (const [index, portfolioPosition] of holdings.entries()) { + holdings[index] = { ...portfolioPosition, assetProfile: { ...portfolioPosition.assetProfile, diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 7cc256cf6..712117832 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -9,7 +9,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { TAG_ID_EMERGENCY_FUND, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier, @@ -115,10 +115,10 @@ describe('PortfolioService', () => { }); describe('getAggregatedMarkets', () => { - const getAggregatedMarkets = (holdings: object) => { + const getAggregatedMarkets = (holdings: object[]) => { return ( portfolioService as unknown as { - getAggregatedMarkets: (aHoldings: object) => { + getAggregatedMarkets: (aHoldings: object[]) => { markets: Record< string, { valueInBaseCurrency: number; valueInPercentage: number } @@ -130,9 +130,9 @@ describe('PortfolioService', () => { }; it('should distribute holdings with countries to their market and route holdings without countries (e.g. commodities, cryptocurrencies) to the unknown bucket', () => { - const holdings = { - 'GC=F': { - // Gold + const holdings = [ + { + // Gold (GC=F) assetProfile: { countries: [] }, markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }, marketsAdvanced: { @@ -145,7 +145,8 @@ describe('PortfolioService', () => { }, valueInBaseCurrency: 500 }, - MSFT: { + { + // MSFT assetProfile: { countries: [{ code: 'US', weight: 1 }] }, markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 }, marketsAdvanced: { @@ -158,7 +159,7 @@ describe('PortfolioService', () => { }, valueInBaseCurrency: 1000 } - }; + ]; const { markets, marketsAdvanced } = getAggregatedMarkets(holdings); @@ -213,15 +214,16 @@ describe('PortfolioService', () => { }); describe('getDetails', () => { - it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { - const accountId = randomUUID(); - + const setUpCashOnlyPortfolio = ({ + baseCurrency = 'CHF', + emergencyFund + }: { baseCurrency?: string; emergencyFund?: number } = {}) => { const cashAccount: AccountWithBalance = { balance: 2000, comment: null, createdAt: parseDate('2024-01-01'), currency: 'USD', - id: accountId, + id: randomUUID(), name: 'USD', platformId: null, updatedAt: parseDate('2024-01-01'), @@ -252,7 +254,8 @@ describe('PortfolioService', () => { id: userDummyData.id, settings: { settings: { - baseCurrency: 'CHF' + baseCurrency, + emergencyFund } } } as unknown as Awaited>); @@ -319,15 +322,37 @@ describe('PortfolioService', () => { 'getValueOfAccountsAndPlatforms' ) .mockResolvedValue({ accounts: {}, platforms: {} }); + }; + + it('should return cash holdings when the calculator emits cash positions with the exchange-rate data source', async () => { + setUpCashOnlyPortfolio(); const { holdings } = await portfolioService.getDetails({ filters: [], userId: userDummyData.id }); - expect(holdings['USD']).toBeDefined(); - expect(holdings['USD'].assetProfile.dataSource).toBe(DataSource.YAHOO); - expect(holdings['USD'].assetProfile.symbol).toBe('USD'); + expect(holdings).toEqual([ + expect.objectContaining({ + assetProfile: expect.objectContaining({ + dataSource: DataSource.YAHOO, + symbol: 'USD' + }) + }) + ]); + }); + + it('should replace the existing cash holding instead of adding a second one when filtering by the emergency fund tag', async () => { + setUpCashOnlyPortfolio({ baseCurrency: 'USD', emergencyFund: 1000 }); + + const { holdings } = await portfolioService.getDetails({ + filters: [{ id: TAG_ID_EMERGENCY_FUND, type: 'TAG' }], + userId: userDummyData.id + }); + + expect(holdings).toHaveLength(1); + expect(holdings[0].assetProfile.symbol).toBe('USD'); + expect(holdings[0].valueInBaseCurrency).toBe(1000); }); }); @@ -450,22 +475,22 @@ describe('PortfolioService', () => { { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' }, { account: null, accountId: null, - assetProfile: { symbol: 'BABA' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'BABA' }, quantity: 2, type: 'BUY' } ], filters: [], portfolioItemsNow: { - AAPL: { marketPriceInBaseCurrency: 10 }, - BABA: { marketPriceInBaseCurrency: 20 } + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 }, + 'YAHOO-BABA': { marketPriceInBaseCurrency: 20 } }, userCurrency: 'USD', userId: userDummyData.id @@ -486,14 +511,14 @@ describe('PortfolioService', () => { { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' } ], filters: [], portfolioItemsNow: { - AAPL: { marketPriceInBaseCurrency: 10 } + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id @@ -509,28 +534,28 @@ describe('PortfolioService', () => { { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.1, type: 'BUY' }, { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.2, type: 'BUY' }, { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.3, type: 'SELL' } ], filters: [], portfolioItemsNow: { - AAPL: { marketPriceInBaseCurrency: 1234.5678 } + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 1234.5678 } }, userCurrency: 'USD', userId: userDummyData.id @@ -547,21 +572,21 @@ describe('PortfolioService', () => { { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.1, type: 'BUY' }, { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 0.2, type: 'BUY' } ], filters: [{ id: 'AAPL', type: 'SYMBOL' }], portfolioItemsNow: { - AAPL: { marketPriceInBaseCurrency: 10 } + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id @@ -576,14 +601,14 @@ describe('PortfolioService', () => { { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' } ], filters: [], portfolioItemsNow: { - AAPL: { marketPriceInBaseCurrency: 10 } + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id @@ -616,14 +641,14 @@ describe('PortfolioService', () => { { account, accountId: account.id, - assetProfile: { symbol: 'AAPL' }, + assetProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, quantity: 1, type: 'BUY' } ], filters: [{ id: 'AAPL', type: 'SYMBOL' }], portfolioItemsNow: { - AAPL: { marketPriceInBaseCurrency: 10 } + 'YAHOO-AAPL': { marketPriceInBaseCurrency: 10 } }, userCurrency: 'USD', userId: userDummyData.id diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 30eb53c59..e7ea9a898 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -397,14 +397,12 @@ export class PortfolioService { return type !== 'SEARCH_QUERY'; }); - const { holdings: holdingsMap } = await this.getDetails({ + let { holdings } = await this.getDetails({ dateRange, userId, filters: filtersWithoutSearchQueryFilter }); - let holdings = Object.values(holdingsMap); - if (filterBySearchQuery) { const fuse = new Fuse(holdings, { keys: ['assetProfile.isin', 'assetProfile.name', 'assetProfile.symbol'], @@ -546,7 +544,7 @@ export class PortfolioService { currency: userCurrency }); - const holdings: PortfolioDetails['holdings'] = {}; + const holdings: PortfolioDetails['holdings'] = []; const { HOLDING_TYPE: [filterByHoldingType] = [], @@ -586,9 +584,12 @@ export class PortfolioService { ] = symbolProfile; } - const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; + const portfolioItemsNow: { + [assetProfileIdentifier: string]: TimelinePosition; + } = {}; + for (const position of positions) { - portfolioItemsNow[position.symbol] = position; + portfolioItemsNow[getAssetProfileIdentifier(position)] = position; } for (const { @@ -643,7 +644,7 @@ export class PortfolioService { })); } - holdings[symbol] = { + holdings.push({ activitiesCount, markets, marketsAdvanced, @@ -694,7 +695,7 @@ export class PortfolioService { netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, quantity: quantity.toNumber(), valueInBaseCurrency: valueInBaseCurrency.toNumber() - }; + }); } const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ @@ -730,11 +731,29 @@ export class PortfolioService { valueInBaseCurrency: emergencyFundInCash }; - holdings[userCurrency] = { + const emergencyFundCashHolding = { ...emergencyFundCashPositions[userCurrency], investment: emergencyFundInCash, valueInBaseCurrency: emergencyFundInCash }; + + const emergencyFundCashHoldingAssetProfileIdentifier = + getAssetProfileIdentifier(emergencyFundCashHolding.assetProfile); + + const indexOfEmergencyFundCashHolding = holdings.findIndex( + ({ assetProfile }) => { + return ( + getAssetProfileIdentifier(assetProfile) === + emergencyFundCashHoldingAssetProfileIdentifier + ); + } + ); + + if (indexOfEmergencyFundCashHolding >= 0) { + holdings[indexOfEmergencyFundCashHolding] = emergencyFundCashHolding; + } else { + holdings.push(emergencyFundCashHolding); + } } let markets: PortfolioDetails['markets']; @@ -1157,7 +1176,7 @@ export class PortfolioService { userSettings }).toNumber(); - const hasOpenHoldings = Object.keys(holdings).length > 0; + const hasOpenHoldings = holdings.length > 0; const marketsAdvancedTotalInBaseCurrency = getSum( Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { @@ -1234,13 +1253,13 @@ export class PortfolioService { new CurrencyClusterRiskBaseCurrencyCurrentInvestment( this.exchangeRateDataService, this.i18nService, - Object.values(holdings), + holdings, userSettings.language ), new CurrencyClusterRiskCurrentInvestment( this.exchangeRateDataService, this.i18nService, - Object.values(holdings), + holdings, userSettings.language ) ], @@ -1261,13 +1280,13 @@ export class PortfolioService { this.exchangeRateDataService, this.i18nService, userSettings.language, - Object.values(holdings) + holdings ), new AssetClassClusterRiskFixedIncome( this.exchangeRateDataService, this.i18nService, userSettings.language, - Object.values(holdings) + holdings ) ], userSettings @@ -1428,7 +1447,7 @@ export class PortfolioService { }); } - private getAggregatedMarkets(holdings: Record): { + private getAggregatedMarkets(holdings: PortfolioPosition[]): { markets: PortfolioDetails['markets']; marketsAdvanced: PortfolioDetails['marketsAdvanced']; } { @@ -1493,7 +1512,7 @@ export class PortfolioService { } }; - for (const [, position] of Object.entries(holdings)) { + for (const position of holdings) { const value = position.valueInBaseCurrency; if (position.assetProfile.countries.length > 0) { @@ -1574,7 +1593,7 @@ export class PortfolioService { userCurrency: string; value: Big; }) { - const cashPositions: PortfolioDetails['holdings'] = { + const cashPositions: { [currency: string]: PortfolioPosition } = { [userCurrency]: this.getInitialCashPosition({ balance: 0, currency: userCurrency @@ -1603,12 +1622,10 @@ export class PortfolioService { } } - for (const symbol of Object.keys(cashPositions)) { + for (const cashPosition of Object.values(cashPositions)) { // Calculate allocations for each currency - cashPositions[symbol].allocationInPercentage = value.gt(0) - ? new Big(cashPositions[symbol].valueInBaseCurrency) - .div(value) - .toNumber() + cashPosition.allocationInPercentage = value.gt(0) + ? new Big(cashPosition.valueInBaseCurrency).div(value).toNumber() : 0; } @@ -1715,7 +1732,7 @@ export class PortfolioService { }) { // TODO: Use current value of activities instead of holdings // tagged with EMERGENCY_FUND_TAG_ID - const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { + const emergencyFundHoldings = holdings.filter(({ tags }) => { return ( tags?.some(({ id }) => { return id === TAG_ID_EMERGENCY_FUND; @@ -1748,7 +1765,7 @@ export class PortfolioService { assetClass: AssetClass.LIQUIDITY, assetSubClass: AssetSubClass.CASH, countries: [], - dataSource: undefined, + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), holdings: [], name: currency, sectors: [], @@ -2258,8 +2275,8 @@ export class PortfolioService { valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus( currentQuantityOfSymbol.mul( - portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? - 0 + portfolioItemsNow[getAssetProfileIdentifier(assetProfile)] + ?.marketPriceInBaseCurrency ?? 0 ) ); } 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 e1dc8703d..358db2edb 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 @@ -8,6 +8,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { canOpenHoldingDetail, + getAssetProfileIdentifier, getCountryName } from '@ghostfolio/common/helper'; import { @@ -87,7 +88,7 @@ export class GfAllocationsPageComponent implements OnInit { () => this.deviceDetectorService.deviceInfo().deviceType ); protected holdings: { - [symbol: string]: Pick< + [assetProfileIdentifier: string]: Pick< PortfolioPosition['assetProfile'], | 'assetClass' | 'assetClassLabel' @@ -118,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; @@ -329,7 +330,7 @@ export class GfAllocationsPageComponent implements OnInit { this.portfolioDetails = { accounts: {}, createdAt: new Date(), - holdings: {}, + holdings: [], platforms: {}, summary: undefined }; @@ -369,10 +370,12 @@ export class GfAllocationsPageComponent implements OnInit { }; } - for (const [symbol, position] of Object.entries( - this.portfolioDetails.holdings - )) { - this.holdings[symbol] = { + for (const position of this.portfolioDetails.holdings) { + const assetProfileIdentifier = getAssetProfileIdentifier( + position.assetProfile + ); + + this.holdings[assetProfileIdentifier] = { assetClass: position.assetProfile.assetClass || (UNKNOWN_KEY as AssetClass), assetClassLabel: position.assetProfile.assetClassLabel ?? UNKNOWN_KEY, @@ -498,20 +501,33 @@ export class GfAllocationsPageComponent implements OnInit { } } - if (this.holdings[symbol].assetSubClass === 'ETF') { - this.totalValueInEtf += this.holdings[symbol].value; + if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') { + this.totalValueInEtf += this.holdings[assetProfileIdentifier].value; } - this.symbols[symbol] = { - symbol, - dataSource: position.assetProfile.dataSource, - isClickable: canOpenHoldingDetail(position), - name: position.assetProfile.name ?? '', - 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.dataSource = undefined; + symbolData.isClickable = false; + 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; @@ -558,8 +574,8 @@ export class GfAllocationsPageComponent implements OnInit { name, allocationInPercentage: this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, - parents: Object.entries(this.portfolioDetails.holdings) - .map(([symbol, holding]) => { + parents: this.portfolioDetails.holdings + .map((holding) => { if (holding.assetProfile.holdings.length > 0) { const currentParentHolding = holding.assetProfile.holdings.find( (parentHolding) => { @@ -573,11 +589,11 @@ export class GfAllocationsPageComponent implements OnInit { return currentParentHolding && isNumber(currentParentHolding.valueInBaseCurrency) ? { - symbol, allocationInPercentage: currentParentHolding.valueInBaseCurrency / value, name: holding.assetProfile.name ?? '', position: holding, + symbol: holding.assetProfile.symbol, valueInBaseCurrency: currentParentHolding.valueInBaseCurrency } 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 e354f8a1b..e76154ce0 100644 --- a/apps/client/src/app/pages/public/public-page.component.ts +++ b/apps/client/src/app/pages/public/public-page.component.ts @@ -1,5 +1,8 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config'; -import { getCountryName } from '@ghostfolio/common/helper'; +import { + getAssetProfileIdentifier, + getCountryName +} from '@ghostfolio/common/helper'; import { InfoItem, PortfolioPosition, @@ -67,7 +70,7 @@ export class GfPublicPageComponent implements OnInit { () => this.deviceDetectorService.deviceInfo().deviceType ); protected hasPermissionForSubscription: boolean; - protected holdings: PublicPortfolioResponse['holdings'][string][]; + protected holdings: PublicPortfolioResponse['holdings']; protected info: InfoItem; protected isLoading = true; protected latestActivitiesDataSource: MatTableDataSource< @@ -78,7 +81,7 @@ export class GfPublicPageComponent implements OnInit { }; protected readonly pageSize = Number.MAX_SAFE_INTEGER; protected positions: { - [symbol: string]: Pick< + [assetProfileIdentifier: string]: Pick< PortfolioPosition['assetProfile'], 'currency' | 'name' > & { @@ -90,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; @@ -175,12 +178,14 @@ export class GfPublicPageComponent implements OnInit { } }; - for (const [symbol, position] of Object.entries( - this.publicPortfolioDetails.holdings - )) { + for (const position of this.publicPortfolioDetails.holdings) { + const assetProfileIdentifier = getAssetProfileIdentifier( + position.assetProfile + ); + this.holdings.push(position); - this.positions[symbol] = { + this.positions[assetProfileIdentifier] = { currency: position.assetProfile.currency, name: position.assetProfile.name, value: position.allocationInPercentage @@ -199,10 +204,7 @@ export class GfPublicPageComponent implements OnInit { } else { this.continents[continent] = { name: translate(continent), - value: - weight * - (this.publicPortfolioDetails.holdings[symbol] - .valueInBaseCurrency ?? 0) + value: weight * (position.valueInBaseCurrency ?? 0) }; } @@ -212,21 +214,16 @@ export class GfPublicPageComponent implements OnInit { } else { this.countries[code] = { name: getCountryName({ code }), - value: - weight * - (this.publicPortfolioDetails.holdings[symbol] - .valueInBaseCurrency ?? 0) + value: weight * (position.valueInBaseCurrency ?? 0) }; } } } else { this.continents[UNKNOWN_KEY].value += - this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ?? - 0; + position.valueInBaseCurrency ?? 0; this.countries[UNKNOWN_KEY].value += - this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ?? - 0; + position.valueInBaseCurrency ?? 0; } if (position.assetProfile.sectors.length > 0) { @@ -239,27 +236,33 @@ export class GfPublicPageComponent implements OnInit { } else { this.sectors[name] = { name: translate(name), - value: - weight * - (this.publicPortfolioDetails.holdings[symbol] - .valueInBaseCurrency ?? 0) + value: weight * (position.valueInBaseCurrency ?? 0) }; } } } else { - this.sectors[UNKNOWN_KEY].value += - this.publicPortfolioDetails.holdings[symbol].valueInBaseCurrency ?? - 0; + this.sectors[UNKNOWN_KEY].value += position.valueInBaseCurrency ?? 0; } } - this.symbols[symbol] = { - symbol, - name: position.assetProfile.name ?? 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 + }; + } } } } diff --git a/libs/common/src/lib/interfaces/portfolio-details.interface.ts b/libs/common/src/lib/interfaces/portfolio-details.interface.ts index 15a07f671..2b1e8bc42 100644 --- a/libs/common/src/lib/interfaces/portfolio-details.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-details.interface.ts @@ -17,7 +17,7 @@ export interface PortfolioDetails { }; }; createdAt: Date; - holdings: { [symbol: string]: PortfolioPosition }; + holdings: PortfolioPosition[]; markets?: { [key in Market]: { id: Market; diff --git a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts index 99ac0608b..65ccf7646 100644 --- a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts +++ b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts @@ -10,18 +10,16 @@ import { Order } from '@prisma/client'; export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 { alias?: string; hasDetails: boolean; - holdings: { - [symbol: string]: Pick< - PortfolioPosition, - | 'allocationInPercentage' - | 'assetProfile' - | 'dateOfFirstActivity' - | 'markets' - | 'netPerformancePercentWithCurrencyEffect' - | 'valueInBaseCurrency' - | 'valueInPercentage' - >; - }; + holdings: Pick< + PortfolioPosition, + | 'allocationInPercentage' + | 'assetProfile' + | 'dateOfFirstActivity' + | 'markets' + | 'netPerformancePercentWithCurrencyEffect' + | 'valueInBaseCurrency' + | 'valueInPercentage' + >[]; latestActivities: (Pick< Order, 'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice' diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts index fc9c90886..fb8a5aea6 100644 --- a/libs/ui/src/lib/services/data.service.ts +++ b/libs/ui/src/lib/services/data.service.ts @@ -647,24 +647,22 @@ export class DataService { .pipe( map((response) => { if (response.holdings) { - for (const symbol of Object.keys(response.holdings)) { - response.holdings[symbol].assetProfile.assetClassLabel = - translate(response.holdings[symbol].assetProfile.assetClass); + for (const holding of response.holdings) { + holding.assetProfile.assetClassLabel = translate( + holding.assetProfile.assetClass + ); - response.holdings[symbol].assetProfile.assetSubClassLabel = - translate(response.holdings[symbol].assetProfile.assetSubClass); + holding.assetProfile.assetSubClassLabel = translate( + holding.assetProfile.assetSubClass + ); - response.holdings[symbol].dateOfFirstActivity = response.holdings[ - symbol - ].dateOfFirstActivity - ? parseISO(response.holdings[symbol].dateOfFirstActivity) + holding.dateOfFirstActivity = holding.dateOfFirstActivity + ? parseISO(holding.dateOfFirstActivity) : undefined; - response.holdings[symbol].value = isNumber( - response.holdings[symbol].value - ) - ? response.holdings[symbol].value - : response.holdings[symbol].valueInPercentage; + holding.value = isNumber(holding.value) + ? holding.value + : holding.valueInPercentage; } } @@ -782,18 +780,20 @@ export class DataService { .pipe( map((response) => { if (response.holdings) { - for (const symbol of Object.keys(response.holdings)) { - response.holdings[symbol].assetProfile.assetClassLabel = - translate(response.holdings[symbol].assetProfile.assetClass); + for (const holding of response.holdings) { + holding.assetProfile.assetClassLabel = translate( + holding.assetProfile.assetClass + ); - response.holdings[symbol].assetProfile.assetSubClassLabel = - translate(response.holdings[symbol].assetProfile.assetSubClass); + holding.assetProfile.assetSubClassLabel = translate( + holding.assetProfile.assetSubClass + ); - response.holdings[symbol].valueInBaseCurrency = isNumber( - response.holdings[symbol].valueInBaseCurrency + holding.valueInBaseCurrency = isNumber( + holding.valueInBaseCurrency ) - ? response.holdings[symbol].valueInBaseCurrency - : response.holdings[symbol].valueInPercentage; + ? holding.valueInBaseCurrency + : holding.valueInPercentage; } }