diff --git a/CHANGELOG.md b/CHANGELOG.md index 07b37e80b..bbcbeb7db 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 asset profile data in the _Financial Modeling Prep_ service - Extended the search by `isin` in the _Financial Modeling Prep_ service - Switched to _ESLint_’s flat config format - Upgraded `eslint` dependencies diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts index edde92cbb..af8a9e9a2 100644 --- a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -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> { - return { + const response: Partial = { 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 }; + } }