Browse Source

Merge 1b321cd741 into c2cb508f22

pull/8238/merge
Mister_Ideas 1 day ago
committed by GitHub
parent
commit
106a8e6ecd
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 14
      apps/api/src/app/user/user.controller.ts
  3. 1
      apps/api/src/app/user/user.service.ts
  4. 13
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  5. 5
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  6. 93
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  7. 103
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  8. 6
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  9. 16
      libs/common/src/lib/chart-helper.ts
  10. 5
      libs/common/src/lib/dtos/update-user-setting.dto.ts
  11. 45
      libs/common/src/lib/helper.spec.ts
  12. 20
      libs/common/src/lib/helper.ts
  13. 7
      libs/common/src/lib/interfaces/benchmark-series.interface.ts
  14. 2
      libs/common/src/lib/interfaces/index.ts
  15. 1
      libs/common/src/lib/interfaces/user-settings.interface.ts

1
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

14
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;

1
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;

13
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -17,19 +17,22 @@
>
<mat-label i18n>Compare with...</mat-label>
<mat-select
name="benchmark"
multiple
name="benchmarks"
[disabled]="user()?.subscription?.type === 'Basic'"
[value]="benchmark()?.id"
(selectionChange)="onChangeBenchmark($event.value)"
[value]="selectedBenchmarkIds()"
(selectionChange)="onChangeBenchmarks($event.value)"
>
<mat-option [value]="null" />
@for (symbolProfile of benchmarks(); track symbolProfile) {
<mat-option [value]="symbolProfile.id">{{
symbolProfile.name
}}</mat-option>
}
@if (hasPermissionToAccessAdminControl) {
<mat-option [routerLink]="routerLinkAdminControlMarketData">
<mat-option
class="manage-benchmarks-option"
[routerLink]="routerLinkAdminControlMarketData"
>
<div class="align-items-center d-flex">
<ion-icon
class="me-2 text-body-secondary"

5
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss

@ -9,3 +9,8 @@
}
}
}
// The link to the benchmark management is an action and not a selectable value
::ng-deep .manage-benchmarks-option .mat-pseudo-checkbox {
display: none;
}

93
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -1,4 +1,5 @@
import {
getBenchmarkColor,
getChartBorderColor,
getChartElementsOptions,
getTimeAxisOptions,
@ -6,9 +7,13 @@ import {
getVerticalHoverLinePlugin,
getZeroLineAnnotation
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { getLocale, parseDate } from '@ghostfolio/common/helper';
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
import {
BenchmarkSeries,
LineChartItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types';
@ -67,8 +72,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
templateUrl: './benchmark-comparator.component.html'
})
export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public readonly benchmark = input<Partial<SymbolProfile>>();
public readonly benchmarkDataItems = input<LineChartItem[]>([]);
public readonly benchmarkSeries = input<BenchmarkSeries[]>([]);
public readonly benchmarks = input<Partial<SymbolProfile>[]>();
public readonly colorScheme = input<ColorScheme>();
public readonly isLoading = input<boolean>();
@ -80,9 +84,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
});
public readonly performanceDataItems = input.required<LineChartItem[]>();
public readonly selectedBenchmarkIds = input<string[]>([]);
public readonly user = input<User>();
public readonly benchmarkChanged = output<string>();
public readonly benchmarksChanged = output<string[]>();
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<string, number> = {};
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<string, number> = {};
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<TooltipOptions<'line'>> {
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;
}
};
}
}

103
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<SymbolProfile> &
Required<Pick<SymbolProfile, 'dataSource' | 'id' | 'symbol'>>;
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -79,9 +86,8 @@ import { forkJoin } from 'rxjs';
templateUrl: './analysis-page.html'
})
export class GfAnalysisPageComponent implements OnInit {
protected benchmark?: Partial<SymbolProfile>;
protected benchmarkDataItems: LineChartItem[] = [];
protected readonly benchmarks: Partial<SymbolProfile>[];
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<BenchmarkSeries>({
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();
});
}
}

6
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -133,15 +133,15 @@
<div class="col-lg">
<gf-benchmark-comparator
class="h-100"
[benchmark]="benchmark"
[benchmarkDataItems]="benchmarkDataItems"
[benchmarks]="benchmarks"
[benchmarkSeries]="benchmarkSeries"
[colorScheme]="user?.settings?.colorScheme"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage"
[selectedBenchmarkIds]="selectedBenchmarkIds"
[user]="user"
(benchmarkChanged)="onChangeBenchmark($event)"
(benchmarksChanged)="onChangeBenchmarks($event)"
/>
</div>
</div>

16
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)`;
}

5
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;

45
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([]);
});
});
});

20
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<UserSettings, 'benchmark' | 'benchmarks'>;
}) {
const selectedIds =
settings?.benchmarks ?? (settings?.benchmark ? [settings.benchmark] : []);
return selectedIds.filter((id) => {
return availableIds.includes(id);
});
}
export function getLocale() {
return navigator.language ?? DEFAULT_LOCALE;
}

7
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;
}

2
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,

1
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;

Loading…
Cancel
Save