diff --git a/CHANGELOG.md b/CHANGELOG.md
index 297677b966..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
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 d17f2377b7..c34bfc412f 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';
@@ -139,6 +140,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;