Browse Source

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 1 year ago
committed by GitHub
parent
commit
726e727c7d
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      CHANGELOG.md
  2. 11
      apps/api/src/app/benchmark/benchmark.controller.ts
  3. 2
      apps/api/src/app/benchmark/benchmark.module.ts
  4. 1
      apps/api/src/app/benchmark/benchmark.service.spec.ts
  5. 114
      apps/api/src/app/benchmark/benchmark.service.ts
  6. 119
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

1
CHANGELOG.md

@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed the performance calculation to a time-weighted approach - 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 - Increased the timeout to load currencies in the exchange rate data service
- Exposed the environment variable `REQUEST_TIMEOUT` - Exposed the environment variable `REQUEST_TIMEOUT`
- Used the `HasPermission` annotation in endpoints - Used the `HasPermission` annotation in endpoints

11
apps/api/src/app/benchmark/benchmark.controller.ts

@ -8,6 +8,7 @@ import type {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -20,6 +21,7 @@ import {
UseGuards, UseGuards,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -28,7 +30,10 @@ import { BenchmarkService } from './benchmark.service';
@Controller('benchmark') @Controller('benchmark')
export class BenchmarkController { export class BenchmarkController {
public constructor(private readonly benchmarkService: BenchmarkService) {} public constructor(
private readonly benchmarkService: BenchmarkService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Post() @Post()
@ -103,11 +108,13 @@ export class BenchmarkController {
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const startDate = new Date(startDateString); const startDate = new Date(startDateString);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataBySymbol({ return this.benchmarkService.getMarketDataBySymbol({
dataSource, dataSource,
startDate, startDate,
symbol symbol,
userCurrency
}); });
} }
} }

2
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 { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
@ -17,6 +18,7 @@ import { BenchmarkService } from './benchmark.service';
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule, PropertyModule,

1
apps/api/src/app/benchmark/benchmark.service.spec.ts

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

114
apps/api/src/app/benchmark/benchmark.service.ts

@ -1,6 +1,7 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -11,7 +12,8 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
calculateBenchmarkTrend calculateBenchmarkTrend,
parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
Benchmark, Benchmark,
@ -21,11 +23,11 @@ import {
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { BenchmarkTrend } from '@ghostfolio/common/types'; import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { format, subDays } from 'date-fns'; import { format, isSameDay, subDays } from 'date-fns';
import { uniqBy } from 'lodash'; import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
@ -34,6 +36,7 @@ export class BenchmarkService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
@ -203,8 +206,14 @@ export class BenchmarkService {
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
startDate, startDate,
symbol symbol,
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> { userCurrency
}: {
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const [currentSymbolItem, marketDataItems] = await Promise.all([ const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({ this.symbolService.get({
dataGatheringItem: { 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( const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS) marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
); );
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0; let i = 0;
const response = {
marketData: [ for (let marketDataItem of marketDataItems) {
...marketDataItems if (i % step !== 0) {
.filter((marketDataItem, index) => { continue;
return index % step === 0; }
})
.map((marketDataItem) => { const exchangeRate =
return { exchangeRates[format(marketDataItem.date, DATE_FORMAT)];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT), date: format(marketDataItem.date, DATE_FORMAT),
value: value:
marketPriceAtStartDate === 0 marketPriceAtStartDate === 0
? 0 ? 0
: this.calculateChangeInPercentage( : this.calculateChangeInPercentage(
marketPriceAtStartDate, marketPriceAtStartDate,
marketDataItem.marketPrice marketDataItem.marketPrice * exchangeRateFactor
) * 100 ) * 100
}; });
}) }
]
}; const includesToday = isSameDay(
parseDate(last(marketData).date),
new Date()
);
if (currentSymbolItem?.marketPrice) { if (currentSymbolItem?.marketPrice && !includesToday) {
response.marketData.push({ 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), date: format(new Date(), DATE_FORMAT),
value: value:
this.calculateChangeInPercentage( this.calculateChangeInPercentage(
marketPriceAtStartDate, marketPriceAtStartDate,
currentSymbolItem.marketPrice currentSymbolItem.marketPrice * exchangeRateFactor
) * 100 ) * 100
}); });
} }
return response; return {
marketData
};
} }
public async addBenchmark({ public async addBenchmark({

119
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -34,6 +34,125 @@ export class ExchangeRateDataService {
return this.currencyPairs; 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) { public hasCurrencyPair(currency1: string, currency2: string) {
return this.currencyPairs.some(({ symbol }) => { return this.currencyPairs.some(({ symbol }) => {
return ( return (

Loading…
Cancel
Save