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
|
||||||
|
|
||||||
- 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
|
||||||
|
@ -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,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,
|
||||||
|
@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
|
|||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
|
null,
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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: [
|
|
||||||
...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
|
|
||||||
};
|
|
||||||
})
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
if (currentSymbolItem?.marketPrice) {
|
for (let marketDataItem of marketDataItems) {
|
||||||
response.marketData.push({
|
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),
|
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({
|
||||||
|
@ -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…
x
Reference in New Issue
Block a user