diff --git a/CHANGELOG.md b/CHANGELOG.md index 8200209ec6..ece8033a81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Extended the sectors mapping in the data enhancer for asset profile data via _Trackinsight_ - Improved the logging of the `web_fetch` tool in the `FetchService` - Improved the validation of the `WEB_FETCH_ROUTES` setting in the `FetchService` - Improved the validation of the response of the `web_fetch` tool in the `FetchService` diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.spec.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.spec.ts index 8f6720fddf..978f41f669 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.spec.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.spec.ts @@ -327,6 +327,102 @@ describe('TrackinsightDataEnhancerService', () => { expect(response.holdings).toEqual([{ name: 'NVIDIA Corp.', weight: 0.08 }]); }); + it.each([ + ['Business Services', 'Industrials'], + ['Consumer Cyclicals', 'Consumer Cyclical'], + ['Consumer Discretionary', 'Consumer Cyclical'], + ['Consumer Non-Cyclicals', 'Consumer Defensive'], + ['Consumer Services', 'Consumer Cyclical'], + ['Consumer Staples', 'Consumer Defensive'], + ['Finance', 'Financial Services'], + ['Financials', 'Financial Services'], + ['Health Care', 'Healthcare'], + ['Information Technology', 'Technology'], + ['Materials', 'Basic Materials'], + ['Non-Energy Materials', 'Basic Materials'], + ['Telecommunications', 'Communication Services'] + ])('should map the sector %s to %s', async (name, expected) => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + mockResponses({ + holdings: Response.json({ + ...HOLDINGS, + sectors: { [name]: { weight: 0.5 } } + }) + }); + + const response = await enhance(); + + expect(response.sectors).toEqual([{ name: expected, weight: 0.5 }]); + expect(warn).not.toHaveBeenCalled(); + + warn.mockRestore(); + }); + + it('should merge the sectors with the same mapping', async () => { + mockResponses({ + holdings: Response.json({ + ...HOLDINGS, + sectors: { + 'Business Services': { weight: 0.25 }, + 'Consumer Cyclicals': { weight: 0.125 }, + 'Consumer Services': { weight: 0.125 }, + Industrials: { weight: 0.5 } + } + }) + }); + + const response = await enhance(); + + expect(response.sectors).toEqual([ + { name: 'Industrials', weight: 0.75 }, + { name: 'Consumer Cyclical', weight: 0.25 } + ]); + }); + + it('should merge the sectors without floating point noise', async () => { + mockResponses({ + holdings: Response.json({ + ...HOLDINGS, + sectors: { + 'Consumer Cyclicals': { weight: 0.1 }, + 'Consumer Services': { weight: 0.2 } + } + }) + }); + + const response = await enhance(); + + expect(response.sectors).toEqual([ + { name: 'Consumer Cyclical', weight: 0.3 } + ]); + }); + + it('should merge the sectors without a mapping', async () => { + const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + + mockResponses({ + holdings: Response.json({ + ...HOLDINGS, + sectors: { + 'New Sector A': { weight: 0.25 }, + 'New Sector B': { weight: 0.25 }, + Technology: { weight: 0.5 } + } + }) + }); + + const response = await enhance(); + + expect(response.sectors).toEqual([ + { name: 'Other', weight: 0.5 }, + { name: 'Technology', weight: 0.5 } + ]); + expect(warn).toHaveBeenCalledTimes(2); + + warn.mockRestore(); + }); + it('should accept floating point noise in a weight', async () => { mockResponses({ holdings: Response.json({ 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 6956e8cf75..5815131000 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 @@ -10,6 +10,7 @@ import { SectorName } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; +import { Big } from 'big.js'; import { isPlainObject } from 'lodash-es'; @Injectable() @@ -29,12 +30,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { private static holdingsWeightTreshold = 0.85; private static sectorsMapping: Record = { + 'Business Services': 'Industrials', + 'Consumer Cyclicals': 'Consumer Cyclical', 'Consumer Discretionary': 'Consumer Cyclical', + 'Consumer Non-Cyclicals': 'Consumer Defensive', + 'Consumer Services': 'Consumer Cyclical', 'Consumer Staples': 'Consumer Defensive', + Finance: 'Financial Services', Financials: 'Financial Services', 'Health Care': 'Healthcare', 'Information Technology': 'Technology', - Materials: 'Basic Materials' + Materials: 'Basic Materials', + 'Non-Energy Materials': 'Basic Materials', + Telecommunications: 'Communication Services' }; // Allows for floating point noise in the sums of Trackinsight @@ -163,17 +171,25 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { !response.sectors || (response.sectors as unknown as Sector[]).length === 0 ) { - response.sectors = []; + // Several names of Trackinsight can map to the same sector + const weightsBySector: Record = {}; for (const [name, value] of Object.entries(holdings.sectors ?? {})) { - response.sectors.push({ - name: getSectorName({ - name, - aliases: TrackinsightDataEnhancerService.sectorsMapping - }), - weight: value.weight + const sectorName = getSectorName({ + name, + aliases: TrackinsightDataEnhancerService.sectorsMapping }); + + weightsBySector[sectorName] = new Big(weightsBySector[sectorName] ?? 0) + .plus(value.weight) + .toNumber(); } + + response.sectors = Object.entries(weightsBySector).map( + ([name, weight]) => { + return { name, weight }; + } + ); } return Promise.resolve(response);