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>
This commit is contained in:
parent
cb664774c0
commit
726e727c7d
@ -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 {
|
||||
let i = 0;
|
||||
|
||||
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
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
marketDataItem.marketPrice * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
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 * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
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…
x
Reference in New Issue
Block a user