diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed1e80a3..61a3e091a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Prefilled the form in the account balance management with the current cash balance - Disabled the selection of future dates in the account balance management +- Grouped commodities and cryptocurrencies into the unknown bucket of the allocations by continent, country, currency, market and sector charts on the allocations page - Moved the support for specific calendar year date ranges (`2025`, `2024`, `2023`, etc.) in the assistant from experimental to general availability - Migrated various components from `NgStyle` to style bindings - Improved the language localization for Korean (`ko`) +### Fixed + +- Grouped activities without an account into the unknown bucket of the allocations by account and platform charts on the allocations page + ## 3.8.0 - 2026-06-07 ### Added diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 2d73bce3d..e0e7a8255 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -9,6 +9,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { parseDate } from '@ghostfolio/common/helper'; import { Account, DataSource } from '@prisma/client'; @@ -108,6 +109,67 @@ describe('PortfolioService', () => { ); }); + describe('getAggregatedMarkets', () => { + const getAggregatedMarkets = (holdings: object) => { + return ( + portfolioService as unknown as { + getAggregatedMarkets: (aHoldings: object) => { + markets: Record< + string, + { valueInBaseCurrency: number; valueInPercentage: number } + >; + marketsAdvanced: Record; + }; + } + ).getAggregatedMarkets(holdings); + }; + + 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 + assetProfile: { countries: [] }, + markets: { developedMarkets: 0, emergingMarkets: 0, otherMarkets: 0 }, + marketsAdvanced: { + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + valueInBaseCurrency: 500 + }, + MSFT: { + assetProfile: { countries: [{ code: 'US', weight: 1 }] }, + markets: { developedMarkets: 1, emergingMarkets: 0, otherMarkets: 0 }, + marketsAdvanced: { + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + valueInBaseCurrency: 1000 + } + }; + + const { markets, marketsAdvanced } = getAggregatedMarkets(holdings); + + expect(markets.developedMarkets.valueInBaseCurrency).toBe(1000); + expect(markets[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); + + expect(markets.developedMarkets.valueInPercentage).toBeCloseTo( + 1000 / 1500 + ); + expect(markets[UNKNOWN_KEY].valueInPercentage).toBeCloseTo(500 / 1500); + + expect(marketsAdvanced.northAmerica.valueInBaseCurrency).toBe(1000); + expect(marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency).toBe(500); + }); + }); + describe('getCashSymbolProfiles', () => { it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { jest @@ -271,4 +333,96 @@ describe('PortfolioService', () => { expect(holdings['USD'].assetProfile.symbol).toBe('USD'); }); }); + + describe('getValueOfAccountsAndPlatforms', () => { + const getValueOfAccountsAndPlatforms = (args: object) => { + return ( + portfolioService as unknown as { + getValueOfAccountsAndPlatforms: (aArgs: object) => Promise<{ + accounts: Record; + platforms: Record; + }>; + } + ).getValueOfAccountsAndPlatforms(args); + }; + + const account = { + balance: 100, + currency: 'USD', + id: randomUUID(), + isExcluded: false, + name: 'Account 1', + platform: { name: 'Platform 1' }, + platformId: randomUUID() + }; + + beforeEach(() => { + jest + .spyOn(accountService, 'getAccounts') + .mockResolvedValue([account] as unknown as Account[]); + + jest + .spyOn(exchangeRateDataService, 'toCurrency') + .mockImplementation((aValue) => aValue); + }); + + it('should group activities without an account into the unknown bucket of accounts and platforms', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + quantity: 1, + SymbolProfile: { symbol: 'AAPL' }, + type: 'BUY' + }, + { + account: null, + accountId: null, + quantity: 2, + SymbolProfile: { symbol: 'BABA' }, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 }, + BABA: { marketPriceInBaseCurrency: 20 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + // 100 (balance) + 1 * 10 (activity) + expect(accounts[account.id].valueInBaseCurrency).toBe(110); + expect(platforms[account.platformId].valueInBaseCurrency).toBe(110); + + // 2 * 20 (activity without an account) + expect(accounts[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); + expect(platforms[UNKNOWN_KEY].valueInBaseCurrency).toBe(40); + }); + + it('should not create an unknown bucket when every activity has an account', async () => { + const { accounts, platforms } = await getValueOfAccountsAndPlatforms({ + activities: [ + { + account, + accountId: account.id, + quantity: 1, + SymbolProfile: { symbol: 'AAPL' }, + type: 'BUY' + } + ], + filters: [], + portfolioItemsNow: { + AAPL: { marketPriceInBaseCurrency: 10 } + }, + userCurrency: 'USD', + userId: userDummyData.id + }); + + expect(accounts[UNKNOWN_KEY]).toBeUndefined(); + expect(platforms[UNKNOWN_KEY]).toBeUndefined(); + }); + }); }); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 4feb0f77a..24d760888 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1453,31 +1453,29 @@ export class PortfolioService { for (const [, position] of Object.entries(holdings)) { const value = position.valueInBaseCurrency; - if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { - if (position.assetProfile.countries.length > 0) { - markets.developedMarkets.valueInBaseCurrency += - position.markets.developedMarkets * value; - markets.emergingMarkets.valueInBaseCurrency += - position.markets.emergingMarkets * value; - markets.otherMarkets.valueInBaseCurrency += - position.markets.otherMarkets * value; - - marketsAdvanced.asiaPacific.valueInBaseCurrency += - position.marketsAdvanced.asiaPacific * value; - marketsAdvanced.emergingMarkets.valueInBaseCurrency += - position.marketsAdvanced.emergingMarkets * value; - marketsAdvanced.europe.valueInBaseCurrency += - position.marketsAdvanced.europe * value; - marketsAdvanced.japan.valueInBaseCurrency += - position.marketsAdvanced.japan * value; - marketsAdvanced.northAmerica.valueInBaseCurrency += - position.marketsAdvanced.northAmerica * value; - marketsAdvanced.otherMarkets.valueInBaseCurrency += - position.marketsAdvanced.otherMarkets * value; - } else { - markets[UNKNOWN_KEY].valueInBaseCurrency += value; - marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; - } + if (position.assetProfile.countries.length > 0) { + markets.developedMarkets.valueInBaseCurrency += + position.markets.developedMarkets * value; + markets.emergingMarkets.valueInBaseCurrency += + position.markets.emergingMarkets * value; + markets.otherMarkets.valueInBaseCurrency += + position.markets.otherMarkets * value; + + marketsAdvanced.asiaPacific.valueInBaseCurrency += + position.marketsAdvanced.asiaPacific * value; + marketsAdvanced.emergingMarkets.valueInBaseCurrency += + position.marketsAdvanced.emergingMarkets * value; + marketsAdvanced.europe.valueInBaseCurrency += + position.marketsAdvanced.europe * value; + marketsAdvanced.japan.valueInBaseCurrency += + position.marketsAdvanced.japan * value; + marketsAdvanced.northAmerica.valueInBaseCurrency += + position.marketsAdvanced.northAmerica * value; + marketsAdvanced.otherMarkets.valueInBaseCurrency += + position.marketsAdvanced.otherMarkets * value; + } else { + markets[UNKNOWN_KEY].valueInBaseCurrency += value; + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; } } @@ -2163,40 +2161,44 @@ export class PortfolioService { return withExcludedAccounts || account.isExcluded === false; }); - for (const account of currentAccounts) { + // Iterate over the accounts plus a null entry to group activities without + // an account into the unknown bucket + for (const account of [...currentAccounts, null]) { const ordersByAccount = activities.filter(({ accountId }) => { - return accountId === account.id; + return account ? accountId === account.id : !accountId; }); - accounts[account.id] = { - balance: account.balance, - currency: account.currency, - name: account.name, - valueInBaseCurrency: this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ) - }; - - if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { - platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += - this.exchangeRateDataService.toCurrency( - account.balance, - account.currency, - userCurrency - ); - } else { - platforms[account.platformId || UNKNOWN_KEY] = { + if (account) { + accounts[account.id] = { balance: account.balance, currency: account.currency, - name: account.platform?.name, + name: account.name, valueInBaseCurrency: this.exchangeRateDataService.toCurrency( account.balance, account.currency, userCurrency ) }; + + if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { + platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += + this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + } else { + platforms[account.platformId || UNKNOWN_KEY] = { + balance: account.balance, + currency: account.currency, + name: account.platform?.name, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ) + }; + } } for (const { 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 d0eb3788b..d53977ae8 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 @@ -201,6 +201,26 @@ export class GfAllocationsPageComponent implements OnInit { } } + private extractCurrency({ + assetClass, + assetSubClass, + currency + }: { + assetClass: PortfolioPosition['assetProfile']['assetClass']; + assetSubClass: PortfolioPosition['assetProfile']['assetSubClass']; + currency?: PortfolioPosition['assetProfile']['currency']; + }) { + if ( + assetClass === AssetClass.COMMODITY || + assetSubClass === AssetSubClass.CRYPTOCURRENCY + ) { + // Commodities and cryptocurrencies have no meaningful currency exposure + return UNKNOWN_KEY; + } + + return currency; + } + private extractEtfProvider({ assetSubClass, name @@ -339,7 +359,7 @@ export class GfAllocationsPageComponent implements OnInit { position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), assetSubClassLabel: position.assetProfile.assetSubClassLabel || UNKNOWN_KEY, - currency: position.assetProfile.currency, + currency: this.extractCurrency(position.assetProfile), etfProvider: this.extractEtfProvider({ assetSubClass: position.assetProfile.assetSubClass, name: position.assetProfile.name @@ -348,119 +368,117 @@ export class GfAllocationsPageComponent implements OnInit { name: position.assetProfile.name }; - if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { - // Prepare analysis data by continents, countries, holdings and sectors except for liquidity - - if (position.assetProfile.countries.length > 0) { - for (const country of position.assetProfile.countries) { - const { code, continent, weight } = country; - - if (this.continents[continent]?.value) { - this.continents[continent].value += + // Prepare analysis data by continents, countries, holdings and sectors + + if (position.assetProfile.countries.length > 0) { + for (const country of position.assetProfile.countries) { + const { code, continent, weight } = country; + + if (this.continents[continent]?.value) { + this.continents[continent].value += + weight * + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.continents[continent] = { + name: translate(continent), + value: weight * (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.continents[continent] = { - name: translate(continent), - value: - weight * - (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; - } - - if (this.countries[code]?.value) { - this.countries[code].value += + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage) + }; + } + + if (this.countries[code]?.value) { + this.countries[code].value += + weight * + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.countries[code] = { + name: getCountryName({ + code, + locale: this.user?.settings?.locale + }), + value: weight * (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.countries[code] = { - name: getCountryName({ - code, - locale: this.user?.settings?.locale - }), - value: - weight * - (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; - } + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage) + }; } - } else { - this.continents[UNKNOWN_KEY].value += isNumber( - position.valueInBaseCurrency - ) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage; - - this.countries[UNKNOWN_KEY].value += isNumber( - position.valueInBaseCurrency - ) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage; } + } else { + this.continents[UNKNOWN_KEY].value += isNumber( + position.valueInBaseCurrency + ) + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage; + + this.countries[UNKNOWN_KEY].value += isNumber( + position.valueInBaseCurrency + ) + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage; + } - if (position.assetProfile.holdings.length > 0) { - for (const { - allocationInPercentage, - name, - valueInBaseCurrency - } of position.assetProfile.holdings) { - const normalizedAssetName = this.normalizeAssetName(name); - - if (this.topHoldingsMap[normalizedAssetName]?.value) { - this.topHoldingsMap[normalizedAssetName].value += isNumber( - valueInBaseCurrency - ) + if (position.assetProfile.holdings.length > 0) { + for (const { + allocationInPercentage, + name, + valueInBaseCurrency + } of position.assetProfile.holdings) { + const normalizedAssetName = this.normalizeAssetName(name); + + if (this.topHoldingsMap[normalizedAssetName]?.value) { + this.topHoldingsMap[normalizedAssetName].value += isNumber( + valueInBaseCurrency + ) + ? valueInBaseCurrency + : allocationInPercentage * + this.portfolioDetails.holdings[symbol].valueInPercentage; + } else { + this.topHoldingsMap[normalizedAssetName] = { + name, + value: isNumber(valueInBaseCurrency) ? valueInBaseCurrency : allocationInPercentage * - this.portfolioDetails.holdings[symbol].valueInPercentage; - } else { - this.topHoldingsMap[normalizedAssetName] = { - name, - value: isNumber(valueInBaseCurrency) - ? valueInBaseCurrency - : allocationInPercentage * - this.portfolioDetails.holdings[symbol].valueInPercentage - }; - } + this.portfolioDetails.holdings[symbol].valueInPercentage + }; } } + } - if (position.assetProfile.sectors.length > 0) { - for (const sector of position.assetProfile.sectors) { - const { name, weight } = sector; - - if (this.sectors[name]?.value) { - this.sectors[name].value += + if (position.assetProfile.sectors.length > 0) { + for (const sector of position.assetProfile.sectors) { + const { name, weight } = sector; + + if (this.sectors[name]?.value) { + this.sectors[name].value += + weight * + (isNumber(position.valueInBaseCurrency) + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.sectors[name] = { + name: translate(name), + value: weight * (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.sectors[name] = { - name: translate(name), - value: - weight * - (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; - } + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage) + }; } - } else { - this.sectors[UNKNOWN_KEY].value += isNumber( - position.valueInBaseCurrency - ) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage; } + } else { + this.sectors[UNKNOWN_KEY].value += isNumber( + position.valueInBaseCurrency + ) + ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency + : this.portfolioDetails.holdings[symbol].valueInPercentage; } if (this.holdings[symbol].assetSubClass === 'ETF') {