Browse Source

Task/improve unknown bucket grouping in allocations (#7011)

* Improve unknown bucket grouping

* Update changelog
pull/7017/head
Thomas Kaul 18 hours ago
committed by GitHub
parent
commit
98c984c6c9
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 154
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  3. 98
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 214
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

5
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

154
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<string, { valueInBaseCurrency: number }>;
};
}
).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<string, { valueInBaseCurrency: number }>;
platforms: Record<string, { valueInBaseCurrency: number }>;
}>;
}
).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();
});
});
});

98
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 {

214
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') {

Loading…
Cancel
Save