diff --git a/CHANGELOG.md b/CHANGELOG.md index 744f3f6f7..6344c815f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Changed the performance calculation to a time-weighted approach +- Normalized the benchmark by currency in the benchmark comparator - Increased the timeout to load currencies in the exchange rate data service - Exposed the environment variable `REQUEST_TIMEOUT` - Used the `HasPermission` annotation in endpoints diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index 0fd47f4e2..ebee6ab8e 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -8,6 +8,7 @@ import type { UniqueAsset } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; import { Body, Controller, @@ -20,6 +21,7 @@ import { UseGuards, UseInterceptors } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { DataSource } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; @@ -28,7 +30,10 @@ import { BenchmarkService } from './benchmark.service'; @Controller('benchmark') export class BenchmarkController { - public constructor(private readonly benchmarkService: BenchmarkService) {} + public constructor( + private readonly benchmarkService: BenchmarkService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} @HasPermission(permissions.accessAdminControl) @Post() @@ -103,11 +108,13 @@ export class BenchmarkController { @Param('symbol') symbol: string ): Promise { const startDate = new Date(startDateString); + const userCurrency = this.request.user.Settings.settings.baseCurrency; return this.benchmarkService.getMarketDataBySymbol({ dataSource, startDate, - symbol + symbol, + userCurrency }); } } diff --git a/apps/api/src/app/benchmark/benchmark.module.ts b/apps/api/src/app/benchmark/benchmark.module.ts index c2cc3fbb5..b3a4d8f2e 100644 --- a/apps/api/src/app/benchmark/benchmark.module.ts +++ b/apps/api/src/app/benchmark/benchmark.module.ts @@ -2,6 +2,7 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; @@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service'; imports: [ ConfigurationModule, DataProviderModule, + ExchangeRateDataModule, MarketDataModule, PrismaModule, PropertyModule, diff --git a/apps/api/src/app/benchmark/benchmark.service.spec.ts b/apps/api/src/app/benchmark/benchmark.service.spec.ts index 5fa2c3e7b..42a29e6d1 100644 --- a/apps/api/src/app/benchmark/benchmark.service.spec.ts +++ b/apps/api/src/app/benchmark/benchmark.service.spec.ts @@ -11,6 +11,7 @@ describe('BenchmarkService', () => { null, null, null, + null, null ); }); diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index cbd5e7e1f..75cb52ea6 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -1,6 +1,7 @@ import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; 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'; @@ -11,7 +12,8 @@ import { } from '@ghostfolio/common/config'; import { DATE_FORMAT, - calculateBenchmarkTrend + calculateBenchmarkTrend, + parseDate } from '@ghostfolio/common/helper'; import { Benchmark, @@ -21,11 +23,11 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { BenchmarkTrend } from '@ghostfolio/common/types'; -import { Injectable } from '@nestjs/common'; +import { Injectable, Logger } from '@nestjs/common'; import { SymbolProfile } from '@prisma/client'; import Big from 'big.js'; -import { format, subDays } from 'date-fns'; -import { uniqBy } from 'lodash'; +import { format, isSameDay, subDays } from 'date-fns'; +import { isNumber, last, uniqBy } from 'lodash'; import ms from 'ms'; @Injectable() @@ -34,6 +36,7 @@ export class BenchmarkService { public constructor( private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService, private readonly prismaService: PrismaService, private readonly propertyService: PropertyService, @@ -203,8 +206,14 @@ export class BenchmarkService { public async getMarketDataBySymbol({ dataSource, startDate, - symbol - }: { startDate: Date } & UniqueAsset): Promise { + symbol, + userCurrency + }: { + startDate: Date; + userCurrency: string; + } & UniqueAsset): Promise { + const marketData: { date: string; value: number }[] = []; + const [currentSymbolItem, marketDataItems] = await Promise.all([ this.symbolService.get({ dataGatheringItem: { @@ -226,44 +235,101 @@ export class BenchmarkService { }) ]); + const exchangeRates = await this.exchangeRateDataService.getExchangeRates({ + currencyFrom: currentSymbolItem.currency, + currencyTo: userCurrency, + dates: marketDataItems.map(({ date }) => { + return date; + }) + }); + + const exchangeRateAtStartDate = + exchangeRates[format(startDate, DATE_FORMAT)]; + + if (!exchangeRateAtStartDate) { + Logger.error( + `No exchange rate has been found for ${ + currentSymbolItem.currency + }${userCurrency} at ${format(startDate, DATE_FORMAT)}`, + 'BenchmarkService' + ); + + return { marketData }; + } + + const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { + return isSameDay(date, startDate); + })?.marketPrice; + + if (!marketPriceAtStartDate) { + Logger.error( + `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + startDate, + DATE_FORMAT + )}`, + 'BenchmarkService' + ); + + return { marketData }; + } + const step = Math.round( marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS) ); - const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0; - const response = { - marketData: [ - ...marketDataItems - .filter((marketDataItem, index) => { - return index % step === 0; - }) - .map((marketDataItem) => { - return { - date: format(marketDataItem.date, DATE_FORMAT), - value: - marketPriceAtStartDate === 0 - ? 0 - : this.calculateChangeInPercentage( - marketPriceAtStartDate, - marketDataItem.marketPrice - ) * 100 - }; - }) - ] - }; + let i = 0; - if (currentSymbolItem?.marketPrice) { - response.marketData.push({ + for (let marketDataItem of marketDataItems) { + if (i % step !== 0) { + continue; + } + + const exchangeRate = + exchangeRates[format(marketDataItem.date, DATE_FORMAT)]; + + const exchangeRateFactor = + isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) + ? exchangeRate / exchangeRateAtStartDate + : 1; + + marketData.push({ + date: format(marketDataItem.date, DATE_FORMAT), + value: + marketPriceAtStartDate === 0 + ? 0 + : this.calculateChangeInPercentage( + marketPriceAtStartDate, + marketDataItem.marketPrice * exchangeRateFactor + ) * 100 + }); + } + + const includesToday = isSameDay( + parseDate(last(marketData).date), + new Date() + ); + + if (currentSymbolItem?.marketPrice && !includesToday) { + const exchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; + + const exchangeRateFactor = + isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) + ? exchangeRate / exchangeRateAtStartDate + : 1; + + marketData.push({ date: format(new Date(), DATE_FORMAT), value: this.calculateChangeInPercentage( marketPriceAtStartDate, - currentSymbolItem.marketPrice + currentSymbolItem.marketPrice * exchangeRateFactor ) * 100 }); } - return response; + return { + marketData + }; } public async addBenchmark({ diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 9eecd400d..5ddef02a5 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -34,6 +34,125 @@ export class ExchangeRateDataService { return this.currencyPairs; } + public async getExchangeRates({ + currencyFrom, + currencyTo, + dates + }: { + currencyFrom: string; + currencyTo: string; + dates: Date[]; + }) { + let 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({ + dateQuery: { in: dates }, + uniqueAssets: [ + { + dataSource, + symbol + } + ] + }); + + if (marketData?.length > 0) { + for (const { date, marketPrice } of marketData) { + factors[format(date, DATE_FORMAT)] = marketPrice; + } + } else { + // Calculate indirectly via base currency + + let marketPriceBaseCurrencyFromCurrency: { + [dateString: string]: number; + } = {}; + let 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({ + dateQuery: { in: dates }, + uniqueAssets: [ + { + dataSource, + symbol: `${DEFAULT_CURRENCY}${currencyFrom}` + } + ] + }); + + 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({ + dateQuery: { + in: dates + }, + uniqueAssets: [ + { + dataSource, + symbol: `${DEFAULT_CURRENCY}${currencyTo}` + } + ] + }); + + 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)]; + + factors[format(date, DATE_FORMAT)] = factor; + } catch { + Logger.error( + `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( + date, + DATE_FORMAT + )}`, + 'ExchangeRateDataService' + ); + } + } + } + } + + return factors; + } + public hasCurrencyPair(currency1: string, currency2: string) { return this.currencyPairs.some(({ symbol }) => { return (