From e72ed3678388a538312108fcda1f50b98aef656a Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Mon, 19 Oct 2026 01:35:08 +0200 Subject: [PATCH 1/2] Feature/add support for comparing multiple benchmarks in benchmark comparator --- CHANGELOG.md | 4 + apps/api/src/app/user/user.controller.ts | 14 ++- apps/api/src/app/user/user.service.ts | 1 + .../benchmark-comparator.component.html | 13 ++- .../benchmark-comparator.component.scss | 5 + .../benchmark-comparator.component.ts | 93 ++++++++++------ .../analysis/analysis-page.component.ts | 103 +++++++++++++----- .../portfolio/analysis/analysis-page.html | 6 +- libs/common/src/lib/chart-helper.ts | 16 +++ .../src/lib/dtos/update-user-setting.dto.ts | 5 + libs/common/src/lib/helper.spec.ts | 45 ++++++++ libs/common/src/lib/helper.ts | 20 ++++ .../interfaces/benchmark-series.interface.ts | 7 ++ libs/common/src/lib/interfaces/index.ts | 2 + .../lib/interfaces/user-settings.interface.ts | 1 + 15 files changed, 266 insertions(+), 69 deletions(-) create mode 100644 libs/common/src/lib/interfaces/benchmark-series.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e35621f0b..5a63867c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added support for comparing the performance with multiple benchmarks in the benchmark comparator of the analysis page + ### Changed - Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog 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/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index 72b629556a..46b5298b01 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -14,6 +14,7 @@ import type { import type { AnnotationOptions } from 'chartjs-plugin-annotation'; import { format } from 'date-fns'; +import { secondaryColorRgb } from './config'; import { DATE_FORMAT, DATE_FORMAT_MONTHLY, @@ -41,6 +42,21 @@ export function formatGroupedDate({ return format(date, DATE_FORMAT); } +// Colors for the lines of the compared benchmarks, the portfolio uses the +// primary color +const BENCHMARK_COLORS = [ + `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, + 'rgb(255, 159, 64)', + 'rgb(75, 192, 112)', + 'rgb(153, 102, 255)', + 'rgb(255, 99, 132)', + 'rgb(201, 203, 207)' +]; + +export function getBenchmarkColor(index: number) { + return BENCHMARK_COLORS[index % BENCHMARK_COLORS.length]; +} + export function getChartBorderColor(colorScheme?: ColorScheme) { return `rgba(${getTextColor(colorScheme)}, 0.1)`; } 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 b4ef699e5b..a9d24cc30b 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, @@ -856,4 +857,48 @@ 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([]); + }); + }); }); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts index 242cf4ef57..94d3f0383a 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 5f22f4734e..026815d9dd 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'; @@ -137,6 +138,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; From 1b321cd7415a3142839bcebc7188ca27b7e7d91b Mon Sep 17 00:00:00 2001 From: mister-ideas <36502095+mister-ideas@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:44:41 +0200 Subject: [PATCH 2/2] Move the changelog entry to the unreleased section --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 573667db95..9978227069 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- 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 @@ -36,7 +37,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Added support for comparing the performance with multiple benchmarks in the benchmark comparator of the analysis page - Added a _Storybook_ story for the file drop area component ### Changed