From be801b481e2ed04420a758ab65788e010667bf8c Mon Sep 17 00:00:00 2001 From: gizmodus Date: Sat, 13 Jan 2024 13:07:33 +0100 Subject: [PATCH] Feature/Add exchange rate effects to portfolio calculation (#2834) * Add exchange rate effects to portfolio calculation * Update changelog --------- Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> --- CHANGELOG.md | 6 + .../src/app/benchmark/benchmark.service.ts | 28 +- .../portfolio/current-rate.service.mock.ts | 21 +- .../portfolio/current-rate.service.spec.ts | 21 +- .../src/app/portfolio/current-rate.service.ts | 36 +- .../interfaces/current-positions.interface.ts | 5 + .../interfaces/get-value-object.interface.ts | 2 +- .../interfaces/get-values-params.interface.ts | 2 - .../portfolio-calculator.interface.ts | 4 + .../portfolio-position-detail.interface.ts | 4 + ...folio-calculator-baln-buy-and-sell.spec.ts | 34 +- .../portfolio-calculator-baln-buy.spec.ts | 34 +- ...ator-btcusd-buy-and-sell-partially.spec.ts | 64 +- .../portfolio-calculator-googl-buy.spec.ts | 144 ++++ .../portfolio-calculator-no-orders.spec.ts | 16 +- ...ulator-novn-buy-and-sell-partially.spec.ts | 36 +- ...folio-calculator-novn-buy-and-sell.spec.ts | 50 +- .../portfolio/portfolio-calculator.spec.ts | 12 +- .../src/app/portfolio/portfolio-calculator.ts | 704 +++++++++++++----- .../src/app/portfolio/portfolio.service.ts | 137 ++-- .../exchange-rate-data.service.mock.ts | 29 + .../exchange-rate-data.service.ts | 288 ++++--- .../historical-data-item.interface.ts | 4 + .../portfolio-performance.interface.ts | 4 + .../interfaces/timeline-position.interface.ts | 7 + 25 files changed, 1270 insertions(+), 422 deletions(-) create mode 100644 apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts create mode 100644 apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 79c08f70d..a4e67288b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- Prepared the portfolio calculation for exchange rate effects + ## 2.37.0 - 2024-01-11 ### Changed diff --git a/apps/api/src/app/benchmark/benchmark.service.ts b/apps/api/src/app/benchmark/benchmark.service.ts index 75cb52ea6..40fd59d9a 100644 --- a/apps/api/src/app/benchmark/benchmark.service.ts +++ b/apps/api/src/app/benchmark/benchmark.service.ts @@ -235,27 +235,17 @@ export class BenchmarkService { }) ]); - const exchangeRates = await this.exchangeRateDataService.getExchangeRates({ - currencyFrom: currentSymbolItem.currency, - currencyTo: userCurrency, - dates: marketDataItems.map(({ date }) => { - return date; - }) - }); + const exchangeRates = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + startDate, + currencies: [currentSymbolItem.currency], + targetCurrency: userCurrency + }); 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 }; - } + exchangeRates[currentSymbolItem.currency]?.[ + format(startDate, DATE_FORMAT) + ]; const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { return isSameDay(date, startDate); diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts index 6d64f5869..7b251551c 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -33,6 +33,15 @@ function mockGetValue(symbol: string, date: Date) { return { marketPrice: 0 }; + case 'GOOGL': + if (isSameDay(parseDate('2023-01-03'), date)) { + return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 116.45 }; + } + + return { marketPrice: 0 }; + case 'NOVN.SW': if (isSameDay(parseDate('2022-04-11'), date)) { return { marketPrice: 87.8 }; @@ -62,10 +71,8 @@ export const CurrentRateServiceMock = { values.push({ date, dataSource: dataGatheringItem.dataSource, - marketPriceInBaseCurrency: mockGetValue( - dataGatheringItem.symbol, - date - ).marketPrice, + marketPrice: mockGetValue(dataGatheringItem.symbol, date) + .marketPrice, symbol: dataGatheringItem.symbol }); } @@ -76,10 +83,8 @@ export const CurrentRateServiceMock = { values.push({ date, dataSource: dataGatheringItem.dataSource, - marketPriceInBaseCurrency: mockGetValue( - dataGatheringItem.symbol, - date - ).marketPrice, + marketPrice: mockGetValue(dataGatheringItem.symbol, date) + .marketPrice, symbol: dataGatheringItem.symbol }); } 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 44d8bedb8..955e82b37 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -1,5 +1,4 @@ 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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { UniqueAsset } from '@ghostfolio/common/interfaces'; @@ -67,7 +66,8 @@ jest.mock( initialize: () => Promise.resolve(), toCurrency: (value: number) => { return 1 * value; - } + }, + getExchangeRates: () => Promise.resolve() }; }) }; @@ -87,7 +87,6 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => { describe('CurrentRateService', () => { let currentRateService: CurrentRateService; let dataProviderService: DataProviderService; - let exchangeRateDataService: ExchangeRateDataService; let marketDataService: MarketDataService; let propertyService: PropertyService; @@ -102,19 +101,11 @@ describe('CurrentRateService', () => { propertyService, null ); - exchangeRateDataService = new ExchangeRateDataService( - null, - null, - null, - null - ); - marketDataService = new MarketDataService(null); - await exchangeRateDataService.initialize(); + marketDataService = new MarketDataService(null); currentRateService = new CurrentRateService( dataProviderService, - exchangeRateDataService, marketDataService ); }); @@ -122,13 +113,11 @@ describe('CurrentRateService', () => { it('getValues', async () => { expect( await currentRateService.getValues({ - currencies: { AMZN: 'USD' }, dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], dateQuery: { lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) - }, - userCurrency: 'CHF' + } }) ).toMatchObject({ dataProviderInfos: [], @@ -137,7 +126,7 @@ describe('CurrentRateService', () => { { dataSource: 'YAHOO', date: undefined, - marketPriceInBaseCurrency: 1841.823902, + marketPrice: 1841.823902, symbol: 'AMZN' } ] diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 718ec6095..75abc0acd 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -1,5 +1,4 @@ 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 { resetHours } from '@ghostfolio/common/helper'; import { @@ -19,17 +18,15 @@ import { GetValuesParams } from './interfaces/get-values-params.interface'; export class CurrentRateService { public constructor( private readonly dataProviderService: DataProviderService, - private readonly exchangeRateDataService: ExchangeRateDataService, private readonly marketDataService: MarketDataService ) {} public async getValues({ - currencies, dataGatheringItems, - dateQuery, - userCurrency + dateQuery }: GetValuesParams): Promise { const dataProviderInfos: DataProviderInfo[] = []; + const includeToday = (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && @@ -45,6 +42,7 @@ export class CurrentRateService { .getQuotes({ items: dataGatheringItems }) .then((dataResultProvider) => { const result: GetValueObject[] = []; + for (const dataGatheringItem of dataGatheringItems) { if ( dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo @@ -58,13 +56,8 @@ export class CurrentRateService { result.push({ dataSource: dataGatheringItem.dataSource, date: today, - marketPriceInBaseCurrency: - this.exchangeRateDataService.toCurrency( - dataResultProvider?.[dataGatheringItem.symbol] - ?.marketPrice, - dataResultProvider?.[dataGatheringItem.symbol]?.currency, - userCurrency - ), + marketPrice: + dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice, symbol: dataGatheringItem.symbol }); } else { @@ -97,13 +90,8 @@ export class CurrentRateService { return { dataSource, date, - symbol, - marketPriceInBaseCurrency: - this.exchangeRateDataService.toCurrency( - marketPrice, - currencies[symbol], - userCurrency - ) + marketPrice, + symbol }; }); }) @@ -132,7 +120,7 @@ export class CurrentRateService { dataSource, symbol, date: today, - marketPriceInBaseCurrency: 0 + marketPrice: 0 }; response.values.push(value); @@ -140,10 +128,7 @@ export class CurrentRateService { const [latestValue] = response.values .filter((currentValue) => { - return ( - currentValue.symbol === symbol && - currentValue.marketPriceInBaseCurrency - ); + return currentValue.symbol === symbol && currentValue.marketPrice; }) .sort((a, b) => { if (a.date < b.date) { @@ -157,8 +142,7 @@ export class CurrentRateService { return 0; }); - value.marketPriceInBaseCurrency = - latestValue.marketPriceInBaseCurrency; + value.marketPrice = latestValue.marketPrice; } catch {} } } diff --git a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts index 48e6038f3..b1e6a3154 100644 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts @@ -4,10 +4,15 @@ import Big from 'big.js'; export interface CurrentPositions extends ResponseError { positions: TimelinePosition[]; grossPerformance: Big; + grossPerformanceWithCurrencyEffect: Big; grossPerformancePercentage: Big; + grossPerformancePercentageWithCurrencyEffect: Big; netAnnualizedPerformance?: Big; + netAnnualizedPerformanceWithCurrencyEffect?: Big; netPerformance: Big; + netPerformanceWithCurrencyEffect: Big; netPerformancePercentage: Big; + netPerformancePercentageWithCurrencyEffect: Big; currentValue: Big; totalInvestment: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts index 661170470..6c42d260c 100644 --- a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts @@ -2,5 +2,5 @@ import { UniqueAsset } from '@ghostfolio/common/interfaces'; export interface GetValueObject extends UniqueAsset { date: Date; - marketPriceInBaseCurrency: number; + marketPrice: number; } diff --git a/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts b/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts index 5b3ab7053..5cf7c8811 100644 --- a/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts @@ -3,8 +3,6 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac import { DateQuery } from './date-query.interface'; export interface GetValuesParams { - currencies: { [symbol: string]: string }; dataGatheringItems: IDataGatheringItem[]; dateQuery: DateQuery; - userCurrency: string; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts index 88026cdc7..5d4c57fa1 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-calculator.interface.ts @@ -1,5 +1,9 @@ +import Big from 'big.js'; + import { PortfolioOrder } from './portfolio-order.interface'; export interface PortfolioOrderItem extends PortfolioOrder { itemType?: '' | 'start' | 'end'; + unitPriceInBaseCurrency?: Big; + unitPriceInBaseCurrencyWithCurrencyEffect?: Big; } diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts index 827aa25fe..07b658666 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts @@ -14,6 +14,8 @@ export interface PortfolioPositionDetail { firstBuyDate: string; grossPerformance: number; grossPerformancePercent: number; + grossPerformancePercentWithCurrencyEffect: number; + grossPerformanceWithCurrencyEffect: number; historicalData: HistoricalDataItem[]; investment: number; marketPrice: number; @@ -21,6 +23,8 @@ export interface PortfolioPositionDetail { minPrice: number; netPerformance: number; netPerformancePercent: number; + netPerformancePercentWithCurrencyEffect: number; + netPerformanceWithCurrencyEffect: number; orders: OrderWithAccount[]; quantity: number; SymbolProfile: EnhancedSymbolProfile; diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts index f4fec026a..a841311ba 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; @@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('get current positions', () => { it.only('with BALN.SW buy and sell', async () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'CHF', orders: [ { @@ -74,9 +84,17 @@ describe('PortfolioCalculator', () => { errors: [], grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.0440867739678096571' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), hasErrors: false, netPerformance: new Big('-15.8'), netPerformancePercentage: new Big('-0.0552834149755073478'), + netPerformancePercentageWithCurrencyEffect: new Big( + '-0.0552834149755073478' + ), + netPerformanceWithCurrencyEffect: new Big('-15.8'), positions: [ { averagePrice: new Big('0'), @@ -86,17 +104,29 @@ describe('PortfolioCalculator', () => { firstBuyDate: '2021-11-22', grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.0440867739678096571' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), netPerformance: new Big('-15.8'), netPerformancePercentage: new Big('-0.0552834149755073478'), + netPerformancePercentageWithCurrencyEffect: new Big( + '-0.0552834149755073478' + ), + netPerformanceWithCurrencyEffect: new Big('-15.8'), marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, quantity: new Big('0'), symbol: 'BALN.SW', timeWeightedInvestment: new Big('285.8'), + timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), transactionCount: 2 } ], - totalInvestment: new Big('0') + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts index e2560cfbb..8d416ddc8 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-baln-buy.spec.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; @@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('get current positions', () => { it.only('with BALN.SW buy', async () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'CHF', orders: [ { @@ -63,9 +73,17 @@ describe('PortfolioCalculator', () => { errors: [], grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.09004392386530014641' + ), + grossPerformanceWithCurrencyEffect: new Big('24.6'), hasErrors: false, netPerformance: new Big('23.05'), netPerformancePercentage: new Big('0.08437042459736456808'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.08437042459736456808' + ), + netPerformanceWithCurrencyEffect: new Big('23.05'), positions: [ { averagePrice: new Big('136.6'), @@ -75,17 +93,29 @@ describe('PortfolioCalculator', () => { firstBuyDate: '2021-11-30', grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.09004392386530014641' + ), + grossPerformanceWithCurrencyEffect: new Big('24.6'), investment: new Big('273.2'), + investmentWithCurrencyEffect: new Big('273.2'), netPerformance: new Big('23.05'), netPerformancePercentage: new Big('0.08437042459736456808'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.08437042459736456808' + ), + netPerformanceWithCurrencyEffect: new Big('23.05'), marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, quantity: new Big('2'), symbol: 'BALN.SW', timeWeightedInvestment: new Big('273.2'), + timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), transactionCount: 1 } ], - totalInvestment: new Big('273.2') + totalInvestment: new Big('273.2'), + totalInvestmentWithCurrencyEffect: new Big('273.2') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts index 4f1bc8cdc..cb96751f1 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -1,4 +1,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; @@ -14,21 +16,42 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { }; }); +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('get current positions', () => { it.only('with BTCUSD buy and sell partially', async () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'CHF', orders: [ { - currency: 'CHF', + currency: 'USD', date: '2015-01-01', dataSource: 'YAHOO', fee: new Big(0), @@ -39,7 +62,7 @@ describe('PortfolioCalculator', () => { unitPrice: new Big(320.43) }, { - currency: 'CHF', + currency: 'USD', date: '2017-12-31', dataSource: 'YAHOO', fee: new Big(0), @@ -70,33 +93,60 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('13657.2'), + currentValue: new Big('13298.425356'), errors: [], grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.41978276196153750666'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '41.6401219622042072686' + ), + grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'), hasErrors: false, netPerformance: new Big('27172.74'), netPerformancePercentage: new Big('42.41978276196153750666'), + netPerformancePercentageWithCurrencyEffect: new Big( + '41.6401219622042072686' + ), + netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'), positions: [ { averagePrice: new Big('320.43'), - currency: 'CHF', + currency: 'USD', dataSource: 'YAHOO', fee: new Big('0'), firstBuyDate: '2015-01-01', grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.41978276196153750666'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '41.6401219622042072686' + ), + grossPerformanceWithCurrencyEffect: new Big( + '26516.208701400000064086' + ), investment: new Big('320.43'), + investmentWithCurrencyEffect: new Big('318.542667299999967957'), + marketPrice: 13657.2, + marketPriceInBaseCurrency: 13298.425356, netPerformance: new Big('27172.74'), netPerformancePercentage: new Big('42.41978276196153750666'), - marketPrice: 13657.2, + netPerformancePercentageWithCurrencyEffect: new Big( + '41.6401219622042072686' + ), + netPerformanceWithCurrencyEffect: new Big( + '26516.208701400000064086' + ), quantity: new Big('1'), symbol: 'BTCUSD', + tags: undefined, timeWeightedInvestment: new Big('640.56763686131386861314'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '636.79469348020066587024' + ), transactionCount: 2 } ], - totalInvestment: new Big('320.43') + totalInvestment: new Big('320.43'), + totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts new file mode 100644 index 000000000..fcd3e8bd5 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts @@ -0,0 +1,144 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import Big from 'big.js'; + +import { CurrentRateServiceMock } from './current-rate.service.mock'; +import { PortfolioCalculator } from './portfolio-calculator'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + // eslint-disable-next-line @typescript-eslint/naming-convention + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + + beforeEach(() => { + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + }); + + describe('get current positions', () => { + it.only('with GOOGL buy', async () => { + const portfolioCalculator = new PortfolioCalculator({ + currentRateService, + exchangeRateDataService, + currency: 'CHF', + orders: [ + { + currency: 'USD', + date: '2023-01-03', + dataSource: 'YAHOO', + fee: new Big(1), + name: 'Alphabet Inc.', + quantity: new Big(1), + symbol: 'GOOGL', + type: 'BUY', + unitPrice: new Big(89.12) + } + ] + }); + + portfolioCalculator.computeTransactionPoints(); + + const spy = jest + .spyOn(Date, 'now') + .mockImplementation(() => parseDate('2023-07-10').getTime()); + + const currentPositions = await portfolioCalculator.getCurrentPositions( + parseDate('2023-01-03') + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = + portfolioCalculator.getInvestmentsByGroup('month'); + + spy.mockRestore(); + + expect(currentPositions).toEqual({ + currentValue: new Big('103.10483'), + errors: [], + grossPerformance: new Big('27.33'), + grossPerformancePercentage: new Big('0.3066651705565529623'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.25235044599563974109' + ), + grossPerformanceWithCurrencyEffect: new Big('20.775774'), + hasErrors: false, + netPerformance: new Big('26.33'), + netPerformancePercentage: new Big('0.29544434470377019749'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.24112962014285697628' + ), + netPerformanceWithCurrencyEffect: new Big('19.851974'), + positions: [ + { + averagePrice: new Big('89.12'), + currency: 'USD', + dataSource: 'YAHOO', + fee: new Big('1'), + firstBuyDate: '2023-01-03', + grossPerformance: new Big('27.33'), + grossPerformancePercentage: new Big('0.3066651705565529623'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.25235044599563974109' + ), + grossPerformanceWithCurrencyEffect: new Big('20.775774'), + investment: new Big('89.12'), + investmentWithCurrencyEffect: new Big('82.329056'), + netPerformance: new Big('26.33'), + netPerformancePercentage: new Big('0.29544434470377019749'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.24112962014285697628' + ), + netPerformanceWithCurrencyEffect: new Big('19.851974'), + marketPrice: 116.45, + marketPriceInBaseCurrency: 103.10483, + quantity: new Big('1'), + symbol: 'GOOGL', + tags: undefined, + timeWeightedInvestment: new Big('89.12'), + timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), + transactionCount: 1 + } + ], + totalInvestment: new Big('89.12'), + totalInvestmentWithCurrencyEffect: new Big('82.329056') + }); + + expect(investments).toEqual([ + { date: '2023-01-03', investment: new Big('89.12') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2023-01-01', investment: new Big('89.12') } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts index a26426017..aeffaae26 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-no-orders.spec.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; @@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('get current positions', () => { it('with no orders', async () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'CHF', orders: [] }); @@ -50,9 +60,13 @@ describe('PortfolioCalculator', () => { currentValue: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), hasErrors: false, netPerformance: new Big(0), netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffect: new Big(0), positions: [], totalInvestment: new Big(0) }); diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts index 2c8bff238..7bf0bc1e1 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; @@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('get current positions', () => { it.only('with NOVN.SW buy and sell partially', async () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'CHF', orders: [ { @@ -74,9 +84,17 @@ describe('PortfolioCalculator', () => { errors: [], grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.15113417083448194384' + ), + grossPerformanceWithCurrencyEffect: new Big('21.93'), hasErrors: false, netPerformance: new Big('17.68'), netPerformancePercentage: new Big('0.12184460284330327256'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.12184460284330327256' + ), + netPerformanceWithCurrencyEffect: new Big('17.68'), positions: [ { averagePrice: new Big('75.80'), @@ -86,17 +104,31 @@ describe('PortfolioCalculator', () => { firstBuyDate: '2022-03-07', grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.15113417083448194384' + ), + grossPerformanceWithCurrencyEffect: new Big('21.93'), investment: new Big('75.80'), + investmentWithCurrencyEffect: new Big('75.80'), netPerformance: new Big('17.68'), netPerformancePercentage: new Big('0.12184460284330327256'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.12184460284330327256' + ), + netPerformanceWithCurrencyEffect: new Big('17.68'), marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, quantity: new Big('1'), symbol: 'NOVN.SW', timeWeightedInvestment: new Big('145.10285714285714285714'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '145.10285714285714285714' + ), transactionCount: 2 } ], - totalInvestment: new Big('75.80') + totalInvestment: new Big('75.80'), + totalInvestmentWithCurrencyEffect: new Big('75.80') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts index 3b34a0e34..3e30374c4 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -1,4 +1,5 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { parseDate } from '@ghostfolio/common/helper'; import Big from 'big.js'; @@ -16,15 +17,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('get current positions', () => { it.only('with NOVN.SW buy and sell', async () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'CHF', orders: [ { @@ -75,18 +85,26 @@ describe('PortfolioCalculator', () => { expect(chartData[0]).toEqual({ date: '2022-03-07', - netPerformanceInPercentage: 0, netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, totalInvestment: 151.6, - value: 151.6 + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 151.6, + valueWithCurrencyEffect: 151.6 }); expect(chartData[chartData.length - 1]).toEqual({ date: '2022-04-11', - netPerformanceInPercentage: 13.100263852242744, netPerformance: 19.86, + netPerformanceInPercentage: 13.100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744, + netPerformanceWithCurrencyEffect: 19.86, totalInvestment: 0, - value: 0 + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 }); expect(currentPositions).toEqual({ @@ -94,9 +112,17 @@ describe('PortfolioCalculator', () => { errors: [], grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), hasErrors: false, netPerformance: new Big('19.86'), netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + netPerformanceWithCurrencyEffect: new Big('19.86'), positions: [ { averagePrice: new Big('0'), @@ -106,17 +132,29 @@ describe('PortfolioCalculator', () => { firstBuyDate: '2022-03-07', grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), netPerformance: new Big('19.86'), netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + netPerformanceWithCurrencyEffect: new Big('19.86'), marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, quantity: new Big('0'), symbol: 'NOVN.SW', timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), transactionCount: 2 } ], - totalInvestment: new Big('0') + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0') }); expect(investments).toEqual([ diff --git a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts index 23f0a8a8d..d45ce0383 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.spec.ts @@ -1,3 +1,4 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import Big from 'big.js'; import { CurrentRateService } from './current-rate.service'; @@ -5,14 +6,23 @@ import { PortfolioCalculator } from './portfolio-calculator'; describe('PortfolioCalculator', () => { let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; beforeEach(() => { - currentRateService = new CurrentRateService(null, null, null); + currentRateService = new CurrentRateService(null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); }); describe('annualized performance percentage', () => { const portfolioCalculator = new PortfolioCalculator({ currentRateService, + exchangeRateDataService, currency: 'USD', orders: [] }); diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 71ddbb6dd..61f6d7590 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1,5 +1,7 @@ import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DataProviderInfo, @@ -28,7 +30,15 @@ import { set, subDays } from 'date-fns'; -import { first, flatten, isNumber, last, sortBy, uniq } from 'lodash'; +import { + cloneDeep, + first, + flatten, + isNumber, + last, + sortBy, + uniq +} from 'lodash'; import { CurrentRateService } from './current-rate.service'; import { CurrentPositions } from './interfaces/current-positions.interface'; @@ -44,28 +54,29 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in import { TransactionPoint } from './interfaces/transaction-point.interface'; export class PortfolioCalculator { - private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT = - true; - private static readonly ENABLE_LOGGING = false; private currency: string; private currentRateService: CurrentRateService; private dataProviderInfos: DataProviderInfo[]; + private exchangeRateDataService: ExchangeRateDataService; private orders: PortfolioOrder[]; private transactionPoints: TransactionPoint[]; public constructor({ currency, currentRateService, + exchangeRateDataService, orders }: { currency: string; currentRateService: CurrentRateService; + exchangeRateDataService: ExchangeRateDataService; orders: PortfolioOrder[]; }) { this.currency = currency; this.currentRateService = currentRateService; + this.exchangeRateDataService = exchangeRateDataService; this.orders = orders; this.orders.sort((a, b) => a.date?.localeCompare(b.date)); @@ -213,12 +224,10 @@ export class PortfolioCalculator { const { dataProviderInfos, values: marketSymbols } = await this.currentRateService.getValues({ - currencies, dataGatheringItems, dateQuery: { in: dates - }, - userCurrency: this.currency + } }); this.dataProviderInfos = dataProviderInfos; @@ -227,60 +236,81 @@ export class PortfolioCalculator { [date: string]: { [symbol: string]: Big }; } = {}; + let exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: uniq(Object.values(currencies)), + endDate: endOfDay(end), + startDate: parseDate(this.transactionPoints?.[0]?.date), + targetCurrency: this.currency + }); + for (const marketSymbol of marketSymbols) { const dateString = format(marketSymbol.date, DATE_FORMAT); if (!marketSymbolMap[dateString]) { marketSymbolMap[dateString] = {}; } - if (marketSymbol.marketPriceInBaseCurrency) { + if (marketSymbol.marketPrice) { marketSymbolMap[dateString][marketSymbol.symbol] = new Big( - marketSymbol.marketPriceInBaseCurrency + marketSymbol.marketPrice ); } } const accumulatedValuesByDate: { [date: string]: { - maxTotalInvestmentValue: Big; totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; }; } = {}; const valuesBySymbol: { [symbol: string]: { currentValues: { [date: string]: Big }; + currentValuesWithCurrencyEffect: { [date: string]: Big }; investmentValues: { [date: string]: Big }; - maxInvestmentValues: { [date: string]: Big }; + investmentValuesWithCurrencyEffect: { [date: string]: Big }; netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; timeWeightedInvestmentValues: { [date: string]: Big }; + timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; }; } = {}; for (const symbol of Object.keys(symbols)) { const { currentValues, + currentValuesWithCurrencyEffect, investmentValues, - maxInvestmentValues, + investmentValuesWithCurrencyEffect, netPerformanceValues, - timeWeightedInvestmentValues + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect } = this.getSymbolMetrics({ end, marketSymbolMap, start, step, symbol, + exchangeRates: exchangeRatesByCurrency[currencies[symbol]], isChartMode: true }); valuesBySymbol[symbol] = { currentValues, + currentValuesWithCurrencyEffect, investmentValues, - maxInvestmentValues, + investmentValuesWithCurrencyEffect, netPerformanceValues, - timeWeightedInvestmentValues + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect }; } @@ -292,63 +322,108 @@ export class PortfolioCalculator { const currentValue = symbolValues.currentValues?.[dateString] ?? new Big(0); + + const currentValueWithCurrencyEffect = + symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + const investmentValue = symbolValues.investmentValues?.[dateString] ?? new Big(0); - const maxInvestmentValue = - symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0); + + const investmentValueWithCurrencyEffect = + symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + const netPerformanceValue = symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); + + const netPerformanceValueWithCurrencyEffect = + symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + const timeWeightedInvestmentValue = symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); + const timeWeightedInvestmentValueWithCurrencyEffect = + symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + accumulatedValuesByDate[dateString] = { totalCurrentValue: ( accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) ).add(currentValue), + totalCurrentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) + ).add(currentValueWithCurrencyEffect), totalInvestmentValue: ( accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? new Big(0) ).add(investmentValue), - totalTimeWeightedInvestmentValue: ( + totalInvestmentValueWithCurrencyEffect: ( accumulatedValuesByDate[dateString] - ?.totalTimeWeightedInvestmentValue ?? new Big(0) - ).add(timeWeightedInvestmentValue), - maxTotalInvestmentValue: ( - accumulatedValuesByDate[dateString]?.maxTotalInvestmentValue ?? - new Big(0) - ).add(maxInvestmentValue), + ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) + ).add(investmentValueWithCurrencyEffect), totalNetPerformanceValue: ( accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0) - ).add(netPerformanceValue) + ).add(netPerformanceValue), + totalNetPerformanceValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) + ).add(netPerformanceValueWithCurrencyEffect), + totalTimeWeightedInvestmentValue: ( + accumulatedValuesByDate[dateString] + ?.totalTimeWeightedInvestmentValue ?? new Big(0) + ).add(timeWeightedInvestmentValue), + totalTimeWeightedInvestmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0) + ).add(timeWeightedInvestmentValueWithCurrencyEffect) }; } } return Object.entries(accumulatedValuesByDate).map(([date, values]) => { const { - maxTotalInvestmentValue, totalCurrentValue, + totalCurrentValueWithCurrencyEffect, totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, totalNetPerformanceValue, - totalTimeWeightedInvestmentValue + totalNetPerformanceValueWithCurrencyEffect, + totalTimeWeightedInvestmentValue, + totalTimeWeightedInvestmentValueWithCurrencyEffect } = values; - let investmentValue = - PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT - ? totalTimeWeightedInvestmentValue - : maxTotalInvestmentValue; - - const netPerformanceInPercentage = investmentValue.eq(0) + const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) ? 0 - : totalNetPerformanceValue.div(investmentValue).mul(100).toNumber(); + : totalNetPerformanceValue + .div(totalTimeWeightedInvestmentValue) + .mul(100) + .toNumber(); + + const netPerformanceInPercentageWithCurrencyEffect = + totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) + .mul(100) + .toNumber(); return { date, netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, netPerformance: totalNetPerformanceValue.toNumber(), + netPerformanceWithCurrencyEffect: + totalNetPerformanceValueWithCurrencyEffect.toNumber(), totalInvestment: totalInvestmentValue.toNumber(), - value: totalCurrentValue.toNumber() + totalInvestmentValueWithCurrencyEffect: + totalInvestmentValueWithCurrencyEffect.toNumber(), + value: totalCurrentValue.toNumber(), + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() }; }); } @@ -367,9 +442,13 @@ export class PortfolioCalculator { currentValue: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), hasErrors: false, netPerformance: new Big(0), netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffect: new Big(0), positions: [], totalInvestment: new Big(0) }; @@ -378,11 +457,11 @@ export class PortfolioCalculator { const lastTransactionPoint = transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1]; - let firstTransactionPoint: TransactionPoint = null; - let firstIndex = transactionPointsBeforeEndDate.length; - let dates = []; - const dataGatheringItems: IDataGatheringItem[] = []; const currencies: { [symbol: string]: string } = {}; + const dataGatheringItems: IDataGatheringItem[] = []; + let dates: Date[] = []; + let firstIndex = transactionPointsBeforeEndDate.length; + let firstTransactionPoint: TransactionPoint = null; dates.push(resetHours(start)); for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { @@ -390,8 +469,10 @@ export class PortfolioCalculator { dataSource: item.dataSource, symbol: item.symbol }); + currencies[item.symbol] = item.currency; } + for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { if ( !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && @@ -423,22 +504,31 @@ export class PortfolioCalculator { dates.map((date) => { return date.getTime(); }) - ).map((timestamp) => { - return new Date(timestamp); - }); - dates.sort((a, b) => a.getTime() - b.getTime()); + ) + .map((timestamp) => { + return new Date(timestamp); + }) + .sort((a, b) => { + return a.getTime() - b.getTime(); + }); + + let exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: uniq(Object.values(currencies)), + endDate: endOfDay(end), + startDate: parseDate(this.transactionPoints?.[0]?.date), + targetCurrency: this.currency + }); const { dataProviderInfos, errors: currentRateErrors, values: marketSymbols } = await this.currentRateService.getValues({ - currencies, dataGatheringItems, dateQuery: { in: dates - }, - userCurrency: this.currency + } }); this.dataProviderInfos = dataProviderInfos; @@ -449,12 +539,14 @@ export class PortfolioCalculator { for (const marketSymbol of marketSymbols) { const date = format(marketSymbol.date, DATE_FORMAT); + if (!marketSymbolMap[date]) { marketSymbolMap[date] = {}; } - if (marketSymbol.marketPriceInBaseCurrency) { + + if (marketSymbol.marketPrice) { marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPriceInBaseCurrency + marketSymbol.marketPrice ); } } @@ -471,19 +563,29 @@ export class PortfolioCalculator { const errors: ResponseError['errors'] = []; for (const item of lastTransactionPoint.items) { - const marketValue = marketSymbolMap[endDateString]?.[item.symbol]; + const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[ + item.symbol + ]?.mul(exchangeRatesByCurrency[item.currency]?.[endDateString]); const { grossPerformance, grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, hasErrors, netPerformance, netPerformancePercentage, - timeWeightedInvestment + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect } = this.getSymbolMetrics({ end, marketSymbolMap, start, + exchangeRates: exchangeRatesByCurrency[item.currency], symbol: item.symbol }); @@ -491,6 +593,7 @@ export class PortfolioCalculator { positions.push({ timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, averagePrice: item.quantity.eq(0) ? new Big(0) : item.investment.div(item.quantity), @@ -502,12 +605,28 @@ export class PortfolioCalculator { grossPerformancePercentage: !hasErrors ? grossPerformancePercentage ?? null : null, - investment: item.investment, - marketPrice: marketValue?.toNumber() ?? null, + grossPerformancePercentageWithCurrencyEffect: !hasErrors + ? grossPerformancePercentageWithCurrencyEffect ?? null + : null, + grossPerformanceWithCurrencyEffect: !hasErrors + ? grossPerformanceWithCurrencyEffect ?? null + : null, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + marketPrice: + marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null, + marketPriceInBaseCurrency: + marketPriceInBaseCurrency?.toNumber() ?? null, netPerformance: !hasErrors ? netPerformance ?? null : null, netPerformancePercentage: !hasErrors ? netPerformancePercentage ?? null : null, + netPerformancePercentageWithCurrencyEffect: !hasErrors + ? netPerformancePercentageWithCurrencyEffect ?? null + : null, + netPerformanceWithCurrencyEffect: !hasErrors + ? netPerformanceWithCurrencyEffect ?? null + : null, quantity: item.quantity, symbol: item.symbol, tags: item.tags, @@ -751,28 +870,56 @@ export class PortfolioCalculator { private calculateOverallPerformance(positions: TimelinePosition[]) { let currentValue = new Big(0); let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; let netPerformance = new Big(0); + let netPerformanceWithCurrencyEffect = new Big(0); let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestment = new Big(0); + let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); for (const currentPosition of positions) { - if (currentPosition.marketPrice) { + if ( + currentPosition.investment && + currentPosition.marketPriceInBaseCurrency + ) { currentValue = currentValue.plus( - new Big(currentPosition.marketPrice).mul(currentPosition.quantity) + new Big(currentPosition.marketPriceInBaseCurrency).mul( + currentPosition.quantity + ) ); } else { hasErrors = true; } - totalInvestment = totalInvestment.plus(currentPosition.investment); + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } if (currentPosition.grossPerformance) { grossPerformance = grossPerformance.plus( currentPosition.grossPerformance ); + grossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.plus( + currentPosition.grossPerformanceWithCurrencyEffect + ); + netPerformance = netPerformance.plus(currentPosition.netPerformance); + + netPerformanceWithCurrencyEffect = + netPerformanceWithCurrencyEffect.plus( + currentPosition.netPerformanceWithCurrencyEffect + ); } else if (!currentPosition.quantity.eq(0)) { hasErrors = true; } @@ -781,11 +928,17 @@ export class PortfolioCalculator { totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( currentPosition.timeWeightedInvestment ); + + totalTimeWeightedInvestmentWithCurrencyEffect = + totalTimeWeightedInvestmentWithCurrencyEffect.plus( + currentPosition.timeWeightedInvestmentWithCurrencyEffect + ); } else if (!currentPosition.quantity.eq(0)) { Logger.warn( `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, 'PortfolioCalculator' ); + hasErrors = true; } } @@ -793,15 +946,30 @@ export class PortfolioCalculator { return { currentValue, grossPerformance, + grossPerformanceWithCurrencyEffect, hasErrors, netPerformance, + netPerformanceWithCurrencyEffect, totalInvestment, + totalInvestmentWithCurrencyEffect, netPerformancePercentage: totalTimeWeightedInvestment.eq(0) ? new Big(0) : netPerformance.div(totalTimeWeightedInvestment), + netPerformancePercentageWithCurrencyEffect: + totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) + ? new Big(0) + : netPerformanceWithCurrencyEffect.div( + totalTimeWeightedInvestmentWithCurrencyEffect + ), grossPerformancePercentage: totalTimeWeightedInvestment.eq(0) ? new Big(0) - : grossPerformance.div(totalTimeWeightedInvestment) + : grossPerformance.div(totalTimeWeightedInvestment), + grossPerformancePercentageWithCurrencyEffect: + totalTimeWeightedInvestmentWithCurrencyEffect.eq(0) + ? new Big(0) + : grossPerformanceWithCurrencyEffect.div( + totalTimeWeightedInvestmentWithCurrencyEffect + ) }; } @@ -834,13 +1002,11 @@ export class PortfolioCalculator { if (dataGatheringItems.length > 0) { try { const { values } = await this.currentRateService.getValues({ - currencies, dataGatheringItems, dateQuery: { gte: startDate, lt: endOfDay(endDate) - }, - userCurrency: this.currency + } }); marketSymbols = values; } catch (error) { @@ -858,9 +1024,9 @@ export class PortfolioCalculator { if (!marketSymbolMap[date]) { marketSymbolMap[date] = {}; } - if (marketSymbol.marketPriceInBaseCurrency) { + if (marketSymbol.marketPrice) { marketSymbolMap[date][marketSymbol.symbol] = new Big( - marketSymbol.marketPriceInBaseCurrency + marketSymbol.marketPrice ); } } @@ -955,6 +1121,7 @@ export class PortfolioCalculator { private getSymbolMetrics({ end, + exchangeRates, isChartMode = false, marketSymbolMap, start, @@ -962,6 +1129,7 @@ export class PortfolioCalculator { symbol }: { end: Date; + exchangeRates: { [dateString: string]: number }; isChartMode?: boolean; marketSymbolMap: { [date: string]: { [symbol: string]: Big }; @@ -970,22 +1138,69 @@ export class PortfolioCalculator { step?: number; symbol: string; }) { - let orders: PortfolioOrderItem[] = this.orders.filter((order) => { - return order.symbol === symbol; - }); + const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; + const currentValues: { [date: string]: Big } = {}; + const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let fees = new Big(0); + let feesAtStartDate = new Big(0); + let feesAtStartDateWithCurrencyEffect = new Big(0); + let feesWithCurrencyEffect = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let grossPerformanceAtStartDate = new Big(0); + let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); + let grossPerformanceFromSells = new Big(0); + let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); + let initialValue: Big; + let initialValueWithCurrencyEffect: Big; + let investmentAtStartDate: Big; + let investmentAtStartDateWithCurrencyEffect: Big; + const investmentValues: { [date: string]: Big } = {}; + const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let lastAveragePrice = new Big(0); + let lastAveragePriceWithCurrencyEffect = new Big(0); + const netPerformanceValues: { [date: string]: Big } = {}; + const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; + const timeWeightedInvestmentValues: { [date: string]: Big } = {}; + + const timeWeightedInvestmentValuesWithCurrencyEffect: { + [date: string]: Big; + } = {}; + + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalInvestmentWithGrossPerformanceFromSell = new Big(0); + + let totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = new Big( + 0 + ); + + let totalUnits = new Big(0); + let valueAtStartDate: Big; + let valueAtStartDateWithCurrencyEffect: Big; + + // Clone orders to keep the original values in this.orders + let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter( + (order) => { + return order.symbol === symbol; + } + ); if (orders.length <= 0) { return { currentValues: {}, grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffectPercentage: new Big(0), hasErrors: false, initialValue: new Big(0), investmentValues: {}, - maxInvestmentValues: {}, netPerformance: new Big(0), netPerformancePercentage: new Big(0), - netPerformanceValues: {} + netPerformanceValues: {}, + netPerformanceWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffectPercentage: new Big(0) }; } @@ -1002,36 +1217,19 @@ export class PortfolioCalculator { (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) ) { return { + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffectPercentage: new Big(0), hasErrors: true, initialValue: new Big(0), netPerformance: new Big(0), netPerformancePercentage: new Big(0), - grossPerformance: new Big(0), - grossPerformancePercentage: new Big(0) + netPerformanceWithCurrencyEffect: new Big(0), + netPerformanceWithCurrencyEffectPercentage: new Big(0) }; } - let averagePriceAtEndDate = new Big(0); - let averagePriceAtStartDate = new Big(0); - const currentValues: { [date: string]: Big } = {}; - let feesAtStartDate = new Big(0); - let fees = new Big(0); - let grossPerformance = new Big(0); - let grossPerformanceAtStartDate = new Big(0); - let grossPerformanceFromSells = new Big(0); - let initialValue: Big; - let investmentAtStartDate: Big; - const investmentValues: { [date: string]: Big } = {}; - const maxInvestmentValues: { [date: string]: Big } = {}; - let lastAveragePrice = new Big(0); - let maxTotalInvestment = new Big(0); - const netPerformanceValues: { [date: string]: Big } = {}; - const timeWeightedInvestmentValues: { [date: string]: Big } = {}; - let totalInvestment = new Big(0); - let totalInvestmentWithGrossPerformanceFromSell = new Big(0); - let totalUnits = new Big(0); - let valueAtStartDate: Big; - // Add a synthetic order at the start and the end date orders.push({ symbol, @@ -1120,6 +1318,7 @@ export class PortfolioCalculator { let totalInvestmentDays = 0; let sumOfTimeWeightedInvestments = new Big(0); + let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); for (let i = 0; i < orders.length; i += 1) { const order = orders[i]; @@ -1130,6 +1329,8 @@ export class PortfolioCalculator { console.log(i + 1, order.type, order.itemType); } + const exchangeRateAtOrderDate = exchangeRates[order.date]; + if (order.itemType === 'start') { // Take the unit price of the order as the market price if there are no // orders of this symbol before the start date @@ -1139,27 +1340,40 @@ export class PortfolioCalculator { : unitPriceAtStartDate; } - // Calculate the average start price as soon as any units are held - if ( - averagePriceAtStartDate.eq(0) && - i >= indexOfStartOrder && - totalUnits.gt(0) - ) { - averagePriceAtStartDate = totalInvestment.div(totalUnits); + if (order.unitPrice) { + order.unitPriceInBaseCurrency = order.unitPrice.mul( + currentExchangeRate ?? 1 + ); + + order.unitPriceInBaseCurrencyWithCurrencyEffect = order.unitPrice.mul( + exchangeRateAtOrderDate ?? 1 + ); } const valueOfInvestmentBeforeTransaction = totalUnits.mul( - order.unitPrice + order.unitPriceInBaseCurrency ); + const valueOfInvestmentBeforeTransactionWithCurrencyEffect = + totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect); + if (!investmentAtStartDate && i >= indexOfStartOrder) { investmentAtStartDate = totalInvestment ?? new Big(0); + + investmentAtStartDateWithCurrencyEffect = + totalInvestmentWithCurrencyEffect ?? new Big(0); + valueAtStartDate = valueOfInvestmentBeforeTransaction; + + valueAtStartDateWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; } const transactionInvestment = order.type === 'BUY' - ? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type)) + ? order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(this.getFactor(order.type)) : totalUnits.gt(0) ? totalInvestment .div(totalUnits) @@ -1167,22 +1381,46 @@ export class PortfolioCalculator { .mul(this.getFactor(order.type)) : new Big(0); + const transactionInvestmentWithCurrencyEffect = + order.type === 'BUY' + ? order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(this.getFactor(order.type)) + : totalUnits.gt(0) + ? totalInvestmentWithCurrencyEffect + .div(totalUnits) + .mul(order.quantity) + .mul(this.getFactor(order.type)) + : new Big(0); + if (PortfolioCalculator.ENABLE_LOGGING) { console.log('totalInvestment', totalInvestment.toNumber()); + + console.log( + 'totalInvestmentWithCurrencyEffect', + totalInvestmentWithCurrencyEffect.toNumber() + ); + console.log('order.quantity', order.quantity.toNumber()); console.log('transactionInvestment', transactionInvestment.toNumber()); + + console.log( + 'transactionInvestmentWithCurrencyEffect', + transactionInvestmentWithCurrencyEffect.toNumber() + ); } const totalInvestmentBeforeTransaction = totalInvestment; - totalInvestment = totalInvestment.plus(transactionInvestment); - if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) { - maxTotalInvestment = totalInvestment; - } + const totalInvestmentBeforeTransactionWithCurrencyEffect = + totalInvestmentWithCurrencyEffect; - if (i === indexOfEndOrder && totalUnits.gt(0)) { - averagePriceAtEndDate = totalInvestment.div(totalUnits); - } + totalInvestment = totalInvestment.plus(transactionInvestment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); if (i >= indexOfStartOrder && !initialValue) { if ( @@ -1190,57 +1428,116 @@ export class PortfolioCalculator { !valueOfInvestmentBeforeTransaction.eq(0) ) { initialValue = valueOfInvestmentBeforeTransaction; + + initialValueWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; } else if (transactionInvestment.gt(0)) { initialValue = transactionInvestment; + + initialValueWithCurrencyEffect = + transactionInvestmentWithCurrencyEffect; } } fees = fees.plus(order.fee); + feesWithCurrencyEffect = feesWithCurrencyEffect.plus( + order.fee.mul(exchangeRateAtOrderDate ?? 1) + ); + totalUnits = totalUnits.plus( order.quantity.mul(this.getFactor(order.type)) ); - const valueOfInvestment = totalUnits.mul(order.unitPrice); + const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); + + const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( + order.unitPriceInBaseCurrencyWithCurrencyEffect + ); const grossPerformanceFromSell = order.type === TypeOfOrder.SELL - ? order.unitPrice.minus(lastAveragePrice).mul(order.quantity) + ? order.unitPriceInBaseCurrency + .minus(lastAveragePrice) + .mul(order.quantity) + : new Big(0); + + const grossPerformanceFromSellWithCurrencyEffect = + order.type === TypeOfOrder.SELL + ? order.unitPriceInBaseCurrencyWithCurrencyEffect + .minus(lastAveragePriceWithCurrencyEffect) + .mul(order.quantity) : new Big(0); grossPerformanceFromSells = grossPerformanceFromSells.plus( grossPerformanceFromSell ); + grossPerformanceFromSellsWithCurrencyEffect = + grossPerformanceFromSellsWithCurrencyEffect.plus( + grossPerformanceFromSellWithCurrencyEffect + ); + totalInvestmentWithGrossPerformanceFromSell = totalInvestmentWithGrossPerformanceFromSell .plus(transactionInvestment) .plus(grossPerformanceFromSell); + totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = + totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect + .plus(transactionInvestmentWithCurrencyEffect) + .plus(grossPerformanceFromSellWithCurrencyEffect); + lastAveragePrice = totalUnits.eq(0) ? new Big(0) : totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); + lastAveragePriceWithCurrencyEffect = totalUnits.eq(0) + ? new Big(0) + : totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.div( + totalUnits + ); + if (PortfolioCalculator.ENABLE_LOGGING) { console.log( 'totalInvestmentWithGrossPerformanceFromSell', totalInvestmentWithGrossPerformanceFromSell.toNumber() ); + console.log( + 'totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect', + totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.toNumber() + ); console.log( 'grossPerformanceFromSells', grossPerformanceFromSells.toNumber() ); + console.log( + 'grossPerformanceFromSellWithCurrencyEffect', + grossPerformanceFromSellWithCurrencyEffect.toNumber() + ); } const newGrossPerformance = valueOfInvestment .minus(totalInvestment) .plus(grossPerformanceFromSells); + const newGrossPerformanceWithCurrencyEffect = + valueOfInvestmentWithCurrencyEffect + .minus(totalInvestmentWithCurrencyEffect) + .plus(grossPerformanceFromSellsWithCurrencyEffect); + grossPerformance = newGrossPerformance; + grossPerformanceWithCurrencyEffect = + newGrossPerformanceWithCurrencyEffect; + if (order.itemType === 'start') { feesAtStartDate = fees; + feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; grossPerformanceAtStartDate = grossPerformance; + + grossPerformanceAtStartDateWithCurrencyEffect = + grossPerformanceWithCurrencyEffect; } if (i > indexOfStartOrder) { @@ -1272,30 +1569,71 @@ export class PortfolioCalculator { .plus(totalInvestmentBeforeTransaction) .mul(daysSinceLastOrder) ); + + sumOfTimeWeightedInvestmentsWithCurrencyEffect = + sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( + valueAtStartDateWithCurrencyEffect + .minus(investmentAtStartDateWithCurrencyEffect) + .plus(totalInvestmentBeforeTransactionWithCurrencyEffect) + .mul(daysSinceLastOrder) + ); } if (isChartMode) { currentValues[order.date] = valueOfInvestment; + + currentValuesWithCurrencyEffect[order.date] = + valueOfInvestmentWithCurrencyEffect; + netPerformanceValues[order.date] = grossPerformance .minus(grossPerformanceAtStartDate) .minus(fees.minus(feesAtStartDate)); + netPerformanceValuesWithCurrencyEffect[order.date] = + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .minus( + feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) + ); + investmentValues[order.date] = totalInvestment; - maxInvestmentValues[order.date] = maxTotalInvestment; + + investmentValuesWithCurrencyEffect[order.date] = + totalInvestmentWithCurrencyEffect; timeWeightedInvestmentValues[order.date] = totalInvestmentDays > 0 ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) : new Big(0); + + timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); } } if (PortfolioCalculator.ENABLE_LOGGING) { console.log('totalInvestment', totalInvestment.toNumber()); + + console.log( + 'totalInvestmentWithCurrencyEffect', + totalInvestmentWithCurrencyEffect.toNumber() + ); + console.log( 'totalGrossPerformance', grossPerformance.minus(grossPerformanceAtStartDate).toNumber() ); + + console.log( + 'totalGrossPerformanceWithCurrencyEffect', + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .toNumber() + ); } if (i === indexOfEndOrder) { @@ -1307,83 +1645,73 @@ export class PortfolioCalculator { grossPerformanceAtStartDate ); + const totalGrossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.minus( + grossPerformanceAtStartDateWithCurrencyEffect + ); + const totalNetPerformance = grossPerformance .minus(grossPerformanceAtStartDate) .minus(fees.minus(feesAtStartDate)); + const totalNetPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .minus(feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)); + const timeWeightedAverageInvestmentBetweenStartAndEndDate = totalInvestmentDays > 0 ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) : new Big(0); - const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus( - maxTotalInvestment.minus(investmentAtStartDate) - ); + const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); - let grossPerformancePercentage: Big; + const grossPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); - if ( - PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT - ) { - grossPerformancePercentage = - timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate - ) - : new Big(0); - } else { - grossPerformancePercentage = - averagePriceAtStartDate.eq(0) || - averagePriceAtEndDate.eq(0) || - orders[indexOfStartOrder].unitPrice.eq(0) - ? maxInvestmentBetweenStartAndEndDate.gt(0) - ? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate) - : new Big(0) - : // This formula has the issue that buying more units with a price - // lower than the average buying price results in a positive - // performance even if the market price stays constant - unitPriceAtEndDate - .div(averagePriceAtEndDate) - .div( - orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) - ) - .minus(1); - } + const grossPerformancePercentageWithCurrencyEffect = + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) + ? totalGrossPerformanceWithCurrencyEffect.div( + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + : new Big(0); const feesPerUnit = totalUnits.gt(0) ? fees.minus(feesAtStartDate).div(totalUnits) : new Big(0); - let netPerformancePercentage: Big; + const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) + ? feesWithCurrencyEffect + .minus(feesAtStartDateWithCurrencyEffect) + .div(totalUnits) + : new Big(0); - if ( - PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_TIME_WEIGHTED_INVESTMENT - ) { - netPerformancePercentage = - timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) - ? totalNetPerformance.div( - timeWeightedAverageInvestmentBetweenStartAndEndDate - ) - : new Big(0); - } else { - netPerformancePercentage = - averagePriceAtStartDate.eq(0) || - averagePriceAtEndDate.eq(0) || - orders[indexOfStartOrder].unitPrice.eq(0) - ? maxInvestmentBetweenStartAndEndDate.gt(0) - ? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate) - : new Big(0) - : // This formula has the issue that buying more units with a price - // lower than the average buying price results in a positive - // performance even if the market price stays constant - unitPriceAtEndDate - .minus(feesPerUnit) - .div(averagePriceAtEndDate) - .div( - orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate) - ) - .minus(1); - } + const netPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const netPerformancePercentageWithCurrencyEffect = + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) + ? totalNetPerformanceWithCurrencyEffect.div( + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + : new Big(0); if (PortfolioCalculator.ENABLE_LOGGING) { console.log( @@ -1392,38 +1720,64 @@ export class PortfolioCalculator { Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( 2 )} -> ${unitPriceAtEndDate.toFixed(2)} - Average price: ${averagePriceAtStartDate.toFixed( - 2 - )} -> ${averagePriceAtEndDate.toFixed(2)} Total investment: ${totalInvestment.toFixed(2)} + Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( + 2 + )} Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( 2 )} - Max. total investment: ${maxTotalInvestment.toFixed(2)} + Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( + 2 + )} Gross performance: ${totalGrossPerformance.toFixed( 2 )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% + Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( + 2 + )} / ${grossPerformancePercentageWithCurrencyEffect + .mul(100) + .toFixed(2)}% Fees per unit: ${feesPerUnit.toFixed(2)} + Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( + 2 + )} Net performance: ${totalNetPerformance.toFixed( 2 - )} / ${netPerformancePercentage.mul(100).toFixed(2)}%` + )} / ${netPerformancePercentage.mul(100).toFixed(2)}% + Net performance with currency effect: ${totalNetPerformanceWithCurrencyEffect.toFixed( + 2 + )} / ${netPerformancePercentageWithCurrencyEffect.mul(100).toFixed(2)}%` ); } return { currentValues, + currentValuesWithCurrencyEffect, grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, initialValue, + initialValueWithCurrencyEffect, investmentValues, - maxInvestmentValues, + investmentValuesWithCurrencyEffect, netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), netPerformance: totalNetPerformance, + netPerformanceWithCurrencyEffect: totalNetPerformanceWithCurrencyEffect, timeWeightedInvestment: - timeWeightedAverageInvestmentBetweenStartAndEndDate + timeWeightedAverageInvestmentBetweenStartAndEndDate, + timeWeightedInvestmentWithCurrencyEffect: + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect }; } diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index cd4fa0bae..bbd81a501 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -285,6 +285,7 @@ export class PortfolioService { const portfolioCalculator = new PortfolioCalculator({ currency: this.request.user.Settings.settings.baseCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -407,6 +408,7 @@ export class PortfolioService { const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -480,7 +482,7 @@ export class PortfolioService { continue; } - const value = item.quantity.mul(item.marketPrice ?? 0); + const value = item.quantity.mul(item.marketPriceInBaseCurrency ?? 0); const symbolProfile = symbolProfileMap[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol]; @@ -704,6 +706,8 @@ export class PortfolioService { firstBuyDate: undefined, grossPerformance: undefined, grossPerformancePercent: undefined, + grossPerformancePercentWithCurrencyEffect: undefined, + grossPerformanceWithCurrencyEffect: undefined, historicalData: [], investment: undefined, marketPrice: undefined, @@ -711,6 +715,8 @@ export class PortfolioService { minPrice: undefined, netPerformance: undefined, netPerformancePercent: undefined, + netPerformancePercentWithCurrencyEffect: undefined, + netPerformanceWithCurrencyEffect: undefined, orders: [], quantity: undefined, SymbolProfile: undefined, @@ -719,7 +725,6 @@ export class PortfolioService { }; } - const positionCurrency = orders[0].SymbolProfile.currency; const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ { dataSource: aDataSource, symbol: aSymbol } ]); @@ -746,8 +751,9 @@ export class PortfolioService { tags = uniqBy(tags, 'id'); const portfolioCalculator = new PortfolioCalculator({ - currency: positionCurrency, + currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -755,6 +761,7 @@ export class PortfolioService { const transactionPoints = portfolioCalculator.getTransactionPoints(); const portfolioStart = parseDate(transactionPoints[0].date); + const currentPositions = await portfolioCalculator.getCurrentPositions(portfolioStart); @@ -784,23 +791,6 @@ export class PortfolioService { }) ); - // Convert investment, gross and net performance to currency of user - const investment = this.exchangeRateDataService.toCurrency( - position.investment?.toNumber(), - currency, - userCurrency - ); - const grossPerformance = this.exchangeRateDataService.toCurrency( - position.grossPerformance?.toNumber(), - currency, - userCurrency - ); - const netPerformance = this.exchangeRateDataService.toCurrency( - position.netPerformance?.toNumber(), - currency, - userCurrency - ); - const historicalData = await this.dataProviderService.getHistorical( [{ dataSource, symbol: aSymbol }], 'day', @@ -865,12 +855,9 @@ export class PortfolioService { return { firstBuyDate, - grossPerformance, - investment, marketPrice, maxPrice, minPrice, - netPerformance, orders, SymbolProfile, tags, @@ -883,10 +870,21 @@ export class PortfolioService { SymbolProfile.currency, userCurrency ), + grossPerformance: position.grossPerformance?.toNumber(), grossPerformancePercent: position.grossPerformancePercentage?.toNumber(), + grossPerformancePercentWithCurrencyEffect: + position.grossPerformancePercentageWithCurrencyEffect?.toNumber(), + grossPerformanceWithCurrencyEffect: + position.grossPerformanceWithCurrencyEffect?.toNumber(), historicalData: historicalDataArray, + investment: position.investment?.toNumber(), + netPerformance: position.netPerformance?.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(), + netPerformancePercentWithCurrencyEffect: + position.netPerformancePercentageWithCurrencyEffect?.toNumber(), + netPerformanceWithCurrencyEffect: + position.netPerformanceWithCurrencyEffect?.toNumber(), quantity: quantity.toNumber(), value: this.exchangeRateDataService.toCurrency( quantity.mul(marketPrice ?? 0).toNumber(), @@ -945,10 +943,14 @@ export class PortfolioService { firstBuyDate: undefined, grossPerformance: undefined, grossPerformancePercent: undefined, + grossPerformancePercentWithCurrencyEffect: undefined, + grossPerformanceWithCurrencyEffect: undefined, historicalData: historicalDataArray, investment: 0, netPerformance: undefined, netPerformancePercent: undefined, + netPerformancePercentWithCurrencyEffect: undefined, + netPerformanceWithCurrencyEffect: undefined, quantity: 0, transactionCount: undefined, value: 0 @@ -986,6 +988,7 @@ export class PortfolioService { const portfolioCalculator = new PortfolioCalculator({ currency: this.request.user.Settings.settings.baseCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -1017,6 +1020,7 @@ export class PortfolioService { ]); const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; + for (const symbolProfile of symbolProfiles) { symbolProfileMap[symbolProfile.symbol] = symbolProfile; } @@ -1041,13 +1045,20 @@ export class PortfolioService { currency, dataSource, firstBuyDate, - investment, grossPerformance, grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + investment, + investmentWithCurrencyEffect, netPerformance, netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, quantity, symbol, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, transactionCount }) => { return { @@ -1062,14 +1073,27 @@ export class PortfolioService { grossPerformance: grossPerformance?.toNumber() ?? null, grossPerformancePercentage: grossPerformancePercentage?.toNumber() ?? null, + grossPerformancePercentageWithCurrencyEffect: + grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? null, + grossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect?.toNumber() ?? null, investment: investment.toNumber(), + investmentWithCurrencyEffect: + investmentWithCurrencyEffect?.toNumber(), marketState: dataProviderResponses[symbol]?.marketState ?? 'delayed', name: symbolProfileMap[symbol].name, netPerformance: netPerformance?.toNumber() ?? null, netPerformancePercentage: netPerformancePercentage?.toNumber() ?? null, - quantity: quantity.toNumber() + netPerformancePercentageWithCurrencyEffect: + netPerformancePercentageWithCurrencyEffect?.toNumber() ?? null, + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffect?.toNumber() ?? null, + quantity: quantity.toNumber(), + timeWeightedInvestment: timeWeightedInvestment?.toNumber(), + timeWeightedInvestmentWithCurrencyEffect: + timeWeightedInvestmentWithCurrencyEffect?.toNumber() }; } ) @@ -1128,6 +1152,7 @@ export class PortfolioService { const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -1139,8 +1164,12 @@ export class PortfolioService { performance: { currentGrossPerformance: 0, currentGrossPerformancePercent: 0, + currentGrossPerformancePercentWithCurrencyEffect: 0, + currentGrossPerformanceWithCurrencyEffect: 0, currentNetPerformance: 0, currentNetPerformancePercent: 0, + currentNetPerformancePercentWithCurrencyEffect: 0, + currentNetPerformanceWithCurrencyEffect: 0, currentNetWorth: 0, currentValue: 0, totalInvestment: 0 @@ -1165,17 +1194,26 @@ export class PortfolioService { errors, grossPerformance, grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, hasErrors, netPerformance, netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, totalInvestment } = await portfolioCalculator.getCurrentPositions(startDate); - const currentGrossPerformance = grossPerformance; - const currentGrossPerformancePercent = grossPerformancePercentage; let currentNetPerformance = netPerformance; + let currentNetPerformancePercent = netPerformancePercentage; + let currentNetPerformancePercentWithCurrencyEffect = + netPerformancePercentageWithCurrencyEffect; + + let currentNetPerformanceWithCurrencyEffect = + netPerformanceWithCurrencyEffect; + const { items } = await this.getChart({ dateRange, impersonationId, @@ -1191,9 +1229,18 @@ export class PortfolioService { if (itemOfToday) { currentNetPerformance = new Big(itemOfToday.netPerformance); + currentNetPerformancePercent = new Big( itemOfToday.netPerformanceInPercentage ).div(100); + + currentNetPerformancePercentWithCurrencyEffect = new Big( + itemOfToday.netPerformanceInPercentageWithCurrencyEffect + ).div(100); + + currentNetPerformanceWithCurrencyEffect = new Big( + itemOfToday.netPerformanceWithCurrencyEffect + ); } accountBalanceItems = accountBalanceItems.filter(({ date }) => { @@ -1226,11 +1273,18 @@ export class PortfolioService { firstOrderDate: parseDate(items[0]?.date), performance: { currentNetWorth, - currentGrossPerformance: currentGrossPerformance.toNumber(), - currentGrossPerformancePercent: - currentGrossPerformancePercent.toNumber(), + currentGrossPerformance: grossPerformance.toNumber(), + currentGrossPerformancePercent: grossPerformancePercentage.toNumber(), + currentGrossPerformancePercentWithCurrencyEffect: + grossPerformancePercentageWithCurrencyEffect.toNumber(), + currentGrossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect.toNumber(), currentNetPerformance: currentNetPerformance.toNumber(), currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), + currentNetPerformancePercentWithCurrencyEffect: + currentNetPerformancePercentWithCurrencyEffect.toNumber(), + currentNetPerformanceWithCurrencyEffect: + currentNetPerformanceWithCurrencyEffect.toNumber(), currentValue: currentValue.toNumber(), totalInvestment: totalInvestment.toNumber() } @@ -1250,6 +1304,7 @@ export class PortfolioService { const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -1413,6 +1468,7 @@ export class PortfolioService { const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -1757,6 +1813,7 @@ export class PortfolioService { const annualizedPerformancePercent = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: [] }) .getAnnualizedPerformancePercent({ @@ -1866,30 +1923,19 @@ export class PortfolioService { currency: order.SymbolProfile.currency, dataSource: order.SymbolProfile.dataSource, date: format(order.date, DATE_FORMAT), - fee: new Big( - this.exchangeRateDataService.toCurrency( - order.fee, - order.SymbolProfile.currency, - userCurrency - ) - ), + fee: new Big(order.fee), name: order.SymbolProfile?.name, quantity: new Big(order.quantity), symbol: order.SymbolProfile.symbol, tags: order.tags, type: order.type, - unitPrice: new Big( - this.exchangeRateDataService.toCurrency( - order.unitPrice, - order.SymbolProfile.currency, - userCurrency - ) - ) + unitPrice: new Big(order.unitPrice) })); const portfolioCalculator = new PortfolioCalculator({ currency: userCurrency, currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, orders: portfolioOrders }); @@ -2025,7 +2071,8 @@ export class PortfolioService { for (const order of ordersByAccount) { let currentValueOfSymbolInBaseCurrency = order.quantity * - (portfolioItemsNow[order.SymbolProfile.symbol]?.marketPrice ?? + (portfolioItemsNow[order.SymbolProfile.symbol] + ?.marketPriceInBaseCurrency ?? order.unitPrice ?? 0); diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts new file mode 100644 index 000000000..1fc88bb6a --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -0,0 +1,29 @@ +export const ExchangeRateDataServiceMock = { + getExchangeRatesByCurrency: ({ + currencies, + endDate, + startDate, + targetCurrency + }): Promise => { + if (targetCurrency === 'CHF') { + return Promise.resolve({ + CHF: { + '2015-01-01': 1, + '2017-12-31': 1, + '2018-01-01': 1, + '2023-01-03': 1, + '2023-07-10': 1 + }, + USD: { + '2015-01-01': 0.9941099999999999, + '2017-12-31': 0.9787, + '2018-01-01': 0.97373, + '2023-01-03': 0.9238, + '2023-07-10': 0.8854 + } + }); + } + + return Promise.resolve({}); + } +}; diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts index 5ddef02a5..f7e6541c7 100644 --- a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -7,9 +7,19 @@ import { DEFAULT_CURRENCY, PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; -import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; +import { + DATE_FORMAT, + getYesterday, + resetHours +} from '@ghostfolio/common/helper'; import { Injectable, Logger } from '@nestjs/common'; -import { format, isToday } from 'date-fns'; +import { + eachDayOfInterval, + format, + isBefore, + isToday, + subDays +} from 'date-fns'; import { isNumber, uniq } from 'lodash'; import ms from 'ms'; @@ -34,123 +44,60 @@ export class ExchangeRateDataService { return this.currencyPairs; } - public async getExchangeRates({ - currencyFrom, - currencyTo, - dates + public async getExchangeRatesByCurrency({ + currencies, + endDate = new Date(), + startDate, + targetCurrency }: { - currencyFrom: string; - currencyTo: string; - dates: Date[]; + currencies: string[]; + endDate?: Date; + startDate: Date; + targetCurrency: string; }) { - let factors: { [dateString: string]: number } = {}; + if (!startDate) { + return {}; + } - if (currencyFrom === currencyTo) { - for (const date of dates) { - factors[format(date, DATE_FORMAT)] = 1; - } - } else { - const dataSource = - this.dataProviderService.getDataSourceForExchangeRates(); - const symbol = `${currencyFrom}${currencyTo}`; + let exchangeRatesByCurrency: { + [currency: string]: { [dateString: string]: number }; + } = {}; - const marketData = await this.marketDataService.getRange({ - dateQuery: { in: dates }, - uniqueAssets: [ - { - dataSource, - symbol - } - ] + for (let currency of currencies) { + exchangeRatesByCurrency[currency] = await this.getExchangeRates({ + startDate, + currencyFrom: currency, + currencyTo: targetCurrency }); - 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' - ); - } + let previousExchangeRate = 1; + + // Start from the most recent date and fill in missing exchange rates + // using the latest available rate + for ( + let date = endDate; + !isBefore(date, startDate); + date = subDays(resetHours(date), 1) + ) { + let dateString = format(date, DATE_FORMAT); + + // Check if the exchange rate for the current date is missing + if (isNaN(exchangeRatesByCurrency[currency][dateString])) { + // If missing, fill with the previous exchange rate + exchangeRatesByCurrency[currency][dateString] = previousExchangeRate; + + Logger.error( + `No exchange rate has been found for ${DEFAULT_CURRENCY}${targetCurrency} at ${dateString}`, + 'ExchangeRateDataService' + ); + } else { + // If available, update the previous exchange rate + previousExchangeRate = exchangeRatesByCurrency[currency][dateString]; } } } - return factors; + return exchangeRatesByCurrency; } public hasCurrencyPair(currency1: string, currency2: string) { @@ -396,6 +343,129 @@ export class ExchangeRateDataService { return undefined; } + private async getExchangeRates({ + currencyFrom, + currencyTo, + endDate = new Date(), + startDate + }: { + currencyFrom: string; + currencyTo: string; + endDate?: Date; + startDate: Date; + }) { + const dates = eachDayOfInterval({ end: endDate, start: startDate }); + 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: { gte: startDate, lt: endDate }, + 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: { gte: startDate, lt: endDate }, + 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: { + gte: startDate, + lt: endDate + }, + 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; + } + private async prepareCurrencies(): Promise { let currencies: string[] = []; diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts index b348e33aa..049a87fb9 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -5,11 +5,15 @@ export interface HistoricalDataItem { marketPrice?: number; netPerformance?: number; netPerformanceInPercentage?: number; + netPerformanceInPercentageWithCurrencyEffect?: number; + netPerformanceWithCurrencyEffect?: number; netWorth?: number; netWorthInPercentage?: number; quantity?: number; totalAccountBalance?: number; totalInvestment?: number; + totalInvestmentValueWithCurrencyEffect?: number; value?: number; valueInPercentage?: number; + valueWithCurrencyEffect?: number; } diff --git a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts index 0343ef338..1c6f50b30 100644 --- a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts @@ -2,8 +2,12 @@ export interface PortfolioPerformance { annualizedPerformancePercent?: number; currentGrossPerformance: number; currentGrossPerformancePercent: number; + currentGrossPerformancePercentWithCurrencyEffect: number; + currentGrossPerformanceWithCurrencyEffect: number; currentNetPerformance: number; currentNetPerformancePercent: number; + currentNetPerformancePercentWithCurrencyEffect: number; + currentNetPerformanceWithCurrencyEffect: number; currentNetWorth: number; currentValue: number; totalInvestment: number; diff --git a/libs/common/src/lib/interfaces/timeline-position.interface.ts b/libs/common/src/lib/interfaces/timeline-position.interface.ts index 1b27de8dc..220a0aa8f 100644 --- a/libs/common/src/lib/interfaces/timeline-position.interface.ts +++ b/libs/common/src/lib/interfaces/timeline-position.interface.ts @@ -9,13 +9,20 @@ export interface TimelinePosition { firstBuyDate: string; grossPerformance: Big; grossPerformancePercentage: Big; + grossPerformancePercentageWithCurrencyEffect: Big; + grossPerformanceWithCurrencyEffect: Big; investment: Big; + investmentWithCurrencyEffect: Big; marketPrice: number; + marketPriceInBaseCurrency: number; netPerformance: Big; netPerformancePercentage: Big; + netPerformancePercentageWithCurrencyEffect: Big; + netPerformanceWithCurrencyEffect: Big; quantity: Big; symbol: string; tags?: Tag[]; timeWeightedInvestment: Big; + timeWeightedInvestmentWithCurrencyEffect: Big; transactionCount: number; }