diff --git a/CHANGELOG.md b/CHANGELOG.md index a79f119857..999c65e557 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added support for a manual exchange rate in the create or update activity dialog +- Extended the _Public API_ with the liveness probe endpoint (`GET api/v1/health/liveness`) (experimental) +- Added the graceful shutdown of the server on `SIGINT` and `SIGTERM` +- Added `stop_grace_period` to the _Ghostfolio_ service in the `docker-compose` file (`docker-compose.yml`) ### Changed +- Localized the number formatting in the chart of the holdings tab on the home page +- Changed the installation of the dependencies from `npm install` to `npm ci` in the `Dockerfile` - Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `13.3` to `14.0` - Upgraded `nestjs` from version `11.2.3` to `11.2.6` +### Fixed + +- Fixed an issue with the algebraic sign in the tooltip of the chart of the holdings tab on the home page +- Fixed the asset profile of the activities after a fee, an interest or a liability with the same symbol in the activities import + +### Todo + +- Add `stop_grace_period: 1m` to the _Ghostfolio_ service in your `docker-compose` file (see `docker-compose.yml`) + ## 3.78.0 - 2026-10-03 ### Added diff --git a/Dockerfile b/Dockerfile index e73cd73e65..ebee4e60d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ COPY ./package.json package.json COPY ./package-lock.json package-lock.json COPY ./prisma/schema.prisma prisma/ -RUN npm install +RUN npm ci COPY ./apps apps/ COPY ./libs libs/ @@ -39,7 +39,7 @@ WORKDIR /ghostfolio/dist/apps/api # package-lock.json needs to be used to ensure the same versions COPY ./package-lock.json /ghostfolio/dist/apps/api/ -RUN npm install +RUN npm ci COPY .config /ghostfolio/dist/apps/api/.config/ COPY prisma /ghostfolio/dist/apps/api/prisma/ diff --git a/README.md b/README.md index b9352e3bd3..59c7c5d2c6 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,8 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony ### Health Check (experimental) +The endpoint checks the connection to the database and to _Redis_. It is suitable as the readiness probe of an orchestrator (e.g. `readinessProbe` in _Kubernetes_) and as the `healthcheck` in _Docker Compose_. The check of _Redis_ can take up to 5 seconds. Thus, set the timeout of the probe to at least 5 seconds (e.g. `timeoutSeconds: 5` in _Kubernetes_). + #### Request `GET http://localhost:3333/api/v1/health` @@ -211,6 +213,28 @@ You can get the _Bearer Token_ via `POST http://localhost:3333/api/v1/auth/anony } ``` +### Liveness Probe (experimental) + +The endpoint answers without a check of the database or _Redis_. It is suitable as the liveness probe of an orchestrator (e.g. `livenessProbe` in _Kubernetes_), so that an outage of a dependency after the start does not restart the container. The database migrations run before the server starts. Thus, also use the endpoint as the startup probe (e.g. `startupProbe` in _Kubernetes_), so that a long migration does not restart the container. + +#### Request + +`GET http://localhost:3333/api/v1/health/liveness` + +**Info:** No Bearer Token is required for liveness probe + +#### Response + +##### Success + +`200 OK` + +``` +{ + "status": "OK" +} +``` + ### Import Portfolio Data #### Prerequisites diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts index 4f88a03f0c..32aae38e3c 100644 --- a/apps/api/src/app/health/health.controller.ts +++ b/apps/api/src/app/health/health.controller.ts @@ -3,7 +3,8 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce import { AiServiceHealthResponse, DataEnhancerHealthResponse, - DataProviderHealthResponse + DataProviderHealthResponse, + LivenessHealthResponse } from '@ghostfolio/common/interfaces'; import { @@ -33,9 +34,11 @@ export class HealthController { @Get() public async getHealth(@Res() response: Response) { - const databaseServiceHealthy = await this.healthService.isDatabaseHealthy(); - const redisCacheServiceHealthy = - await this.healthService.isRedisCacheHealthy(); + const [databaseServiceHealthy, redisCacheServiceHealthy] = + await Promise.all([ + this.healthService.isDatabaseHealthy(), + this.healthService.isRedisCacheHealthy() + ]); if (databaseServiceHealthy && redisCacheServiceHealthy) { return response @@ -116,4 +119,9 @@ export class HealthController { .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); } } + + @Get('liveness') + public getLiveness(): LivenessHealthResponse { + return { status: getReasonPhrase(StatusCodes.OK) }; + } } diff --git a/apps/api/src/app/import/import.service.spec.ts b/apps/api/src/app/import/import.service.spec.ts new file mode 100644 index 0000000000..6bbe38d7b3 --- /dev/null +++ b/apps/api/src/app/import/import.service.spec.ts @@ -0,0 +1,366 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { NON_INVESTMENT_ACTIVITY_TYPES } from '@ghostfolio/common/config'; +import { CreateOrderDto } from '@ghostfolio/common/dtos'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + Activity, + AssetProfileIdentifier +} from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { DataSource, SymbolProfile } from '@prisma/client'; +import { parseISO } from 'date-fns'; + +import { ImportService } from './import.service'; + +const CUSTOM_ASSET_PROFILE_SYMBOL = '1ad7d4a2-6b2d-4e0f-9b1f-2c0f8d3e5a7b'; + +describe('ImportService', () => { + let createActivity: jest.Mock; + let gatherSymbols: jest.Mock; + let importService: ImportService; + + beforeEach(() => { + const configuration = { + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: [], + ENABLE_FEATURE_SUBSCRIPTION: false, + MAX_ACTIVITIES_TO_IMPORT: Number.MAX_SAFE_INTEGER + }; + + createActivity = jest.fn(); + gatherSymbols = jest.fn(); + + const validateActivities = jest.fn( + ({ activitiesDto }: { activitiesDto: CreateOrderDto[] }) => { + const assetProfiles: { + [assetProfileIdentifier: string]: Partial; + } = {}; + + for (const { currency, dataSource, symbol } of activitiesDto) { + assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] ??= { + currency, + dataSource, + symbol, + name: symbol + }; + } + + return assetProfiles; + } + ); + + importService = new ImportService( + { + getAccounts: jest.fn().mockResolvedValue([]) + } as unknown as AccountService, + { + createActivity, + getActivities: jest.fn().mockResolvedValue({ activities: [], count: 0 }) + } as unknown as ActivitiesService, + null, + { + get: (key: keyof typeof configuration) => { + return configuration[key]; + } + } as unknown as ConfigurationService, + { gatherSymbols } as unknown as DataGatheringService, + { + validateActivities, + getDataSourceForImport: jest.fn().mockReturnValue(DataSource.MANUAL) + } as unknown as DataProviderService, + { + toCurrencyAtDate: jest.fn().mockResolvedValue(0), + toCurrencyAtDateWithExchangeRate: jest.fn().mockResolvedValue(0) + } as unknown as ExchangeRateDataService, + null, + null, + null, + null, + { + getTagsForUser: jest.fn().mockResolvedValue([]) + } as unknown as TagService + ); + }); + + describe('import', () => { + it('keeps the asset profile of an investment activity after a non-investment activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + + expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ + { + dataSource: DataSource.MANUAL, + date: parseISO('2024-01-01T00:00:00.000Z'), + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }, + { + dataSource: DataSource.YAHOO, + date: parseISO('2024-01-02T00:00:00.000Z'), + symbol: 'AAPL' + } + ]); + }); + + it('shows the custom asset profile of a non-investment activity after an investment activity', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ dataSource: DataSource.YAHOO, symbol: 'AAPL' }); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + }); + + it.each(NON_INVESTMENT_ACTIVITY_TYPES)( + 'refers the next %s activities of a data provider to the custom asset profile which createActivity() has created', + async (type) => { + mockCreatedAssetProfiles([ + { + dataSource: DataSource.MANUAL, + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + type, + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL' + }), + createActivityDto({ + type, + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ symbol: CUSTOM_ASSET_PROFILE_SYMBOL }); + } + ); + + it('refers the next activities without a data source to the custom asset profile which createActivity() has created', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + date: '2024-01-01T00:00:00.000Z', + symbol: 'Broker fee', + type: 'FEE' + }), + createActivityDto({ + date: '2024-01-02T00:00:00.000Z', + symbol: 'Broker fee', + type: 'FEE' + }) + ] + }); + + expect( + createActivity.mock.calls[1][0].SymbolProfile.connectOrCreate.create + ).toMatchObject({ + dataSource: DataSource.MANUAL, + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + }); + }); + + it('refers the next investment activities without a data source to the custom asset profile which createActivity() has created', async () => { + mockCreatedAssetProfiles([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + await importActivities({ + activitiesDto: [ + createActivityDto({ + date: '2024-01-01T00:00:00.000Z', + symbol: 'Gold', + type: 'BUY' + }), + createActivityDto({ + date: '2024-01-02T00:00:00.000Z', + symbol: 'Gold', + type: 'BUY' + }), + createActivityDto({ + date: '2024-01-03T00:00:00.000Z', + symbol: 'Gold', + type: 'SELL' + }) + ] + }); + + expect( + createActivity.mock.calls.slice(1).map(([activity]) => { + return activity.SymbolProfile.connectOrCreate.create; + }) + ).toMatchObject([ + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL }, + { dataSource: DataSource.MANUAL, symbol: CUSTOM_ASSET_PROFILE_SYMBOL } + ]); + + expect(gatherSymbols.mock.calls[0][0].dataGatheringItems).toEqual([ + { + dataSource: DataSource.MANUAL, + date: parseISO('2024-01-01T00:00:00.000Z'), + symbol: CUSTOM_ASSET_PROFILE_SYMBOL + } + ]); + }); + + it('keeps the asset profiles of the activities in a dry run', async () => { + const activities = await importActivities({ + activitiesDto: [ + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-01T00:00:00.000Z', + symbol: 'AAPL', + type: 'FEE' + }), + createActivityDto({ + dataSource: DataSource.YAHOO, + date: '2024-01-02T00:00:00.000Z', + symbol: 'AAPL', + type: 'BUY' + }) + ], + isDryRun: true + }); + + expect(createActivity).not.toHaveBeenCalled(); + expect(gatherSymbols).not.toHaveBeenCalled(); + + expect(getAssetProfileIdentifiers(activities)).toEqual([ + { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + { dataSource: DataSource.YAHOO, symbol: 'AAPL' } + ]); + }); + }); + + function importActivities({ + activitiesDto, + isDryRun + }: { + activitiesDto: CreateOrderDto[]; + isDryRun?: boolean; + }) { + return importService.import({ + activitiesDto, + isDryRun, + accountsWithBalancesDto: [], + assetProfilesWithMarketDataDto: [], + platformsDto: [], + tagsDto: [], + user: { + id: 'user-id', + permissions: [], + settings: { settings: { baseCurrency: 'USD' } } + } as unknown as UserWithSettings + }); + } + + function mockCreatedAssetProfiles(assetProfiles: AssetProfileIdentifier[]) { + for (const assetProfile of assetProfiles) { + createActivity.mockImplementationOnce( + ({ + date, + type + }: Parameters[0]) => { + return { date, type, SymbolProfile: assetProfile }; + } + ); + } + } +}); + +function createActivityDto({ + dataSource, + date, + symbol, + type +}: Pick) { + return { + dataSource, + date, + symbol, + type, + currency: 'USD', + fee: 0, + quantity: 1, + unitPrice: 100 + } as CreateOrderDto; +} + +function getAssetProfileIdentifiers(activities: Activity[]) { + return activities.map(({ assetProfile: { dataSource, symbol } }) => { + return { dataSource, symbol }; + }); +} diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index 675d4ece98..7c8cec73c6 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -837,6 +837,10 @@ export class ImportService { const activities: Activity[] = []; + const customAssetProfileSymbols: { + [assetProfileIdentifier: string]: string; + } = {}; + for (const activity of activitiesExtendedWithErrors) { const accountId = activity.accountId; const comment = activity.comment; @@ -850,12 +854,12 @@ export class ImportService { const type = activity.type; const unitPrice = activity.unitPrice; - const assetProfile = assetProfiles[ - getAssetProfileIdentifier({ - dataSource: activity.assetProfile.dataSource, - symbol: activity.assetProfile.symbol - }) - ] ?? { + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource: activity.assetProfile.dataSource, + symbol: activity.assetProfile.symbol + }); + + let assetProfile = assetProfiles[assetProfileIdentifier] ?? { dataSource: activity.assetProfile.dataSource, symbol: activity.assetProfile.symbol }; @@ -907,7 +911,14 @@ export class ImportService { continue; } - const { dataSource, name, symbol } = assetProfile; + const customAssetProfileSymbol = + NON_INVESTMENT_ACTIVITY_TYPES.includes(type) || + assetProfile.dataSource === DataSource.MANUAL + ? customAssetProfileSymbols[assetProfileIdentifier] + : undefined; + + const { dataSource, name } = assetProfile; + const symbol = customAssetProfileSymbol ?? assetProfile.symbol; const createdActivity = await this.activitiesService.createActivity({ comment, @@ -944,9 +955,15 @@ export class ImportService { userId: user.id }); - if (createdActivity.SymbolProfile?.symbol) { - // Update symbol that may have been assigned in createOrder() - assetProfile.symbol = createdActivity.SymbolProfile.symbol; + if (createdActivity.SymbolProfile.dataSource === DataSource.MANUAL) { + customAssetProfileSymbols[assetProfileIdentifier] = + createdActivity.SymbolProfile.symbol; + + assetProfile = { + ...assetProfile, + dataSource: DataSource.MANUAL, + symbol: createdActivity.SymbolProfile.symbol + }; } order = createdActivity; diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 8b17cbca50..1282304c33 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -14,6 +14,7 @@ import { import { Logger, LogLevel, + ShutdownSignal, ValidationPipe, VersioningType } from '@nestjs/common'; @@ -65,6 +66,14 @@ async function bootstrap() { }); app.enableCors(); + + // Let the open requests and the active queue jobs finish on SIGINT and + // SIGTERM before the process stops. Exit explicitly because Linux ignores + // the re-raised signal if the process runs as PID 1 + app.enableShutdownHooks([ShutdownSignal.SIGINT, ShutdownSignal.SIGTERM], { + useProcessExit: true + }); + app.enableVersioning({ defaultVersion: '1', type: VersioningType.URI diff --git a/apps/api/src/services/prisma/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts index df55e45f7c..fcf7c6e5ea 100644 --- a/apps/api/src/services/prisma/prisma.service.ts +++ b/apps/api/src/services/prisma/prisma.service.ts @@ -2,7 +2,7 @@ import { Injectable, Logger, LogLevel, - OnModuleDestroy, + OnApplicationShutdown, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -12,7 +12,7 @@ import { Prisma, PrismaClient } from '@prisma/client'; @Injectable() export class PrismaService extends PrismaClient - implements OnModuleInit, OnModuleDestroy + implements OnApplicationShutdown, OnModuleInit { private readonly logger = new Logger(PrismaService.name); @@ -41,6 +41,10 @@ export class PrismaService }); } + public async onApplicationShutdown() { + await this.$disconnect(); + } + public async onModuleInit() { try { await this.$connect(); @@ -48,8 +52,4 @@ export class PrismaService this.logger.error(error); } } - - public async onModuleDestroy() { - await this.$disconnect(); - } } diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 8c69e5420e..cf58ea0143 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -5,6 +5,7 @@ services: container_name: ghostfolio restart: unless-stopped init: true + stop_grace_period: 1m cap_drop: - ALL security_opt: diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index c581b8e56e..113e485b74 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -68,6 +68,7 @@ import type { ExportResponse } from './responses/export-response.interface'; import type { HistoricalResponse } from './responses/historical-response.interface'; import type { ImportResponse } from './responses/import-response.interface'; import type { InfoResponse } from './responses/info-response.interface'; +import type { LivenessHealthResponse } from './responses/liveness-health-response.interface'; import type { LookupResponse } from './responses/lookup-response.interface'; import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface'; @@ -156,6 +157,7 @@ export { InfoResponse, InvestmentItem, LineChartItem, + LivenessHealthResponse, LookupItem, LookupResponse, MarketData, diff --git a/libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts b/libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts new file mode 100644 index 0000000000..28db1fb5b3 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/liveness-health-response.interface.ts @@ -0,0 +1,3 @@ +export interface LivenessHealthResponse { + status: string; +} diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 01037f77f4..11c7edbf63 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -314,23 +314,18 @@ export class GfTreemapChartComponent formatter: (context: GfTreemapScriptableContext) => { const raw = context.raw as GfTreemapDataPoint; - let netPerformancePercentWithCurrencyEffect = round( - raw._data.netPerformancePercentWithCurrencyEffect, - 4 - ); - - if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) { - netPerformancePercentWithCurrencyEffect = Math.abs( - netPerformancePercentWithCurrencyEffect - ); - } - const name = raw._data.assetProfile.name; const symbol = raw._data.assetProfile.symbol; return [ isUUID(symbol) ? (name ?? symbol) : symbol, - `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%` + `${( + raw._data.netPerformancePercentWithCurrencyEffect * 100 + ).toLocaleString(this.locale(), { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + signDisplay: 'exceptZero' + })}%` ]; }, hoverColor: undefined, @@ -402,13 +397,21 @@ export class GfTreemapChartComponent label: (context: TooltipItem<'treemap'>) => { const raw = context.raw as GfTreemapDataPoint; - const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; + const allocationInPercentage = `${( + raw._data.allocationInPercentage * 100 + ).toLocaleString(this.locale(), { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + })}%`; const name = getHoldingName(raw._data.assetProfile); - const sign = - raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; - - const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; + const netPerformanceInPercentageWithSign = `${( + raw._data.netPerformancePercentWithCurrencyEffect * 100 + ).toLocaleString(this.locale(), { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + signDisplay: 'exceptZero' + })}%`; if (raw._data.valueInBaseCurrency !== null) { const value = raw._data.valueInBaseCurrency; @@ -421,11 +424,12 @@ export class GfTreemapChartComponent })} ${this.baseCurrency()}`, '', $localize`Change` + ' (' + $localize`Performance` + ')', - `${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( + `${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( this.locale(), { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + signDisplay: 'exceptZero' } )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` ];