You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

224 lines
5.8 KiB

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';
@Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com';
private static countriesMapping = {
'Republic of Korea': 'KR',
'Russian Federation': 'RU',
Turkey: 'TR',
USA: 'US',
'Virgin Islands, British': 'VG'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping: Record<string, SectorName> = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Staples': 'Consumer Defensive',
Financials: 'Financial Services',
'Health Care': 'Healthcare',
'Information Technology': 'Technology',
Materials: 'Basic Materials'
};
private readonly logger = new Logger(TrackinsightDataEnhancerService.name);
public constructor(
private readonly configurationService: ConfigurationService,
private readonly fetchService: FetchService
) {}
public async enhance({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
response,
symbol
}: {
requestTimeout?: number;
response: Partial<SymbolProfile>;
symbol: string;
}): Promise<Partial<SymbolProfile>> {
if (
!(
response.assetClass === 'EQUITY' &&
['ETF', 'MUTUALFUND'].includes(response.assetSubClass)
)
) {
return response;
}
let trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol
});
if (!trackinsightSymbol) {
trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol: symbol.split('.')?.[0]
});
}
if (!trackinsightSymbol) {
return response;
}
const profile = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.catch(() => {
return {};
});
const cusip = profile?.cusip;
if (cusip) {
response.cusip = cusip;
}
const isin = profile?.isins?.[0];
if (isin) {
response.isin = isin;
}
const holdings = await this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/holdings/${trackinsightSymbol}.json`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.catch(() => {
return {};
});
if (
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold
) {
// Skip if data is inaccurate
return response;
}
if (
!response.countries ||
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {}
)) {
response.countries.push({
code: getCountryCodeByName({
name,
aliases: TrackinsightDataEnhancerService.countriesMapping
}),
weight: value.weight
});
}
}
if (
!response.holdings ||
(response.holdings as unknown as Holding[]).length === 0
) {
response.holdings = [];
for (const { label, weight } of holdings?.topHoldings ?? []) {
if (label?.toLowerCase() === 'other') {
continue;
}
response.holdings.push({
weight,
name: label
});
}
}
if (
!response.sectors ||
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {
response.sectors.push({
name: getSectorName({
name,
aliases: TrackinsightDataEnhancerService.sectorsMapping
}),
weight: value.weight
});
}
}
return Promise.resolve(response);
}
public getName() {
return 'TRACKINSIGHT';
}
public getTestSymbol() {
return 'QQQ';
}
private async searchTrackinsightSymbol({
requestTimeout,
symbol
}: {
requestTimeout: number;
symbol: string;
}) {
return this.fetchService
.fetch(
`${TrackinsightDataEnhancerService.baseUrl}/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.then((jsonRes) => {
if (
jsonRes['results']?.['count'] === 1 ||
// Allow exact match
jsonRes['results']?.['docs']?.[0]?.['ticker'] === symbol ||
// Allow EXCHANGE:SYMBOL
jsonRes['results']?.['docs']?.[0]?.['ticker']?.endsWith(`:${symbol}`)
) {
return jsonRes['results']['docs'][0]['ticker'];
}
return undefined;
})
.catch(({ message }) => {
this.logger.error(
`Failed to search Trackinsight symbol for ${symbol} (${message})`
);
return undefined;
});
}
}