Browse Source

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

* Improve unknown bucket grouping

* Update changelog
pull/7017/head
Thomas Kaul 19 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 - Prefilled the form in the account balance management with the current cash balance
- Disabled the selection of future dates in the account balance management - 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 - 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 - Migrated various components from `NgStyle` to style bindings
- Improved the language localization for Korean (`ko`) - 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 ## 3.8.0 - 2026-06-07
### Added ### 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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.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 { parseDate } from '@ghostfolio/common/helper';
import { Account, DataSource } from '@prisma/client'; 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', () => { describe('getCashSymbolProfiles', () => {
it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => { it('should use the exchange-rate data source so the symbol-profile join in getDetails matches the calculator positions', () => {
jest jest
@ -271,4 +333,96 @@ describe('PortfolioService', () => {
expect(holdings['USD'].assetProfile.symbol).toBe('USD'); 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)) { for (const [, position] of Object.entries(holdings)) {
const value = position.valueInBaseCurrency; const value = position.valueInBaseCurrency;
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { if (position.assetProfile.countries.length > 0) {
if (position.assetProfile.countries.length > 0) { markets.developedMarkets.valueInBaseCurrency +=
markets.developedMarkets.valueInBaseCurrency += position.markets.developedMarkets * value;
position.markets.developedMarkets * value; markets.emergingMarkets.valueInBaseCurrency +=
markets.emergingMarkets.valueInBaseCurrency += position.markets.emergingMarkets * value;
position.markets.emergingMarkets * value; markets.otherMarkets.valueInBaseCurrency +=
markets.otherMarkets.valueInBaseCurrency += position.markets.otherMarkets * value;
position.markets.otherMarkets * value;
marketsAdvanced.asiaPacific.valueInBaseCurrency +=
marketsAdvanced.asiaPacific.valueInBaseCurrency += position.marketsAdvanced.asiaPacific * value;
position.marketsAdvanced.asiaPacific * value; marketsAdvanced.emergingMarkets.valueInBaseCurrency +=
marketsAdvanced.emergingMarkets.valueInBaseCurrency += position.marketsAdvanced.emergingMarkets * value;
position.marketsAdvanced.emergingMarkets * value; marketsAdvanced.europe.valueInBaseCurrency +=
marketsAdvanced.europe.valueInBaseCurrency += position.marketsAdvanced.europe * value;
position.marketsAdvanced.europe * value; marketsAdvanced.japan.valueInBaseCurrency +=
marketsAdvanced.japan.valueInBaseCurrency += position.marketsAdvanced.japan * value;
position.marketsAdvanced.japan * value; marketsAdvanced.northAmerica.valueInBaseCurrency +=
marketsAdvanced.northAmerica.valueInBaseCurrency += position.marketsAdvanced.northAmerica * value;
position.marketsAdvanced.northAmerica * value; marketsAdvanced.otherMarkets.valueInBaseCurrency +=
marketsAdvanced.otherMarkets.valueInBaseCurrency += position.marketsAdvanced.otherMarkets * value;
position.marketsAdvanced.otherMarkets * value; } else {
} else { markets[UNKNOWN_KEY].valueInBaseCurrency += value;
markets[UNKNOWN_KEY].valueInBaseCurrency += value; marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value;
}
} }
} }
@ -2163,40 +2161,44 @@ export class PortfolioService {
return withExcludedAccounts || account.isExcluded === false; 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 }) => { const ordersByAccount = activities.filter(({ accountId }) => {
return accountId === account.id; return account ? accountId === account.id : !accountId;
}); });
accounts[account.id] = { if (account) {
balance: account.balance, accounts[account.id] = {
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] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
name: account.platform?.name, name: account.name,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency 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 { 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({ private extractEtfProvider({
assetSubClass, assetSubClass,
name name
@ -339,7 +359,7 @@ export class GfAllocationsPageComponent implements OnInit {
position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass), position.assetProfile.assetSubClass || (UNKNOWN_KEY as AssetSubClass),
assetSubClassLabel: assetSubClassLabel:
position.assetProfile.assetSubClassLabel || UNKNOWN_KEY, position.assetProfile.assetSubClassLabel || UNKNOWN_KEY,
currency: position.assetProfile.currency, currency: this.extractCurrency(position.assetProfile),
etfProvider: this.extractEtfProvider({ etfProvider: this.extractEtfProvider({
assetSubClass: position.assetProfile.assetSubClass, assetSubClass: position.assetProfile.assetSubClass,
name: position.assetProfile.name name: position.assetProfile.name
@ -348,119 +368,117 @@ export class GfAllocationsPageComponent implements OnInit {
name: position.assetProfile.name name: position.assetProfile.name
}; };
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { // Prepare analysis data by continents, countries, holdings and sectors
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (position.assetProfile.countries.length > 0) {
if (position.assetProfile.countries.length > 0) { for (const country of position.assetProfile.countries) {
for (const country of position.assetProfile.countries) { const { code, continent, weight } = country;
const { code, continent, weight } = country;
if (this.continents[continent]?.value) {
if (this.continents[continent]?.value) { 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 * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: position.valueInPercentage); : this.portfolioDetails.holdings[symbol].valueInPercentage)
} else { };
this.continents[continent] = { }
name: translate(continent),
value: if (this.countries[code]?.value) {
weight * this.countries[code].value +=
(isNumber(position.valueInBaseCurrency) weight *
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency (isNumber(position.valueInBaseCurrency)
: this.portfolioDetails.holdings[symbol].valueInPercentage) ? position.valueInBaseCurrency
}; : position.valueInPercentage);
} } else {
this.countries[code] = {
if (this.countries[code]?.value) { name: getCountryName({
this.countries[code].value += code,
locale: this.user?.settings?.locale
}),
value:
weight * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: position.valueInPercentage); : this.portfolioDetails.holdings[symbol].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)
};
}
} }
} 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) { if (position.assetProfile.holdings.length > 0) {
for (const { for (const {
allocationInPercentage, allocationInPercentage,
name, name,
valueInBaseCurrency valueInBaseCurrency
} of position.assetProfile.holdings) { } of position.assetProfile.holdings) {
const normalizedAssetName = this.normalizeAssetName(name); const normalizedAssetName = this.normalizeAssetName(name);
if (this.topHoldingsMap[normalizedAssetName]?.value) { if (this.topHoldingsMap[normalizedAssetName]?.value) {
this.topHoldingsMap[normalizedAssetName].value += isNumber( this.topHoldingsMap[normalizedAssetName].value += isNumber(
valueInBaseCurrency valueInBaseCurrency
) )
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage;
} else {
this.topHoldingsMap[normalizedAssetName] = {
name,
value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency ? valueInBaseCurrency
: allocationInPercentage * : allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage; this.portfolioDetails.holdings[symbol].valueInPercentage
} else { };
this.topHoldingsMap[normalizedAssetName] = {
name,
value: isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: allocationInPercentage *
this.portfolioDetails.holdings[symbol].valueInPercentage
};
}
} }
} }
}
if (position.assetProfile.sectors.length > 0) { if (position.assetProfile.sectors.length > 0) {
for (const sector of position.assetProfile.sectors) { for (const sector of position.assetProfile.sectors) {
const { name, weight } = sector; const { name, weight } = sector;
if (this.sectors[name]?.value) { if (this.sectors[name]?.value) {
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 * weight *
(isNumber(position.valueInBaseCurrency) (isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: position.valueInPercentage); : this.portfolioDetails.holdings[symbol].valueInPercentage)
} else { };
this.sectors[name] = {
name: translate(name),
value:
weight *
(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;
} }
} 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') { if (this.holdings[symbol].assetSubClass === 'ETF') {

Loading…
Cancel
Save