Feature/benchmark currency correction (#2790)

* Convert benchmark performance to base currency

* Introduce getExchangeRates() for multiple dates

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/2806/head
gizmodus 5 months ago committed by GitHub
parent cb664774c0
commit 726e727c7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

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

@ -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<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({
dataSource,
startDate,
symbol
symbol,
userCurrency
});
}
}

@ -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,

@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
null,
null,
null,
null,
null
);
});

@ -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<BenchmarkMarketDataDetails> {
symbol,
userCurrency
}: {
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
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({

@ -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 (

Loading…
Cancel
Save