210 lines
5.2 KiB
TypeScript
210 lines
5.2 KiB
TypeScript
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
|
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
|
import { Holding } from '@ghostfolio/common/interfaces';
|
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
|
|
|
import { Injectable } from '@nestjs/common';
|
|
import { SymbolProfile } from '@prisma/client';
|
|
import { countries } from 'countries-list';
|
|
|
|
@Injectable()
|
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|
private static baseUrl = 'https://www.trackinsight.com/data-api';
|
|
private static countriesMapping = {
|
|
'Russian Federation': 'Russia'
|
|
};
|
|
private static holdingsWeightTreshold = 0.85;
|
|
private static sectorsMapping = {
|
|
'Consumer Discretionary': 'Consumer Cyclical',
|
|
'Consumer Defensive': 'Consumer Staples',
|
|
'Health Care': 'Healthcare',
|
|
'Information Technology': 'Technology'
|
|
};
|
|
|
|
public constructor(
|
|
private readonly configurationService: ConfigurationService
|
|
) {}
|
|
|
|
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 fetch(
|
|
`${TrackinsightDataEnhancerService.baseUrl}/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 fetch(
|
|
`${TrackinsightDataEnhancerService.baseUrl}/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 ?? {}
|
|
)) {
|
|
let countryCode: string;
|
|
|
|
for (const [code, country] of Object.entries(countries)) {
|
|
if (
|
|
country.name === name ||
|
|
country.name ===
|
|
TrackinsightDataEnhancerService.countriesMapping[name]
|
|
) {
|
|
countryCode = code;
|
|
break;
|
|
}
|
|
}
|
|
|
|
response.countries.push({
|
|
code: countryCode,
|
|
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: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
|
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 fetch(
|
|
`https://www.trackinsight.com/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(() => {
|
|
return undefined;
|
|
});
|
|
}
|
|
}
|