diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts index 4c6e096b8..b8b7a9b70 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..38a057c64 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,8 +162,8 @@ export class PublicService { }) ).toNumber(); - for (const [symbol, portfolioPosition] of Object.entries(holdings)) { - publicPortfolioResponse.holdings[symbol] = { + for (const portfolioPosition of holdings) { + publicPortfolioResponse.holdings.push({ allocationInPercentage: portfolioPosition.valueInBaseCurrency / totalValue, assetProfile: { @@ -207,7 +207,7 @@ export class PublicService { netPerformancePercentWithCurrencyEffect: portfolioPosition.netPerformancePercentWithCurrencyEffect, valueInPercentage: portfolioPosition.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..029e71c5a 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); @@ -519,8 +528,11 @@ export abstract class PortfolioCalculator { } for (const dateString of chartDates) { - for (const symbol of Object.keys(valuesBySymbol)) { - const symbolValues = valuesBySymbol[symbol]; + for (const assetProfileIdentifier of Object.keys( + valuesByAssetProfileIdentifier + )) { + const symbolValues = + valuesByAssetProfileIdentifier[assetProfileIdentifier]; const currentValue = symbolValues.currentValues?.[dateString] ?? new Big(0); @@ -569,7 +581,7 @@ export abstract class PortfolioCalculator { accumulatedValuesByDate[dateString] ?.totalCashValueWithCurrencyEffect ?? new Big(0) ).add( - cashSymbols.has(symbol) + cashAssetProfileIdentifiers.has(assetProfileIdentifier) ? netWorthValueWithCurrencyEffect : new Big(0) ), @@ -879,7 +891,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 +989,9 @@ export abstract class PortfolioCalculator { @LogPerformance private computeTransactionPoints() { this.transactionPoints = []; - const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + const symbols: { + [assetProfileIdentifier: string]: TransactionPointSymbol; + } = {}; let lastDate: string = null; let lastTransactionPoint: TransactionPoint = null; @@ -1001,7 +1015,9 @@ 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 = symbols[assetProfileIdentifier]; if (oldAccumulatedSymbol) { let investment = oldAccumulatedSymbol.investment; @@ -1083,12 +1099,12 @@ export abstract class PortfolioCalculator { 'id' ); - symbols[symbol] = currentTransactionPointItem; + symbols[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); 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/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5b277dee5..83dd1ab6b 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,7 +148,7 @@ export class PortfolioController { return a + b; }, 0); - for (const [, portfolioPosition] of Object.entries(holdings)) { + for (const portfolioPosition of holdings) { portfolioPosition.investment = portfolioPosition.investment / totalInvestment; portfolioPosition.valueInPercentage = @@ -204,8 +204,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..40e80b98b 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -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); @@ -325,9 +326,14 @@ describe('PortfolioService', () => { 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' + }) + }) + ]); }); }); @@ -450,22 +456,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 +492,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 +515,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 +553,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 +582,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 +622,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..6ee390c58 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,23 @@ export class PortfolioService { valueInBaseCurrency: emergencyFundInCash }; - holdings[userCurrency] = { + const emergencyFundCashHolding = { ...emergencyFundCashPositions[userCurrency], investment: emergencyFundInCash, valueInBaseCurrency: emergencyFundInCash }; + + const indexOfHoldingInBaseCurrency = holdings.findIndex( + ({ assetProfile }) => { + return assetProfile.symbol === userCurrency; + } + ); + + if (indexOfHoldingInBaseCurrency >= 0) { + holdings[indexOfHoldingInBaseCurrency] = emergencyFundCashHolding; + } else { + holdings.push(emergencyFundCashHolding); + } } let markets: PortfolioDetails['markets']; @@ -1157,7 +1170,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 +1247,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 +1274,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 +1441,7 @@ export class PortfolioService { }); } - private getAggregatedMarkets(holdings: Record): { + private getAggregatedMarkets(holdings: PortfolioPosition[]): { markets: PortfolioDetails['markets']; marketsAdvanced: PortfolioDetails['marketsAdvanced']; } { @@ -1493,7 +1506,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 +1587,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 +1616,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 +1726,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; @@ -2258,8 +2269,8 @@ export class PortfolioService { valueOfAccountInBaseCurrency = valueOfAccountInBaseCurrency.plus( currentQuantityOfSymbol.mul( - portfolioItemsNow[assetProfile.symbol]?.marketPriceInBaseCurrency ?? - 0 + portfolioItemsNow[getAssetProfileIdentifier(assetProfile)] + ?.marketPriceInBaseCurrency ?? 0 ) ); } 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..4ff746651 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' @@ -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,15 +501,15 @@ 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, + this.symbols[assetProfileIdentifier] = { dataSource: position.assetProfile.dataSource, isClickable: canOpenHoldingDetail(position), name: position.assetProfile.name ?? '', + symbol: position.assetProfile.symbol, value: (isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency @@ -558,8 +561,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 +576,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..cc2baa092 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' > & { @@ -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,23 +236,18 @@ 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, + this.symbols[assetProfileIdentifier] = { + name: position.assetProfile.name ?? position.assetProfile.symbol, + symbol: position.assetProfile.symbol, value: isNumber(position.valueInBaseCurrency) ? position.valueInBaseCurrency : (position.valueInPercentage ?? 0) 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; } }