Browse Source

Merge branch 'feature/compare-with-multiple-benchmarks' into feature/intraday-benchmark-comparison

# Conflicts:
#	CHANGELOG.md
pull/8280/head
mister-ideas 1 day ago
parent
commit
420485149e
  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. 5
      libs/common/src/lib/dtos/update-user-setting.dto.ts
  10. 45
      libs/common/src/lib/helper.spec.ts
  11. 20
      libs/common/src/lib/helper.ts
  12. 7
      libs/common/src/lib/interfaces/benchmark-series.interface.ts
  13. 2
      libs/common/src/lib/interfaces/index.ts
  14. 1
      libs/common/src/lib/interfaces/user-settings.interface.ts

1
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

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>

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,
@ -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', () => {

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';
@ -143,6 +144,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