diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index bb9aa78b6..f86d36cfd 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -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; } diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 3c7a6bde5..2d08721fd 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -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; }) ); diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 0887175f1..eb6cf6594 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -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 { @@ -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: 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)); } } diff --git a/apps/api/src/services/exchange-rate-data.module.ts b/apps/api/src/services/exchange-rate-data.module.ts index 9c886b06a..881c4db63 100644 --- a/apps/api/src/services/exchange-rate-data.module.ts +++ b/apps/api/src/services/exchange-rate-data.module.ts @@ -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 {} diff --git a/apps/api/src/services/exchange-rate-data.service.spec.ts b/apps/api/src/services/exchange-rate-data.service.spec.ts new file mode 100644 index 000000000..5aeeccd58 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data.service.spec.ts @@ -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(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) + } + } + ]); + }); + }); +}); diff --git a/apps/api/src/services/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data.service.ts index e0e0e614f..f81b98afc 100644 --- a/apps/api/src/services/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data.service.ts @@ -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 { + 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) => { diff --git a/apps/api/src/services/interfaces/date-based-exchange-rate.interface.ts b/apps/api/src/services/interfaces/date-based-exchange-rate.interface.ts new file mode 100644 index 000000000..c99c6a4bc --- /dev/null +++ b/apps/api/src/services/interfaces/date-based-exchange-rate.interface.ts @@ -0,0 +1,6 @@ +import Big from 'big.js'; + +export interface DateBasedExchangeRate { + date: Date; + exchangeRates: { [currency: string]: Big }; +}