mirror of https://github.com/ghostfolio/ghostfolio
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
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;
|
|
});
|
|
}
|
|
}
|
|
|