diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b585cb22b..37e1314674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added the endpoint `GET api/v1/portfolio/intraday` to draw the performance chart of the current day (5 minutes), week (15 minutes) and month (60 minutes) on the overview page from the intraday prices of _Yahoo Finance_, including the pre-market and the post-market - Added a time axis to the performance chart on the overview page which only contains the trading days, spans the whole current year and uses hours, days, months or years as unit according to the selected date range +- Added support for comparing the performance with multiple benchmarks in the benchmark comparator of the analysis page - Added a link to the holdings tab in the holdings table on the overview tab of the home page (experimental) ### Changed diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 2e01630129..2e23cc7f1a 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -204,7 +204,7 @@ export class UserController { if ( size(data) === 1 && - (data.benchmark || data.dateRange) && + (data.benchmark || data.benchmarks || data.dateRange) && this.request.user.role === 'DEMO' ) { // Allow benchmark or date range change for demo user @@ -228,6 +228,14 @@ export class UserController { data ); + if (data.benchmarks) { + // Replace instead of merge the list, otherwise removed entries remain + userSettings.benchmarks = data.benchmarks; + + // The list supersedes the legacy single benchmark + delete userSettings.benchmark; + } + if (userSettings['filters.dataSource']) { userSettings['filters.dataSource'] = decodeDataSource( userSettings['filters.dataSource'] @@ -240,6 +248,10 @@ export class UserController { } } + if (userSettings.benchmarks?.length === 0) { + delete userSettings.benchmarks; + } + if (!userSettings.isExperimentalFeatures) { delete userSettings.isGhostfolioAiEnabled; delete userSettings.performanceCalculationType; diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index c2f5ad5661..5da0ac385f 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -466,6 +466,7 @@ export class UserService { // Reset benchmark user.settings.settings.benchmark = undefined; + user.settings.settings.benchmarks = undefined; // Reset holdings view mode user.settings.settings.holdingsViewMode = undefined; diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html index 04adcdeca7..a4d036a1bf 100644 --- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html +++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html @@ -17,19 +17,22 @@ > Compare with... - @for (symbolProfile of benchmarks(); track symbolProfile) { {{ symbolProfile.name }} } @if (hasPermissionToAccessAdminControl) { - +
>(); - public readonly benchmarkDataItems = input([]); + public readonly benchmarkSeries = input([]); public readonly benchmarks = input[]>(); public readonly colorScheme = input(); public readonly isLoading = input(); @@ -80,9 +84,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { }); public readonly performanceDataItems = input.required(); + public readonly selectedBenchmarkIds = input([]); public readonly user = input(); - public readonly benchmarkChanged = output(); + public readonly benchmarksChanged = output(); protected chart?: Chart<'line'>; protected hasPermissionToAccessAdminControl: boolean; @@ -136,22 +141,18 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { this.chart?.destroy(); } - protected onChangeBenchmark(symbolProfileId: string) { - this.benchmarkChanged.emit(symbolProfileId); + protected onChangeBenchmarks(symbolProfileIds: string[]) { + this.benchmarksChanged.emit(symbolProfileIds); } private initialize() { - const benchmarkDataValues: Record = {}; - - for (const { date, value } of this.benchmarkDataItems()) { - benchmarkDataValues[date] = value; - } + const portfolioColor = `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`; const data: ChartData<'line'> = { datasets: [ { - backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, - borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + backgroundColor: portfolioColor, + borderColor: portfolioColor, borderWidth: 2, data: this.performanceDataItems().map(({ date, value }) => { return { @@ -161,18 +162,27 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { }), label: $localize`Portfolio` }, - { - backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, - borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, - borderWidth: 2, - data: this.performanceDataItems().map(({ date }) => { - return { - x: parseDate(date)?.getTime() ?? null, - y: benchmarkDataValues[date] - }; - }), - label: this.benchmark()?.name ?? $localize`Benchmark` - } + ...this.benchmarkSeries().map(({ items, name }, index) => { + const color = getBenchmarkColor(index); + const valuesByDate: Record = {}; + + for (const { date, value } of items) { + valuesByDate[date] = value; + } + + return { + backgroundColor: color, + borderColor: color, + borderWidth: 2, + data: this.performanceDataItems().map(({ date }) => { + return { + x: parseDate(date)?.getTime() ?? null, + y: valuesByDate[date] + }; + }), + label: name ?? $localize`Benchmark` + }; + }) ] }; @@ -199,7 +209,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { } }, legend: { - display: false + display: this.benchmarkSeries().length > 0, + labels: { boxHeight: 2, boxWidth: 16 } }, tooltip: this.getTooltipPluginConfiguration(), verticalHoverLine: { @@ -230,10 +241,28 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy { } private getTooltipPluginConfiguration(): Partial> { - return getTimeSeriesTooltipOptions<'line'>({ - colorScheme: this.colorScheme(), - locale: this.locale(), - unit: '%' - }); + return { + ...getTimeSeriesTooltipOptions<'line'>({ + colorScheme: this.colorScheme(), + locale: this.locale(), + unit: '%' + }), + // List the best performing line first + itemSort: (a, b) => { + // Lines without a value at the hovered date are listed last + const valueOfA = Number(a.parsed.y); + const valueOfB = Number(b.parsed.y); + + if (Number.isFinite(valueOfA) && Number.isFinite(valueOfB)) { + return valueOfB - valueOfA; + } + + return Number.isFinite(valueOfB) + ? 1 + : Number.isFinite(valueOfA) + ? -1 + : 0; + } + }; } } 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 8ba3f0fd8a..e2cdd1de2e 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 @@ -5,8 +5,12 @@ import { NUMERICAL_PRECISION_THRESHOLD_6_FIGURES } from '@ghostfolio/common/config'; import { SubscriptionType } from '@ghostfolio/common/enums'; -import { canOpenHoldingDetail } from '@ghostfolio/common/helper'; import { + canOpenHoldingDetail, + getSelectedBenchmarkIds +} from '@ghostfolio/common/helper'; +import { + BenchmarkSeries, InvestmentItem, LineChartItem, PortfolioInvestmentsResponse, @@ -56,7 +60,10 @@ import { isNumber, keyBy, sortBy, union } from 'lodash-es'; import ms from 'ms'; import { DeviceDetectorService } from 'ngx-device-detector'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; -import { forkJoin } from 'rxjs'; +import { catchError, forkJoin, map, of, Subscription } from 'rxjs'; + +type BenchmarkProfile = Partial & + Required>; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, @@ -79,9 +86,8 @@ import { forkJoin } from 'rxjs'; templateUrl: './analysis-page.html' }) export class GfAnalysisPageComponent implements OnInit { - protected benchmark?: Partial; - protected benchmarkDataItems: LineChartItem[] = []; protected readonly benchmarks: Partial[]; + protected benchmarkSeries: BenchmarkSeries[] = []; protected bottom3: PortfolioPosition[]; protected dividendsByGroup: InvestmentItem[]; protected readonly dividendTimelineDataLabel = $localize`Dividend`; @@ -110,6 +116,7 @@ export class GfAnalysisPageComponent implements OnInit { protected readonly portfolioEvolutionDataLabel = $localize`Invested Capital`; protected precision = 2; protected savingsRatePerMonth: number | undefined; + protected selectedBenchmarkIds: string[] = []; protected streaks: PortfolioInvestmentsResponse['streaks']; protected top3: PortfolioPosition[]; protected unitCurrentStreak: string; @@ -120,6 +127,7 @@ export class GfAnalysisPageComponent implements OnInit { private readonly deviceType = computed( () => this.deviceDetectorService.deviceInfo().deviceType ); + private benchmarkSeriesSubscription?: Subscription; private dateOfFirstActivity: Date; private readonly changeDetectorRef = inject(ChangeDetectorRef); @@ -154,8 +162,11 @@ export class GfAnalysisPageComponent implements OnInit { if (state?.user) { this.user = state.user; - this.benchmark = this.benchmarks.find(({ id }) => { - return id === this.user.settings?.benchmark; + this.selectedBenchmarkIds = getSelectedBenchmarkIds({ + availableIds: this.benchmarks.flatMap(({ id }) => { + return id ? [id] : []; + }), + settings: this.user.settings }); this.hasPermissionToReadAiPrompt = hasPermission( @@ -170,9 +181,9 @@ export class GfAnalysisPageComponent implements OnInit { }); } - protected onChangeBenchmark(symbolProfileId: string) { + protected onChangeBenchmarks(symbolProfileIds: string[]) { this.dataService - .putUserSetting({ benchmark: symbolProfileId }) + .putUserSetting({ benchmarks: symbolProfileIds }) .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(() => { this.userService @@ -466,18 +477,40 @@ export class GfAnalysisPageComponent implements OnInit { } private updateBenchmarkDataItems() { - this.benchmarkDataItems = []; + // Discard the pending requests of a previous selection + this.benchmarkSeriesSubscription?.unsubscribe(); + + this.benchmarkSeries = []; + + const selectedBenchmarks = this.benchmarks.filter( + (benchmark): benchmark is BenchmarkProfile => { + return ( + !!benchmark.id && + !!benchmark.dataSource && + !!benchmark.symbol && + this.selectedBenchmarkIds.includes(benchmark.id) + ); + } + ); - if (this.user.settings.benchmark) { - const { dataSource, symbol } = - this.benchmarks.find(({ id }) => { - return id === this.user.settings.benchmark; - }) ?? {}; + selectedBenchmarks.sort((a, b) => { + return ( + this.selectedBenchmarkIds.indexOf(a.id) - + this.selectedBenchmarkIds.indexOf(b.id) + ); + }); + + if (selectedBenchmarks.length === 0) { + this.isLoadingBenchmarkComparator = false; - if (dataSource && symbol) { - this.isLoadingBenchmarkComparator = true; + return; + } - this.dataService + this.isLoadingBenchmarkComparator = true; + + this.benchmarkSeriesSubscription = forkJoin( + selectedBenchmarks.map(({ dataSource, id, name, symbol }) => { + return this.dataService .fetchBenchmarkForUser({ dataSource, symbol, @@ -485,20 +518,34 @@ export class GfAnalysisPageComponent implements OnInit { range: this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE, startDate: this.dateOfFirstActivity }) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(({ marketData }) => { - this.benchmarkDataItems = marketData.map(({ date, value }) => { + .pipe( + map(({ marketData }): BenchmarkSeries => { return { - date, - value + id, + items: marketData.map(({ date, value }) => { + return { date, value }; + }), + name: name ?? symbol }; - }); + }), + // A benchmark without data must not hide the other benchmarks + catchError(() => { + return of({ + id, + items: [], + name: name ?? symbol + }); + }) + ); + }) + ) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((benchmarkSeries) => { + this.benchmarkSeries = benchmarkSeries; - this.isLoadingBenchmarkComparator = false; + this.isLoadingBenchmarkComparator = false; - this.changeDetectorRef.markForCheck(); - }); - } - } + this.changeDetectorRef.markForCheck(); + }); } } 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 08dad5343c..547b315bc6 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -133,15 +133,15 @@
diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts index b51d8bb61b..13c693e2b6 100644 --- a/libs/common/src/lib/dtos/update-user-setting.dto.ts +++ b/libs/common/src/lib/dtos/update-user-setting.dto.ts @@ -32,6 +32,11 @@ export class UpdateUserSettingDto { @IsOptional() benchmark?: string; + @IsArray() + @IsOptional() + @IsString({ each: true }) + benchmarks?: string[] | null; + @IsIn(['DARK', 'LIGHT'] as ColorScheme[]) @IsOptional() colorScheme?: ColorScheme; diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts index c76b9ff2a4..06dff2da80 100644 --- a/libs/common/src/lib/helper.spec.ts +++ b/libs/common/src/lib/helper.spec.ts @@ -10,6 +10,7 @@ import { getEmojiFlag, getHoldingName, getNumberFormatGroup, + getSelectedBenchmarkIds, getStringOrNull, getStringOrUndefined, isAccountBalanceInFuture, @@ -857,6 +858,50 @@ describe('Helper', () => { }); }); }); + + describe('getSelectedBenchmarkIds', () => { + const availableIds = ['a', 'b', 'c']; + + it('returns an empty list without any setting', () => { + expect(getSelectedBenchmarkIds({ availableIds })).toEqual([]); + expect(getSelectedBenchmarkIds({ availableIds, settings: {} })).toEqual( + [] + ); + }); + + it('falls back to the legacy single benchmark', () => { + expect( + getSelectedBenchmarkIds({ availableIds, settings: { benchmark: 'b' } }) + ).toEqual(['b']); + }); + + it('prefers the list over the legacy single benchmark', () => { + expect( + getSelectedBenchmarkIds({ + availableIds, + settings: { benchmark: 'b', benchmarks: ['a', 'c'] } + }) + ).toEqual(['a', 'c']); + }); + + it('keeps the order and ignores benchmarks which are not available anymore', () => { + expect( + getSelectedBenchmarkIds({ + availableIds, + settings: { benchmarks: ['c', 'x', 'a'] } + }) + ).toEqual(['c', 'a']); + }); + + it('returns an empty list for an explicitly emptied list', () => { + expect( + getSelectedBenchmarkIds({ + availableIds, + settings: { benchmark: 'b', benchmarks: [] } + }) + ).toEqual([]); + }); + }); }); describe('isIntradayDateRange', () => { diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 18665ec315..e566af953b 100644 --- a/libs/common/src/lib/helper.ts +++ b/libs/common/src/lib/helper.ts @@ -77,6 +77,7 @@ export const DATE_FORMAT_YEARLY = 'yyyy'; // overwrite the filters of the authenticated user. const USER_SETTINGS_KEYS_OF_AUTHENTICATED_USER: (keyof UserSettings)[] = [ 'benchmark', + 'benchmarks', 'colorScheme', 'dateRange', 'filters.accounts', @@ -493,6 +494,25 @@ export function getHoldingName({ return name ?? symbol; } +/** + * Returns the ids of the selected benchmarks. Falls back to the legacy single + * benchmark setting and ignores ids which are not available anymore. + */ +export function getSelectedBenchmarkIds({ + availableIds, + settings +}: { + availableIds: string[]; + settings?: Pick; +}) { + const selectedIds = + settings?.benchmarks ?? (settings?.benchmark ? [settings.benchmark] : []); + + return selectedIds.filter((id) => { + return availableIds.includes(id); + }); +} + export function getLocale() { return navigator.language ?? DEFAULT_LOCALE; } diff --git a/libs/common/src/lib/interfaces/benchmark-series.interface.ts b/libs/common/src/lib/interfaces/benchmark-series.interface.ts new file mode 100644 index 0000000000..9926f22e5b --- /dev/null +++ b/libs/common/src/lib/interfaces/benchmark-series.interface.ts @@ -0,0 +1,7 @@ +import { LineChartItem } from './line-chart-item.interface'; + +export interface BenchmarkSeries { + id: string; + items: LineChartItem[]; + name: string; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 21acf50c31..c93c7b9eb4 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -11,6 +11,7 @@ import type { AssetClassSelectorOption } from './asset-class-selector-option.int import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; import type { AssetProfileItem } from './asset-profile-item.interface'; import type { BenchmarkProperty } from './benchmark-property.interface'; +import type { BenchmarkSeries } from './benchmark-series.interface'; import type { Benchmark } from './benchmark.interface'; import type { Coupon } from './coupon.interface'; import type { DataProviderInfo } from './data-provider-info.interface'; @@ -143,6 +144,7 @@ export { BenchmarkMarketDataDetailsResponse, BenchmarkProperty, BenchmarkResponse, + BenchmarkSeries, Coupon, CreateActivityResponse, CreateStripeCheckoutSessionResponse, diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index b093314d79..9a186d89e7 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -12,6 +12,7 @@ export interface UserSettings { annualInterestRate?: number; baseCurrency?: string; benchmark?: string; + benchmarks?: string[]; colorScheme?: ColorScheme; dateRange?: DateRange; emergencyFund?: number;