import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { DEFAULT_CURRENCY, DERIVED_CURRENCIES, PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; import { DATE_FORMAT, getYesterday, resetHours } from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; import { eachDayOfInterval, format, isBefore, isToday, subDays } from 'date-fns'; import { isNumber } from 'lodash'; import ms from 'ms'; @Injectable() export class ExchangeRateDataService { private currencies: string[] = []; private currencyPairs: IDataGatheringItem[] = []; private exchangeRates: { [currencyPair: string]: number } = {}; public constructor( private readonly dataProviderService: DataProviderService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService ) {} public getCurrencies() { return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY]; } public getCurrencyPairs() { return this.currencyPairs; } @LogPerformance public async getExchangeRatesByCurrency({ currencies, endDate = new Date(), startDate, targetCurrency }: { currencies: string[]; endDate?: Date; startDate: Date; targetCurrency: string; }) { if (!startDate) { return {}; } const exchangeRatesByCurrency: { [currency: string]: { [dateString: string]: number }; } = {}; for (const currency of currencies) { exchangeRatesByCurrency[`${currency}${targetCurrency}`] = await this.getExchangeRates({ startDate, currencyFrom: currency, currencyTo: targetCurrency }); const dateStrings = Object.keys( exchangeRatesByCurrency[`${currency}${targetCurrency}`] ); const lastDateString = dateStrings.reduce((a, b) => { return a > b ? a : b; }, undefined); let previousExchangeRate = exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ lastDateString ] ?? 1; // Start from the most recent date and fill in missing exchange rates // using the latest available rate for ( let date = endDate; !isBefore(date, startDate); date = subDays(resetHours(date), 1) ) { const dateString = format(date, DATE_FORMAT); // Check if the exchange rate for the current date is missing if ( isNaN( exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] ) ) { // If missing, fill with the previous exchange rate exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = previousExchangeRate; if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { Logger.error( `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, 'ExchangeRateDataService' ); } } else { // If available, update the previous exchange rate previousExchangeRate = exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString]; } } } return exchangeRatesByCurrency; } public hasCurrencyPair(currency1: string, currency2: string) { return this.currencyPairs.some(({ symbol }) => { return ( symbol === `${currency1}${currency2}` || symbol === `${currency2}${currency1}` ); }); } public async initialize() { this.currencies = await this.prepareCurrencies(); this.currencyPairs = []; this.exchangeRates = {}; for (const { currency1, currency2, dataSource } of this.prepareCurrencyPairs(this.currencies)) { this.currencyPairs.push({ dataSource, symbol: `${currency1}${currency2}` }); } await this.loadCurrencies(); } public async loadCurrencies() { const result = await this.dataProviderService.getHistorical( this.currencyPairs, 'day', getYesterday(), getYesterday() ); const quotes = await this.dataProviderService.getQuotes({ items: this.currencyPairs.map(({ dataSource, symbol }) => { return { dataSource, symbol }; }), requestTimeout: ms('30 seconds') }); for (const symbol of Object.keys(quotes)) { if (isNumber(quotes[symbol].marketPrice)) { result[symbol] = { [format(getYesterday(), DATE_FORMAT)]: { marketPrice: quotes[symbol].marketPrice } }; } } const resultExtended = result; for (const symbol of Object.keys(result)) { const [currency1, currency2] = symbol.match(/.{1,3}/g); const [date] = Object.keys(result[symbol]); // Calculate the opposite direction resultExtended[`${currency2}${currency1}`] = { [date]: { marketPrice: 1 / result[symbol][date].marketPrice } }; } for (const symbol of Object.keys(resultExtended)) { const [currency1, currency2] = symbol.match(/.{1,3}/g); const date = format(getYesterday(), DATE_FORMAT); this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice; if (!this.exchangeRates[symbol]) { // Not found, calculate indirectly via base currency this.exchangeRates[symbol] = resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date] ?.marketPrice * resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date] ?.marketPrice; // Calculate the opposite direction this.exchangeRates[`${currency2}${currency1}`] = 1 / this.exchangeRates[symbol]; } } } public toCurrency( aValue: number, aFromCurrency: string, aToCurrency: string ) { if (aValue === 0) { return 0; } let factor: number; if (aFromCurrency === aToCurrency) { factor = 1; } else { if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; } else { // Calculate indirectly via base currency const factor1 = this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`]; const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`]; factor = factor1 * factor2; this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor; } } if (isNumber(factor) && !isNaN(factor)) { return factor * aValue; } // Fallback with error, if currencies are not available Logger.error( `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, 'ExchangeRateDataService' ); return aValue; } public async toCurrencyAtDate( aValue: number, aFromCurrency: string, aToCurrency: string, aDate: Date ) { if (aValue === 0) { return 0; } if (isToday(aDate)) { return this.toCurrency(aValue, aFromCurrency, aToCurrency); } let factor: number; if (aFromCurrency === aToCurrency) { factor = 1; } else { const dataSource = this.dataProviderService.getDataSourceForExchangeRates(); const symbol = `${aFromCurrency}${aToCurrency}`; const marketData = await this.marketDataService.get({ dataSource, symbol, date: aDate }); if (marketData?.marketPrice) { factor = marketData?.marketPrice; } else { // Calculate indirectly via base currency let marketPriceBaseCurrencyFromCurrency: number; let marketPriceBaseCurrencyToCurrency: number; try { if (aFromCurrency === DEFAULT_CURRENCY) { marketPriceBaseCurrencyFromCurrency = 1; } else { marketPriceBaseCurrencyFromCurrency = ( await this.marketDataService.get({ dataSource, date: aDate, symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` }) )?.marketPrice; } } catch {} try { if (aToCurrency === DEFAULT_CURRENCY) { marketPriceBaseCurrencyToCurrency = 1; } else { marketPriceBaseCurrencyToCurrency = ( await this.marketDataService.get({ dataSource, date: aDate, symbol: `${DEFAULT_CURRENCY}${aToCurrency}` }) )?.marketPrice; } } catch {} // Calculate the opposite direction factor = (1 / marketPriceBaseCurrencyFromCurrency) * marketPriceBaseCurrencyToCurrency; } } if (isNumber(factor) && !isNaN(factor)) { return factor * aValue; } Logger.error( `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( aDate, DATE_FORMAT )}`, 'ExchangeRateDataService' ); return undefined; } private async getExchangeRates({ currencyFrom, currencyTo, endDate = new Date(), startDate }: { currencyFrom: string; currencyTo: string; endDate?: Date; startDate: Date; }) { const dates = eachDayOfInterval({ end: endDate, start: startDate }); const factors: { [dateString: string]: number } = {}; if (currencyFrom === currencyTo) { for (const date of dates) { factors[format(date, DATE_FORMAT)] = 1; } } else { const dataSource = this.dataProviderService.getDataSourceForExchangeRates(); const symbol = `${currencyFrom}${currencyTo}`; const marketData = await this.marketDataService.getRange({ assetProfileIdentifiers: [ { dataSource, symbol } ], dateQuery: { gte: startDate, lt: endDate } }); if (marketData?.length > 0) { for (const { date, marketPrice } of marketData) { factors[format(date, DATE_FORMAT)] = marketPrice; } } else { // Calculate indirectly via base currency const marketPriceBaseCurrencyFromCurrency: { [dateString: string]: number; } = {}; const marketPriceBaseCurrencyToCurrency: { [dateString: string]: number; } = {}; try { if (currencyFrom === DEFAULT_CURRENCY) { for (const date of dates) { marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] = 1; } } else { const marketData = await this.marketDataService.getRange({ assetProfileIdentifiers: [ { dataSource, symbol: `${DEFAULT_CURRENCY}${currencyFrom}` } ], dateQuery: { gte: startDate, lt: endDate } }); for (const { date, marketPrice } of marketData) { marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] = marketPrice; } } } catch {} try { if (currencyTo === DEFAULT_CURRENCY) { for (const date of dates) { marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1; } } else { const marketData = await this.marketDataService.getRange({ assetProfileIdentifiers: [ { dataSource, symbol: `${DEFAULT_CURRENCY}${currencyTo}` } ], dateQuery: { gte: startDate, lt: endDate } }); for (const { date, marketPrice } of marketData) { marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = marketPrice; } } } catch {} for (const date of dates) { try { const factor = (1 / marketPriceBaseCurrencyFromCurrency[ format(date, DATE_FORMAT) ]) * marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; if (isNaN(factor)) { throw new Error('Exchange rate is not a number'); } else { factors[format(date, DATE_FORMAT)] = factor; } } catch { let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( date, DATE_FORMAT )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`; if (DEFAULT_CURRENCY !== currencyTo) { errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; } Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); } } } } return factors; } private async prepareCurrencies(): Promise { let currencies: string[] = [DEFAULT_CURRENCY]; ( await this.prismaService.account.findMany({ distinct: ['currency'], orderBy: [{ currency: 'asc' }], select: { currency: true }, where: { currency: { not: null } } }) ).forEach(({ currency }) => { currencies.push(currency); }); ( await this.prismaService.symbolProfile.findMany({ distinct: ['currency'], orderBy: [{ currency: 'asc' }], select: { currency: true } }) ).forEach(({ currency }) => { currencies.push(currency); }); const customCurrencies = (await this.propertyService.getByKey( PROPERTY_CURRENCIES )) as string[]; if (customCurrencies?.length > 0) { currencies = currencies.concat(customCurrencies); } // Add derived currencies currencies.push('USX'); for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { if (currencies.includes(currency) || currencies.includes(rootCurrency)) { currencies.push(currency); currencies.push(rootCurrency); } } return Array.from(new Set(currencies)).filter(Boolean).sort(); } private prepareCurrencyPairs(aCurrencies: string[]) { return aCurrencies .filter((currency) => { return currency !== DEFAULT_CURRENCY; }) .map((currency) => { return { currency1: DEFAULT_CURRENCY, currency2: currency, dataSource: this.dataProviderService.getDataSourceForExchangeRates(), symbol: `${DEFAULT_CURRENCY}${currency}` }; }); } }