diff --git a/apps/api/src/helper/sector.helper.ts b/apps/api/src/helper/sector.helper.ts new file mode 100644 index 000000000..821daaac8 --- /dev/null +++ b/apps/api/src/helper/sector.helper.ts @@ -0,0 +1,29 @@ +import { SECTORS, UNKNOWN_KEY } from '@ghostfolio/common/config'; + +import { Logger } from '@nestjs/common'; + +export function getSectorName({ + aliases = {}, + name +}: { + aliases?: Record; + name: string; +}): string { + const mappedName = aliases[name]; + + if (mappedName) { + return mappedName; + } + + if ((SECTORS as readonly string[]).includes(name)) { + return name; + } + + if (name) { + const logger = new Logger('getSectorName'); + + logger.warn(`Could not map the sector "${name}" to the ontology`); + } + + return UNKNOWN_KEY; +} diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 3d42de443..c8291a901 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -1,10 +1,12 @@ import { getCountryCodeByName } from '@ghostfolio/api/helper/country.helper'; +import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { FetchService } from '@ghostfolio/api/services/fetch/fetch.service'; import { Holding } from '@ghostfolio/common/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; +import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; @@ -17,11 +19,13 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { USA: 'United States' }; private static holdingsWeightTreshold = 0.85; - private static sectorsMapping = { + private static sectorsMapping: Record = { 'Consumer Discretionary': 'Consumer Cyclical', - 'Consumer Defensive': 'Consumer Staples', + 'Consumer Staples': 'Consumer Defensive', + Financials: 'Financial Services', 'Health Care': 'Healthcare', - 'Information Technology': 'Technology' + 'Information Technology': 'Technology', + Materials: 'Basic Materials' }; private readonly logger = new Logger(TrackinsightDataEnhancerService.name); @@ -155,7 +159,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { holdings?.sectors ?? {} )) { response.sectors.push({ - name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, + name: getSectorName({ + name, + aliases: TrackinsightDataEnhancerService.sectorsMapping + }), weight: value.weight }); } diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts index 034916a5f..4fb0e96ed 100644 --- a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -1,12 +1,13 @@ +import { getSectorName } from '@ghostfolio/api/helper/sector.helper'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DEFAULT_CURRENCY, - REPLACE_NAME_PARTS, - UNKNOWN_KEY + REPLACE_NAME_PARTS } from '@ghostfolio/common/config'; import { isCurrency } from '@ghostfolio/common/helper'; +import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { @@ -23,6 +24,20 @@ import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; @Injectable() export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private static sectorsMapping: Record = { + basic_materials: 'Basic Materials', + communication_services: 'Communication Services', + consumer_cyclical: 'Consumer Cyclical', + consumer_defensive: 'Consumer Defensive', + energy: 'Energy', + financial_services: 'Financial Services', + healthcare: 'Healthcare', + industrials: 'Industrials', + realestate: 'Real Estate', + technology: 'Technology', + utilities: 'Utilities' + }; + private readonly logger = new Logger(YahooFinanceDataEnhancerService.name); private readonly yahooFinance = new YahooFinance({ @@ -224,7 +239,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { .flatMap((sectorWeighting) => { return Object.entries(sectorWeighting).map(([sector, weight]) => { return { - name: this.parseSector(sector), + name: getSectorName({ + aliases: YahooFinanceDataEnhancerService.sectorsMapping, + name: sector + }), weight: weight as number }; }); @@ -331,46 +349,4 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { return { assetClass, assetSubClass }; } - - private parseSector(aString: string) { - let sector = UNKNOWN_KEY; - - switch (aString) { - case 'basic_materials': - sector = 'Basic Materials'; - break; - case 'communication_services': - sector = 'Communication Services'; - break; - case 'consumer_cyclical': - sector = 'Consumer Cyclical'; - break; - case 'consumer_defensive': - sector = 'Consumer Staples'; - break; - case 'energy': - sector = 'Energy'; - break; - case 'financial_services': - sector = 'Financial Services'; - break; - case 'healthcare': - sector = 'Healthcare'; - break; - case 'industrials': - sector = 'Industrials'; - break; - case 'realestate': - sector = 'Real Estate'; - break; - case 'technology': - sector = 'Technology'; - break; - case 'utilities': - sector = 'Utilities'; - break; - } - - return sector; - } } diff --git a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts index 8c42e37ea..1462bd3c8 100644 --- a/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts +++ b/apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts @@ -441,6 +441,8 @@ export class GfHoldingDetailDialogComponent implements OnInit { if (SymbolProfile?.sectors?.length > 0) { for (const sector of SymbolProfile.sectors) { + sector.name = translate(sector.name); + this.sectors[sector.name] = { name: sector.name, value: sector.weight diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index f48b551bb..792f32bf5 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -442,7 +442,7 @@ export class GfAllocationsPageComponent implements OnInit { : position.valueInPercentage); } else { this.sectors[name] = { - name, + name: translate(name), value: weight * (isNumber(position.valueInBaseCurrency) diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index 28d902d71..14b8647ca 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -2,7 +2,7 @@ import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; import { JobOptions, JobStatus } from 'bull'; import ms from 'ms'; -import { ColorScheme, DateRange } from './types'; +import { ColorScheme, DateRange, SectorName } from './types'; export const ghostfolioPrefix = 'GF'; export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; @@ -55,6 +55,21 @@ export const ASSET_CLASS_MAPPING = new Map([ export const BULL_BOARD_COOKIE_NAME = 'bull_board_token'; +export const SECTORS = [ + 'Basic Materials', + 'Communication Services', + 'Consumer Cyclical', + 'Consumer Defensive', + 'Energy', + 'Financial Services', + 'Healthcare', + 'Industrials', + 'Other', + 'Real Estate', + 'Technology', + 'Utilities' +] as const satisfies readonly SectorName[]; + /** * WARNING: This route is mirrored in `apps/client/proxy.conf.json`. * If you update this value, you must also update the proxy configuration. diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts index 781e50c55..b6e513a51 100644 --- a/libs/common/src/lib/types/index.ts +++ b/libs/common/src/lib/types/index.ts @@ -17,6 +17,7 @@ import type { MarketState } from './market-state.type'; import type { Market } from './market.type'; import type { OrderWithAccount } from './order-with-account.type'; import type { RequestWithUser } from './request-with-user.type'; +import type { SectorName } from './sector-name.type'; import type { SubscriptionOfferKey } from './subscription-offer-key.type'; import type { UserWithSettings } from './user-with-settings.type'; import type { ViewMode } from './view-mode.type'; @@ -41,6 +42,7 @@ export type { MarketState, OrderWithAccount, RequestWithUser, + SectorName, SubscriptionOfferKey, UserWithSettings, ViewMode diff --git a/libs/common/src/lib/types/sector-name.type.ts b/libs/common/src/lib/types/sector-name.type.ts new file mode 100644 index 000000000..5cad2514a --- /dev/null +++ b/libs/common/src/lib/types/sector-name.type.ts @@ -0,0 +1,13 @@ +export type SectorName = + | 'Basic Materials' + | 'Communication Services' + | 'Consumer Cyclical' + | 'Consumer Defensive' + | 'Energy' + | 'Financial Services' + | 'Healthcare' + | 'Industrials' + | 'Other' + | 'Real Estate' + | 'Technology' + | 'Utilities'; diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts index c7d8b7c8b..f6f1e8ff9 100644 --- a/libs/ui/src/lib/i18n.ts +++ b/libs/ui/src/lib/i18n.ts @@ -1,3 +1,5 @@ +import type { SectorName } from '@ghostfolio/common/types'; + import '@angular/localize/init'; const locales = { @@ -107,8 +109,22 @@ const locales = { EXTREME_GREED: $localize`Extreme Greed`, FEAR: $localize`Fear`, GREED: $localize`Greed`, - NEUTRAL: $localize`Neutral` -}; + NEUTRAL: $localize`Neutral`, + + // Sectors + 'Basic Materials': $localize`Basic Materials`, + 'Communication Services': $localize`Communication Services`, + 'Consumer Cyclical': $localize`Consumer Cyclical`, + 'Consumer Defensive': $localize`Consumer Defensive`, + Energy: $localize`Energy`, + 'Financial Services': $localize`Financial Services`, + Healthcare: $localize`Healthcare`, + Industrials: $localize`Industrials`, + Other: $localize`Other`, + 'Real Estate': $localize`Real Estate`, + Technology: $localize`Technology`, + Utilities: $localize`Utilities` +} satisfies Record & Record; export function translate(aKey: string): string { return locales[aKey] ?? aKey;