Browse Source

Merge branch 'main' into bugfix/market-price-fallback-before-first-historical-market-data

pull/7761/head
Thomas Kaul 2 months ago
committed by GitHub
parent
commit
e20b761620
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 18
      apps/api/src/app/endpoints/ai/ai.service.spec.ts
  3. 7
      apps/api/src/app/endpoints/ai/ai.service.ts
  4. 16
      apps/api/src/app/endpoints/mcp/mcp.controller.ts
  5. 20
      apps/api/src/app/endpoints/public/public.service.ts
  6. 49
      apps/api/src/app/portfolio/portfolio.controller.ts
  7. 49
      apps/api/src/helper/portfolio.helper.ts
  8. 9
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  9. 4
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  10. 3
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  11. 2
      apps/client/src/app/components/header/header.component.ts
  12. 30
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  13. 6
      apps/client/src/app/components/home-holdings/home-holdings.html
  14. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  15. 2
      apps/client/src/app/components/home-watchlist/home-watchlist.html
  16. 20
      apps/client/src/app/components/investment-chart/investment-chart.component.ts
  17. 12
      apps/client/src/app/components/markets/markets.component.ts
  18. 2
      apps/client/src/app/components/markets/markets.html
  19. 2
      apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html
  20. 8
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  21. 3
      apps/client/src/app/pages/accounts/accounts-page.component.ts
  22. 4
      apps/client/src/app/pages/accounts/accounts-page.html
  23. 2
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  24. 3
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  25. 220
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  26. 8
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  27. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  28. 53
      apps/client/src/app/pages/public/public-page.component.ts
  29. 4
      apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html
  30. 12
      apps/client/tsconfig.json
  31. 1
      libs/common/src/lib/config.ts
  32. 24
      libs/common/src/lib/helper.ts
  33. 1
      libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts
  34. 6
      libs/common/src/lib/types/toggle-option.type.ts
  35. 2
      libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html
  36. 8
      libs/ui/src/lib/line-chart/line-chart.component.ts
  37. 1
      libs/ui/src/lib/portfolio-proportion-chart/index.ts
  38. 6
      libs/ui/src/lib/services/data.service.ts
  39. 2
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts
  40. 11
      libs/ui/src/lib/toggle/toggle.component.ts
  41. 8
      package-lock.json
  42. 2
      package.json

4
CHANGELOG.md

@ -9,10 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Extended the tool to get the accounts of the portfolio in the server of the Model Context Protocol (MCP) to support the filtering by account (experimental)
- Upgraded `undici` from version `8.5.0` to `8.10.0`
- Upgraded `uuid` from version `14.0.1` to `14.0.2` - Upgraded `uuid` from version `14.0.1` to `14.0.2`
### Fixed ### Fixed
- Fixed the cash positions being included in the by continent, by country and by sector charts on the allocations page and the public page
- Fixed the allocations in percentage exceeding 100% in the restricted view
- Fixed the portfolio calculation for holdings with activities before the first known historical market price by falling back to the unit price of the activity - Fixed the portfolio calculation for holdings with activities before the first known historical market price by falling back to the unit price of the activity
## 3.64.0 - 2026-08-30 ## 3.64.0 - 2026-08-30

18
apps/api/src/app/endpoints/ai/ai.service.spec.ts

@ -29,13 +29,16 @@ interface AiServiceWithMarkdownTable {
} }
function createAccount({ function createAccount({
id = 'account-a-id',
isExcluded = false, isExcluded = false,
name = 'Account A' name = 'Account A'
}: { }: {
id?: string;
isExcluded?: boolean; isExcluded?: boolean;
name?: string; name?: string;
} = {}) { } = {}) {
return { return {
id,
name, name,
activitiesCount: 3, activitiesCount: 3,
allocationInPercentage: 0.25, allocationInPercentage: 0.25,
@ -86,6 +89,7 @@ describe('AiService', () => {
describe('getAccountsTableColumnNames', () => { describe('getAccountsTableColumnNames', () => {
it('gives no column with a monetary value', () => { it('gives no column with a monetary value', () => {
expect(AiService.getAccountsTableColumnNames()).toEqual([ expect(AiService.getAccountsTableColumnNames()).toEqual([
'Id',
'Name', 'Name',
'Currency', 'Currency',
'Platform', 'Platform',
@ -121,10 +125,20 @@ describe('AiService', () => {
expect(result).not.toContain('2000'); expect(result).not.toContain('2000');
}); });
// The accountIds parameter of the tool takes the identifiers, hence the
// table has to give them
it('gives the identifier of an account', async () => {
const aiService = createAiService([createAccount()]);
const result = await aiService.getAccountsTable({ userId: 'user-id' });
expect(result).toContain('account-a-id');
});
it('marks an account which is excluded from the analysis', async () => { it('marks an account which is excluded from the analysis', async () => {
const aiService = createAiService([ const aiService = createAiService([
createAccount({ isExcluded: true }), createAccount({ isExcluded: true }),
createAccount({ name: 'Account B' }) createAccount({ id: 'account-b-id', name: 'Account B' })
]); ]);
const result = await aiService.getAccountsTable({ userId: 'user-id' }); const result = await aiService.getAccountsTable({ userId: 'user-id' });
@ -132,7 +146,7 @@ describe('AiService', () => {
const [rowOfAccountA, rowOfAccountB] = result const [rowOfAccountA, rowOfAccountB] = result
.split('\n') .split('\n')
.filter((line) => { .filter((line) => {
return line.startsWith('Account '); return line.startsWith('account-');
}); });
expect(rowOfAccountA).toContain('true'); expect(rowOfAccountA).toContain('true');

7
apps/api/src/app/endpoints/ai/ai.service.ts

@ -30,9 +30,11 @@ export class AiService {
| 'ALLOCATION_PERCENTAGE' | 'ALLOCATION_PERCENTAGE'
| 'CURRENCY' | 'CURRENCY'
| 'EXCLUDED_FROM_ANALYSIS' | 'EXCLUDED_FROM_ANALYSIS'
| 'ID'
| 'NAME' | 'NAME'
| 'PLATFORM'; | 'PLATFORM';
} & ColumnDescriptor)[] = [ } & ColumnDescriptor)[] = [
{ key: 'ID', name: 'Id' },
{ key: 'NAME', name: 'Name' }, { key: 'NAME', name: 'Name' },
{ key: 'CURRENCY', name: 'Currency' }, { key: 'CURRENCY', name: 'Currency' },
{ key: 'PLATFORM', name: 'Platform' }, { key: 'PLATFORM', name: 'Platform' },
@ -160,6 +162,7 @@ export class AiService {
activitiesCount, activitiesCount,
allocationInPercentage, allocationInPercentage,
currency, currency,
id,
name: label, name: label,
platform, platform,
tags tags
@ -183,6 +186,10 @@ export class AiService {
row[name] = isAccountExcluded({ tags }).toString(); row[name] = isAccountExcluded({ tags }).toString();
break; break;
case 'ID':
row[name] = id;
break;
case 'NAME': case 'NAME':
row[name] = label ?? ''; row[name] = label ?? '';
break; break;

16
apps/api/src/app/endpoints/mcp/mcp.controller.ts

@ -8,6 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import { import {
DATE_RANGES, DATE_RANGES,
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
MCP_MAX_ACCOUNTS,
MCP_MAX_ACTIVITIES MCP_MAX_ACTIVITIES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { scopes } from '@ghostfolio/common/scopes'; import { scopes } from '@ghostfolio/common/scopes';
@ -20,6 +21,14 @@ import { McpController, Tool } from '@rekog/mcp-nest';
import { z } from 'zod'; import { z } from 'zod';
const GET_ACCOUNTS_PARAMETERS = z.object({ const GET_ACCOUNTS_PARAMETERS = z.object({
accountIds: z
.array(z.string().min(1))
.min(1)
.max(MCP_MAX_ACCOUNTS)
.optional()
.describe(
`The identifiers of the accounts to get, at most ${MCP_MAX_ACCOUNTS}`
),
assetClasses: z assetClasses: z
.array(z.enum(AssetClass)) .array(z.enum(AssetClass))
.min(1) .min(1)
@ -104,9 +113,14 @@ export class GhostfolioMcpController {
public async getAccounts( public async getAccounts(
@Impersonation() { userId }: ImpersonationContext, @Impersonation() { userId }: ImpersonationContext,
@Payload() @Payload()
{ assetClasses, holding }: z.infer<typeof GET_ACCOUNTS_PARAMETERS> {
accountIds,
assetClasses,
holding
}: z.infer<typeof GET_ACCOUNTS_PARAMETERS>
) { ) {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts: accountIds?.join(','),
filterByAssetClasses: assetClasses?.join(','), filterByAssetClasses: assetClasses?.join(','),
filterByDataSource: holding?.dataSource, filterByDataSource: holding?.dataSource,
filterBySymbol: holding?.symbol filterBySymbol: holding?.symbol

20
apps/api/src/app/endpoints/public/public.service.ts

@ -6,18 +6,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
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 { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { getSum } from '@ghostfolio/common/helper'; import { getSum, isCashPosition } from '@ghostfolio/common/helper';
import { import {
AccessSettings, AccessSettings,
PublicPortfolioResponse PublicPortfolioResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { import { Type as ActivityType } from '@prisma/client';
AssetClass,
AssetSubClass,
Type as ActivityType
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -173,23 +169,19 @@ export class PublicService {
assetProfile: { assetProfile: {
...holding.assetProfile, ...holding.assetProfile,
assetClass: assetClass:
hasDetails || hasDetails || isCashPosition(holding.assetProfile)
holding.assetProfile.assetClass === AssetClass.LIQUIDITY
? holding.assetProfile.assetClass ? holding.assetProfile.assetClass
: undefined, : undefined,
assetClassLabel: assetClassLabel:
hasDetails || hasDetails || isCashPosition(holding.assetProfile)
holding.assetProfile.assetClass === AssetClass.LIQUIDITY
? holding.assetProfile.assetClassLabel ? holding.assetProfile.assetClassLabel
: undefined, : undefined,
assetSubClass: assetSubClass:
hasDetails || hasDetails || isCashPosition(holding.assetProfile)
holding.assetProfile.assetSubClass === AssetSubClass.CASH
? holding.assetProfile.assetSubClass ? holding.assetProfile.assetSubClass
: undefined, : undefined,
assetSubClassLabel: assetSubClassLabel:
hasDetails || hasDetails || isCashPosition(holding.assetProfile)
holding.assetProfile.assetSubClass === AssetSubClass.CASH
? holding.assetProfile.assetSubClassLabel ? holding.assetProfile.assetSubClassLabel
: undefined, : undefined,
holdings: holding.assetProfile.holdings?.map( holdings: holding.assetProfile.holdings?.map(

49
apps/api/src/app/portfolio/portfolio.controller.ts

@ -6,6 +6,7 @@ import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { convertValuesToPercentages } from '@ghostfolio/api/helper/portfolio.helper';
import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
@ -15,6 +16,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { isCashPosition } from '@ghostfolio/common/helper';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividendsResponse, PortfolioDividendsResponse,
@ -44,7 +46,7 @@ import {
Version Version
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -128,38 +130,7 @@ export class PortfolioController {
!hasScope(impersonationScopes, scopes.portfolioReadValues) || !hasScope(impersonationScopes, scopes.portfolioReadValues) ||
isRestrictedView(this.request.user) isRestrictedView(this.request.user)
) { ) {
const totalInvestment = holdings convertValuesToPercentages({ accounts, holdings, platforms });
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = holdings
.filter(({ assetProfile }) => {
return (
assetProfile.assetClass !== AssetClass.LIQUIDITY &&
assetProfile.assetSubClass !== AssetSubClass.CASH
);
})
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.reduce((a, b) => {
return a + b;
}, 0);
for (const holding of holdings) {
holding.investment = holding.investment / totalInvestment;
holding.valueInPercentage = holding.valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
accounts[name].valueInPercentage = valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) {
platforms[name].valueInPercentage = valueInBaseCurrency / totalValue;
}
} }
if ( if (
@ -208,23 +179,19 @@ export class PortfolioController {
assetProfile: { assetProfile: {
...portfolioPosition.assetProfile, ...portfolioPosition.assetProfile,
assetClass: assetClass:
hasDetails || hasDetails || isCashPosition(portfolioPosition.assetProfile)
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClass ? portfolioPosition.assetProfile.assetClass
: undefined, : undefined,
assetClassLabel: assetClassLabel:
hasDetails || hasDetails || isCashPosition(portfolioPosition.assetProfile)
portfolioPosition.assetProfile.assetClass === AssetClass.LIQUIDITY
? portfolioPosition.assetProfile.assetClassLabel ? portfolioPosition.assetProfile.assetClassLabel
: undefined, : undefined,
assetSubClass: assetSubClass:
hasDetails || hasDetails || isCashPosition(portfolioPosition.assetProfile)
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClass ? portfolioPosition.assetProfile.assetSubClass
: undefined, : undefined,
assetSubClassLabel: assetSubClassLabel:
hasDetails || hasDetails || isCashPosition(portfolioPosition.assetProfile)
portfolioPosition.assetProfile.assetSubClass === AssetSubClass.CASH
? portfolioPosition.assetProfile.assetSubClassLabel ? portfolioPosition.assetProfile.assetSubClassLabel
: undefined, : undefined,
...(hasDetails ...(hasDetails

49
apps/api/src/helper/portfolio.helper.ts

@ -1,5 +1,54 @@
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { Type as ActivityType } from '@prisma/client'; import { Type as ActivityType } from '@prisma/client';
export function convertValuesToPercentages({
accounts,
holdings,
platforms
}: {
accounts: PortfolioDetails['accounts'];
holdings: PortfolioDetails['holdings'];
platforms: PortfolioDetails['platforms'];
}) {
const totalInvestment = holdings
.map(({ investment }) => {
return investment;
})
.reduce((a, b) => {
return a + b;
}, 0);
const totalValue = holdings
.map(({ valueInBaseCurrency }) => {
return valueInBaseCurrency;
})
.reduce((a, b) => {
return a + b;
}, 0);
for (const holding of holdings) {
holding.investment = totalInvestment
? holding.investment / totalInvestment
: 0;
holding.valueInPercentage = totalValue
? holding.valueInBaseCurrency / totalValue
: 0;
}
for (const account of Object.values(accounts)) {
account.valueInPercentage = totalValue
? account.valueInBaseCurrency / totalValue
: 0;
}
for (const platform of Object.values(platforms)) {
platform.valueInPercentage = totalValue
? platform.valueInBaseCurrency / totalValue
: 0;
}
}
export function getFactor(activityType: ActivityType) { export function getFactor(activityType: ActivityType) {
let factor: number; let factor: number;

9
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -71,10 +71,10 @@
type="button" type="button"
[disabled]=" [disabled]="
!canDeleteAssetProfile({ !canDeleteAssetProfile({
activitiesCount: assetProfile?.activitiesCount, activitiesCount: assetProfile?.activitiesCount ?? 0,
isBenchmark: isBenchmark, isBenchmark: isBenchmark,
symbol: data.symbol, symbol: data.symbol,
watchedByCount: assetProfile?.watchedByCount watchedByCount: assetProfile?.watchedByCount ?? 0
}) })
" "
(click)=" (click)="
@ -272,8 +272,8 @@
> >
</div> </div>
@if ( @if (
assetProfile?.countries?.length > 0 || (assetProfile?.countries?.length ?? 0) > 0 ||
assetProfile?.sectors?.length > 0 (assetProfile?.sectors?.length ?? 0) > 0
) { ) {
@if ( @if (
assetProfile?.countries?.length === 1 && assetProfile?.countries?.length === 1 &&
@ -660,7 +660,6 @@
formControlName="headers" formControlName="headers"
matInput matInput
type="text" type="text"
[matAutocomplete]="auto"
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </div>

4
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -230,13 +230,13 @@
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Platforms</h2> <h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform [locale]="user?.settings?.locale" /> <gf-admin-platform [locale]="user?.settings?.locale ?? DEFAULT_LOCALE" />
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2 class="text-center" i18n>Tags</h2> <h2 class="text-center" i18n>Tags</h2>
<gf-admin-tag [locale]="user?.settings?.locale" /> <gf-admin-tag [locale]="user?.settings?.locale ?? DEFAULT_LOCALE" />
</div> </div>
</div> </div>
</div> </div>

3
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -3,6 +3,7 @@ import { GfAdminTagComponent } from '@ghostfolio/client/components/admin-tag/adm
import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component'; import { GfDataProviderStatusComponent } from '@ghostfolio/client/components/data-provider-status/data-provider-status.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_LOCALE,
E_MAIL_LINE_BREAK, E_MAIL_LINE_BREAK,
PROPERTY_API_KEY_GHOSTFOLIO PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -98,6 +99,8 @@ export class GfAdminSettingsComponent implements OnInit {
public pricingUrl: string; public pricingUrl: string;
public user: User; public user: User;
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,

2
apps/client/src/app/components/header/header.component.ts

@ -220,7 +220,7 @@ export class GfHeaderComponent implements OnChanges {
this.assistentMenuTriggerElement().closeMenu(); this.assistentMenuTriggerElement().closeMenu();
} }
protected impersonateAccount(aId: string) { protected impersonateAccount(aId: string | null) {
if (aId) { if (aId) {
this.impersonationStorageService.setId(aId); this.impersonationStorageService.setId(aId);
} else { } else {

30
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
PortfolioPosition, PortfolioPosition,
@ -50,26 +51,29 @@ import { DeviceDetectorService } from 'ngx-device-detector';
export class GfHomeHoldingsComponent implements OnInit { export class GfHomeHoldingsComponent implements OnInit {
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE'; public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected deviceType: string; protected deviceType: string;
protected hasPermissionToAccessHoldingsChart: boolean; protected hasPermissionToAccessHoldingsChart: boolean;
protected hasPermissionToCreateActivity: boolean; protected hasPermissionToCreateActivity: boolean;
protected holdings: PortfolioPosition[] | undefined; protected holdings: PortfolioPosition[] | undefined;
protected holdingsViewMode: HoldingsViewMode = protected holdingsViewMode: HoldingsViewMode =
GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE; GfHomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE;
protected readonly holdingsViewModeOptions: ToggleOption[] = [ protected readonly holdingsViewModeOptions: ToggleOption<HoldingsViewMode>[] =
{ [
iconName: 'reorder-four-outline', {
title: $localize`Table`, iconName: 'reorder-four-outline',
value: 'TABLE' title: $localize`Table`,
}, value: 'TABLE'
{ },
iconName: 'grid-outline', {
title: $localize`Chart`, iconName: 'grid-outline',
value: 'CHART' title: $localize`Chart`,
} value: 'CHART'
]; }
];
protected holdingType: HoldingType = 'ACTIVE'; protected holdingType: HoldingType = 'ACTIVE';
protected readonly holdingTypeOptions: ToggleOption[] = [ protected readonly holdingTypeOptions: ToggleOption<HoldingType>[] = [
{ label: $localize`Active`, value: 'ACTIVE' }, { label: $localize`Active`, value: 'ACTIVE' },
{ label: $localize`Closed`, value: 'CLOSED' } { label: $localize`Closed`, value: 'CLOSED' }
]; ];

6
apps/client/src/app/components/home-holdings/home-holdings.html

@ -37,11 +37,11 @@
} }
<div [class.d-none]="holdingsViewMode !== 'TABLE'"> <div [class.d-none]="holdingsViewMode !== 'TABLE'">
<gf-holdings-table <gf-holdings-table
[holdings]="holdings" [holdings]="holdings ?? []"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
(holdingClicked)="onHoldingClicked($event)" (holdingClicked)="onHoldingClicked($event)"
/> />
@if (hasPermissionToCreateActivity && holdings?.length > 0) { @if (hasPermissionToCreateActivity && (holdings?.length ?? 0) > 0) {
<div class="text-center"> <div class="text-center">
<a <a
class="mt-3" class="mt-3"

2
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -44,6 +44,8 @@ import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/
templateUrl: './home-watchlist.html' templateUrl: './home-watchlist.html'
}) })
export class GfHomeWatchlistComponent implements OnInit { export class GfHomeWatchlistComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected hasPermissionToCreateWatchlistItem: boolean; protected hasPermissionToCreateWatchlistItem: boolean;
protected hasPermissionToDeleteWatchlistItem: boolean; protected hasPermissionToDeleteWatchlistItem: boolean;
protected user: User; protected user: User;

2
apps/client/src/app/components/home-watchlist/home-watchlist.html

@ -13,7 +13,7 @@
[benchmarks]="watchlist" [benchmarks]="watchlist"
[deviceType]="deviceType()" [deviceType]="deviceType()"
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" [hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem"
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showIcon]="true" [showIcon]="true"
[user]="user" [user]="user"
(itemDeleted)="onWatchlistItemDeleted($event)" (itemDeleted)="onWatchlistItemDeleted($event)"

20
apps/client/src/app/components/investment-chart/investment-chart.component.ts

@ -53,16 +53,16 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './investment-chart.component.html' templateUrl: './investment-chart.component.html'
}) })
export class GfInvestmentChartComponent implements OnChanges, OnDestroy { export class GfInvestmentChartComponent implements OnChanges, OnDestroy {
@Input() public readonly benchmarkDataItems: InvestmentItem[] = []; @Input() public benchmarkDataItems: InvestmentItem[] = [];
@Input() public readonly benchmarkDataLabel = ''; @Input() public benchmarkDataLabel = '';
@Input() public readonly colorScheme: ColorScheme; @Input() public colorScheme: ColorScheme;
@Input() public readonly currency: string; @Input() public currency: string;
@Input() public readonly groupBy: GroupBy; @Input() public groupBy: GroupBy;
@Input() public readonly historicalDataItems: LineChartItem[] = []; @Input() public historicalDataItems: LineChartItem[] = [];
@Input() public readonly isInPercentage = false; @Input() public isInPercentage = false;
@Input() public readonly isLoading = false; @Input() public isLoading = false;
@Input() public readonly locale = getLocale(); @Input() public locale = getLocale();
@Input() public readonly savingsRate = 0; @Input() public savingsRate = 0;
private readonly chartCanvas = private readonly chartCanvas =
viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas'); viewChild.required<ElementRef<HTMLCanvasElement>>('chartCanvas');

12
apps/client/src/app/components/markets/markets.component.ts

@ -1,4 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -43,16 +44,19 @@ import { DeviceDetectorService } from 'ngx-device-detector';
templateUrl: './markets.html' templateUrl: './markets.html'
}) })
export class GfMarketsComponent implements OnInit { export class GfMarketsComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected readonly benchmarks = signal<Benchmark[] | undefined>(undefined); protected readonly benchmarks = signal<Benchmark[] | undefined>(undefined);
protected readonly deviceType = computed( protected readonly deviceType = computed(
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected readonly fearAndGreedIndexModeOptions: ToggleOption[] = [ protected readonly fearAndGreedIndexModeOptions: ToggleOption<FearAndGreedIndexMode>[] =
{ label: $localize`Stocks`, value: 'STOCKS' }, [
{ label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' } { label: $localize`Stocks`, value: 'STOCKS' },
]; { label: $localize`Cryptocurrencies`, value: 'CRYPTOCURRENCIES' }
];
protected readonly fearLabel = $localize`Fear`; protected readonly fearLabel = $localize`Fear`;
protected readonly greedLabel = $localize`Greed`; protected readonly greedLabel = $localize`Greed`;

2
apps/client/src/app/components/markets/markets.html

@ -52,7 +52,7 @@
<gf-benchmark <gf-benchmark
[benchmarks]="benchmarks()" [benchmarks]="benchmarks()"
[deviceType]="deviceType()" [deviceType]="deviceType()"
[locale]="user?.settings?.locale || undefined" [locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showSymbol]="false" [showSymbol]="false"
[user]="user" [user]="user"
/> />

2
apps/client/src/app/components/portfolio-performance/portfolio-performance.component.html

@ -1,7 +1,7 @@
<div class="container p-0"> <div class="container p-0">
<div class="no-gutters row"> <div class="no-gutters row">
<div class="status-container text-muted text-right"> <div class="status-container text-muted text-right">
@if (errors()?.length > 0 && !isLoading()) { @if ((errors()?.length ?? 0) > 0 && !isLoading()) {
<ion-icon <ion-icon
i18n-title i18n-title
name="time-outline" name="time-outline"

8
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -183,7 +183,7 @@
<ng-container i18n>Holdings</ng-container> <ng-container i18n>Holdings</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 && (summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
) { ) {
<gf-value <gf-value
@ -261,7 +261,7 @@
<ng-container i18n>Cash</ng-container> <ng-container i18n>Cash</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 && (summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
) { ) {
<gf-value <gf-value
@ -324,7 +324,7 @@
<ng-container i18n>Excluded from Analysis</ng-container> <ng-container i18n>Excluded from Analysis</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 && (summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
) { ) {
<gf-value <gf-value
@ -412,7 +412,7 @@
<ng-container i18n>Emergency Fund</ng-container> <ng-container i18n>Emergency Fund</ng-container>
@if ( @if (
!hasImpersonationId && !hasImpersonationId &&
summary?.totalValueInBaseCurrency > 0 && (summary?.totalValueInBaseCurrency ?? 0) > 0 &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
) { ) {
<gf-value <gf-value

3
apps/client/src/app/pages/accounts/accounts-page.component.ts

@ -1,5 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { TransferBalanceDto } from '@ghostfolio/common/dtos'; import { TransferBalanceDto } from '@ghostfolio/common/dtos';
import { AccountsResponse, User } from '@ghostfolio/common/interfaces'; import { AccountsResponse, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -39,6 +40,8 @@ import { GfTransferBalanceDialogComponent } from './transfer-balance/transfer-ba
templateUrl: './accounts-page.html' templateUrl: './accounts-page.html'
}) })
export class GfAccountsPageComponent implements OnInit { export class GfAccountsPageComponent implements OnInit {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected accounts: AccountWithValue[] | undefined; protected accounts: AccountWithValue[] | undefined;
protected activitiesCount = 0; protected activitiesCount = 0;
protected hasPermissionToCreateAccount: boolean; protected hasPermissionToCreateAccount: boolean;

4
apps/client/src/app/pages/accounts/accounts-page.html

@ -3,12 +3,12 @@
<div class="col"> <div class="col">
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Accounts</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Accounts</h1>
<gf-accounts-table <gf-accounts-table
[accounts]="accounts" [accounts]="accounts ?? []"
[activitiesCount]="activitiesCount" [activitiesCount]="activitiesCount"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToDeleteAccount]="hasPermissionToDeleteAccount" [hasPermissionToDeleteAccount]="hasPermissionToDeleteAccount"
[hasPermissionToUpdateAccount]="hasPermissionToUpdateAccount" [hasPermissionToUpdateAccount]="hasPermissionToUpdateAccount"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale ?? DEFAULT_LOCALE"
[showActions]=" [showActions]="
(hasPermissionToDeleteAccount || hasPermissionToUpdateAccount) && (hasPermissionToDeleteAccount || hasPermissionToUpdateAccount) &&
!user.settings.isRestrictedView !user.settings.isRestrictedView

2
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -79,6 +79,8 @@ import { ActivityType } from './types/activity-type.type';
templateUrl: 'create-or-update-activity-dialog.html' templateUrl: 'create-or-update-activity-dialog.html'
}) })
export class GfCreateOrUpdateActivityDialogComponent { export class GfCreateOrUpdateActivityDialogComponent {
protected readonly DEFAULT_LOCALE = DEFAULT_LOCALE;
protected activityForm: FormGroup; protected activityForm: FormGroup;
protected readonly assetClassOptions: AssetClassSelectorOption[] = protected readonly assetClassOptions: AssetClassSelectorOption[] =

3
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -210,6 +210,7 @@
currencyOfAssetProfile === currencyOfAssetProfile ===
activityForm.get('currencyOfUnitPrice')?.value && activityForm.get('currencyOfUnitPrice')?.value &&
currentMarketPrice && currentMarketPrice &&
data.activity.type &&
['BUY', 'SELL'].includes(data.activity.type) && ['BUY', 'SELL'].includes(data.activity.type) &&
isToday(activityForm.get('date')?.value) isToday(activityForm.get('date')?.value)
) { ) {
@ -308,7 +309,7 @@
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[isCurrency]="true" [isCurrency]="true"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale ?? DEFAULT_LOCALE"
[unit]=" [unit]="
activityForm.get('currencyOfUnitPrice')?.value ?? activityForm.get('currencyOfUnitPrice')?.value ??
data.user?.settings?.baseCurrency data.user?.settings?.baseCurrency

220
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -8,11 +8,12 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
canOpenHoldingDetail, canOpenHoldingDetail,
convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCountryName getCountryName,
isCashPosition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier,
HoldingWithParents, HoldingWithParents,
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
@ -22,7 +23,10 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import { MarketAdvanced } from '@ghostfolio/common/types'; import { MarketAdvanced } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n'; import { translate } from '@ghostfolio/ui/i18n';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart'; import {
GfPortfolioProportionChartComponent,
PortfolioProportionChartClickEvent
} from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { DataService } from '@ghostfolio/ui/services'; import { DataService } from '@ghostfolio/ui/services';
import { GfTopHoldingsComponent } from '@ghostfolio/ui/top-holdings'; import { GfTopHoldingsComponent } from '@ghostfolio/ui/top-holdings';
@ -206,7 +210,9 @@ export class GfAllocationsPageComponent implements OnInit {
this.initialize(); this.initialize();
} }
protected onAccountChartClicked({ accountId }: { accountId: string }) { protected onAccountChartClicked(event: PortfolioProportionChartClickEvent) {
const accountId = 'accountId' in event ? event.accountId : undefined;
if (accountId && accountId !== UNKNOWN_KEY) { if (accountId && accountId !== UNKNOWN_KEY) {
void this.router.navigate([], { void this.router.navigate([], {
queryParams: { accountId, accountDetailDialog: true } queryParams: { accountId, accountDetailDialog: true }
@ -214,10 +220,9 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
protected onSymbolChartClicked({ protected onSymbolChartClicked(event: PortfolioProportionChartClickEvent) {
dataSource, const { dataSource, symbol } = 'symbol' in event ? event : {};
symbol
}: AssetProfileIdentifier) {
if (dataSource && symbol) { if (dataSource && symbol) {
void this.router.navigate([], { void this.router.navigate([], {
queryParams: { dataSource, symbol, holdingDetailDialog: true } queryParams: { dataSource, symbol, holdingDetailDialog: true }
@ -267,6 +272,20 @@ export class GfAllocationsPageComponent implements OnInit {
return UNKNOWN_KEY; return UNKNOWN_KEY;
} }
private extractValue({
valueInBaseCurrency,
valueInPercentage
}: {
valueInBaseCurrency?: PortfolioPosition['valueInBaseCurrency'];
valueInPercentage?: PortfolioPosition['valueInPercentage'];
}) {
return (
(isNumber(valueInBaseCurrency)
? valueInBaseCurrency
: valueInPercentage) ?? 0
);
}
private fetchPortfolioDetails() { private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({ return this.dataService.fetchPortfolioDetails({
filters: this.userService.getFilters(), filters: this.userService.getFilters(),
@ -370,6 +389,8 @@ export class GfAllocationsPageComponent implements OnInit {
}; };
} }
let totalValueExcludingCashPositions = 0;
for (const position of this.portfolioDetails.holdings) { for (const position of this.portfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier( const assetProfileIdentifier = getAssetProfileIdentifier(
position.assetProfile position.assetProfile
@ -394,111 +415,104 @@ export class GfAllocationsPageComponent implements OnInit {
: (position.valueInBaseCurrency ?? 0) : (position.valueInBaseCurrency ?? 0)
}; };
// Prepare analysis data by continents, countries, holdings and sectors if (!isCashPosition(position.assetProfile)) {
// Prepare analysis data by continents, countries, holdings and sectors
if (position.assetProfile.countries.length > 0) { // except for cash
for (const country of position.assetProfile.countries) {
const { code, continent, weight } = country; totalValueExcludingCashPositions += this.extractValue(position);
const value =
(isNumber(position.valueInBaseCurrency) if (position.assetProfile.countries.length > 0) {
? position.valueInBaseCurrency for (const country of position.assetProfile.countries) {
: position.valueInPercentage) ?? 0; const { code, continent, weight } = country;
const value = this.extractValue(position);
const continentData = this.continents[continent];
if (continentData) {
continentData.value += weight * value;
} else {
this.continents[continent] = {
name: translate(continent),
value: weight * value
};
}
const countryData = this.countries[code];
if (countryData) {
countryData.value += weight * value;
} else {
this.countries[code] = {
name: getCountryName({ code }),
value: weight * value
};
}
}
} else {
const value = this.extractValue(position);
const continentData = this.continents[continent]; const continentData = this.continents[UNKNOWN_KEY];
if (continentData) { if (continentData) {
continentData.value += weight * value; continentData.value += value;
} else {
this.continents[continent] = {
name: translate(continent),
value: weight * value
};
} }
const countryData = this.countries[code]; const countryData = this.countries[UNKNOWN_KEY];
if (countryData) { if (countryData) {
countryData.value += weight * value; countryData.value += value;
} else {
this.countries[code] = {
name: getCountryName({ code }),
value: weight * value
};
} }
} }
} else {
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const continentData = this.continents[UNKNOWN_KEY];
if (continentData) {
continentData.value += value;
}
const countryData = this.countries[UNKNOWN_KEY];
if (countryData) {
countryData.value += value;
}
}
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);
const value = isNumber(valueInBaseCurrency) const value = isNumber(valueInBaseCurrency)
? valueInBaseCurrency ? valueInBaseCurrency
: allocationInPercentage * (position.valueInPercentage ?? 0); : allocationInPercentage * (position.valueInPercentage ?? 0);
const holdingData = this.topHoldingsMap[normalizedAssetName]; const holdingData = this.topHoldingsMap[normalizedAssetName];
if (holdingData) { if (holdingData) {
holdingData.value += value; holdingData.value += value;
} else { } else {
this.topHoldingsMap[normalizedAssetName] = { this.topHoldingsMap[normalizedAssetName] = {
name, name,
value value
}; };
}
} }
} }
}
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;
const value = const value = this.extractValue(position);
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency const sectorData = this.sectors[name];
: position.valueInPercentage) ?? 0;
if (sectorData) {
sectorData.value += weight * value;
} else {
this.sectors[name] = {
name: translate(name),
value: weight * value
};
}
}
} else {
const value = this.extractValue(position);
const sectorData = this.sectors[name]; const sectorData = this.sectors[UNKNOWN_KEY];
if (sectorData) { if (sectorData) {
sectorData.value += weight * value; sectorData.value += value;
} else {
this.sectors[name] = {
name: translate(name),
value: weight * value
};
} }
} }
} else {
const value =
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const sectorData = this.sectors[UNKNOWN_KEY];
if (sectorData) {
sectorData.value += value;
}
} }
if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') { if (this.holdings[assetProfileIdentifier].assetSubClass === 'ETF') {
@ -507,10 +521,7 @@ export class GfAllocationsPageComponent implements OnInit {
const symbol = position.assetProfile.symbol; const symbol = position.assetProfile.symbol;
const value = const value = this.extractValue(position);
(isNumber(position.valueInBaseCurrency)
? position.valueInBaseCurrency
: position.valueInPercentage) ?? 0;
const symbolData = this.symbols[symbol]; const symbolData = this.symbols[symbol];
@ -530,6 +541,17 @@ export class GfAllocationsPageComponent implements OnInit {
} }
} }
if (this.showValuesInPercentage()) {
// The values are percentages of the whole portfolio, but the analysis
// data does not contain the cash positions
for (const values of [this.continents, this.countries, this.sectors]) {
convertValuesToPercentagesOfTotal({
values,
total: totalValueExcludingCashPositions
});
}
}
this.markets = this.portfolioDetails.markets; this.markets = this.portfolioDetails.markets;
if (this.portfolioDetails.marketsAdvanced) { if (this.portfolioDetails.marketsAdvanced) {

8
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -23,9 +23,11 @@
<mat-progress-bar <mat-progress-bar
mode="determinate" mode="determinate"
[title]="`${( [title]="`${(
portfolioDetails?.summary?.filteredValueInPercentage * 100 (portfolioDetails?.summary?.filteredValueInPercentage ?? 0) * 100
).toFixed(2)}%`" ).toFixed(2)}%`"
[value]="portfolioDetails?.summary?.filteredValueInPercentage * 100" [value]="
(portfolioDetails?.summary?.filteredValueInPercentage ?? 0) * 100
"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
@ -236,7 +238,7 @@
>Other Markets</gf-value >Other Markets</gf-value
> >
</div> </div>
@if (markets?.[UNKNOWN_KEY]?.valueInPercentage > 0) { @if ((markets?.[UNKNOWN_KEY]?.valueInPercentage ?? 0) > 0) {
<div class="col-xs-12 col-md my-2"> <div class="col-xs-12 col-md my-2">
<gf-value <gf-value
i18n i18n

2
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -94,7 +94,7 @@ export class GfAnalysisPageComponent implements OnInit {
protected isLoadingInvestmentTimelineChart: boolean; protected isLoadingInvestmentTimelineChart: boolean;
protected isLoadingPortfolioPrompt: boolean; protected isLoadingPortfolioPrompt: boolean;
protected readonly mode = signal<GroupBy>('month'); protected readonly mode = signal<GroupBy>('month');
protected readonly modeOptions: ToggleOption[] = [ protected readonly modeOptions: ToggleOption<GroupBy>[] = [
{ label: $localize`Monthly`, value: 'month' }, { label: $localize`Monthly`, value: 'month' },
{ label: $localize`Yearly`, value: 'year' } { label: $localize`Yearly`, value: 'year' }
]; ];

53
apps/client/src/app/pages/public/public-page.component.ts

@ -1,7 +1,9 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
convertValuesToPercentagesOfTotal,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getCountryName getCountryName,
isCashPosition
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
InfoItem, InfoItem,
@ -34,9 +36,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AssetClass } from '@prisma/client';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { isNumber } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@ -178,6 +178,8 @@ export class GfPublicPageComponent implements OnInit {
} }
}; };
let totalValueExcludingCashPositions = 0;
for (const position of this.publicPortfolioDetails.holdings) { for (const position of this.publicPortfolioDetails.holdings) {
const assetProfileIdentifier = getAssetProfileIdentifier( const assetProfileIdentifier = getAssetProfileIdentifier(
position.assetProfile position.assetProfile
@ -191,39 +193,40 @@ export class GfPublicPageComponent implements OnInit {
value: position.allocationInPercentage value: position.allocationInPercentage
}; };
if (position.assetProfile.assetClass !== AssetClass.LIQUIDITY) { if (!isCashPosition(position.assetProfile)) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity // Prepare analysis data by continents, countries, holdings and sectors
// except for cash
const value = position.valueInPercentage ?? 0;
totalValueExcludingCashPositions += value;
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 * value;
weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.continents[continent] = { this.continents[continent] = {
name: translate(continent), name: translate(continent),
value: weight * (position.valueInBaseCurrency ?? 0) value: weight * value
}; };
} }
if (this.countries[code]?.value) { if (this.countries[code]?.value) {
this.countries[code].value += this.countries[code].value += weight * value;
weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.countries[code] = { this.countries[code] = {
name: getCountryName({ code }), name: getCountryName({ code }),
value: weight * (position.valueInBaseCurrency ?? 0) value: weight * value
}; };
} }
} }
} else { } else {
this.continents[UNKNOWN_KEY].value += this.continents[UNKNOWN_KEY].value += value;
position.valueInBaseCurrency ?? 0;
this.countries[UNKNOWN_KEY].value += this.countries[UNKNOWN_KEY].value += value;
position.valueInBaseCurrency ?? 0;
} }
if (position.assetProfile.sectors.length > 0) { if (position.assetProfile.sectors.length > 0) {
@ -231,25 +234,22 @@ export class GfPublicPageComponent implements OnInit {
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 * value;
weight * (position.valueInBaseCurrency ?? 0);
} else { } else {
this.sectors[name] = { this.sectors[name] = {
name: translate(name), name: translate(name),
value: weight * (position.valueInBaseCurrency ?? 0) value: weight * value
}; };
} }
} }
} else { } else {
this.sectors[UNKNOWN_KEY].value += position.valueInBaseCurrency ?? 0; this.sectors[UNKNOWN_KEY].value += value;
} }
} }
const symbol = position.assetProfile.symbol; const symbol = position.assetProfile.symbol;
const value = isNumber(position.valueInBaseCurrency) const value = position.valueInPercentage ?? 0;
? position.valueInBaseCurrency
: (position.valueInPercentage ?? 0);
const symbolData = this.symbols[symbol]; const symbolData = this.symbols[symbol];
@ -264,5 +264,14 @@ export class GfPublicPageComponent implements OnInit {
}; };
} }
} }
// The values are percentages of the whole portfolio, but the analysis data
// does not contain the cash positions
for (const values of [this.continents, this.countries, this.sectors]) {
convertValuesToPercentagesOfTotal({
values,
total: totalValueExcludingCashPositions
});
}
} }
} }

4
apps/client/src/app/pages/register/user-account-registration-dialog/user-account-registration-dialog.html

@ -75,13 +75,13 @@
matInput matInput
readonly readonly
type="text" type="text"
[(value)]="accessToken" [value]="accessToken"
></textarea> ></textarea>
<div class="float-right mt-1"> <div class="float-right mt-1">
<button <button
color="secondary" color="secondary"
mat-flat-button mat-flat-button
[cdkCopyToClipboard]="accessToken" [cdkCopyToClipboard]="accessToken ?? ''"
(click)="enableCreateAccountButton()" (click)="enableCreateAccountButton()"
> >
<ion-icon class="mr-1" name="copy-outline" /> <ion-icon class="mr-1" name="copy-outline" />

12
apps/client/tsconfig.json

@ -15,8 +15,18 @@
], ],
"angularCompilerOptions": { "angularCompilerOptions": {
"strictInjectionParameters": true, "strictInjectionParameters": true,
// TODO: Remove these subsets once strictTemplates is enabled
"strictAttributeTypes": true,
"strictContextGenerics": true,
"strictDomEventTypes": true,
"strictDomLocalRefTypes": true,
"strictInputAccessModifiers": true,
"strictInputTypes": false,
"strictLiteralTypes": true,
"strictNullInputTypes": true,
"strictOutputEventTypes": true,
"strictSafeNavigationTypes": true,
// TODO: Enable stricter rules for this project // TODO: Enable stricter rules for this project
"strictInputAccessModifiers": false,
"strictTemplates": false "strictTemplates": false
}, },
"compilerOptions": { "compilerOptions": {

1
libs/common/src/lib/config.ts

@ -277,6 +277,7 @@ export const HTTP_RESPONSE_MESSAGE_IMPERSONATION_UNRESOLVED =
export const MAX_TOP_HOLDINGS = 50; export const MAX_TOP_HOLDINGS = 50;
export const MCP_ENDPOINT = '/mcp'; export const MCP_ENDPOINT = '/mcp';
export const MCP_MAX_ACCOUNTS = 50;
export const MCP_MAX_ACTIVITIES = 100; export const MCP_MAX_ACTIVITIES = 100;
export const MCP_REALM = 'Ghostfolio'; export const MCP_REALM = 'Ghostfolio';

24
libs/common/src/lib/helper.ts

@ -232,6 +232,22 @@ export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
} }
export function convertValuesToPercentagesOfTotal({
total,
values
}: {
total: number;
values: { [key: string]: { value: number } };
}) {
if (!total) {
return;
}
for (const item of Object.values(values)) {
item.value = item.value / total;
}
}
export function downloadAsFile({ export function downloadAsFile({
content, content,
contentType = 'text/plain', contentType = 'text/plain',
@ -553,6 +569,14 @@ export function isAccountExcluded(account?: { tags?: { id: string }[] }) {
); );
} }
export function isCashPosition({
assetSubClass
}: {
assetSubClass?: AssetSubClass;
} = {}) {
return assetSubClass === AssetSubClass.CASH;
}
export function isCurrency(aCurrency: string) { export function isCurrency(aCurrency: string) {
if (!aCurrency) { if (!aCurrency) {
return false; return false;

1
libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts

@ -17,7 +17,6 @@ export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 {
| 'dateOfFirstActivity' | 'dateOfFirstActivity'
| 'markets' | 'markets'
| 'netPerformancePercentWithCurrencyEffect' | 'netPerformancePercentWithCurrencyEffect'
| 'valueInBaseCurrency'
| 'valueInPercentage' | 'valueInPercentage'
>[]; >[];
latestActivities: (Pick< latestActivities: (Pick<

6
libs/common/src/lib/types/toggle-option.type.ts

@ -1,8 +1,8 @@
interface BaseToggleOption { interface BaseToggleOption<T extends string = string> {
value: string; value: T;
} }
export type ToggleOption = BaseToggleOption & export type ToggleOption<T extends string = string> = BaseToggleOption<T> &
( (
| { iconName: string; label?: never; title: string } | { iconName: string; label?: never; title: string }
| { iconName?: never; label: string; title?: never } | { iconName?: never; label: string; title?: never }

2
libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
position="center" position="center"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="assetProfile?.name ?? assetProfile?.symbol" [title]="assetProfile.name ?? assetProfile.symbol ?? ''"
(closeButtonClicked)="onClose()" (closeButtonClicked)="onClose()"
/> />

8
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -4,7 +4,11 @@ import {
getValueAxisOptions, getValueAxisOptions,
getVerticalHoverLinePlugin getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import {
DEFAULT_COLOR_SCHEME,
primaryColorRgb,
secondaryColorRgb
} from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper'; import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/common/interfaces'; import { LineChartItem } from '@ghostfolio/common/interfaces';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
@ -52,7 +56,7 @@ export class GfLineChartComponent
{ {
@Input() benchmarkDataItems: LineChartItem[] = []; @Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmarkLabel = ''; @Input() benchmarkLabel = '';
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme = DEFAULT_COLOR_SCHEME;
@Input() currency: string; @Input() currency: string;
@Input() historicalDataItems: LineChartItem[]; @Input() historicalDataItems: LineChartItem[];
@Input() isAnimated = false; @Input() isAnimated = false;

1
libs/ui/src/lib/portfolio-proportion-chart/index.ts

@ -1 +1,2 @@
export * from './interfaces/interfaces';
export * from './portfolio-proportion-chart.component'; export * from './portfolio-proportion-chart.component';

6
libs/ui/src/lib/services/data.service.ts

@ -788,12 +788,6 @@ export class DataService {
holding.assetProfile.assetSubClassLabel = translate( holding.assetProfile.assetSubClassLabel = translate(
holding.assetProfile.assetSubClass holding.assetProfile.assetSubClass
); );
holding.valueInBaseCurrency = isNumber(
holding.valueInBaseCurrency
)
? holding.valueInBaseCurrency
: holding.valueInPercentage;
} }
} }

2
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -82,7 +82,7 @@ export class GfSymbolAutocompleteComponent
@Input() public defaultLookupItems: LookupItem[] = []; @Input() public defaultLookupItems: LookupItem[] = [];
@Input() public isLoading = false; @Input() public isLoading = false;
@Input() private includeIndices = false; @Input() public includeIndices = false;
public readonly control = new FormControl(); public readonly control = new FormControl();
public lookupItems: (LookupItem & { assetSubClassString: string })[] = []; public lookupItems: (LookupItem & { assetSubClassString: string })[] = [];

11
libs/ui/src/lib/toggle/toggle.component.ts

@ -18,14 +18,15 @@ import { IonIcon } from '@ionic/angular/standalone';
styleUrls: ['./toggle.component.scss'], styleUrls: ['./toggle.component.scss'],
templateUrl: './toggle.component.html' templateUrl: './toggle.component.html'
}) })
export class GfToggleComponent { export class GfToggleComponent<T extends string = string> {
public readonly defaultValue = input.required<string>(); public readonly defaultValue = input.required<T>();
public readonly isDisabled = input<boolean>(false); public readonly isDisabled = input<boolean>(false);
public readonly isLoading = input<boolean>(false); public readonly isLoading = input<boolean>(false);
public readonly options = input<ToggleOption[]>([]); public readonly options = input<ToggleOption<T>[]>([]);
protected readonly optionFormControl = new FormControl<string | null>(null); public readonly valueChange = output<Pick<ToggleOption<T>, 'value'>>();
protected readonly valueChange = output<Pick<ToggleOption, 'value'>>();
protected readonly optionFormControl = new FormControl<T | null>(null);
public constructor() { public constructor() {
effect(() => { effect(() => {

8
package-lock.json

@ -102,7 +102,7 @@
"svgmap": "2.21.0", "svgmap": "2.21.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.1", "twitter-api-v2": "1.29.1",
"undici": "8.5.0", "undici": "8.10.0",
"uuid": "14.0.2", "uuid": "14.0.2",
"yahoo-finance2": "4.0.2", "yahoo-finance2": "4.0.2",
"zod": "4.4.3", "zod": "4.4.3",
@ -34889,9 +34889,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "8.5.0", "version": "8.10.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-8.5.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz",
"integrity": "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==", "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=22.19.0" "node": ">=22.19.0"

2
package.json

@ -146,7 +146,7 @@
"svgmap": "2.21.0", "svgmap": "2.21.0",
"tablemark": "4.1.0", "tablemark": "4.1.0",
"twitter-api-v2": "1.29.1", "twitter-api-v2": "1.29.1",
"undici": "8.5.0", "undici": "8.10.0",
"uuid": "14.0.2", "uuid": "14.0.2",
"yahoo-finance2": "4.0.2", "yahoo-finance2": "4.0.2",
"zod": "4.4.3", "zod": "4.4.3",

Loading…
Cancel
Save