|  |  | @ -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 }; | 
			
		
	
		
			
				
					|  |  |  |   } | 
			
		
	
		
			
				
					|  |  |  | } | 
			
		
	
	
		
			
				
					|  |  | 
 |