diff --git a/CHANGELOG.md b/CHANGELOG.md index 54764beb0..531e0de91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Integrated dividend into the transaction point concept in the portfolio service - Removed the environment variable `WEB_AUTH_RP_ID` +### Fixed + +- Fixed an issue in the calculation of the portfolio summary caused by future liabilities + ## 2.61.1 - 2024-03-06 ### Fixed 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 8f8d06112..ed9229691 100644 --- a/apps/api/src/app/portfolio/current-rate.service.mock.ts +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -46,6 +46,8 @@ function mockGetValue(symbol: string, date: Date) { case 'MSFT': if (isSameDay(parseDate('2021-09-16'), date)) { return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2021-11-16'), date)) { + return { marketPrice: 339.51 }; } else if (isSameDay(parseDate('2023-07-10'), date)) { return { marketPrice: 331.83 }; } 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 5807d6b5e..9ad9ee822 100644 --- a/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/current-positions.interface.ts @@ -3,7 +3,7 @@ import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces'; import Big from 'big.js'; export interface CurrentPositions extends ResponseError { - positions: TimelinePosition[]; + currentValueInBaseCurrency: Big; grossPerformance: Big; grossPerformanceWithCurrencyEffect: Big; grossPerformancePercentage: Big; @@ -14,6 +14,6 @@ export interface CurrentPositions extends ResponseError { netPerformanceWithCurrencyEffect: Big; netPerformancePercentage: Big; netPerformancePercentageWithCurrencyEffect: Big; - currentValue: Big; + positions: TimelinePosition[]; totalInvestment: Big; } 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 9831e5b00..32320dce6 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 @@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('0'), + currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('-12.6'), grossPerformancePercentage: new Big('-0.0440867739678096571'), @@ -131,7 +131,8 @@ describe('PortfolioCalculator', () => { symbol: 'BALN.SW', timeWeightedInvestment: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('0') } ], totalInvestment: new Big('0'), 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 bb5986059..f0aae5536 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 @@ -76,7 +76,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('297.8'), + currentValueInBaseCurrency: new Big('297.8'), errors: [], grossPerformance: new Big('24.6'), grossPerformancePercentage: new Big('0.09004392386530014641'), @@ -120,7 +120,8 @@ describe('PortfolioCalculator', () => { symbol: 'BALN.SW', timeWeightedInvestment: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), - transactionCount: 1 + transactionCount: 1, + valueInBaseCurrency: new Big('297.8') } ], totalInvestment: new Big('273.2'), 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 6126311c3..d0a8aafc0 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 @@ -100,7 +100,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('13298.425356'), + currentValueInBaseCurrency: new Big('13298.425356'), errors: [], grossPerformance: new Big('27172.74'), grossPerformancePercentage: new Big('42.41978276196153750666'), @@ -151,7 +151,8 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '636.79469348020066587024' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('13298.425356') } ], totalInvestment: new Big('320.43'), 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 index d29498292..c3d2eabff 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator-googl-buy.spec.ts @@ -89,7 +89,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('103.10483'), + currentValueInBaseCurrency: new Big('103.10483'), errors: [], grossPerformance: new Big('27.33'), grossPerformancePercentage: new Big('0.3066651705565529623'), @@ -134,7 +134,8 @@ describe('PortfolioCalculator', () => { tags: undefined, timeWeightedInvestment: new Big('89.12'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), - transactionCount: 1 + transactionCount: 1, + valueInBaseCurrency: new Big('103.10483') } ], totalInvestment: 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 ab7234822..f87075c1f 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 @@ -64,7 +64,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big(0), + currentValueInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: 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 afddc5423..8aa5cf0cb 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 @@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => { spy.mockRestore(); expect(currentPositions).toEqual({ - currentValue: new Big('87.8'), + currentValueInBaseCurrency: new Big('87.8'), errors: [], grossPerformance: new Big('21.93'), grossPerformancePercentage: new Big('0.15113417083448194384'), @@ -133,7 +133,8 @@ describe('PortfolioCalculator', () => { timeWeightedInvestmentWithCurrencyEffect: new Big( '145.10285714285714285714' ), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('87.8') } ], totalInvestment: new Big('75.80'), 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 4b7750a63..669013858 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 @@ -113,7 +113,7 @@ describe('PortfolioCalculator', () => { }); expect(currentPositions).toEqual({ - currentValue: new Big('0'), + currentValueInBaseCurrency: new Big('0'), errors: [], grossPerformance: new Big('19.86'), grossPerformancePercentage: new Big('0.13100263852242744063'), @@ -157,7 +157,8 @@ describe('PortfolioCalculator', () => { symbol: 'NOVN.SW', timeWeightedInvestment: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), - transactionCount: 2 + transactionCount: 2, + valueInBaseCurrency: new Big('0') } ], totalInvestment: new Big('0'), diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index f0551f3b8..07016efcf 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -22,6 +22,7 @@ import { format, isBefore, isSameDay, + max, subDays } from 'date-fns'; import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash'; @@ -449,16 +450,27 @@ export class PortfolioCalculator { public async getCurrentPositions( start: Date, - end = new Date(Date.now()) + end?: Date ): Promise { - const transactionPointsBeforeEndDate = - this.transactionPoints?.filter((transactionPoint) => { - return isBefore(parseDate(transactionPoint.date), end); - }) ?? []; + const lastTransactionPoint = last(this.transactionPoints); + + let endDate = end; + + if (!endDate) { + endDate = new Date(Date.now()); - if (!transactionPointsBeforeEndDate.length) { + if (lastTransactionPoint) { + endDate = max([endDate, parseDate(lastTransactionPoint.date)]); + } + } + + const transactionPoints = this.transactionPoints?.filter(({ date }) => { + return isBefore(parseDate(date), endDate); + }); + + if (!transactionPoints.length) { return { - currentValue: new Big(0), + currentValueInBaseCurrency: new Big(0), grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0), @@ -473,41 +485,40 @@ export class PortfolioCalculator { }; } - const lastTransactionPoint = - transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1]; - const currencies: { [symbol: string]: string } = {}; const dataGatheringItems: IDataGatheringItem[] = []; let dates: Date[] = []; - let firstIndex = transactionPointsBeforeEndDate.length; + let firstIndex = transactionPoints.length; let firstTransactionPoint: TransactionPoint = null; dates.push(resetHours(start)); - for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { + + for (const { currency, dataSource, symbol } of transactionPoints[ + firstIndex - 1 + ].items) { dataGatheringItems.push({ - dataSource: item.dataSource, - symbol: item.symbol + dataSource, + symbol }); - currencies[item.symbol] = item.currency; + currencies[symbol] = currency; } - for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { + for (let i = 0; i < transactionPoints.length; i++) { if ( - !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && + !isBefore(parseDate(transactionPoints[i].date), start) && firstTransactionPoint === null ) { - firstTransactionPoint = transactionPointsBeforeEndDate[i]; + firstTransactionPoint = transactionPoints[i]; firstIndex = i; } + if (firstTransactionPoint !== null) { - dates.push( - resetHours(parseDate(transactionPointsBeforeEndDate[i].date)) - ); + dates.push(resetHours(parseDate(transactionPoints[i].date))); } } - dates.push(resetHours(end)); + dates.push(resetHours(endDate)); // Add dates of last week for fallback dates.push(subDays(resetHours(new Date()), 7)); @@ -534,7 +545,7 @@ export class PortfolioCalculator { let exchangeRatesByCurrency = await this.exchangeRateDataService.getExchangeRatesByCurrency({ currencies: uniq(Object.values(currencies)), - endDate: endOfDay(end), + endDate: endOfDay(endDate), startDate: parseDate(this.transactionPoints?.[0]?.date), targetCurrency: this.currency }); @@ -570,7 +581,7 @@ export class PortfolioCalculator { } } - const endDateString = format(end, DATE_FORMAT); + const endDateString = format(endDate, DATE_FORMAT); if (firstIndex > 0) { firstIndex--; @@ -582,9 +593,9 @@ export class PortfolioCalculator { const errors: ResponseError['errors'] = []; for (const item of lastTransactionPoint.items) { - const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[ - item.symbol - ]?.mul( + const marketPriceInBaseCurrency = ( + marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + ).mul( exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ endDateString ] @@ -607,9 +618,9 @@ export class PortfolioCalculator { totalInvestment, totalInvestmentWithCurrencyEffect } = this.getSymbolMetrics({ - end, marketSymbolMap, start, + end: endDate, exchangeRates: exchangeRatesByCurrency[`${item.currency}${this.currency}`], symbol: item.symbol @@ -656,7 +667,10 @@ export class PortfolioCalculator { quantity: item.quantity, symbol: item.symbol, tags: item.tags, - transactionCount: item.transactionCount + transactionCount: item.transactionCount, + valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( + item.quantity + ) }); if ( @@ -725,7 +739,7 @@ export class PortfolioCalculator { } private calculateOverallPerformance(positions: TimelinePosition[]) { - let currentValue = new Big(0); + let currentValueInBaseCurrency = new Big(0); let grossPerformance = new Big(0); let grossPerformanceWithCurrencyEffect = new Big(0); let hasErrors = false; @@ -737,14 +751,9 @@ export class PortfolioCalculator { let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); for (const currentPosition of positions) { - if ( - currentPosition.investment && - currentPosition.marketPriceInBaseCurrency - ) { - currentValue = currentValue.plus( - new Big(currentPosition.marketPriceInBaseCurrency).mul( - currentPosition.quantity - ) + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency ); } else { hasErrors = true; @@ -801,7 +810,7 @@ export class PortfolioCalculator { } return { - currentValue, + currentValueInBaseCurrency, grossPerformance, grossPerformanceWithCurrencyEffect, hasErrors, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index d55b3d647..78a803e9e 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -378,9 +378,10 @@ export class PortfolioService { }); const holdings: PortfolioDetails['holdings'] = {}; - const totalValueInBaseCurrency = currentPositions.currentValue.plus( - cashDetails.balanceInBaseCurrency - ); + const totalValueInBaseCurrency = + currentPositions.currentValueInBaseCurrency.plus( + cashDetails.balanceInBaseCurrency + ); const isFilteredByAccount = filters?.some((filter) => { @@ -389,7 +390,7 @@ export class PortfolioService { let filteredValueInBaseCurrency = isFilteredByAccount ? totalValueInBaseCurrency - : currentPositions.currentValue; + : currentPositions.currentValueInBaseCurrency; if ( filters?.length === 0 || @@ -444,14 +445,14 @@ export class PortfolioService { quantity, symbol, tags, - transactionCount + transactionCount, + valueInBaseCurrency } of currentPositions.positions) { if (quantity.eq(0)) { // Ignore positions without any quantity continue; } - const value = quantity.mul(marketPriceInBaseCurrency ?? 0); const symbolProfile = symbolProfileMap[symbol]; const dataProviderResponse = dataProviderResponses[symbol]; @@ -517,11 +518,11 @@ export class PortfolioService { } } else { markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY]) - .plus(value) + .plus(valueInBaseCurrency) .toNumber(); marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) - .plus(value) + .plus(valueInBaseCurrency) .toNumber(); } @@ -535,7 +536,7 @@ export class PortfolioService { transactionCount, allocationInPercentage: filteredValueInBaseCurrency.eq(0) ? 0 - : value.div(filteredValueInBaseCurrency).toNumber(), + : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), assetClass: symbolProfile.assetClass, assetSubClass: symbolProfile.assetSubClass, countries: symbolProfile.countries, @@ -560,7 +561,7 @@ export class PortfolioService { quantity: quantity.toNumber(), sectors: symbolProfile.sectors, url: symbolProfile.url, - valueInBaseCurrency: value.toNumber() + valueInBaseCurrency: valueInBaseCurrency.toNumber() }; } @@ -1175,7 +1176,7 @@ export class PortfolioService { const startDate = this.getStartDate(dateRange, portfolioStart); const { - currentValue, + currentValueInBaseCurrency, errors, grossPerformance, grossPerformancePercentage, @@ -1270,7 +1271,7 @@ export class PortfolioService { currentNetPerformancePercentWithCurrencyEffect.toNumber(), currentNetPerformanceWithCurrencyEffect: currentNetPerformanceWithCurrencyEffect.toNumber(), - currentValue: currentValue.toNumber(), + currentValue: currentValueInBaseCurrency.toNumber(), totalInvestment: totalInvestment.toNumber() } }; 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 index a25f3a356..59f5144d8 100644 --- 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 @@ -26,6 +26,7 @@ export const ExchangeRateDataServiceMock = { return Promise.resolve({ USDUSD: { '2018-01-01': 1, + '2021-11-16': 1, '2023-07-10': 1 } }); 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 148fac560..a02ddb597 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 @@ -73,7 +73,17 @@ export class ExchangeRateDataService { currencyTo: targetCurrency }); - let previousExchangeRate = 1; + const dateStrings = Object.keys( + exchangeRatesByCurrency[`${currency}${targetCurrency}`] + ); + const lastDateString = dateStrings.reduce((a, b) => { + return a > b ? a : b; + }); + + let previousExchangeRate = + exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ + lastDateString + ] ?? 1; // Start from the most recent date and fill in missing exchange rates // using the latest available rate @@ -94,7 +104,7 @@ export class ExchangeRateDataService { exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = previousExchangeRate; - if (currency === DEFAULT_CURRENCY) { + if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { Logger.error( `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, 'ExchangeRateDataService' @@ -433,13 +443,17 @@ export class ExchangeRateDataService { ]) * marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; - factors[format(date, DATE_FORMAT)] = factor; + if (isNaN(factor)) { + throw new Error('Exchange rate is not a number'); + } else { + factors[format(date, DATE_FORMAT)] = factor; + } } catch { Logger.error( `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( date, DATE_FORMAT - )}`, + )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`, 'ExchangeRateDataService' ); } diff --git a/libs/common/src/lib/interfaces/timeline-position.interface.ts b/libs/common/src/lib/interfaces/timeline-position.interface.ts index 831c29b31..8e5dbb8e8 100644 --- a/libs/common/src/lib/interfaces/timeline-position.interface.ts +++ b/libs/common/src/lib/interfaces/timeline-position.interface.ts @@ -27,4 +27,5 @@ export interface TimelinePosition { timeWeightedInvestment: Big; timeWeightedInvestmentWithCurrencyEffect: Big; transactionCount: number; + valueInBaseCurrency: Big; }