|
|
@ -1,4 +1,5 @@ |
|
|
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|
|
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; |
|
|
|
import { |
|
|
|
DataProviderInterface, |
|
|
|
GetDividendsParams, |
|
|
@ -10,7 +11,6 @@ import { |
|
|
|
IDataProviderHistoricalResponse, |
|
|
|
IDataProviderResponse |
|
|
|
} from '@ghostfolio/api/services/interfaces/interfaces'; |
|
|
|
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; |
|
|
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; |
|
|
|
import { |
|
|
|
DataProviderInfo, |
|
|
@ -19,8 +19,14 @@ import { |
|
|
|
} from '@ghostfolio/common/interfaces'; |
|
|
|
|
|
|
|
import { Injectable, Logger } from '@nestjs/common'; |
|
|
|
import { DataSource, SymbolProfile } from '@prisma/client'; |
|
|
|
import { |
|
|
|
AssetClass, |
|
|
|
AssetSubClass, |
|
|
|
DataSource, |
|
|
|
SymbolProfile |
|
|
|
} from '@prisma/client'; |
|
|
|
import { isISIN } from 'class-validator'; |
|
|
|
import { countries } from 'countries-list'; |
|
|
|
import { format, isAfter, isBefore, isSameDay } from 'date-fns'; |
|
|
|
|
|
|
|
@Injectable() |
|
|
@ -29,7 +35,8 @@ export class FinancialModelingPrepService implements DataProviderInterface { |
|
|
|
private readonly URL = this.getUrl({ version: 3 }); |
|
|
|
|
|
|
|
public constructor( |
|
|
|
private readonly configurationService: ConfigurationService |
|
|
|
private readonly configurationService: ConfigurationService, |
|
|
|
private readonly cryptocurrencyService: CryptocurrencyService |
|
|
|
) { |
|
|
|
this.apiKey = this.configurationService.get( |
|
|
|
'API_KEY_FINANCIAL_MODELING_PREP' |
|
|
@ -45,10 +52,152 @@ export class FinancialModelingPrepService implements DataProviderInterface { |
|
|
|
}: { |
|
|
|
symbol: string; |
|
|
|
}): Promise<Partial<SymbolProfile>> { |
|
|
|
return { |
|
|
|
const response: Partial<SymbolProfile> = { |
|
|
|
symbol, |
|
|
|
dataSource: this.getName() |
|
|
|
}; |
|
|
|
|
|
|
|
try { |
|
|
|
if (this.cryptocurrencyService.isCryptocurrency(symbol)) { |
|
|
|
const [quote] = await fetch( |
|
|
|
`${this.URL}/quote/${symbol}?apikey=${this.apiKey}`, |
|
|
|
{ |
|
|
|
signal: AbortSignal.timeout( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') |
|
|
|
) |
|
|
|
} |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
response.assetClass = AssetClass.LIQUIDITY; |
|
|
|
response.assetSubClass = AssetSubClass.CRYPTOCURRENCY; |
|
|
|
response.currency = symbol.substring(symbol.length - 3); |
|
|
|
response.name = quote.name; |
|
|
|
} else { |
|
|
|
const [assetProfile] = await fetch( |
|
|
|
`${this.URL}/profile/${symbol}?apikey=${this.apiKey}`, |
|
|
|
{ |
|
|
|
signal: AbortSignal.timeout( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') |
|
|
|
) |
|
|
|
} |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
const { assetClass, assetSubClass } = |
|
|
|
this.parseAssetClass(assetProfile); |
|
|
|
|
|
|
|
response.assetClass = assetClass; |
|
|
|
response.assetSubClass = assetSubClass; |
|
|
|
|
|
|
|
if (assetSubClass === AssetSubClass.ETF) { |
|
|
|
const etfCountryWeightings = await fetch( |
|
|
|
`${this.URL}/etf-country-weightings/${symbol}?apikey=${this.apiKey}`, |
|
|
|
{ |
|
|
|
signal: AbortSignal.timeout( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') |
|
|
|
) |
|
|
|
} |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
response.countries = etfCountryWeightings.map( |
|
|
|
({ country: countryName, weightPercentage }) => { |
|
|
|
let countryCode: string; |
|
|
|
|
|
|
|
for (const [code, country] of Object.entries(countries)) { |
|
|
|
if (country.name === countryName) { |
|
|
|
countryCode = code; |
|
|
|
break; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
return { |
|
|
|
code: countryCode, |
|
|
|
weight: parseFloat(weightPercentage.slice(0, -1)) / 100 |
|
|
|
}; |
|
|
|
} |
|
|
|
); |
|
|
|
|
|
|
|
const [portfolioDate] = await fetch( |
|
|
|
`${this.getUrl({ version: 4 })}/etf-holdings/portfolio-date?symbol=${symbol}&apikey=${this.apiKey}`, |
|
|
|
{ |
|
|
|
signal: AbortSignal.timeout( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') |
|
|
|
) |
|
|
|
} |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
if (portfolioDate) { |
|
|
|
const etfHoldings = await fetch( |
|
|
|
`${this.getUrl({ version: 4 })}/etf-holdings?date=${portfolioDate.date}&symbol=${symbol}&apikey=${this.apiKey}`, |
|
|
|
{ |
|
|
|
signal: AbortSignal.timeout( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') |
|
|
|
) |
|
|
|
} |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
const sortedTopHoldings = etfHoldings |
|
|
|
.sort((a, b) => { |
|
|
|
return b.pctVal - a.pctVal; |
|
|
|
}) |
|
|
|
.slice(0, 10); |
|
|
|
|
|
|
|
response.holdings = sortedTopHoldings.map(({ name, pctVal }) => { |
|
|
|
return { name, weight: pctVal / 100 }; |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
const etfSectorWeightings = await fetch( |
|
|
|
`${this.URL}/etf-sector-weightings/${symbol}?apikey=${this.apiKey}`, |
|
|
|
{ |
|
|
|
signal: AbortSignal.timeout( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') |
|
|
|
) |
|
|
|
} |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
response.sectors = etfSectorWeightings.map( |
|
|
|
({ sector, weightPercentage }) => { |
|
|
|
return { |
|
|
|
name: sector, |
|
|
|
weight: parseFloat(weightPercentage.slice(0, -1)) / 100 |
|
|
|
}; |
|
|
|
} |
|
|
|
); |
|
|
|
} else if (assetSubClass === AssetSubClass.STOCK) { |
|
|
|
if (assetProfile.country) { |
|
|
|
response.countries = [{ code: assetProfile.country, weight: 1 }]; |
|
|
|
} |
|
|
|
|
|
|
|
if (assetProfile.sector) { |
|
|
|
response.sectors = [{ name: assetProfile.sector, weight: 1 }]; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
response.currency = assetProfile.currency; |
|
|
|
|
|
|
|
if (assetProfile.isin) { |
|
|
|
response.isin = assetProfile.isin; |
|
|
|
} |
|
|
|
|
|
|
|
response.name = assetProfile.companyName; |
|
|
|
|
|
|
|
if (assetProfile.website) { |
|
|
|
response.url = assetProfile.website; |
|
|
|
} |
|
|
|
} |
|
|
|
} catch (error) { |
|
|
|
let message = error; |
|
|
|
|
|
|
|
if (error?.name === 'AbortError') { |
|
|
|
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( |
|
|
|
this.configurationService.get('REQUEST_TIMEOUT') / 1000 |
|
|
|
).toFixed(3)} seconds`;
|
|
|
|
} |
|
|
|
|
|
|
|
Logger.error(message, 'FinancialModelingPrepService'); |
|
|
|
} |
|
|
|
|
|
|
|
return response; |
|
|
|
} |
|
|
|
|
|
|
|
public getDataProviderInfo(): DataProviderInfo { |
|
|
@ -131,8 +280,10 @@ export class FinancialModelingPrepService implements DataProviderInterface { |
|
|
|
).then((res) => res.json()); |
|
|
|
|
|
|
|
for (const { price, symbol } of quotes) { |
|
|
|
const { currency } = await this.getAssetProfile({ symbol }); |
|
|
|
|
|
|
|
response[symbol] = { |
|
|
|
currency: DEFAULT_CURRENCY, |
|
|
|
currency, |
|
|
|
dataProviderInfo: this.getDataProviderInfo(), |
|
|
|
dataSource: DataSource.FINANCIAL_MODELING_PREP, |
|
|
|
marketPrice: price, |
|
|
@ -223,4 +374,25 @@ export class FinancialModelingPrepService implements DataProviderInterface { |
|
|
|
private getUrl({ version }: { version: number }) { |
|
|
|
return `https://financialmodelingprep.com/api/v${version}`; |
|
|
|
} |
|
|
|
|
|
|
|
public parseAssetClass(profile: any): { |
|
|
|
assetClass: AssetClass; |
|
|
|
assetSubClass: AssetSubClass; |
|
|
|
} { |
|
|
|
let assetClass: AssetClass; |
|
|
|
let assetSubClass: AssetSubClass; |
|
|
|
|
|
|
|
if (profile.isEtf) { |
|
|
|
assetClass = AssetClass.EQUITY; |
|
|
|
assetSubClass = AssetSubClass.ETF; |
|
|
|
} else if (profile.isFund) { |
|
|
|
assetClass = AssetClass.EQUITY; |
|
|
|
assetSubClass = AssetSubClass.MUTUALFUND; |
|
|
|
} else { |
|
|
|
assetClass = AssetClass.EQUITY; |
|
|
|
assetSubClass = AssetSubClass.STOCK; |
|
|
|
} |
|
|
|
|
|
|
|
return { assetClass, assetSubClass }; |
|
|
|
} |
|
|
|
} |
|
|
|