diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index 263992bfa..e71f59478 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -1585,26 +1585,38 @@ export class PortfolioCalculator { const previousOrder = orders[i - 1]; if (order.unitPrice.toNumber() && previousOrder.unitPrice.toNumber()) { - netPerformanceValuesPercentage[order.date] = previousOrder.unitPrice - .div(order.unitPrice) + netPerformanceValuesPercentage[order.date] = order.unitPrice + .div(previousOrder.unitPrice) .minus(1); } else if ( order.type === 'STAKE' && - marketSymbolMap[order.date][order.symbol] + marketSymbolMap[order.date][order.symbol] && + ((marketSymbolMap[previousOrder.date][ + previousOrder.symbol + ]?.toNumber() && + previousOrder.type === 'STAKE') || + (previousOrder.type !== 'STAKE' && + previousOrder.unitPrice.toNumber())) ) { - netPerformanceValuesPercentage[order.date] = + let previousUnitPrice = previousOrder.type === 'STAKE' ? marketSymbolMap[previousOrder.date][previousOrder.symbol] - : previousOrder.unitPrice - .div(marketSymbolMap[order.date][order.symbol]) - .minus(1); + : previousOrder.unitPrice; + netPerformanceValuesPercentage[order.date] = marketSymbolMap[ + order.date + ][order.symbol] + ? marketSymbolMap[order.date][order.symbol] + .div(previousUnitPrice) + .minus(1) + : new Big(0); } else if (previousOrder.unitPrice.toNumber()) { netPerformanceValuesPercentage[order.date] = new Big(-1); - } else if (previousOrder.type === 'STAKE' && order.unitPrice.toNumber()) { - netPerformanceValuesPercentage[order.date] = marketSymbolMap[ - previousOrder.date - ][previousOrder.symbol] - .div(order.unitPrice) + } else if ( + previousOrder.type === 'STAKE' && + marketSymbolMap[previousOrder.date][previousOrder.symbol]?.toNumber() + ) { + netPerformanceValuesPercentage[order.date] = order.unitPrice + .div(marketSymbolMap[previousOrder.date][previousOrder.symbol]) .minus(1); } else { netPerformanceValuesPercentage[order.date] = new Big(0); diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index b1967faba..1c8e42cd0 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -68,4 +68,8 @@ export class UpdateUserSettingDto { @IsIn(['DEFAULT', 'ZEN']) @IsOptional() viewMode?: ViewMode; + + @IsIn(['N', 'B', 'O']) + @IsOptional() + timeWeightedPerformance?: string; } diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss index e02c91e3d..af1248fec 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss @@ -7,5 +7,6 @@ ngx-skeleton-loader { height: 100%; } + margin-bottom: 0.5rem; } } diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts index 9a6bd1d30..dbb0aa792 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts @@ -53,6 +53,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { @Input() isLoading: boolean; @Input() locale: string; @Input() performanceDataItems: LineChartItem[]; + @Input() timeWeightedPerformanceDataItems: LineChartItem[]; @Input() user: User; @Output() benchmarkChanged = new EventEmitter(); @@ -83,7 +84,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { permissions.accessAdminControl ); - if (this.performanceDataItems) { + if ( + this.performanceDataItems || + this.timeWeightedPerformanceDataItems?.length > 0 + ) { this.initialize(); } } @@ -108,6 +112,15 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy { }), label: $localize`Portfolio` }, + { + backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + borderWidth: 2, + data: this.timeWeightedPerformanceDataItems.map(({ date, value }) => { + return { x: parseDate(date).getTime(), y: value }; + }), + label: $localize`Portfolio (time-weighted)` + }, { backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts index 51dcee24c..c27ab5b65 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts @@ -19,6 +19,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types'; import { translate } from '@ghostfolio/ui/i18n'; import { AssetClass, DataSource, SymbolProfile } from '@prisma/client'; +import Big from 'big.js'; import { differenceInDays } from 'date-fns'; import { isNumber, sortBy } from 'lodash'; import { DeviceDetectorService } from 'ngx-device-detector'; @@ -37,6 +38,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { public benchmarks: Partial[]; public bottom3: Position[]; public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS; + public timeWeightedPerformanceOptions = [ + { label: $localize`No`, value: 'N' }, + { label: $localize`Both`, value: 'B' }, + { label: $localize`Only`, value: 'O' } + ]; + public selectedTimeWeightedPerformanceOption: string; public daysInMarket: number; public deviceType: string; public dividendsByGroup: InvestmentItem[]; @@ -56,6 +63,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { ]; public performanceDataItems: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[]; + public performanceDataItemsTimeWeightedInPercentage: HistoricalDataItem[] = + []; public placeholder = ''; public portfolioEvolutionDataLabel = $localize`Deposit`; public streaks: PortfolioInvestments['streaks']; @@ -212,6 +221,24 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); } + public onTimeWeightedPerformanceChanged(timeWeightedPerformance: string) { + this.dataService + .putUserSetting({ timeWeightedPerformance }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public onChangeGroupBy(aMode: GroupBy) { this.mode = aMode; this.fetchDividendsAndInvestments(); @@ -252,16 +279,16 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { ? translate('YEAR') : translate('YEARS') : this.streaks?.currentStreak === 1 - ? translate('MONTH') - : translate('MONTHS'); + ? translate('MONTH') + : translate('MONTHS'); this.unitLongestStreak = this.mode === 'year' ? this.streaks?.longestStreak === 1 ? translate('YEAR') : translate('YEARS') : this.streaks?.longestStreak === 1 - ? translate('MONTH') - : translate('MONTHS'); + ? translate('MONTH') + : translate('MONTHS'); this.changeDetectorRef.markForCheck(); }); @@ -314,7 +341,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.dataService .fetchPortfolioPerformance({ filters: this.activeFilters, - range: this.user?.settings?.dateRange + range: this.user?.settings?.dateRange, + timeWeightedPerformance: + this.user?.settings?.timeWeightedPerformance === 'N' ? false : true }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart, firstOrderDate }) => { @@ -324,6 +353,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { this.investments = []; this.performanceDataItems = []; this.performanceDataItemsInPercentage = []; + this.performanceDataItemsTimeWeightedInPercentage = []; for (const [ index, @@ -332,7 +362,8 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { netPerformanceInPercentage, totalInvestment, value, - valueInPercentage + valueInPercentage, + timeWeightedPerformance } ] of chart.entries()) { if (index > 0 || this.user?.settings?.dateRange === 'max') { @@ -347,6 +378,23 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { date, value: netPerformanceInPercentage }); + if ((this.user?.settings?.timeWeightedPerformance ?? 'N') !== 'N') { + let lastPerformance = 0; + if (index > 0) { + lastPerformance = new Big( + chart[index - 1].timeWeightedPerformance + ) + .plus(1) + .mul(new Big(chart[index].timeWeightedPerformance).plus(1)) + .minus(1) + .toNumber(); + } + chart[index].timeWeightedPerformance = lastPerformance; + this.performanceDataItemsTimeWeightedInPercentage.push({ + date, + value: lastPerformance + }); + } } this.isLoadingInvestmentChart = false; diff --git a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html index f875907bb..7e15345bb 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -17,7 +17,7 @@
+
+
+
+ Include time-weighted performance + +
+
+
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index e61fa2406..647e66879 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -398,11 +398,13 @@ export class DataService { public fetchPortfolioPerformance({ filters, range, - withExcludedAccounts = false + withExcludedAccounts = false, + timeWeightedPerformance = false }: { filters?: Filter[]; range: DateRange; withExcludedAccounts?: boolean; + timeWeightedPerformance?: boolean; }): Observable { let params = this.buildFiltersAsQueryParams({ filters }); params = params.append('range', range); @@ -410,6 +412,12 @@ export class DataService { if (withExcludedAccounts) { params = params.append('withExcludedAccounts', withExcludedAccounts); } + if (timeWeightedPerformance) { + params = params.append( + 'timeWeightedPerformance', + timeWeightedPerformance + ); + } return this.http .get(`/api/v2/portfolio/performance`, { 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..8b23b825d 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -12,4 +12,5 @@ export interface HistoricalDataItem { totalInvestment?: number; value?: number; valueInPercentage?: number; + timeWeightedPerformance?: number; } diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index d3864ab64..716f632a7 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -15,4 +15,5 @@ export interface UserSettings { retirementDate?: string; savingsRate?: number; viewMode?: ViewMode; + timeWeightedPerformance?: string; }