introduce exchange rate service to fetch rates based on date query

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/270/head
Valentin Zickner 3 years ago
parent e255b76053
commit a91f6c783e

@ -2,8 +2,10 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DataSource, MarketData } from '@prisma/client';
import { Big } from 'big.js';
import { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
@ -57,6 +59,26 @@ jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
getExchangeRates: ({
dateQuery,
sourceCurrencies,
destinationCurrency
}: {
dateQuery: DateQuery;
sourceCurrencies: string[];
destinationCurrency: string;
}) => {
return [
{
date: new Date(),
exchangeRates: {
USD: new Big(1),
CHF: new Big(1),
EUR: new Big(1)
}
}
];
},
toCurrency: (value: number) => {
return 1 * value;
}

@ -1,9 +1,10 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { format, isAfter, isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
@ -77,6 +78,13 @@ export class CurrentRateService {
}[]
>[] = [];
const sourceCurrencies = Object.values(currencies);
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
dateQuery,
sourceCurrencies,
destinationCurrency: userCurrency
});
if (includeToday) {
const today = resetHours(new Date());
promises.push(
@ -112,17 +120,59 @@ export class CurrentRateService {
symbols
})
.then((data) => {
return data.map((marketDataItem) => {
return {
date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency(
const result = [];
let j = 0;
for (const marketDataItem of data) {
const currency = currencies[marketDataItem.symbol];
while (
j + 1 < exchangeRates.length &&
!isAfter(exchangeRates[j + 1].date, marketDataItem.date)
) {
j++;
}
let exchangeRate: Big;
if (currency !== userCurrency) {
exchangeRate = exchangeRates[j]?.exchangeRates[currency];
for (
let k = j;
k >= 0 && !exchangeRates[k]?.exchangeRates[currency];
k--
) {
exchangeRate = exchangeRates[k]?.exchangeRates[currency];
}
} else {
exchangeRate = new Big(1);
}
let marketPrice: number;
if (exchangeRate) {
marketPrice = exchangeRate
.mul(marketDataItem.marketPrice)
.toNumber();
} else {
if (!isToday(marketDataItem.date)) {
Logger.error(
`Failed to get exchange rate for ${
currencies[marketDataItem.symbol]
} to ${userCurrency} at ${format(
marketDataItem.date,
DATE_FORMAT
)}, using today's exchange rate as a fallback`
);
}
marketPrice = this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
);
}
result.push({
date: marketDataItem.date,
marketPrice: marketPrice,
symbol: marketDataItem.symbol
};
});
});
}
return result;
})
);

@ -26,7 +26,7 @@ import {
baseCurrency,
ghostfolioCashSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
@ -43,7 +43,7 @@ import type {
OrderWithAccount,
RequestWithUser
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
@ -52,6 +52,7 @@ import {
format,
isAfter,
isBefore,
isToday,
max,
parse,
parseISO,
@ -735,20 +736,24 @@ export class PortfolioService {
};
}
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
public getFees(
orders: OrderWithAccount[],
exchangeRates: { [date: string]: { [currency: string]: Big } },
date = new Date(0)
) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
.map((order) =>
this.convertCurrency({
exchangeRates,
...order,
value: order.fee
})
)
.reduce((previous, current) => current.plus(previous), new Big(0));
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
@ -786,6 +791,7 @@ export class PortfolioService {
currency,
userId
);
const exchangeRates = await this.exchangeRateForOrders(orders);
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
@ -831,7 +837,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees(orders)
this.getFees(orders, exchangeRates).toNumber()
)
],
{ baseCurrency: currency }
@ -851,11 +857,20 @@ export class PortfolioService {
currency
);
const orders = await this.orderService.getOrders({ userId });
const fees = this.getFees(orders);
const exchangeRates = await this.exchangeRateForOrders(orders);
const fees = this.getFees(orders, exchangeRates);
const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
const totalBuy = this.getTotalByType(
orders,
TypeOfOrder.BUY,
exchangeRates
);
const totalSell = this.getTotalByType(
orders,
TypeOfOrder.SELL,
exchangeRates
);
const committedFunds = new Big(totalBuy).sub(totalSell);
@ -865,14 +880,14 @@ export class PortfolioService {
return {
...performanceInformation.performance,
fees,
firstOrderDate,
netWorth,
cash: balance,
committedFunds: committedFunds.toNumber(),
fees: fees.toNumber(),
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
totalBuy: totalBuy.toNumber(),
totalSell: totalSell.toNumber()
};
}
@ -980,28 +995,25 @@ export class PortfolioService {
}
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const exchangeRates = await this.exchangeRateForOrders(orders);
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
)
),
fee: this.convertCurrency({
exchangeRates,
...order,
value: order.fee
}),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
order.currency,
userCurrency
)
)
unitPrice: this.convertCurrency({
exchangeRates,
...order,
value: order.unitPrice
})
}));
const portfolioCalculator = new PortfolioCalculator(
@ -1071,6 +1083,60 @@ export class PortfolioService {
return accounts;
}
private convertCurrency({
exchangeRates,
date,
currency,
value
}: {
exchangeRates: { [date: string]: { [currency: string]: Big } };
date: Date;
currency: string;
value: number | Big;
}): Big {
const exchangeRate = exchangeRates[format(date, DATE_FORMAT)]?.[currency];
if (exchangeRate) {
return exchangeRate?.mul(value);
}
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
if (!isToday(date)) {
Logger.error(
`Failed to convert value for date ${format(
date,
DATE_FORMAT
)} from ${currency} to ${userCurrency}`
);
}
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(value).toNumber(),
currency,
userCurrency
)
);
}
private async exchangeRateForOrders(
orders: OrderWithAccount[]
): Promise<{ [date: string]: { [currency: string]: Big } }> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const dates = orders.map((order) => resetHours(order.date));
const exchangeRates = await this.exchangeRateDataService.getExchangeRates({
dateQuery: {
in: dates
},
sourceCurrencies: orders.map((order) => order.currency),
destinationCurrency: userCurrency
});
const exchangeRateLookupMap = {};
for (const exchangeRate of exchangeRates) {
exchangeRateLookupMap[format(exchangeRate.date, DATE_FORMAT)] =
exchangeRate.exchangeRates;
}
return exchangeRateLookupMap;
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
@ -1083,20 +1149,20 @@ export class PortfolioService {
private getTotalByType(
orders: OrderWithAccount[],
currency: string,
type: TypeOfOrder
type: TypeOfOrder,
exchangeRates: { [date: string]: { [currency: string]: Big } }
) {
return orders
.filter(
(order) => !isAfter(order.date, endOfToday()) && order.type === type
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
currency
);
})
.reduce((previous, current) => previous + current, 0);
.map((order) =>
this.convertCurrency({
exchangeRates,
...order,
value: order.quantity * order.unitPrice
})
)
.reduce((previous, current) => current.plus(previous), new Big(0));
}
}

@ -1,5 +1,6 @@
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
@ -7,7 +8,7 @@ import { PropertyModule } from './property/property.module';
@Module({
imports: [DataProviderModule, PrismaModule, PropertyModule],
providers: [ExchangeRateDataService],
providers: [ExchangeRateDataService, MarketDataService],
exports: [ExchangeRateDataService]
})
export class ExchangeRateDataModule {}

@ -0,0 +1,201 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { MarketData } from '@prisma/client';
import { Big } from 'big.js';
import { addDays, endOfDay, isBefore } from 'date-fns';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
MarketDataService: jest.fn().mockImplementation(() => {
return {
getRange: ({
dateQuery,
symbols
}: {
dateQuery: DateQuery;
symbols: string[];
}) => {
const exchangeRateMap = {
USDEUR: 1,
USDCHF: 2,
USDUSD: 0
};
const result = [];
let j = 1;
for (
let i = dateQuery.gte;
isBefore(i, dateQuery.lt);
i = addDays(i, 1)
) {
const marketPrice = j++;
for (const symbol of symbols) {
result.push({
createdAt: i,
date: i,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: marketPrice * exchangeRateMap[symbol] + 1,
symbol: symbol
});
}
}
return Promise.resolve<MarketData[]>(result);
}
};
})
};
});
describe('ExchangeRateDataService', () => {
let exchangeRateDataService: ExchangeRateDataService;
let marketDataService: MarketDataService;
beforeAll(async () => {
marketDataService = new MarketDataService(null);
exchangeRateDataService = new ExchangeRateDataService(
null,
marketDataService,
null
);
});
describe('getExchangeRates', () => {
it('source and destination USD', async () => {
const startDate = new Date(2021, 0, 1);
const exchangeRates = await exchangeRateDataService.getExchangeRates({
dateQuery: {
gte: startDate,
lt: endOfDay(startDate)
},
sourceCurrencies: ['USD'],
destinationCurrency: 'USD'
});
expect(exchangeRates).toEqual([
{
date: startDate,
exchangeRates: {
USD: new Big(1)
}
}
]);
});
it('source USD and destination CHF', async () => {
const startDate = new Date(2021, 0, 1);
const exchangeRates = await exchangeRateDataService.getExchangeRates({
dateQuery: {
gte: startDate,
lt: endOfDay(startDate)
},
sourceCurrencies: ['USD'],
destinationCurrency: 'CHF'
});
expect(exchangeRates).toEqual([
{
date: startDate,
exchangeRates: {
USD: new Big(3)
}
}
]);
});
it('source CHF and destination USD', async () => {
const startDate = new Date(2021, 0, 1);
const exchangeRates = await exchangeRateDataService.getExchangeRates({
dateQuery: {
gte: startDate,
lt: endOfDay(startDate)
},
sourceCurrencies: ['CHF'],
destinationCurrency: 'USD'
});
expect(exchangeRates).toEqual([
{
date: startDate,
exchangeRates: {
CHF: new Big(1).div(3)
}
}
]);
});
it('source CHF and destination EUR', async () => {
const startDate = new Date(2021, 0, 1);
const exchangeRates = await exchangeRateDataService.getExchangeRates({
dateQuery: {
gte: startDate,
lt: endOfDay(startDate)
},
sourceCurrencies: ['CHF'],
destinationCurrency: 'EUR'
});
expect(exchangeRates).toEqual([
{
date: startDate,
exchangeRates: {
CHF: new Big(2).div(3)
}
}
]);
});
it('source CHF,EUR,USD and destination EUR', async () => {
const startDate = new Date(2021, 0, 1);
const exchangeRates = await exchangeRateDataService.getExchangeRates({
dateQuery: {
gte: startDate,
lt: endOfDay(startDate)
},
sourceCurrencies: ['CHF', 'USD', 'EUR'],
destinationCurrency: 'EUR'
});
expect(exchangeRates).toEqual([
{
date: startDate,
exchangeRates: {
CHF: new Big(2).div(3),
USD: new Big(2),
EUR: new Big(1)
}
}
]);
});
it('with multiple days', async () => {
const startDate = new Date(2021, 0, 1);
const exchangeRates = await exchangeRateDataService.getExchangeRates({
dateQuery: {
gte: startDate,
lt: endOfDay(addDays(startDate, 1))
},
sourceCurrencies: ['CHF', 'USD', 'EUR'],
destinationCurrency: 'EUR'
});
expect(exchangeRates).toEqual([
{
date: startDate,
exchangeRates: {
CHF: new Big(2).div(3),
USD: new Big(2),
EUR: new Big(1)
}
},
{
date: addDays(startDate, 1),
exchangeRates: {
CHF: new Big(3).div(5),
USD: new Big(3),
EUR: new Big(1)
}
}
]);
});
});
});

@ -1,7 +1,11 @@
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { DateBasedExchangeRate } from '@ghostfolio/api/services/interfaces/date-based-exchange-rate.interface';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns';
import Big from 'big.js';
import { format, isSameDay } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service';
@ -17,6 +21,7 @@ export class ExchangeRateDataService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {
@ -31,6 +36,55 @@ export class ExchangeRateDataService {
return this.currencyPairs;
}
public async getExchangeRates({
dateQuery,
sourceCurrencies,
destinationCurrency
}: {
dateQuery: DateQuery;
sourceCurrencies: string[];
destinationCurrency: string;
}): Promise<DateBasedExchangeRate[]> {
const symbols = [...sourceCurrencies, destinationCurrency]
.map((currency) => `${baseCurrency}${currency}`)
.filter((v, i, a) => a.indexOf(v) === i);
const exchangeRates = await this.marketDataService.getRange({
dateQuery,
symbols
});
if (exchangeRates.length === 0) {
return [];
}
const results: DateBasedExchangeRate[] = [];
let currentDate = exchangeRates[0].date;
let currentRates: { [symbol: string]: Big } = {};
for (const exchangeRate of exchangeRates) {
if (!isSameDay(currentDate, exchangeRate.date)) {
results.push({
date: currentDate,
exchangeRates: this.getUserExchangeRates(
currentRates,
destinationCurrency,
sourceCurrencies
)
});
currentDate = exchangeRate.date;
currentRates = {};
}
currentRates[exchangeRate.symbol] = new Big(exchangeRate.marketPrice);
}
results.push({
date: currentDate,
exchangeRates: this.getUserExchangeRates(
currentRates,
destinationCurrency,
sourceCurrencies
)
});
return results;
}
public async initialize() {
this.currencies = await this.prepareCurrencies();
this.currencyPairs = [];
@ -97,10 +151,10 @@ export class ExchangeRateDataService {
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via USD
// Not found, calculate indirectly via base currency
this.exchangeRates[symbol] =
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
resultExtended[`${currency1}${baseCurrency}`]?.[date]?.marketPrice *
resultExtended[`${baseCurrency}${currency2}`]?.[date]?.marketPrice;
// Calculate the opposite direction
this.exchangeRates[`${currency2}${currency1}`] =
@ -129,9 +183,9 @@ export class ExchangeRateDataService {
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else {
// Calculate indirectly via USD
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
// Calculate indirectly via base currency
const factor1 = this.exchangeRates[`${aFromCurrency}${baseCurrency}`];
const factor2 = this.exchangeRates[`${baseCurrency}${aToCurrency}`];
factor = factor1 * factor2;
@ -194,6 +248,46 @@ export class ExchangeRateDataService {
return uniq(currencies).sort();
}
private getUserExchangeRates(
currentRates: { [symbol: string]: Big },
destinationCurrency: string,
sourceCurrencies: string[]
): { [currency: string]: Big } {
const result: { [currency: string]: Big } = {};
for (const sourceCurrency of sourceCurrencies) {
let exchangeRate: Big;
if (sourceCurrency === destinationCurrency) {
exchangeRate = new Big(1);
} else if (
destinationCurrency === baseCurrency &&
currentRates[`${destinationCurrency}${sourceCurrency}`]
) {
exchangeRate = new Big(1).div(
currentRates[`${destinationCurrency}${sourceCurrency}`]
);
} else if (
sourceCurrency === baseCurrency &&
currentRates[`${sourceCurrency}${destinationCurrency}`]
) {
exchangeRate = currentRates[`${sourceCurrency}${destinationCurrency}`];
} else if (
currentRates[`${baseCurrency}${destinationCurrency}`] &&
currentRates[`${baseCurrency}${sourceCurrency}`]
) {
exchangeRate = currentRates[
`${baseCurrency}${destinationCurrency}`
].div(currentRates[`${baseCurrency}${sourceCurrency}`]);
}
if (exchangeRate) {
result[sourceCurrency] = exchangeRate;
}
}
return result;
}
private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies
.filter((currency) => {

@ -0,0 +1,6 @@
import Big from 'big.js';
export interface DateBasedExchangeRate {
date: Date;
exchangeRates: { [currency: string]: Big };
}
Loading…
Cancel
Save