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 01/12] 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 7757daca3b0171ee55d153394125693e37298db0 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:54:00 +0200
Subject: [PATCH 02/12] Feature/hide the performance chart of the home overview
if it has less than three data points
---
.../components/home-overview/home-overview.component.ts | 8 ++++++++
.../src/app/components/home-overview/home-overview.html | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts
index c7b880efce..fc33a10d0d 100644
--- a/apps/client/src/app/components/home-overview/home-overview.component.ts
+++ b/apps/client/src/app/components/home-overview/home-overview.component.ts
@@ -38,6 +38,8 @@ import { MatCardModule } from '@angular/material/card';
import { Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
+const MINIMUM_CHART_ITEMS = 3;
+
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -56,6 +58,12 @@ export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal([]);
protected readonly hasImpersonationId = signal(false);
protected readonly historicalDataItems = signal(null);
+
+ // A line through only two points (e.g. the close of the previous day and
+ // now) is drawn as a misleading slope across the whole width
+ protected readonly hasEnoughChartData = computed(() => {
+ return (this.historicalDataItems()?.length ?? 0) >= MINIMUM_CHART_ITEMS;
+ });
protected readonly holdings = signal(
undefined
);
diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html
index 64789ddc7d..1d1110dc9e 100644
--- a/apps/client/src/app/components/home-overview/home-overview.html
+++ b/apps/client/src/app/components/home-overview/home-overview.html
@@ -72,7 +72,7 @@
unit="%"
[class.pe-3]="deviceType() === 'mobile'"
[colorScheme]="user()?.settings?.colorScheme"
- [hidden]="historicalDataItems()?.length === 0"
+ [hidden]="!hasEnoughChartData()"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
[label]="performanceLabel"
From 1470758e308b4db0f0b1eb5fe817cdcf7f81f532 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:58:28 +0200
Subject: [PATCH 03/12] Feature/label the time axis of the home overview chart
according to the date range
---
.../home-overview/home-overview.component.ts | 1 +
.../home-overview/home-overview.html | 3 +-
libs/common/src/lib/chart-helper.spec.ts | 17 +++++++++
libs/common/src/lib/chart-helper.ts | 36 +++++++++++++++++--
.../lib/line-chart/line-chart.component.ts | 7 ++--
5 files changed, 58 insertions(+), 6 deletions(-)
create mode 100644 libs/common/src/lib/chart-helper.spec.ts
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts
index fc33a10d0d..1abe7aeb77 100644
--- a/apps/client/src/app/components/home-overview/home-overview.component.ts
+++ b/apps/client/src/app/components/home-overview/home-overview.component.ts
@@ -57,6 +57,7 @@ const MINIMUM_CHART_ITEMS = 3;
export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal([]);
protected readonly hasImpersonationId = signal(false);
+ protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
protected readonly historicalDataItems = signal(null);
// A line through only two points (e.g. the close of the previous day and
diff --git a/apps/client/src/app/components/home-overview/home-overview.html b/apps/client/src/app/components/home-overview/home-overview.html
index 1d1110dc9e..11732af0f4 100644
--- a/apps/client/src/app/components/home-overview/home-overview.html
+++ b/apps/client/src/app/components/home-overview/home-overview.html
@@ -72,6 +72,7 @@
unit="%"
[class.pe-3]="deviceType() === 'mobile'"
[colorScheme]="user()?.settings?.colorScheme"
+ [dateRange]="user()?.settings?.dateRange ?? defaultDateRange"
[hidden]="!hasEnoughChartData()"
[historicalDataItems]="historicalDataItems()"
[isAnimated]="user()?.settings?.dateRange === '1d' ? false : true"
@@ -79,7 +80,7 @@
[locale]="locale()"
[showGradient]="true"
[showLoader]="false"
- [showXAxis]="false"
+ [showXAxis]="true"
[showYAxis]="false"
/>
diff --git a/libs/common/src/lib/chart-helper.spec.ts b/libs/common/src/lib/chart-helper.spec.ts
new file mode 100644
index 0000000000..2cd77e9f96
--- /dev/null
+++ b/libs/common/src/lib/chart-helper.spec.ts
@@ -0,0 +1,17 @@
+import { getTimeAxisUnit } from './chart-helper';
+
+describe('getTimeAxisUnit', () => {
+ it.each([
+ ['1d', 'day'],
+ ['wtd', 'day'],
+ ['mtd', 'day'],
+ ['ytd', 'month'],
+ ['1y', 'month'],
+ ['2025', 'month'],
+ ['5y', 'year'],
+ ['max', 'year'],
+ [undefined, 'year']
+ ])('uses the unit of %s', (dateRange, unit) => {
+ expect(getTimeAxisUnit(dateRange)).toBe(unit);
+ });
+});
diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts
index 72b629556a..b4bc5f36c6 100644
--- a/libs/common/src/lib/chart-helper.ts
+++ b/libs/common/src/lib/chart-helper.ts
@@ -23,7 +23,7 @@ import {
getLocale,
getTextColor
} from './helper';
-import { ColorScheme, GroupBy } from './types';
+import { ColorScheme, DateRange, GroupBy } from './types';
export function formatGroupedDate({
date,
@@ -60,16 +60,41 @@ export function getChartElementsOptions(
};
}
+/**
+ * Returns the unit of the ticks of the time axis, so that the labels fit the
+ * length of the date range: days up to a month, months up to a year and years
+ * above
+ */
+export function getTimeAxisUnit(
+ aDateRange?: DateRange
+): 'day' | 'month' | 'year' {
+ switch (aDateRange) {
+ case '1d':
+ case 'mtd':
+ case 'wtd':
+ return 'day';
+ case '5y':
+ case 'max':
+ case undefined:
+ return 'year';
+ default:
+ // 'ytd', '1y' and single years
+ return 'month';
+ }
+}
+
export function getTimeAxisOptions({
borderWidth = 1,
colorScheme,
display = true,
- locale = getLocale()
+ locale = getLocale(),
+ unit = 'year'
}: {
borderWidth?: number;
colorScheme?: ColorScheme;
display?: boolean;
locale?: string;
+ unit?: 'day' | 'month' | 'year';
}): ScaleOptions<'time'> {
return {
display,
@@ -80,9 +105,14 @@ export function getTimeAxisOptions({
grid: {
display: false
},
+ ticks: {
+ autoSkip: true,
+ maxRotation: 0,
+ maxTicksLimit: 6
+ },
time: {
tooltipFormat: getDateFormatString(locale),
- unit: 'year'
+ unit
},
type: 'time'
};
diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts
index d910033bc0..bda6d0a38c 100644
--- a/libs/ui/src/lib/line-chart/line-chart.component.ts
+++ b/libs/ui/src/lib/line-chart/line-chart.component.ts
@@ -1,6 +1,7 @@
import {
getChartBorderColor,
getTimeAxisOptions,
+ getTimeAxisUnit,
getValueAxisOptions,
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
@@ -10,7 +11,7 @@ import {
LineChartItem,
NullableLineChartItem
} from '@ghostfolio/common/interfaces';
-import { ColorScheme } from '@ghostfolio/common/types';
+import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import {
AfterViewInit,
@@ -60,6 +61,7 @@ export class GfLineChartComponent
@Input() benchmarkLabel = '';
@Input() colorScheme?: ColorScheme;
@Input() currency?: string;
+ @Input() dateRange?: DateRange;
@Input() historicalDataItems: LineChartItem[] | null;
@Input() isAnimated = false;
@Input() label: string;
@@ -236,7 +238,8 @@ export class GfLineChartComponent
x: getTimeAxisOptions({
colorScheme: this.colorScheme,
display: this.showXAxis,
- locale: this.locale
+ locale: this.locale,
+ unit: getTimeAxisUnit(this.dateRange)
}),
y: {
...getValueAxisOptions({
From 0fbe3f1e2c972916ddccd91b0427318b351aa0e0 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 10:59:44 +0200
Subject: [PATCH 04/12] Feature/update the changelog
---
CHANGELOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e35621f0b..6b9073957e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,8 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
+### Added
+
+- Added the unit (days, months or years) to the time axis of the performance chart on the overview page according to the selected date range
+
### Changed
+- Hid the performance chart on the overview page if it has less than three data points
- Improved the usability of the activities page by eliminating the reload on canceling the import activities dialog
- Improved the accessibility of the _Buy me a coffee_ button on the about page
- Improved the accessibility of the entity logo component
From 6b7f4093c30c757b2ecd4399e3a760e53284a64d Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:16:24 +0200
Subject: [PATCH 05/12] Feature/show the whole current period on the time axis
of the overview chart
---
.../home-overview/home-overview.component.ts | 5 +-
libs/common/src/lib/chart-helper.spec.ts | 53 +++++++++++++-
libs/common/src/lib/chart-helper.ts | 69 ++++++++++++++++++-
.../lib/line-chart/line-chart.component.ts | 2 +
4 files changed, 123 insertions(+), 6 deletions(-)
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts
index 1abe7aeb77..cc98251232 100644
--- a/apps/client/src/app/components/home-overview/home-overview.component.ts
+++ b/apps/client/src/app/components/home-overview/home-overview.component.ts
@@ -63,7 +63,10 @@ export class GfHomeOverviewComponent implements OnInit {
// A line through only two points (e.g. the close of the previous day and
// now) is drawn as a misleading slope across the whole width
protected readonly hasEnoughChartData = computed(() => {
- return (this.historicalDataItems()?.length ?? 0) >= MINIMUM_CHART_ITEMS;
+ const dateRange = this.user()?.settings?.dateRange;
+ const minimum = dateRange === '1d' ? 1 : MINIMUM_CHART_ITEMS;
+
+ return (this.historicalDataItems()?.length ?? 0) >= minimum;
});
protected readonly holdings = signal(
undefined
diff --git a/libs/common/src/lib/chart-helper.spec.ts b/libs/common/src/lib/chart-helper.spec.ts
index 2cd77e9f96..efb66a36e9 100644
--- a/libs/common/src/lib/chart-helper.spec.ts
+++ b/libs/common/src/lib/chart-helper.spec.ts
@@ -1,8 +1,8 @@
-import { getTimeAxisUnit } from './chart-helper';
+import { getTimeAxisBounds, getTimeAxisUnit } from './chart-helper';
describe('getTimeAxisUnit', () => {
it.each([
- ['1d', 'day'],
+ ['1d', 'hour'],
['wtd', 'day'],
['mtd', 'day'],
['ytd', 'month'],
@@ -15,3 +15,52 @@ describe('getTimeAxisUnit', () => {
expect(getTimeAxisUnit(dateRange)).toBe(unit);
});
});
+
+describe('getTimeAxisBounds', () => {
+ const reference = new Date(2026, 9, 21, 15, 30);
+
+ it('spans the whole current year for ytd', () => {
+ const { max, min } = getTimeAxisBounds('ytd', reference);
+
+ expect(new Date(min).toDateString()).toBe(
+ new Date(2026, 0, 1).toDateString()
+ );
+ expect(new Date(max).toDateString()).toBe(
+ new Date(2026, 11, 31).toDateString()
+ );
+ });
+
+ it('spans the whole current month for mtd', () => {
+ const { max, min } = getTimeAxisBounds('mtd', reference);
+
+ expect(new Date(min).getDate()).toBe(1);
+ expect(new Date(max).getDate()).toBe(31);
+ });
+
+ it('spans the whole current week for wtd', () => {
+ const { max, min } = getTimeAxisBounds('wtd', reference);
+
+ expect(new Date(min).getDay()).toBe(1);
+ expect(new Date(max).getDay()).toBe(0);
+ });
+
+ it('spans the whole current day for 1d', () => {
+ const { max, min } = getTimeAxisBounds('1d', reference);
+
+ expect(new Date(min).getHours()).toBe(0);
+ expect(new Date(max).getHours()).toBe(23);
+ });
+
+ it('spans a past year', () => {
+ const { max, min } = getTimeAxisBounds('2024', reference);
+
+ expect(new Date(min).getFullYear()).toBe(2024);
+ expect(new Date(max).getMonth()).toBe(11);
+ });
+
+ it('does not fix the bounds of rolling ranges', () => {
+ expect(getTimeAxisBounds('1y', reference)).toEqual({});
+ expect(getTimeAxisBounds('5y', reference)).toEqual({});
+ expect(getTimeAxisBounds('max', reference)).toEqual({});
+ });
+});
diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts
index b4bc5f36c6..3de8ade41e 100644
--- a/libs/common/src/lib/chart-helper.ts
+++ b/libs/common/src/lib/chart-helper.ts
@@ -12,7 +12,17 @@ import type {
TooltipPosition
} from 'chart.js';
import type { AnnotationOptions } from 'chartjs-plugin-annotation';
-import { format } from 'date-fns';
+import {
+ endOfDay,
+ endOfMonth,
+ endOfWeek,
+ endOfYear,
+ format,
+ startOfDay,
+ startOfMonth,
+ startOfWeek,
+ startOfYear
+} from 'date-fns';
import {
DATE_FORMAT,
@@ -67,9 +77,10 @@ export function getChartElementsOptions(
*/
export function getTimeAxisUnit(
aDateRange?: DateRange
-): 'day' | 'month' | 'year' {
+): 'day' | 'hour' | 'month' | 'year' {
switch (aDateRange) {
case '1d':
+ return 'hour';
case 'mtd':
case 'wtd':
return 'day';
@@ -83,18 +94,68 @@ export function getTimeAxisUnit(
}
}
+/**
+ * Returns the bounds of the time axis for the date ranges which have a fixed
+ * end (the current week, month or year), so that the chart shows the whole
+ * period with the part still to come left empty. Other date ranges (rolling
+ * periods and `max`) end at the latest data point.
+ */
+export function getTimeAxisBounds(
+ aDateRange?: DateRange,
+ aReferenceDate = new Date()
+): { max?: number; min?: number } {
+ switch (aDateRange) {
+ case '1d':
+ return {
+ max: endOfDay(aReferenceDate).getTime(),
+ min: startOfDay(aReferenceDate).getTime()
+ };
+ case 'wtd':
+ return {
+ max: endOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime(),
+ min: startOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime()
+ };
+ case 'mtd':
+ return {
+ max: endOfMonth(aReferenceDate).getTime(),
+ min: startOfMonth(aReferenceDate).getTime()
+ };
+ case 'ytd':
+ return {
+ max: endOfYear(aReferenceDate).getTime(),
+ min: startOfYear(aReferenceDate).getTime()
+ };
+ default: {
+ if (typeof aDateRange === 'string' && /^\d{4}$/.test(aDateRange)) {
+ const year = new Date(Number(aDateRange), 0, 1);
+
+ return {
+ max: endOfYear(year).getTime(),
+ min: startOfYear(year).getTime()
+ };
+ }
+
+ return {};
+ }
+ }
+}
+
export function getTimeAxisOptions({
borderWidth = 1,
colorScheme,
display = true,
locale = getLocale(),
+ max,
+ min,
unit = 'year'
}: {
borderWidth?: number;
colorScheme?: ColorScheme;
display?: boolean;
locale?: string;
- unit?: 'day' | 'month' | 'year';
+ max?: number;
+ min?: number;
+ unit?: 'day' | 'hour' | 'month' | 'year';
}): ScaleOptions<'time'> {
return {
display,
@@ -105,6 +166,8 @@ export function getTimeAxisOptions({
grid: {
display: false
},
+ max,
+ min,
ticks: {
autoSkip: true,
maxRotation: 0,
diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts
index bda6d0a38c..7ace37da54 100644
--- a/libs/ui/src/lib/line-chart/line-chart.component.ts
+++ b/libs/ui/src/lib/line-chart/line-chart.component.ts
@@ -1,5 +1,6 @@
import {
getChartBorderColor,
+ getTimeAxisBounds,
getTimeAxisOptions,
getTimeAxisUnit,
getValueAxisOptions,
@@ -239,6 +240,7 @@ export class GfLineChartComponent
colorScheme: this.colorScheme,
display: this.showXAxis,
locale: this.locale,
+ ...getTimeAxisBounds(this.dateRange),
unit: getTimeAxisUnit(this.dateRange)
}),
y: {
From 9cd5c283b7f873b955995875261bb451d140aa0b Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:16:28 +0200
Subject: [PATCH 06/12] Feature/update the changelog
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6b9073957e..159f9bd7c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Added the unit (days, months or years) to the time axis of the performance chart on the overview page according to the selected date range
+- Added a time axis to the performance chart on the overview page which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range
### Changed
From 73085e710b35bae3598291a8656a51aad58f9069 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:54:06 +0200
Subject: [PATCH 07/12] Fix the time axis of the overview chart after changing
the date range and draw the day from samples
---
.../home-overview/home-overview.component.ts | 49 ++++++++---
.../home-overview/intraday-samples.spec.ts | 50 +++++++++++
.../home-overview/intraday-samples.ts | 83 +++++++++++++++++++
libs/common/src/lib/chart-helper.ts | 2 +-
.../lib/line-chart/line-chart.component.ts | 22 +++--
5 files changed, 188 insertions(+), 18 deletions(-)
create mode 100644 apps/client/src/app/components/home-overview/intraday-samples.spec.ts
create mode 100644 apps/client/src/app/components/home-overview/intraday-samples.ts
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts
index cc98251232..05a70ac9c2 100644
--- a/apps/client/src/app/components/home-overview/home-overview.component.ts
+++ b/apps/client/src/app/components/home-overview/home-overview.component.ts
@@ -11,6 +11,7 @@ import {
import { isCashPosition } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
+ HistoricalDataItem,
LineChartItem,
PortfolioPerformance,
PortfolioPosition,
@@ -19,6 +20,7 @@ import {
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
+import { DateRange } from '@ghostfolio/common/types';
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
import { DataService } from '@ghostfolio/ui/services';
@@ -38,6 +40,11 @@ import { MatCardModule } from '@angular/material/card';
import { Router, RouterModule } from '@angular/router';
import { DeviceDetectorService } from 'ngx-device-detector';
+import {
+ getIntradayChartItems,
+ recordIntradaySample
+} from './intraday-samples';
+
const MINIMUM_CHART_ITEMS = 3;
@Component({
@@ -171,6 +178,37 @@ export class GfHomeOverviewComponent implements OnInit {
}
}
+ private setChartItems({
+ chart,
+ dateRange
+ }: {
+ chart?: HistoricalDataItem[];
+ dateRange?: DateRange;
+ }) {
+ const items =
+ chart?.map(({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
+ return {
+ date,
+ value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
+ };
+ }) ?? null;
+
+ const latest = items?.at(-1);
+
+ if ((dateRange ?? DEFAULT_DATE_RANGE) === '1d' && latest) {
+ // There is only one value per day on the server: draw the day from the
+ // close of the previous day to the samples recorded on this device
+ this.historicalDataItems.set(
+ getIntradayChartItems({
+ baseline: items?.[0]?.value ?? 0,
+ samples: recordIntradaySample({ value: latest.value })
+ })
+ );
+ } else {
+ this.historicalDataItems.set(items);
+ }
+ }
+
private update() {
const userSettings = this.user()?.settings;
@@ -210,16 +248,7 @@ export class GfHomeOverviewComponent implements OnInit {
this.errors.set(errors ?? []);
this.performance.set(performance);
- this.historicalDataItems.set(
- chart?.map(
- ({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
- return {
- date,
- value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
- };
- }
- ) ?? null
- );
+ this.setChartItems({ chart, dateRange: userSettings?.dateRange });
this.precision.set(2);
diff --git a/apps/client/src/app/components/home-overview/intraday-samples.spec.ts b/apps/client/src/app/components/home-overview/intraday-samples.spec.ts
new file mode 100644
index 0000000000..6d3ab357c4
--- /dev/null
+++ b/apps/client/src/app/components/home-overview/intraday-samples.spec.ts
@@ -0,0 +1,50 @@
+import {
+ getIntradayChartItems,
+ INTRADAY_SAMPLE_INTERVAL_MS,
+ loadIntradaySamples,
+ recordIntradaySample
+} from './intraday-samples';
+
+describe('intraday samples', () => {
+ const now = new Date(2026, 9, 21, 10, 0, 0);
+
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('records at most one sample per minute', () => {
+ recordIntradaySample({ now, value: 1 });
+ recordIntradaySample({ now: new Date(now.getTime() + 5_000), value: 2 });
+
+ expect(loadIntradaySamples(now)).toEqual([
+ { timestamp: now.getTime(), value: 2 }
+ ]);
+
+ recordIntradaySample({
+ now: new Date(now.getTime() + INTRADAY_SAMPLE_INTERVAL_MS),
+ value: 3
+ });
+
+ expect(loadIntradaySamples(now)).toHaveLength(2);
+ });
+
+ it('removes the samples of previous days', () => {
+ recordIntradaySample({ now: new Date(2026, 9, 20, 15, 0), value: 1 });
+ recordIntradaySample({ now, value: 2 });
+
+ expect(loadIntradaySamples(new Date(2026, 9, 20))).toEqual([]);
+ expect(loadIntradaySamples(now)).toHaveLength(1);
+ });
+
+ it('starts the chart of the day with the baseline at midnight', () => {
+ const items = getIntradayChartItems({
+ now,
+ baseline: 0,
+ samples: [{ timestamp: now.getTime(), value: 0.5 }]
+ });
+
+ expect(items).toHaveLength(2);
+ expect(new Date(items[0].date).getHours()).toBe(0);
+ expect(items[1].value).toBe(0.5);
+ });
+});
diff --git a/apps/client/src/app/components/home-overview/intraday-samples.ts b/apps/client/src/app/components/home-overview/intraday-samples.ts
new file mode 100644
index 0000000000..8828c746fa
--- /dev/null
+++ b/apps/client/src/app/components/home-overview/intraday-samples.ts
@@ -0,0 +1,83 @@
+import { LineChartItem } from '@ghostfolio/common/interfaces';
+
+import { format, startOfDay } from 'date-fns';
+
+export const INTRADAY_SAMPLES_KEY_PREFIX = 'gf-intraday-';
+export const INTRADAY_SAMPLE_INTERVAL_MS = 60_000;
+
+export interface IntradaySample {
+ timestamp: number;
+ value: number;
+}
+
+function getKey(aDate: Date) {
+ return `${INTRADAY_SAMPLES_KEY_PREFIX}${format(aDate, 'yyyy-MM-dd')}`;
+}
+
+export function loadIntradaySamples(aDate = new Date()): IntradaySample[] {
+ try {
+ const samples = JSON.parse(localStorage.getItem(getKey(aDate)) ?? '[]');
+
+ return Array.isArray(samples) ? samples : [];
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Stores the current value of the day (at most one sample per minute) in the
+ * local storage of the device, because only a single value per day is stored
+ * on the server. Samples of previous days are removed.
+ */
+export function recordIntradaySample({
+ now = new Date(),
+ value
+}: {
+ now?: Date;
+ value: number;
+}): IntradaySample[] {
+ const samples = loadIntradaySamples(now);
+ const last = samples.at(-1);
+
+ if (!last || now.getTime() - last.timestamp >= INTRADAY_SAMPLE_INTERVAL_MS) {
+ samples.push({ value, timestamp: now.getTime() });
+ } else {
+ // Keep the latest value of the current minute
+ last.value = value;
+ }
+
+ try {
+ for (let index = localStorage.length - 1; index >= 0; index--) {
+ const key = localStorage.key(index);
+
+ if (key?.startsWith(INTRADAY_SAMPLES_KEY_PREFIX) && key !== getKey(now)) {
+ localStorage.removeItem(key);
+ }
+ }
+
+ localStorage.setItem(getKey(now), JSON.stringify(samples));
+ } catch {}
+
+ return samples;
+}
+
+/**
+ * Builds the chart of the current day: the baseline (the close of the previous
+ * day) at the start of the day followed by the recorded samples of the day.
+ */
+export function getIntradayChartItems({
+ baseline,
+ now = new Date(),
+ samples
+}: {
+ baseline: number;
+ now?: Date;
+ samples: IntradaySample[];
+}): LineChartItem[] {
+ return [
+ { date: startOfDay(now).toISOString(), value: baseline },
+ ...samples.map(({ timestamp, value }) => {
+ return { value, date: new Date(timestamp).toISOString() };
+ })
+ ];
+}
diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts
index 3de8ade41e..3e96ca381d 100644
--- a/libs/common/src/lib/chart-helper.ts
+++ b/libs/common/src/lib/chart-helper.ts
@@ -174,7 +174,7 @@ export function getTimeAxisOptions({
maxTicksLimit: 6
},
time: {
- tooltipFormat: getDateFormatString(locale),
+ tooltipFormat: unit === 'hour' ? 'HH:mm' : getDateFormatString(locale),
unit
},
type: 'time'
diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts
index 7ace37da54..4ecf9426b8 100644
--- a/libs/ui/src/lib/line-chart/line-chart.component.ts
+++ b/libs/ui/src/lib/line-chart/line-chart.component.ts
@@ -210,6 +210,10 @@ export class GfLineChartComponent
? animations
: undefined;
+ // The date range can change while the chart stays alive
+ this.chart.options.scales ??= {};
+ this.chart.options.scales['x'] = this.getTimeAxisConfiguration();
+
this.chart.update();
} else {
this.chart = new Chart<'line'>(this.chartCanvas.nativeElement, {
@@ -236,13 +240,7 @@ export class GfLineChartComponent
}
},
scales: {
- x: getTimeAxisOptions({
- colorScheme: this.colorScheme,
- display: this.showXAxis,
- locale: this.locale,
- ...getTimeAxisBounds(this.dateRange),
- unit: getTimeAxisUnit(this.dateRange)
- }),
+ x: this.getTimeAxisConfiguration(),
y: {
...getValueAxisOptions({
colorScheme: this.colorScheme,
@@ -292,6 +290,16 @@ export class GfLineChartComponent
this.isLoading = false;
}
+ private getTimeAxisConfiguration() {
+ return getTimeAxisOptions({
+ colorScheme: this.colorScheme,
+ display: this.showXAxis,
+ locale: this.locale,
+ ...getTimeAxisBounds(this.dateRange),
+ unit: getTimeAxisUnit(this.dateRange)
+ });
+ }
+
private getAnimationConfigurationForAxis({
axis,
labels
From 7461a1eb316c6398c70e63b16fe7c46c569a14c6 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Wed, 21 Oct 2026 11:55:31 +0200
Subject: [PATCH 08/12] Feature/update the changelog
---
CHANGELOG.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 159f9bd7c4..eeb316e581 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Added a time axis to the performance chart on the overview page which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range
+- Added a time axis to the performance chart on the overview page, drawing the current day from samples recorded on the device, which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range
### Changed
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 09/12] 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
From bccbca05d2f3da566b0974b763cc4b08f1414190 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Thu, 22 Oct 2026 22:49:34 +0200
Subject: [PATCH 10/12] Feature/draw the performance chart of the overview page
on the trading days and remove the samples of the day
---
CHANGELOG.md | 8 +-
.../home-overview/home-overview.component.ts | 58 ++--
.../home-overview/intraday-samples.spec.ts | 50 ---
.../home-overview/intraday-samples.ts | 83 -----
libs/common/src/lib/chart-helper.spec.ts | 143 +++++++--
libs/common/src/lib/chart-helper.ts | 295 +++++++++++++-----
.../lib/line-chart/line-chart.component.ts | 15 +-
7 files changed, 368 insertions(+), 284 deletions(-)
delete mode 100644 apps/client/src/app/components/home-overview/intraday-samples.spec.ts
delete mode 100644 apps/client/src/app/components/home-overview/intraday-samples.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 926bd28651..10950709cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,15 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
-- Added a time axis to the performance chart on the overview page, drawing the current day from samples recorded on the device, which spans the whole current day, week, month or year and uses days, months or years as unit according to the selected date range
-
-### Changed
-
-- Hid the performance chart on the overview page if it has less than three data points
+- 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 a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
### Changed
+- Hid the performance chart on the overview page if it has less than three data points
+- Reduced the performance chart on the overview page to one point per week above 400 trading days
- Improved the market price in the benchmark detail dialog by adding the currency and reducing the precision on mobile
- Improved the style of the close button in the dialog footer component
- Improved the style of the close button in the dialog header component
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts
index 8f73a10281..bd78988456 100644
--- a/apps/client/src/app/components/home-overview/home-overview.component.ts
+++ b/apps/client/src/app/components/home-overview/home-overview.component.ts
@@ -2,6 +2,7 @@ import { GfPortfolioPerformanceComponent } from '@ghostfolio/client/components/p
import { LayoutService } from '@ghostfolio/client/core/layout.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
+import { getTradingDayItems } from '@ghostfolio/common/chart-helper';
import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
@@ -12,7 +13,7 @@ import { isCashPosition } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HistoricalDataItem,
- LineChartItem,
+ NullableLineChartItem,
PortfolioPerformance,
PortfolioPosition,
User
@@ -43,11 +44,6 @@ import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
-import {
- getIntradayChartItems,
- recordIntradaySample
-} from './intraday-samples';
-
const MINIMUM_CHART_ITEMS = 3;
@Component({
@@ -69,15 +65,18 @@ export class GfHomeOverviewComponent implements OnInit {
protected readonly errors = signal([]);
protected readonly hasImpersonationId = signal(false);
protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
- protected readonly historicalDataItems = signal(null);
+ protected readonly historicalDataItems = signal<
+ NullableLineChartItem[] | null
+ >(null);
// A line through only two points (e.g. the close of the previous day and
// now) is drawn as a misleading slope across the whole width
protected readonly hasEnoughChartData = computed(() => {
- const dateRange = this.user()?.settings?.dateRange;
- const minimum = dateRange === '1d' ? 1 : MINIMUM_CHART_ITEMS;
-
- return (this.historicalDataItems()?.length ?? 0) >= minimum;
+ return (
+ (this.historicalDataItems() ?? []).filter(({ value }) => {
+ return value !== null;
+ }).length >= MINIMUM_CHART_ITEMS
+ );
});
protected readonly holdings = signal(
undefined
@@ -197,28 +196,21 @@ export class GfHomeOverviewComponent implements OnInit {
chart?: HistoricalDataItem[];
dateRange?: DateRange;
}) {
- const items =
- chart?.map(({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
- return {
- date,
- value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
- };
- }) ?? null;
-
- const latest = items?.at(-1);
-
- if ((dateRange ?? DEFAULT_DATE_RANGE) === '1d' && latest) {
- // There is only one value per day on the server: draw the day from the
- // close of the previous day to the samples recorded on this device
- this.historicalDataItems.set(
- getIntradayChartItems({
- baseline: items?.[0]?.value ?? 0,
- samples: recordIntradaySample({ value: latest.value })
- })
- );
- } else {
- this.historicalDataItems.set(items);
- }
+ // Only the trading days, and the remaining ones of the current year
+ this.historicalDataItems.set(
+ getTradingDayItems({
+ dateRange,
+ items:
+ chart?.map(
+ ({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
+ return {
+ date,
+ value: (netPerformanceInPercentageWithCurrencyEffect ?? 0) * 100
+ };
+ }
+ ) ?? []
+ })
+ );
}
private update() {
diff --git a/apps/client/src/app/components/home-overview/intraday-samples.spec.ts b/apps/client/src/app/components/home-overview/intraday-samples.spec.ts
deleted file mode 100644
index 6d3ab357c4..0000000000
--- a/apps/client/src/app/components/home-overview/intraday-samples.spec.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import {
- getIntradayChartItems,
- INTRADAY_SAMPLE_INTERVAL_MS,
- loadIntradaySamples,
- recordIntradaySample
-} from './intraday-samples';
-
-describe('intraday samples', () => {
- const now = new Date(2026, 9, 21, 10, 0, 0);
-
- beforeEach(() => {
- localStorage.clear();
- });
-
- it('records at most one sample per minute', () => {
- recordIntradaySample({ now, value: 1 });
- recordIntradaySample({ now: new Date(now.getTime() + 5_000), value: 2 });
-
- expect(loadIntradaySamples(now)).toEqual([
- { timestamp: now.getTime(), value: 2 }
- ]);
-
- recordIntradaySample({
- now: new Date(now.getTime() + INTRADAY_SAMPLE_INTERVAL_MS),
- value: 3
- });
-
- expect(loadIntradaySamples(now)).toHaveLength(2);
- });
-
- it('removes the samples of previous days', () => {
- recordIntradaySample({ now: new Date(2026, 9, 20, 15, 0), value: 1 });
- recordIntradaySample({ now, value: 2 });
-
- expect(loadIntradaySamples(new Date(2026, 9, 20))).toEqual([]);
- expect(loadIntradaySamples(now)).toHaveLength(1);
- });
-
- it('starts the chart of the day with the baseline at midnight', () => {
- const items = getIntradayChartItems({
- now,
- baseline: 0,
- samples: [{ timestamp: now.getTime(), value: 0.5 }]
- });
-
- expect(items).toHaveLength(2);
- expect(new Date(items[0].date).getHours()).toBe(0);
- expect(items[1].value).toBe(0.5);
- });
-});
diff --git a/apps/client/src/app/components/home-overview/intraday-samples.ts b/apps/client/src/app/components/home-overview/intraday-samples.ts
deleted file mode 100644
index 8828c746fa..0000000000
--- a/apps/client/src/app/components/home-overview/intraday-samples.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { LineChartItem } from '@ghostfolio/common/interfaces';
-
-import { format, startOfDay } from 'date-fns';
-
-export const INTRADAY_SAMPLES_KEY_PREFIX = 'gf-intraday-';
-export const INTRADAY_SAMPLE_INTERVAL_MS = 60_000;
-
-export interface IntradaySample {
- timestamp: number;
- value: number;
-}
-
-function getKey(aDate: Date) {
- return `${INTRADAY_SAMPLES_KEY_PREFIX}${format(aDate, 'yyyy-MM-dd')}`;
-}
-
-export function loadIntradaySamples(aDate = new Date()): IntradaySample[] {
- try {
- const samples = JSON.parse(localStorage.getItem(getKey(aDate)) ?? '[]');
-
- return Array.isArray(samples) ? samples : [];
- } catch {
- return [];
- }
-}
-
-/**
- * Stores the current value of the day (at most one sample per minute) in the
- * local storage of the device, because only a single value per day is stored
- * on the server. Samples of previous days are removed.
- */
-export function recordIntradaySample({
- now = new Date(),
- value
-}: {
- now?: Date;
- value: number;
-}): IntradaySample[] {
- const samples = loadIntradaySamples(now);
- const last = samples.at(-1);
-
- if (!last || now.getTime() - last.timestamp >= INTRADAY_SAMPLE_INTERVAL_MS) {
- samples.push({ value, timestamp: now.getTime() });
- } else {
- // Keep the latest value of the current minute
- last.value = value;
- }
-
- try {
- for (let index = localStorage.length - 1; index >= 0; index--) {
- const key = localStorage.key(index);
-
- if (key?.startsWith(INTRADAY_SAMPLES_KEY_PREFIX) && key !== getKey(now)) {
- localStorage.removeItem(key);
- }
- }
-
- localStorage.setItem(getKey(now), JSON.stringify(samples));
- } catch {}
-
- return samples;
-}
-
-/**
- * Builds the chart of the current day: the baseline (the close of the previous
- * day) at the start of the day followed by the recorded samples of the day.
- */
-export function getIntradayChartItems({
- baseline,
- now = new Date(),
- samples
-}: {
- baseline: number;
- now?: Date;
- samples: IntradaySample[];
-}): LineChartItem[] {
- return [
- { date: startOfDay(now).toISOString(), value: baseline },
- ...samples.map(({ timestamp, value }) => {
- return { value, date: new Date(timestamp).toISOString() };
- })
- ];
-}
diff --git a/libs/common/src/lib/chart-helper.spec.ts b/libs/common/src/lib/chart-helper.spec.ts
index efb66a36e9..8b35f0f493 100644
--- a/libs/common/src/lib/chart-helper.spec.ts
+++ b/libs/common/src/lib/chart-helper.spec.ts
@@ -1,4 +1,11 @@
-import { getTimeAxisBounds, getTimeAxisUnit } from './chart-helper';
+import { addDays, format } from 'date-fns';
+
+import {
+ getTimeAxisUnit,
+ getTimeSeriesTickCallback,
+ getTradingDayItems
+} from './chart-helper';
+import { DATE_FORMAT } from './helper';
describe('getTimeAxisUnit', () => {
it.each([
@@ -6,61 +13,129 @@ describe('getTimeAxisUnit', () => {
['wtd', 'day'],
['mtd', 'day'],
['ytd', 'month'],
- ['1y', 'month'],
+ ['1y', false],
['2025', 'month'],
- ['5y', 'year'],
- ['max', 'year'],
+ ['5y', false],
+ ['max', false],
[undefined, 'year']
])('uses the unit of %s', (dateRange, unit) => {
expect(getTimeAxisUnit(dateRange)).toBe(unit);
});
});
-describe('getTimeAxisBounds', () => {
- const reference = new Date(2026, 9, 21, 15, 30);
+describe('getTradingDayItems', () => {
+ // Thursday
+ const now = new Date(2026, 11, 24);
- it('spans the whole current year for ytd', () => {
- const { max, min } = getTimeAxisBounds('ytd', reference);
+ it('skips the weekends', () => {
+ const items = getTradingDayItems({
+ dateRange: '1y',
+ now,
+ items: [
+ { date: '2026-12-18', value: 1 },
+ { date: '2026-12-19', value: 1 },
+ { date: '2026-12-20', value: 1 },
+ { date: '2026-12-21', value: 2 }
+ ]
+ });
- expect(new Date(min).toDateString()).toBe(
- new Date(2026, 0, 1).toDateString()
- );
- expect(new Date(max).toDateString()).toBe(
- new Date(2026, 11, 31).toDateString()
- );
+ expect(items.map(({ date }) => date)).toEqual(['2026-12-18', '2026-12-21']);
});
- it('spans the whole current month for mtd', () => {
- const { max, min } = getTimeAxisBounds('mtd', reference);
+ it('adds the remaining trading days of the year', () => {
+ const items = getTradingDayItems({
+ dateRange: 'ytd',
+ now,
+ items: [{ date: '2026-12-24', value: 1 }]
+ });
- expect(new Date(min).getDate()).toBe(1);
- expect(new Date(max).getDate()).toBe(31);
+ expect(items).toEqual([
+ { date: '2026-12-24', value: 1 },
+ { date: '2026-12-25', value: null },
+ { date: '2026-12-28', value: null },
+ { date: '2026-12-29', value: null },
+ { date: '2026-12-30', value: null },
+ { date: '2026-12-31', value: null }
+ ]);
});
+});
- it('spans the whole current week for wtd', () => {
- const { max, min } = getTimeAxisBounds('wtd', reference);
+describe('getTimeSeriesTickCallback', () => {
+ it('labels the first point of each hour', () => {
+ const ticks = [
+ new Date(2026, 9, 22, 9, 0),
+ new Date(2026, 9, 22, 9, 30),
+ new Date(2026, 9, 22, 10, 0),
+ new Date(2026, 9, 22, 10, 30)
+ ].map((date) => {
+ return { value: date.getTime() };
+ });
- expect(new Date(min).getDay()).toBe(1);
- expect(new Date(max).getDay()).toBe(0);
+ const callback = getTimeSeriesTickCallback('hour');
+
+ expect(
+ ticks.map(({ value }, index) => {
+ return callback(value, index, ticks);
+ })
+ ).toEqual(['09:00', '', '10:00', '']);
});
- it('spans the whole current day for 1d', () => {
- const { max, min } = getTimeAxisBounds('1d', reference);
+ it('labels the start of each trading day, also after a post-market past midnight', () => {
+ const ticks = [
+ new Date(2026, 9, 22, 21, 0),
+ new Date(2026, 9, 22, 23, 0),
+ new Date(2026, 9, 23, 1, 0),
+ new Date(2026, 9, 23, 9, 0)
+ ].map((date) => {
+ return { value: date.getTime() };
+ });
+
+ const callback = getTimeSeriesTickCallback('day');
- expect(new Date(min).getHours()).toBe(0);
- expect(new Date(max).getHours()).toBe(23);
+ expect(
+ ticks.map(({ value }, index) => {
+ return callback(value, index, ticks);
+ })
+ ).toEqual(['Thu 22', '', '', 'Fri 23']);
});
- it('spans a past year', () => {
- const { max, min } = getTimeAxisBounds('2024', reference);
+ it('thins out the labels', () => {
+ const ticks = Array.from({ length: 20 }, (_, index) => {
+ return { value: new Date(2026, 0, 1 + index).getTime() };
+ });
- expect(new Date(min).getFullYear()).toBe(2024);
- expect(new Date(max).getMonth()).toBe(11);
+ const callback = getTimeSeriesTickCallback('day', 5);
+ const labels = ticks
+ .map(({ value }, index) => {
+ return callback(value, index, ticks);
+ })
+ .filter(Boolean);
+
+ expect(labels.length).toBeLessThanOrEqual(5);
});
+});
+
+describe('getTradingDayItems with many items', () => {
+ it('keeps one point per week (the last trading day)', () => {
+ const items = Array.from({ length: 28 }, (_, index) => {
+ return {
+ date: format(addDays(new Date(2026, 0, 5), index), DATE_FORMAT),
+ value: index
+ };
+ });
+
+ const result = getTradingDayItems({ dateRange: '1y', items, maxItems: 10 });
- it('does not fix the bounds of rolling ranges', () => {
- expect(getTimeAxisBounds('1y', reference)).toEqual({});
- expect(getTimeAxisBounds('5y', reference)).toEqual({});
- expect(getTimeAxisBounds('max', reference)).toEqual({});
+ expect(
+ result.map(({ date }) => {
+ return date;
+ })
+ ).toEqual([
+ '2026-01-05',
+ '2026-01-09',
+ '2026-01-16',
+ '2026-01-23',
+ '2026-01-30'
+ ]);
});
});
diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts
index 3e96ca381d..c75b7d135d 100644
--- a/libs/common/src/lib/chart-helper.ts
+++ b/libs/common/src/lib/chart-helper.ts
@@ -12,18 +12,9 @@ import type {
TooltipPosition
} from 'chart.js';
import type { AnnotationOptions } from 'chartjs-plugin-annotation';
-import {
- endOfDay,
- endOfMonth,
- endOfWeek,
- endOfYear,
- format,
- startOfDay,
- startOfMonth,
- startOfWeek,
- startOfYear
-} from 'date-fns';
+import { addDays, format, startOfWeek } from 'date-fns';
+import { secondaryColorRgb } from './config';
import {
DATE_FORMAT,
DATE_FORMAT_MONTHLY,
@@ -31,7 +22,8 @@ import {
getBackgroundColor,
getDateFormatString,
getLocale,
- getTextColor
+ getTextColor,
+ parseDate
} from './helper';
import { ColorScheme, DateRange, GroupBy } from './types';
@@ -51,6 +43,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)`;
}
@@ -75,69 +82,196 @@ export function getChartElementsOptions(
* length of the date range: days up to a month, months up to a year and years
* above
*/
+/**
+ * Returns the daily items of the trading days (Monday to Friday) and, for a
+ * date range with a fixed end (the current year), the remaining trading days
+ * without a value, so that the chart shows the whole period
+ */
+function getWeekKey(aDate?: Date) {
+ return aDate
+ ? format(startOfWeek(aDate, { weekStartsOn: 1 }), DATE_FORMAT)
+ : '';
+}
+
+export function getTradingDayItems({
+ dateRange,
+ items,
+ maxItems = 400,
+ now = new Date()
+}: {
+ dateRange?: DateRange;
+ items: T[];
+ /** Above, one point per week (the last trading day) is kept */
+ maxItems?: number;
+ now?: Date;
+}): (T | { date: string; value: null })[] {
+ const isWeekend = (aDate: Date) => {
+ return aDate.getDay() === 0 || aDate.getDay() === 6;
+ };
+
+ const result: (T | { date: string; value: null })[] = items.filter(
+ ({ date }) => {
+ const parsedDate = parseDate(date);
+
+ return !parsedDate || !isWeekend(parsedDate);
+ }
+ );
+
+ if (result.length > maxItems) {
+ const weekly = result.filter(({ date }, index) => {
+ const next = result[index + 1];
+
+ return (
+ index === 0 ||
+ !next ||
+ getWeekKey(parseDate(date)) !== getWeekKey(parseDate(next.date))
+ );
+ });
+
+ result.splice(0, result.length, ...weekly);
+ }
+
+ let end: Date | undefined;
+ let start: Date | undefined;
+
+ if (dateRange === 'ytd') {
+ end = new Date(now.getFullYear(), 11, 31);
+ start = new Date(now.getFullYear(), 0, 1);
+ } else if (/^\d{4}$/.test(dateRange ?? '')) {
+ end = new Date(Number(dateRange), 11, 31);
+ start = new Date(Number(dateRange), 0, 1);
+ }
+
+ const firstDate = result[0] ? parseDate(result[0].date) : undefined;
+
+ if (start && firstDate && firstDate < start) {
+ // The baseline (the close of the previous year) is shown at the start
+ result[0] = { ...result[0], date: format(start, DATE_FORMAT) };
+ }
+
+ const lastDate = items.at(-1)?.date;
+ const last = lastDate ? parseDate(lastDate) : undefined;
+
+ if (end && last) {
+ for (
+ let date = addDays(last, 1);
+ date.getTime() <= end.getTime();
+ date = addDays(date, 1)
+ ) {
+ if (!isWeekend(date)) {
+ result.push({ date: format(date, DATE_FORMAT), value: null });
+ }
+ }
+ }
+
+ return result;
+}
+
export function getTimeAxisUnit(
aDateRange?: DateRange
-): 'day' | 'hour' | 'month' | 'year' {
+): 'day' | 'hour' | 'month' | 'year' | false {
switch (aDateRange) {
+ case undefined:
+ return 'year';
case '1d':
return 'hour';
case 'mtd':
case 'wtd':
return 'day';
+ case '1y':
case '5y':
case 'max':
- case undefined:
- return 'year';
+ // Chosen by the chart according to the length of the period
+ return false;
default:
- // 'ytd', '1y' and single years
+ // 'ytd' and single years
return 'month';
}
}
+const TIME_SERIES_LABEL_FORMATS = {
+ day: 'EEE d',
+ hour: 'HH:mm',
+ month: 'MMM',
+ year: 'yyyy'
+} as const;
+
/**
- * Returns the bounds of the time axis for the date ranges which have a fixed
- * end (the current week, month or year), so that the chart shows the whole
- * period with the part still to come left empty. Other date ranges (rolling
- * periods and `max`) end at the latest data point.
+ * Returns the labels of an axis with equidistant points (without the closed
+ * markets): a label at the first point of a period (hour, day, month or
+ * year), thinned out to at most a few labels
*/
-export function getTimeAxisBounds(
- aDateRange?: DateRange,
- aReferenceDate = new Date()
-): { max?: number; min?: number } {
- switch (aDateRange) {
- case '1d':
- return {
- max: endOfDay(aReferenceDate).getTime(),
- min: startOfDay(aReferenceDate).getTime()
- };
- case 'wtd':
- return {
- max: endOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime(),
- min: startOfWeek(aReferenceDate, { weekStartsOn: 1 }).getTime()
- };
- case 'mtd':
- return {
- max: endOfMonth(aReferenceDate).getTime(),
- min: startOfMonth(aReferenceDate).getTime()
- };
- case 'ytd':
- return {
- max: endOfYear(aReferenceDate).getTime(),
- min: startOfYear(aReferenceDate).getTime()
+export function getTimeSeriesTickCallback(
+ aUnit: 'day' | 'hour' | 'month' | 'year' | false,
+ aMaxLabels = 7
+) {
+ let cache: { key: string; labels: Map } | undefined;
+
+ return (
+ _value: number | string,
+ index: number,
+ ticks: { value: number }[]
+ ) => {
+ const key = `${ticks.length}-${ticks[0]?.value}-${ticks.at(-1)?.value}`;
+
+ if (cache?.key !== key) {
+ const span = (ticks.at(-1)?.value ?? 0) - (ticks[0]?.value ?? 0);
+ const unit =
+ aUnit ||
+ (span > 3 * 365 * 24 * 60 * 60 * 1000
+ ? 'year'
+ : span > 45 * 24 * 60 * 60 * 1000
+ ? 'month'
+ : 'day');
+
+ const getPeriod = (aTime: number) => {
+ const date = new Date(aTime);
+
+ switch (unit) {
+ case 'hour':
+ return `${date.toDateString()} ${date.getHours()}`;
+ case 'month':
+ return `${date.getFullYear()}-${date.getMonth()}`;
+ default:
+ return `${date.getFullYear()}`;
+ }
};
- default: {
- if (typeof aDateRange === 'string' && /^\d{4}$/.test(aDateRange)) {
- const year = new Date(Number(aDateRange), 0, 1);
-
- return {
- max: endOfYear(year).getTime(),
- min: startOfYear(year).getTime()
- };
- }
- return {};
+ // The first point of each period
+ const starts: number[] = [];
+
+ ticks.forEach(({ value }, tickIndex) => {
+ const previous = ticks[tickIndex - 1]?.value;
+
+ if (
+ tickIndex === 0 ||
+ (unit === 'day'
+ ? // A trading day starts after the closed market (the post-market
+ // can last after midnight)
+ value - previous > 2 * 60 * 60 * 1000
+ : getPeriod(value) !== getPeriod(previous))
+ ) {
+ starts.push(tickIndex);
+ }
+ });
+
+ const step = Math.max(1, Math.ceil(starts.length / aMaxLabels));
+ const labels = new Map();
+
+ starts.forEach((tickIndex, startIndex) => {
+ if (startIndex % step === 0) {
+ labels.set(
+ tickIndex,
+ format(ticks[tickIndex].value, TIME_SERIES_LABEL_FORMATS[unit])
+ );
+ }
+ });
+
+ cache = { key, labels };
}
- }
+
+ return cache.labels.get(index) ?? '';
+ };
}
export function getTimeAxisOptions({
@@ -145,18 +279,19 @@ export function getTimeAxisOptions({
colorScheme,
display = true,
locale = getLocale(),
- max,
- min,
- unit = 'year'
+ type = 'time',
+ unit = 'year',
+ withTime = false
}: {
borderWidth?: number;
colorScheme?: ColorScheme;
display?: boolean;
locale?: string;
- max?: number;
- min?: number;
- unit?: 'day' | 'hour' | 'month' | 'year';
-}): ScaleOptions<'time'> {
+ type?: 'time' | 'timeseries';
+ unit?: 'day' | 'hour' | 'month' | 'year' | false;
+ /** Whether the points are intraday (the tooltip shows the time) */
+ withTime?: boolean;
+}): ScaleOptions<'time' | 'timeseries'> {
return {
display,
border: {
@@ -166,18 +301,36 @@ export function getTimeAxisOptions({
grid: {
display: false
},
- max,
- min,
- ticks: {
- autoSkip: true,
- maxRotation: 0,
- maxTicksLimit: 6
- },
+ ticks:
+ type === 'timeseries'
+ ? {
+ // A label at the first point of an hour, day, month or year
+ autoSkip: false,
+ callback: getTimeSeriesTickCallback(unit),
+ maxRotation: 0,
+ source: 'data'
+ }
+ : {
+ autoSkip: true,
+ maxRotation: 0,
+ maxTicksLimit: 6
+ },
time: {
- tooltipFormat: unit === 'hour' ? 'HH:mm' : getDateFormatString(locale),
+ tooltipFormat: withTime
+ ? unit === 'hour'
+ ? 'HH:mm'
+ : `${getDateFormatString(locale)} HH:mm`
+ : getDateFormatString(locale),
+ displayFormats: {
+ day: 'EEE d',
+ hour: 'HH:mm',
+ month: unit === 'month' ? 'MMM' : 'MMM yy',
+ week: 'd MMM',
+ year: 'yyyy'
+ },
unit
},
- type: 'time'
+ type
};
}
diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts
index 4ecf9426b8..b5b90ee35b 100644
--- a/libs/ui/src/lib/line-chart/line-chart.component.ts
+++ b/libs/ui/src/lib/line-chart/line-chart.component.ts
@@ -1,6 +1,5 @@
import {
getChartBorderColor,
- getTimeAxisBounds,
getTimeAxisOptions,
getTimeAxisUnit,
getValueAxisOptions,
@@ -8,10 +7,7 @@ import {
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
-import {
- LineChartItem,
- NullableLineChartItem
-} from '@ghostfolio/common/interfaces';
+import { NullableLineChartItem } from '@ghostfolio/common/interfaces';
import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import {
@@ -36,6 +32,7 @@ import {
LineElement,
PointElement,
TimeScale,
+ TimeSeriesScale,
Tooltip,
type TooltipOptions
} from 'chart.js';
@@ -63,7 +60,7 @@ export class GfLineChartComponent
@Input() colorScheme?: ColorScheme;
@Input() currency?: string;
@Input() dateRange?: DateRange;
- @Input() historicalDataItems: LineChartItem[] | null;
+ @Input() historicalDataItems: NullableLineChartItem[] | null;
@Input() isAnimated = false;
@Input() label: string;
@Input() locale = getLocale();
@@ -93,6 +90,7 @@ export class GfLineChartComponent
PointElement,
LinearScale,
TimeScale,
+ TimeSeriesScale,
Tooltip
);
@@ -143,7 +141,7 @@ export class GfLineChartComponent
this.isLoading = true;
const benchmarkPrices: (number | null)[] = [];
const labels: string[] = [];
- const marketPrices: number[] = [];
+ const marketPrices: (number | null)[] = [];
this.historicalDataItems?.forEach((historicalDataItem, index) => {
benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value ?? null);
@@ -295,7 +293,8 @@ export class GfLineChartComponent
colorScheme: this.colorScheme,
display: this.showXAxis,
locale: this.locale,
- ...getTimeAxisBounds(this.dateRange),
+ // Equidistant points: the closed markets (nights, weekends) are skipped
+ type: this.dateRange ? 'timeseries' : 'time',
unit: getTimeAxisUnit(this.dateRange)
});
}
From 4b46ffd5495f4e66e95664f8745c43b9c0ab8777 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Thu, 22 Oct 2026 22:53:21 +0200
Subject: [PATCH 11/12] Feature/draw the performance chart of the current day,
week and month from intraday prices
---
CHANGELOG.md | 1 +
.../api/src/app/portfolio/get-intraday.dto.ts | 22 +
.../intraday/intraday.helper.spec.ts | 246 ++++++++++
.../app/portfolio/intraday/intraday.helper.ts | 457 ++++++++++++++++++
.../portfolio/intraday/intraday.service.ts | 251 ++++++++++
.../src/app/portfolio/portfolio.controller.ts | 19 +
.../api/src/app/portfolio/portfolio.module.ts | 2 +
.../yahoo-finance/yahoo-finance.service.ts | 100 ++++
.../home-overview/home-overview.component.ts | 81 +++-
libs/common/src/lib/helper.spec.ts | 14 +
libs/common/src/lib/helper.ts | 13 +-
libs/common/src/lib/interfaces/index.ts | 6 +
.../portfolio-intraday-response.interface.ts | 22 +
.../lib/line-chart/line-chart.component.ts | 9 +-
libs/ui/src/lib/services/data.service.ts | 20 +
15 files changed, 1238 insertions(+), 25 deletions(-)
create mode 100644 apps/api/src/app/portfolio/get-intraday.dto.ts
create mode 100644 apps/api/src/app/portfolio/intraday/intraday.helper.spec.ts
create mode 100644 apps/api/src/app/portfolio/intraday/intraday.helper.ts
create mode 100644 apps/api/src/app/portfolio/intraday/intraday.service.ts
create mode 100644 libs/common/src/lib/interfaces/responses/portfolio-intraday-response.interface.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 10950709cc..3b585cb22b 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 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 a link to the holdings tab in the holdings table on the overview tab of the home page (experimental)
diff --git a/apps/api/src/app/portfolio/get-intraday.dto.ts b/apps/api/src/app/portfolio/get-intraday.dto.ts
new file mode 100644
index 0000000000..3e9d12d3e2
--- /dev/null
+++ b/apps/api/src/app/portfolio/get-intraday.dto.ts
@@ -0,0 +1,22 @@
+import { Transform, TransformFnParams } from 'class-transformer';
+import { IsArray, IsIn, IsOptional, IsString } from 'class-validator';
+
+import type { IntradayDateRange } from './intraday/intraday.helper';
+
+export class GetIntradayDto {
+ /** Comma-separated ids of the asset profiles of the benchmarks */
+ @IsArray()
+ @IsOptional()
+ @IsString({ each: true })
+ @Transform(({ value }: TransformFnParams) => {
+ return typeof value === 'string'
+ ? value.split(',').filter((id) => {
+ return id.length > 0;
+ })
+ : value;
+ })
+ benchmarks?: string[];
+
+ @IsIn(['1d', 'mtd', 'wtd'] as IntradayDateRange[])
+ range: IntradayDateRange;
+}
diff --git a/apps/api/src/app/portfolio/intraday/intraday.helper.spec.ts b/apps/api/src/app/portfolio/intraday/intraday.helper.spec.ts
new file mode 100644
index 0000000000..81dd64695d
--- /dev/null
+++ b/apps/api/src/app/portfolio/intraday/intraday.helper.spec.ts
@@ -0,0 +1,246 @@
+import {
+ getBaselinePrice,
+ getInstrumentIntradayItems,
+ getIntradayGrid,
+ getIntradaySessions,
+ getPortfolioIntradayItems,
+ getPriceAt,
+ IntradayInstrument,
+ isAnyMarketOpen
+} from './intraday.helper';
+
+const MINUTE = 60 * 1000;
+const time = (aIso: string) => {
+ return Date.parse(aIso);
+};
+
+// Thursday, 22 October 2026
+const europe: IntradayInstrument = {
+ prices: [
+ { price: 100, time: time('2026-10-21T15:29:00Z') },
+ { price: 101, time: time('2026-10-22T07:00:00Z') },
+ { price: 102, time: time('2026-10-22T08:00:00Z') }
+ ],
+ tradingPeriods: [
+ {
+ end: time('2026-10-21T15:30:00Z'),
+ start: time('2026-10-21T07:00:00Z'),
+ type: 'regular'
+ },
+ {
+ end: time('2026-10-22T15:30:00Z'),
+ start: time('2026-10-22T07:00:00Z'),
+ type: 'regular'
+ }
+ ]
+};
+
+const unitedStates: IntradayInstrument = {
+ prices: [
+ { price: 50, time: time('2026-10-21T19:59:00Z') },
+ // Post-market of the previous day
+ { price: 55, time: time('2026-10-21T23:00:00Z') },
+ // Pre-market
+ { price: 51, time: time('2026-10-22T08:30:00Z') }
+ ],
+ tradingPeriods: [
+ {
+ end: time('2026-10-21T20:00:00Z'),
+ start: time('2026-10-21T13:30:00Z'),
+ type: 'regular'
+ },
+ {
+ end: time('2026-10-22T00:00:00Z'),
+ start: time('2026-10-21T20:00:00Z'),
+ type: 'post'
+ },
+ {
+ end: time('2026-10-22T13:30:00Z'),
+ start: time('2026-10-22T08:00:00Z'),
+ type: 'pre'
+ },
+ {
+ end: time('2026-10-22T20:00:00Z'),
+ start: time('2026-10-22T13:30:00Z'),
+ type: 'regular'
+ },
+ {
+ end: time('2026-10-23T00:00:00Z'),
+ start: time('2026-10-22T20:00:00Z'),
+ type: 'post'
+ }
+ ]
+};
+
+describe('intraday helper', () => {
+ const now = time('2026-10-22T09:00:00Z');
+
+ it('spans the trading sessions of the day including the pre-market and the post-market', () => {
+ expect(
+ getIntradaySessions({
+ now,
+ dateRange: '1d',
+ instruments: [europe, unitedStates]
+ })
+ ).toEqual([
+ {
+ end: time('2026-10-23T00:00:00Z'),
+ start: time('2026-10-22T07:00:00Z')
+ }
+ ]);
+ });
+
+ it('uses the previous trading day before the first session has started', () => {
+ expect(
+ getIntradaySessions({
+ dateRange: '1d',
+ instruments: [europe],
+ now: time('2026-10-22T05:00:00Z')
+ })
+ ).toEqual([
+ {
+ end: time('2026-10-21T15:30:00Z'),
+ start: time('2026-10-21T07:00:00Z')
+ }
+ ]);
+ });
+
+ it('spans the trading sessions of the whole week', () => {
+ const sessions = getIntradaySessions({
+ now,
+ dateRange: 'wtd',
+ instruments: [europe]
+ });
+
+ // Wednesday and Thursday are known, Friday is projected
+ expect(sessions.map(({ start }) => new Date(start).toISOString())).toEqual([
+ '2026-10-21T07:00:00.000Z',
+ '2026-10-22T07:00:00.000Z',
+ '2026-10-23T07:00:00.000Z'
+ ]);
+ });
+
+ it('builds a grid of the intervals of the sessions', () => {
+ const grid = getIntradayGrid({
+ intervalInMs: 30 * MINUTE,
+ sessions: [
+ {
+ end: time('2026-10-22T08:00:00Z'),
+ start: time('2026-10-22T07:00:00Z')
+ }
+ ]
+ });
+
+ expect(grid).toEqual([
+ time('2026-10-22T07:00:00Z'),
+ time('2026-10-22T07:30:00Z')
+ ]);
+ });
+
+ it('takes the last price at or before a time', () => {
+ expect(
+ getPriceAt({ prices: europe.prices, time: time('2026-10-22T07:59:00Z') })
+ ).toBe(101);
+ expect(
+ getPriceAt({ prices: europe.prices, time: time('2026-10-20T00:00:00Z') })
+ ).toBeUndefined();
+ });
+
+ it('takes the close of the regular session as baseline, not the post-market', () => {
+ expect(
+ getBaselinePrice({
+ instrument: unitedStates,
+ start: time('2026-10-22T07:00:00Z')
+ })
+ ).toBe(50);
+ });
+
+ it('computes the change of an instrument while it is traded', () => {
+ const grid = [
+ time('2026-10-22T06:00:00Z'),
+ time('2026-10-22T07:00:00Z'),
+ time('2026-10-22T08:00:00Z'),
+ time('2026-10-22T08:30:00Z'),
+ time('2026-10-22T10:00:00Z')
+ ];
+
+ const items = getInstrumentIntradayItems({
+ grid,
+ now,
+ instrument: europe,
+ intervalInMs: MINUTE,
+ start: time('2026-10-22T07:00:00Z')
+ });
+
+ // Not drawn before the first and after the last price
+ expect(items.map(({ value }) => value)).toEqual([
+ null,
+ expect.closeTo(0.01, 10),
+ expect.closeTo(0.02, 10),
+ null,
+ null
+ ]);
+ });
+
+ it('computes the change of the portfolio adjusted for a buy', () => {
+ const start = time('2026-10-22T07:00:00Z');
+
+ const items = getPortfolioIntradayItems({
+ now,
+ start,
+ cashFlows: [{ amount: 102, time: time('2026-10-22T08:00:00Z') }],
+ grid: [start, time('2026-10-22T08:00:00Z')],
+ holdings: [
+ {
+ ...europe,
+ exchangeRate: 1,
+ // One share bought at 08:00 for 102
+ quantity: 2,
+ quantityChanges: [{ quantity: 1, time: time('2026-10-22T08:00:00Z') }]
+ }
+ ],
+ intervalInMs: MINUTE
+ });
+
+ // 07:00: 1 share from 100 to 101
+ expect(items[0].value).toBeCloseTo(0.01);
+
+ // 08:00: 2 shares at 102 (204) - 100 - 102 = 2 on an investment of 202
+ expect(items[1].value).toBeCloseTo(2 / 202);
+ });
+
+ it('detects an open market including the pre-market', () => {
+ expect(
+ isAnyMarketOpen({
+ instruments: [unitedStates],
+ now: time('2026-10-22T09:00:00Z')
+ })
+ ).toBe(true);
+
+ expect(
+ isAnyMarketOpen({
+ instruments: [unitedStates],
+ now: time('2026-10-22T05:00:00Z')
+ })
+ ).toBe(false);
+ });
+});
+
+describe('intraday helper with several trading days', () => {
+ it('leaves a gap while the market of an instrument is closed', () => {
+ const items = getInstrumentIntradayItems({
+ grid: [
+ time('2026-10-21T15:00:00Z'),
+ time('2026-10-21T20:00:00Z'),
+ time('2026-10-22T07:00:00Z')
+ ],
+ instrument: europe,
+ intervalInMs: MINUTE,
+ now: time('2026-10-22T09:00:00Z'),
+ start: time('2026-10-21T07:00:00Z')
+ });
+
+ expect(items[1].value).toBeNull();
+ expect(items[2].value).not.toBeNull();
+ });
+});
diff --git a/apps/api/src/app/portfolio/intraday/intraday.helper.ts b/apps/api/src/app/portfolio/intraday/intraday.helper.ts
new file mode 100644
index 0000000000..b8b2ee78c5
--- /dev/null
+++ b/apps/api/src/app/portfolio/intraday/intraday.helper.ts
@@ -0,0 +1,457 @@
+import type { PortfolioIntradayItem } from '@ghostfolio/common/interfaces';
+
+export type IntradayDateRange = '1d' | 'mtd' | 'wtd';
+
+export interface IntradayPeriod {
+ end: number;
+ start: number;
+ type: 'post' | 'pre' | 'regular';
+}
+
+export interface IntradayInstrument {
+ /** Realtime price, used for the interval which contains the current time */
+ livePrice?: number;
+
+ /** Sorted by time */
+ prices: { price: number; time: number }[];
+
+ tradingPeriods: IntradayPeriod[];
+}
+
+export interface IntradayHolding extends IntradayInstrument {
+ /** Exchange rate from the currency of the holding to the base currency */
+ exchangeRate: number;
+
+ /** Current quantity */
+ quantity: number;
+
+ /** Quantity bought (positive) or sold (negative) per time */
+ quantityChanges: { quantity: number; time: number }[];
+}
+
+export interface IntradayCashFlow {
+ /** Positive for a buy, negative for a sell (in the base currency) */
+ amount: number;
+ time: number;
+}
+
+const DAY_IN_MS = 24 * 60 * 60 * 1000;
+
+export const INTRADAY_INTERVALS: Record<
+ IntradayDateRange,
+ { interval: '5m' | '15m' | '60m'; intervalInMs: number }
+> = {
+ // About 200 to 400 points per chart, as on the charts of the brokers
+ '1d': { interval: '5m', intervalInMs: 5 * 60 * 1000 },
+ mtd: { interval: '60m', intervalInMs: 60 * 60 * 1000 },
+ wtd: { interval: '15m', intervalInMs: 15 * 60 * 1000 }
+};
+
+function getDayKey(aTime: number) {
+ return new Date(aTime).toISOString().slice(0, 10);
+}
+
+function getTimeOfDayKey(aDayKey: string) {
+ return Date.parse(`${aDayKey}T00:00:00.000Z`);
+}
+
+/**
+ * Returns the merged trading sessions (including the pre-market and the
+ * post-market) of the date range. The sessions of the remaining days of the
+ * week or month are projected from the latest known trading day.
+ */
+export function getIntradaySessions({
+ dateRange,
+ instruments,
+ now
+}: {
+ dateRange: IntradayDateRange;
+ instruments: IntradayInstrument[];
+ now: number;
+}): { end: number; start: number }[] {
+ const periodsByDay = new Map();
+
+ for (const { tradingPeriods } of instruments) {
+ for (const period of tradingPeriods) {
+ const key = getDayKey(period.start);
+
+ if (!periodsByDay.has(key)) {
+ periodsByDay.set(key, []);
+ }
+
+ periodsByDay.get(key).push(period);
+ }
+ }
+
+ const startedDays = [...periodsByDay.keys()]
+ .filter((key) => {
+ return (
+ Math.min(...periodsByDay.get(key).map(({ start }) => start)) <= now
+ );
+ })
+ .sort();
+
+ if (startedDays.length === 0) {
+ return [];
+ }
+
+ const latestStartedDay = startedDays.at(-1);
+ let days: string[];
+
+ if (dateRange === '1d') {
+ days = [latestStartedDay];
+ } else {
+ const today = new Date(now);
+ let firstDay: Date;
+ let lastDay: Date;
+
+ if (dateRange === 'wtd') {
+ // Monday to Friday of the current week
+ const weekday = (today.getUTCDay() + 6) % 7;
+ firstDay = new Date(
+ getTimeOfDayKey(getDayKey(now)) - weekday * DAY_IN_MS
+ );
+ lastDay = new Date(firstDay.getTime() + 4 * DAY_IN_MS);
+ } else {
+ firstDay = new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), 1)
+ );
+ lastDay = new Date(
+ Date.UTC(today.getUTCFullYear(), today.getUTCMonth() + 1, 0)
+ );
+ }
+
+ const firstKey = getDayKey(firstDay.getTime());
+ const lastKey = getDayKey(lastDay.getTime());
+
+ days = startedDays.filter((key) => {
+ return key >= firstKey && key <= lastKey;
+ });
+
+ // Project the trading sessions of the remaining weekdays
+ const pattern = periodsByDay.get([...periodsByDay.keys()].sort().at(-1));
+ const patternDay = getTimeOfDayKey(getDayKey(pattern[0].start));
+
+ for (
+ let time = getTimeOfDayKey(latestStartedDay) + DAY_IN_MS;
+ getDayKey(time) <= lastKey;
+ time += DAY_IN_MS
+ ) {
+ const weekday = new Date(time).getUTCDay();
+
+ if (weekday === 0 || weekday === 6) {
+ continue;
+ }
+
+ const key = getDayKey(time);
+
+ if (!periodsByDay.has(key)) {
+ periodsByDay.set(
+ key,
+ pattern.map((period) => {
+ return {
+ ...period,
+ end: period.end + (time - patternDay),
+ start: period.start + (time - patternDay)
+ };
+ })
+ );
+ }
+
+ days.push(key);
+ }
+
+ if (days.length === 0) {
+ days = [latestStartedDay];
+ }
+ }
+
+ const periods = days
+ .flatMap((key) => {
+ return periodsByDay.get(key);
+ })
+ .filter(({ end, start }) => {
+ return end > start;
+ })
+ .sort((a, b) => {
+ return a.start - b.start;
+ });
+
+ const sessions: { end: number; start: number }[] = [];
+
+ for (const { end, start } of periods) {
+ const last = sessions.at(-1);
+
+ if (last && start <= last.end) {
+ last.end = Math.max(last.end, end);
+ } else {
+ sessions.push({ end, start });
+ }
+ }
+
+ return sessions;
+}
+
+/**
+ * Returns the start times of the intervals of the trading sessions
+ */
+export function getIntradayGrid({
+ intervalInMs,
+ sessions
+}: {
+ intervalInMs: number;
+ sessions: { end: number; start: number }[];
+}) {
+ const grid = new Set();
+
+ for (const { end, start } of sessions) {
+ for (
+ let time = Math.floor(start / intervalInMs) * intervalInMs;
+ time < end;
+ time += intervalInMs
+ ) {
+ grid.add(time);
+ }
+ }
+
+ return [...grid].sort((a, b) => {
+ return a - b;
+ });
+}
+
+/**
+ * Returns the closing price before the start of the date range, preferring
+ * the close of the regular session over the post-market
+ */
+export function getBaselinePrice({
+ instrument: { livePrice, prices, tradingPeriods },
+ start
+}: {
+ instrument: IntradayInstrument;
+ start: number;
+}) {
+ const lastRegularEnd = Math.max(
+ ...tradingPeriods
+ .filter(({ end, type }) => {
+ return type === 'regular' && end <= start;
+ })
+ .map(({ end }) => {
+ return end;
+ })
+ );
+
+ const limit = Number.isFinite(lastRegularEnd) ? lastRegularEnd : start;
+
+ return (
+ getPriceAt({ prices, time: limit - 1 }) ??
+ getPriceAt({ prices, time: start - 1 }) ??
+ prices[0]?.price ??
+ livePrice
+ );
+}
+
+/**
+ * Returns the last price at or before the given time
+ */
+export function getPriceAt({
+ prices,
+ time
+}: {
+ prices: IntradayInstrument['prices'];
+ time: number;
+}) {
+ let low = 0;
+ let high = prices.length - 1;
+ let result: number | undefined;
+
+ while (low <= high) {
+ const middle = Math.floor((low + high) / 2);
+
+ if (prices[middle].time <= time) {
+ result = prices[middle].price;
+ low = middle + 1;
+ } else {
+ high = middle - 1;
+ }
+ }
+
+ return result;
+}
+
+function getPriceOfInterval({
+ baseline,
+ instrument,
+ intervalInMs,
+ now,
+ time
+}: {
+ baseline: number;
+ instrument: IntradayInstrument;
+ intervalInMs: number;
+ now: number;
+ time: number;
+}) {
+ if (
+ instrument.livePrice !== undefined &&
+ now >= time &&
+ now < time + intervalInMs
+ ) {
+ return instrument.livePrice;
+ }
+
+ return (
+ getPriceAt({ prices: instrument.prices, time: time + intervalInMs - 1 }) ??
+ baseline
+ );
+}
+
+/**
+ * Returns the change of the value of the holdings since the start of the date
+ * range per interval, adjusted for the buys and sells in the date range
+ */
+export function getPortfolioIntradayItems({
+ cashFlows,
+ grid,
+ holdings,
+ intervalInMs,
+ now,
+ start
+}: {
+ cashFlows: IntradayCashFlow[];
+ grid: number[];
+ holdings: IntradayHolding[];
+ intervalInMs: number;
+ now: number;
+ start: number;
+}): PortfolioIntradayItem[] {
+ const getQuantityAt = (aHolding: IntradayHolding, aTime: number) => {
+ // Revert the changes after the given time
+ return aHolding.quantityChanges.reduce((quantity, change) => {
+ return change.time > aTime ? quantity - change.quantity : quantity;
+ }, aHolding.quantity);
+ };
+
+ const baselines = holdings.map((holding) => {
+ return getBaselinePrice({ start, instrument: holding }) ?? 0;
+ });
+
+ const startValue = holdings.reduce((sum, holding, index) => {
+ return (
+ sum +
+ getQuantityAt(holding, start) * baselines[index] * holding.exchangeRate
+ );
+ }, 0);
+
+ return grid.map((time) => {
+ if (time > now) {
+ return { date: new Date(time).toISOString(), value: null };
+ }
+
+ const value = holdings.reduce((sum, holding, index) => {
+ return (
+ sum +
+ getQuantityAt(holding, time + intervalInMs - 1) *
+ getPriceOfInterval({
+ intervalInMs,
+ now,
+ time,
+ baseline: baselines[index],
+ instrument: holding
+ }) *
+ holding.exchangeRate
+ );
+ }, 0);
+
+ const cashFlow = cashFlows.reduce((sum, { amount, time: flowTime }) => {
+ return flowTime >= start && flowTime < time + intervalInMs
+ ? sum + amount
+ : sum;
+ }, 0);
+
+ const investment = startValue + Math.max(cashFlow, 0);
+
+ return {
+ date: new Date(time).toISOString(),
+ value: investment > 0 ? (value - startValue - cashFlow) / investment : 0
+ };
+ });
+}
+
+/**
+ * Returns the change of the price of an instrument since the start of the
+ * date range per interval
+ */
+export function getInstrumentIntradayItems({
+ grid,
+ instrument,
+ intervalInMs,
+ now,
+ start
+}: {
+ grid: number[];
+ instrument: IntradayInstrument;
+ intervalInMs: number;
+ now: number;
+ start: number;
+}): PortfolioIntradayItem[] {
+ const baseline = getBaselinePrice({ instrument, start });
+
+ // The line of an instrument is only drawn while it is traded
+ const pricesInRange = instrument.prices.filter(({ time }) => {
+ return time >= start;
+ });
+ const firstTime = pricesInRange[0]?.time ?? Number.POSITIVE_INFINITY;
+ const lastTime = Math.max(
+ pricesInRange.at(-1)?.time ?? Number.NEGATIVE_INFINITY,
+ instrument.livePrice !== undefined ? now : Number.NEGATIVE_INFINITY
+ );
+
+ const isTraded = (aTime: number) => {
+ return (
+ instrument.tradingPeriods.length === 0 ||
+ instrument.tradingPeriods.some(({ end, start }) => {
+ return aTime + intervalInMs > start && aTime < end;
+ })
+ );
+ };
+
+ return grid.map((time) => {
+ if (
+ time > now ||
+ !baseline ||
+ time + intervalInMs <= firstTime ||
+ time > lastTime ||
+ !isTraded(time)
+ ) {
+ return { date: new Date(time).toISOString(), value: null };
+ }
+
+ const price = getPriceOfInterval({
+ baseline,
+ instrument,
+ intervalInMs,
+ now,
+ time
+ });
+
+ return { date: new Date(time).toISOString(), value: price / baseline - 1 };
+ });
+}
+
+/**
+ * Whether the current time is in a trading period (including the pre-market
+ * and the post-market) of one of the instruments
+ */
+export function isAnyMarketOpen({
+ instruments,
+ now
+}: {
+ instruments: IntradayInstrument[];
+ now: number;
+}) {
+ return instruments.some(({ tradingPeriods }) => {
+ return tradingPeriods.some(({ end, start }) => {
+ return now >= start && now < end;
+ });
+ });
+}
diff --git a/apps/api/src/app/portfolio/intraday/intraday.service.ts b/apps/api/src/app/portfolio/intraday/intraday.service.ts
new file mode 100644
index 0000000000..f388e96772
--- /dev/null
+++ b/apps/api/src/app/portfolio/intraday/intraday.service.ts
@@ -0,0 +1,251 @@
+import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
+import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
+import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
+import { RedisCacheService } from '@ghostfolio/api/services/redis-cache/redis-cache.service';
+import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
+import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
+import { PortfolioIntradayResponse } from '@ghostfolio/common/interfaces';
+
+import { Injectable, Logger } from '@nestjs/common';
+import { AssetSubClass, DataSource } from '@prisma/client';
+import { startOfMonth, startOfWeek, subDays } from 'date-fns';
+
+import { PortfolioService } from '../portfolio.service';
+import {
+ getInstrumentIntradayItems,
+ getIntradayGrid,
+ getIntradaySessions,
+ getPortfolioIntradayItems,
+ INTRADAY_INTERVALS,
+ IntradayDateRange,
+ IntradayHolding,
+ IntradayInstrument,
+ isAnyMarketOpen
+} from './intraday.helper';
+
+@Injectable()
+export class IntradayService {
+ private static readonly CACHE_TTL_IN_MS = 60 * 1000;
+
+ private readonly logger = new Logger(IntradayService.name);
+
+ public constructor(
+ private readonly activitiesService: ActivitiesService,
+ private readonly exchangeRateDataService: ExchangeRateDataService,
+ private readonly portfolioService: PortfolioService,
+ private readonly redisCacheService: RedisCacheService,
+ private readonly symbolProfileService: SymbolProfileService,
+ private readonly yahooFinanceService: YahooFinanceService
+ ) {}
+
+ public async getIntraday({
+ benchmarkIds = [],
+ dateRange,
+ userCurrency = DEFAULT_CURRENCY,
+ userId
+ }: {
+ benchmarkIds?: string[];
+ dateRange: IntradayDateRange;
+ userCurrency?: string;
+ userId: string;
+ }): Promise {
+ const now = Date.now();
+ const { interval, intervalInMs } = INTRADAY_INTERVALS[dateRange];
+ const from = this.getFrom(dateRange);
+
+ const [{ holdings }, benchmarkProfiles] = await Promise.all([
+ this.portfolioService.getDetails({
+ userId,
+ filters: [{ id: 'ACTIVE', type: 'HOLDING_TYPE' }]
+ }),
+ benchmarkIds.length > 0
+ ? this.symbolProfileService.getSymbolProfilesByIds(benchmarkIds)
+ : Promise.resolve([])
+ ]);
+
+ const positions = Object.values(holdings).filter(({ assetProfile }) => {
+ return assetProfile.assetSubClass !== AssetSubClass.CASH;
+ });
+
+ const { activities } = await this.activitiesService.getActivities({
+ userCurrency,
+ userId,
+ startDate: subDays(from, 1),
+ types: ['BUY', 'SELL']
+ });
+
+ const intradayHoldings: IntradayHolding[] = await Promise.all(
+ positions.map(async ({ assetProfile, marketPrice, quantity }) => {
+ const instrument = await this.getInstrument({
+ from,
+ interval,
+ dataSource: assetProfile.dataSource,
+ symbol: assetProfile.symbol
+ });
+
+ const activitiesOfHolding = activities.filter(
+ ({ assetProfile: { dataSource, symbol } }) => {
+ return (
+ dataSource === assetProfile.dataSource &&
+ symbol === assetProfile.symbol
+ );
+ }
+ );
+
+ return {
+ ...instrument,
+ quantity,
+ exchangeRate: this.exchangeRateDataService.toCurrency(
+ 1,
+ assetProfile.currency,
+ userCurrency
+ ),
+ livePrice: marketPrice,
+ quantityChanges: activitiesOfHolding.map(
+ ({ date, quantity: activityQuantity, type }) => {
+ return {
+ quantity:
+ type === 'SELL' ? -activityQuantity : activityQuantity,
+ time: new Date(date).getTime()
+ };
+ }
+ )
+ };
+ })
+ );
+
+ const benchmarks = await Promise.all(
+ benchmarkProfiles.map(async ({ dataSource, id, symbol }) => {
+ return {
+ id,
+ instrument: await this.getInstrument({
+ dataSource,
+ from,
+ interval,
+ symbol
+ })
+ };
+ })
+ );
+
+ const sessions = getIntradaySessions({
+ dateRange,
+ now,
+ instruments: [
+ ...intradayHoldings,
+ ...benchmarks.map(({ instrument }) => {
+ return instrument;
+ })
+ ]
+ });
+
+ const grid = getIntradayGrid({ intervalInMs, sessions });
+ const start = sessions[0]?.start ?? now;
+
+ return {
+ interval,
+ benchmarks: benchmarks.map(({ id, instrument }) => {
+ return {
+ id,
+ items: getInstrumentIntradayItems({
+ grid,
+ instrument,
+ intervalInMs,
+ now,
+ start
+ })
+ };
+ }),
+ chart: getPortfolioIntradayItems({
+ grid,
+ intervalInMs,
+ now,
+ start,
+ cashFlows: activities.map(
+ ({ date, feeInBaseCurrency, type, valueInBaseCurrency }) => {
+ return {
+ amount:
+ type === 'SELL'
+ ? -(valueInBaseCurrency - feeInBaseCurrency)
+ : valueInBaseCurrency + feeInBaseCurrency,
+ time: new Date(date).getTime()
+ };
+ }
+ ),
+ holdings: intradayHoldings
+ }),
+ isMarketOpen: isAnyMarketOpen({ now, instruments: intradayHoldings })
+ };
+ }
+
+ private getFrom(aDateRange: IntradayDateRange) {
+ const today = new Date();
+
+ // A few days more to get the close before the start of the date range
+ switch (aDateRange) {
+ case '1d':
+ return subDays(today, 5);
+ case 'mtd':
+ return subDays(startOfMonth(today), 5);
+ case 'wtd':
+ return subDays(startOfWeek(today, { weekStartsOn: 1 }), 5);
+ }
+ }
+
+ private async getInstrument({
+ dataSource,
+ from,
+ interval,
+ symbol
+ }: {
+ dataSource: DataSource;
+ from: Date;
+ interval: '5m' | '15m' | '60m';
+ symbol: string;
+ }): Promise {
+ if (dataSource !== DataSource.YAHOO) {
+ // No intraday data: the value is constant
+ return { prices: [], tradingPeriods: [] };
+ }
+
+ const key = `intraday-${dataSource}-${symbol}-${interval}-${from
+ .toISOString()
+ .slice(0, 10)}`;
+
+ try {
+ const cached = await this.redisCacheService.get(key);
+
+ if (cached) {
+ return JSON.parse(cached) as IntradayInstrument;
+ }
+ } catch {}
+
+ try {
+ const { prices, tradingPeriods } =
+ await this.yahooFinanceService.getIntraday({ from, interval, symbol });
+
+ const instrument: IntradayInstrument = {
+ prices: prices.map(({ date, price }) => {
+ return { price, time: new Date(date).getTime() };
+ }),
+ tradingPeriods: tradingPeriods.map(({ end, start, type }) => {
+ return { type, end: end.getTime(), start: start.getTime() };
+ })
+ };
+
+ await this.redisCacheService.set(
+ key,
+ JSON.stringify(instrument),
+ IntradayService.CACHE_TTL_IN_MS
+ );
+
+ return instrument;
+ } catch (error) {
+ this.logger.warn(
+ `Could not get the intraday prices of ${symbol} (${dataSource}): ${error?.message}`
+ );
+
+ return { prices: [], tradingPeriods: [] };
+ }
+ }
+}
diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts
index 72468f1d6e..2ba7f72b59 100644
--- a/apps/api/src/app/portfolio/portfolio.controller.ts
+++ b/apps/api/src/app/portfolio/portfolio.controller.ts
@@ -23,6 +23,7 @@ import {
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
+ PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioReportResponse
@@ -54,8 +55,10 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { GetDetailsDto } from './get-details.dto';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHoldingsDto } from './get-holdings.dto';
+import { GetIntradayDto } from './get-intraday.dto';
import { GetInvestmentsDto } from './get-investments.dto';
import { GetPerformanceDto } from './get-performance.dto';
+import { IntradayService } from './intraday/intraday.service';
import { PortfolioService } from './portfolio.service';
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
@@ -65,6 +68,7 @@ export class PortfolioController {
private readonly activitiesService: ActivitiesService,
private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService,
+ private readonly intradayService: IntradayService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@@ -410,6 +414,21 @@ export class PortfolioController {
return { holdings };
}
+ @Get('intraday')
+ @RequiresScope(scopes.portfolioRead)
+ @UseInterceptors(PerformanceLoggingInterceptor)
+ public async getIntraday(
+ @Impersonation() { userId }: ImpersonationContext,
+ @Query() { benchmarks, range }: GetIntradayDto
+ ): Promise {
+ return this.intradayService.getIntraday({
+ userId,
+ benchmarkIds: benchmarks,
+ dateRange: range,
+ userCurrency: this.request.user.settings.settings.baseCurrency
+ });
+ }
+
@Get('investments')
@RequiresScope(scopes.portfolioRead)
@UseInterceptors(TransformDataSourceInRequestInterceptor)
diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts
index 82eeb5395a..7cf42da511 100644
--- a/apps/api/src/app/portfolio/portfolio.module.ts
+++ b/apps/api/src/app/portfolio/portfolio.module.ts
@@ -26,6 +26,7 @@ import { Module } from '@nestjs/common';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
+import { IntradayService } from './intraday/intraday.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@@ -60,6 +61,7 @@ import { RulesService } from './rules.service';
AccountBalanceService,
AccountService,
CurrentRateService,
+ IntradayService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
index d2b741d372..48a49ac7b4 100644
--- a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
+++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
@@ -38,6 +38,12 @@ import type {
} from 'yahoo-finance2/modules/quoteSummary';
import type { SearchQuoteNonYahoo } from 'yahoo-finance2/modules/search';
+export interface IntradayTradingPeriod {
+ end: Date;
+ start: Date;
+ type: 'post' | 'pre' | 'regular';
+}
+
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private static readonly DELISTED_ERROR_MESSAGE =
@@ -196,6 +202,100 @@ export class YahooFinanceService implements DataProviderInterface {
}
}
+ /**
+ * Returns the intraday prices (including the pre-market and the
+ * post-market) and the trading periods of a symbol
+ */
+ public async getIntraday({
+ from,
+ interval,
+ symbol
+ }: {
+ from: Date;
+ interval: '5m' | '15m' | '60m';
+ symbol: string;
+ }): Promise<{
+ prices: { date: Date; price: number }[];
+ tradingPeriods: IntradayTradingPeriod[];
+ }> {
+ const { meta, quotes } = await this.yahooFinance.chart(
+ this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol),
+ { interval, includePrePost: true, period1: from }
+ );
+
+ const prices = quotes
+ .filter(({ close }) => {
+ return typeof close === 'number' && Number.isFinite(close);
+ })
+ .map(({ close, date }) => {
+ return { date, price: close };
+ });
+
+ // Depending on the exchange, the times are dates or Unix timestamps
+ const toDate = (aTime: unknown) => {
+ return typeof aTime === 'number'
+ ? new Date(aTime < 1e12 ? aTime * 1000 : aTime)
+ : new Date(aTime as Date | string);
+ };
+
+ const toPeriod = (aPeriod?: { end: unknown; start: unknown }) => {
+ return aPeriod?.start && aPeriod?.end
+ ? { end: toDate(aPeriod.end), start: toDate(aPeriod.start) }
+ : undefined;
+ };
+
+ // The trading periods are an array of days (each an array of regular
+ // periods), or an object with the arrays of the pre-market, regular and
+ // post-market periods
+ const tradingPeriods: IntradayTradingPeriod[] = [];
+
+ const collect = (
+ aValue: unknown,
+ aType: IntradayTradingPeriod['type'] = 'regular'
+ ) => {
+ if (Array.isArray(aValue)) {
+ for (const item of aValue) {
+ collect(item, aType);
+ }
+ } else if (aValue && typeof aValue === 'object') {
+ const period = toPeriod(aValue as { end: unknown; start: unknown });
+
+ if (period) {
+ tradingPeriods.push({ ...period, type: aType });
+ } else {
+ for (const [key, item] of Object.entries(aValue)) {
+ collect(item, key === 'pre' || key === 'post' ? key : 'regular');
+ }
+ }
+ }
+ };
+
+ collect(meta.tradingPeriods);
+
+ // The trading periods of the current (or next) trading day
+ for (const [type, period] of Object.entries(
+ meta.currentTradingPeriod ?? {}
+ )) {
+ const currentPeriod = toPeriod(
+ period as { end: unknown; start: unknown }
+ );
+
+ if (
+ currentPeriod &&
+ !tradingPeriods.some(({ start }) => {
+ return start.getTime() === currentPeriod.start.getTime();
+ })
+ ) {
+ tradingPeriods.push({
+ ...currentPeriod,
+ type: type === 'pre' || type === 'post' ? type : 'regular'
+ });
+ }
+ }
+
+ return { prices, tradingPeriods };
+ }
+
public getMaxNumberOfSymbolsPerRequest() {
return 50;
}
diff --git a/apps/client/src/app/components/home-overview/home-overview.component.ts b/apps/client/src/app/components/home-overview/home-overview.component.ts
index bd78988456..1112d3ad05 100644
--- a/apps/client/src/app/components/home-overview/home-overview.component.ts
+++ b/apps/client/src/app/components/home-overview/home-overview.component.ts
@@ -9,7 +9,7 @@ import {
DEFAULT_LOCALE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
} from '@ghostfolio/common/config';
-import { isCashPosition } from '@ghostfolio/common/helper';
+import { isCashPosition, isIntradayDateRange } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
HistoricalDataItem,
@@ -43,6 +43,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { arrowForwardOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
+import { catchError, forkJoin, map, Observable, of } from 'rxjs';
const MINIMUM_CHART_ITEMS = 3;
@@ -75,7 +76,10 @@ export class GfHomeOverviewComponent implements OnInit {
return (
(this.historicalDataItems() ?? []).filter(({ value }) => {
return value !== null;
- }).length >= MINIMUM_CHART_ITEMS
+ }).length >=
+ (isIntradayDateRange(this.user()?.settings?.dateRange)
+ ? 1
+ : MINIMUM_CHART_ITEMS)
);
});
protected readonly holdings = signal(
@@ -189,6 +193,29 @@ export class GfHomeOverviewComponent implements OnInit {
}
}
+ /**
+ * The current day, week and month are drawn from intraday prices, with a
+ * fallback to the daily chart
+ */
+ private fetchIntradayChartItems(
+ aDateRange: DateRange
+ ): Observable {
+ if (!isIntradayDateRange(aDateRange)) {
+ return of(null);
+ }
+
+ return this.dataService.fetchPortfolioIntraday({ range: aDateRange }).pipe(
+ map(({ chart }) => {
+ return chart.map(({ date, value }) => {
+ return { date, value: value === null ? null : value * 100 };
+ });
+ }),
+ catchError(() => {
+ return of(null);
+ })
+ );
+ }
+
private setChartItems({
chart,
dateRange
@@ -243,28 +270,38 @@ export class GfHomeOverviewComponent implements OnInit {
});
}
- this.dataService
- .fetchPortfolioPerformance({
- range: userSettings?.dateRange ?? DEFAULT_DATE_RANGE
+ const dateRange = userSettings?.dateRange ?? DEFAULT_DATE_RANGE;
+
+ forkJoin({
+ intradayChart: this.fetchIntradayChartItems(dateRange),
+ response: this.dataService.fetchPortfolioPerformance({
+ range: dateRange
})
+ })
.pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe(({ chart, errors, performance }) => {
- this.errors.set(errors ?? []);
- this.performance.set(performance);
-
- this.setChartItems({ chart, dateRange: userSettings?.dateRange });
-
- this.precision.set(2);
-
- if (
- this.deviceType() === 'mobile' &&
- performance.currentValueInBaseCurrency >=
- NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
- ) {
- this.precision.set(0);
+ .subscribe(
+ ({ intradayChart, response: { chart, errors, performance } }) => {
+ this.errors.set(errors ?? []);
+ this.performance.set(performance);
+
+ if (intradayChart) {
+ this.historicalDataItems.set(intradayChart);
+ } else {
+ this.setChartItems({ chart, dateRange });
+ }
+
+ this.precision.set(2);
+
+ if (
+ this.deviceType() === 'mobile' &&
+ performance.currentValueInBaseCurrency >=
+ NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
+ ) {
+ this.precision.set(0);
+ }
+
+ this.isLoadingPerformance.set(false);
}
-
- this.isLoadingPerformance.set(false);
- });
+ );
}
}
diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts
index b4ef699e5b..c76b9ff2a4 100644
--- a/libs/common/src/lib/helper.spec.ts
+++ b/libs/common/src/lib/helper.spec.ts
@@ -13,6 +13,7 @@ import {
getStringOrNull,
getStringOrUndefined,
isAccountBalanceInFuture,
+ isIntradayDateRange,
isAccountExcluded,
isCurrency,
isCurrencySymbol,
@@ -857,3 +858,16 @@ describe('Helper', () => {
});
});
});
+
+describe('isIntradayDateRange', () => {
+ it.each([
+ ['1d', true],
+ ['wtd', true],
+ ['mtd', true],
+ ['ytd', false],
+ ['max', false],
+ [undefined, false]
+ ] as const)('returns %s for %s', (dateRange, expected) => {
+ expect(isIntradayDateRange(dateRange)).toBe(expected);
+ });
+});
diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts
index 242cf4ef57..18665ec315 100644
--- a/libs/common/src/lib/helper.ts
+++ b/libs/common/src/lib/helper.ts
@@ -63,7 +63,7 @@ import {
PortfolioPosition,
UserSettings
} from './interfaces';
-import { BenchmarkTrend, ColorScheme } from './types';
+import { BenchmarkTrend, ColorScheme, DateRange } from './types';
export const DATE_FORMAT = 'yyyy-MM-dd';
export const DATE_FORMAT_MONTHLY = 'MMMM yyyy';
@@ -880,3 +880,14 @@ export function resolveUserSettings({
)
};
}
+
+/**
+ * Whether the date range is drawn from intraday prices (5 minutes for the
+ * current day, 15 minutes for the current week, 60 minutes for the current
+ * month) instead of daily prices
+ */
+export function isIntradayDateRange(
+ aDateRange?: DateRange
+): aDateRange is '1d' | 'mtd' | 'wtd' {
+ return aDateRange === '1d' || aDateRange === 'mtd' || aDateRange === 'wtd';
+}
diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts
index d17f2377b7..21acf50c31 100644
--- a/libs/common/src/lib/interfaces/index.ts
+++ b/libs/common/src/lib/interfaces/index.ts
@@ -81,6 +81,10 @@ import type { PlatformsResponse } from './responses/platforms-response.interface
import type { PortfolioDividendsResponse } from './responses/portfolio-dividends-response.interface';
import type { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
+import type {
+ PortfolioIntradayItem,
+ PortfolioIntradayResponse
+} from './responses/portfolio-intraday-response.interface';
import type { PortfolioInvestmentsResponse } from './responses/portfolio-investments.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PortfolioReportResponse } from './responses/portfolio-report.interface';
@@ -180,6 +184,8 @@ export {
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
+ PortfolioIntradayItem,
+ PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPerformanceResponse,
diff --git a/libs/common/src/lib/interfaces/responses/portfolio-intraday-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-intraday-response.interface.ts
new file mode 100644
index 0000000000..60c3cb2ced
--- /dev/null
+++ b/libs/common/src/lib/interfaces/responses/portfolio-intraday-response.interface.ts
@@ -0,0 +1,22 @@
+export interface PortfolioIntradayItem {
+ /** Start of the interval (ISO 8601) */
+ date: string;
+
+ /** Change since the start of the date range, `null` for the future */
+ value: number | null;
+}
+
+export interface PortfolioIntradayResponse {
+ benchmarks: {
+ id: string;
+ items: PortfolioIntradayItem[];
+ }[];
+
+ /** Change of the portfolio since the start of the date range */
+ chart: PortfolioIntradayItem[];
+
+ interval: '5m' | '15m' | '60m';
+
+ /** Whether a market (including the pre-market and the post-market) of a holding is open */
+ isMarketOpen: boolean;
+}
diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts
index b5b90ee35b..e7328ef412 100644
--- a/libs/ui/src/lib/line-chart/line-chart.component.ts
+++ b/libs/ui/src/lib/line-chart/line-chart.component.ts
@@ -6,7 +6,11 @@ import {
getVerticalHoverLinePlugin
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
-import { getBackgroundColor, getLocale } from '@ghostfolio/common/helper';
+import {
+ getBackgroundColor,
+ getLocale,
+ isIntradayDateRange
+} from '@ghostfolio/common/helper';
import { NullableLineChartItem } from '@ghostfolio/common/interfaces';
import { ColorScheme, DateRange } from '@ghostfolio/common/types';
@@ -295,7 +299,8 @@ export class GfLineChartComponent
locale: this.locale,
// Equidistant points: the closed markets (nights, weekends) are skipped
type: this.dateRange ? 'timeseries' : 'time',
- unit: getTimeAxisUnit(this.dateRange)
+ unit: getTimeAxisUnit(this.dateRange),
+ withTime: isIntradayDateRange(this.dateRange)
});
}
diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts
index 6292f8770a..e55ce8987e 100644
--- a/libs/ui/src/lib/services/data.service.ts
+++ b/libs/ui/src/lib/services/data.service.ts
@@ -53,6 +53,7 @@ import {
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
+ PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformanceResponse,
PortfolioReportResponse,
@@ -721,6 +722,25 @@ export class DataService {
);
}
+ public fetchPortfolioIntraday({
+ benchmarks = [],
+ range
+ }: {
+ benchmarks?: string[];
+ range: '1d' | 'mtd' | 'wtd';
+ }) {
+ let params = new HttpParams().append('range', range);
+
+ if (benchmarks.length > 0) {
+ params = params.append('benchmarks', benchmarks.join(','));
+ }
+
+ return this.http.get(
+ '/api/v1/portfolio/intraday',
+ { params }
+ );
+ }
+
public fetchPortfolioPerformance({
filters,
groupBy,
From 07782f6f9f7dda55afb3f941b7206c6834897210 Mon Sep 17 00:00:00 2001
From: mister-ideas <36502095+mister-ideas@users.noreply.github.com>
Date: Thu, 22 Oct 2026 22:58:56 +0200
Subject: [PATCH 12/12] Feature/draw the benchmark comparison of the current
day, week and month from intraday prices
---
CHANGELOG.md | 1 +
.../benchmark-comparator.component.ts | 46 +++++++---
.../analysis/analysis-page.component.ts | 92 ++++++++++++++++++-
.../portfolio/analysis/analysis-page.html | 1 +
.../interfaces/benchmark-series.interface.ts | 4 +-
5 files changed, 128 insertions(+), 16 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 37e1314674..cc9d9efa48 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 the intraday prices to the benchmark comparator of the analysis page for the current day, week and month, and a time axis which only contains the trading days
- 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
diff --git a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
index 33db39195d..720ee603f9 100644
--- a/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
+++ b/apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
@@ -3,20 +3,25 @@ import {
getChartBorderColor,
getChartElementsOptions,
getTimeAxisOptions,
+ getTimeAxisUnit,
getValueAxisOptions,
getVerticalHoverLinePlugin,
getZeroLineAnnotation
} from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config';
-import { getLocale, parseDate } from '@ghostfolio/common/helper';
+import {
+ getLocale,
+ isIntradayDateRange,
+ parseDate
+} from '@ghostfolio/common/helper';
import {
BenchmarkSeries,
- LineChartItem,
+ NullableLineChartItem,
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';
+import { ColorScheme, DateRange } from '@ghostfolio/common/types';
import type { SymbolProfile } from '@ghostfolio/prisma/browser';
import {
getTimeSeriesTooltipOptions,
@@ -48,6 +53,7 @@ import {
LineElement,
PointElement,
TimeScale,
+ TimeSeriesScale,
Tooltip,
type TooltipOptions
} from 'chart.js';
@@ -75,6 +81,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public readonly benchmarkSeries = input([]);
public readonly benchmarks = input[]>();
public readonly colorScheme = input();
+ public readonly dateRange = input();
public readonly isLoading = input();
public readonly locale = input(getLocale(), {
@@ -83,7 +90,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
});
- public readonly performanceDataItems = input.required();
+ public readonly performanceDataItems =
+ input.required();
public readonly selectedBenchmarkIds = input([]);
public readonly user = input();
@@ -104,6 +112,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
LineElement,
PointElement,
TimeScale,
+ TimeSeriesScale,
Tooltip
);
@@ -157,14 +166,14 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
data: this.performanceDataItems().map(({ date, value }) => {
return {
x: parseDate(date)?.getTime() ?? null,
- y: value * 100
+ y: value === null ? null : value * 100
};
}),
label: $localize`Portfolio`
},
...this.benchmarkSeries().map(({ items, name }, index) => {
const color = getBenchmarkColor(index);
- const valuesByDate: Record = {};
+ const valuesByDate: Record = {};
for (const { date, value } of items) {
valuesByDate[date] = value;
@@ -177,7 +186,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
data: this.performanceDataItems().map(({ date }) => {
return {
x: parseDate(date)?.getTime() ?? null,
- y: valuesByDate[date]
+ // The benchmarks are already in percent
+ y: valuesByDate[date] ?? null
};
}),
label: name ?? $localize`Benchmark`
@@ -193,6 +203,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration();
+ // The date range can change while the chart stays alive
+ this.chart.options.scales ??= {};
+ this.chart.options.scales['x'] = this.getTimeAxisConfiguration();
+
this.chart.update();
} else {
this.chart = new Chart<'line'>(this.chartCanvas().nativeElement, {
@@ -219,10 +233,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
},
responsive: true,
scales: {
- x: getTimeAxisOptions({
- colorScheme: this.colorScheme(),
- locale: this.locale()
- }),
+ x: this.getTimeAxisConfiguration(),
y: getValueAxisOptions({
colorScheme: this.colorScheme(),
tickCallback: (tickValue) => {
@@ -240,6 +251,19 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
}
+ private getTimeAxisConfiguration() {
+ const dateRange = this.dateRange();
+
+ return getTimeAxisOptions({
+ colorScheme: this.colorScheme(),
+ locale: this.locale(),
+ // Equidistant points: the closed markets (nights, weekends) are skipped
+ type: dateRange ? 'timeseries' : 'time',
+ unit: getTimeAxisUnit(dateRange),
+ withTime: isIntradayDateRange(dateRange)
+ });
+ }
+
private getTooltipPluginConfiguration(): Partial> {
return {
...getTimeSeriesTooltipOptions<'line'>({
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 e2cdd1de2e..e268e5cf0a 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
@@ -1,5 +1,6 @@
import { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component';
import { UserService } from '@ghostfolio/client/services/user/user.service';
+import { getTradingDayItems } from '@ghostfolio/common/chart-helper';
import {
DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
@@ -7,12 +8,15 @@ import {
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
canOpenHoldingDetail,
- getSelectedBenchmarkIds
+ getSelectedBenchmarkIds,
+ isIntradayDateRange
} from '@ghostfolio/common/helper';
import {
BenchmarkSeries,
InvestmentItem,
LineChartItem,
+ NullableLineChartItem,
+ PortfolioIntradayResponse,
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPosition,
@@ -22,6 +26,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type {
AiPromptMode,
+ DateRange,
GroupBy,
ToggleOption
} from '@ghostfolio/common/types';
@@ -60,7 +65,7 @@ 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 { catchError, forkJoin, map, of, Subscription } from 'rxjs';
+import { catchError, forkJoin, map, Observable, of, Subscription } from 'rxjs';
type BenchmarkProfile = Partial &
Required>;
@@ -110,7 +115,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected performance: PortfolioPerformance;
protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected performanceDataItems: LineChartItem[];
- protected performanceDataItemsInPercentage: LineChartItem[];
+ protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
+ protected performanceDataItemsInPercentage: NullableLineChartItem[];
protected performancePercentagesByYear: InvestmentItem[];
protected readonly performanceTimelineDataLabel = $localize`Net Performance`;
protected readonly portfolioEvolutionDataLabel = $localize`Invested Capital`;
@@ -419,6 +425,12 @@ export class GfAnalysisPageComponent implements OnInit {
this.precision = 0;
}
+ // Only the trading days, and the remaining ones of the current year
+ this.performanceDataItemsInPercentage = getTradingDayItems({
+ dateRange: this.dateRange,
+ items: this.performanceDataItemsInPercentage
+ });
+
this.isLoadingInvestmentChart = false;
this.updateBenchmarkDataItems();
@@ -476,12 +488,86 @@ export class GfAnalysisPageComponent implements OnInit {
this.changeDetectorRef.markForCheck();
}
+ private get dateRange(): DateRange {
+ return this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE;
+ }
+
+ private fetchIntraday(): Observable {
+ const dateRange = this.dateRange;
+
+ if (!isIntradayDateRange(dateRange)) {
+ return of(null);
+ }
+
+ return this.dataService
+ .fetchPortfolioIntraday({
+ benchmarks: this.selectedBenchmarkIds,
+ range: dateRange
+ })
+ .pipe(
+ catchError(() => {
+ return of(null);
+ })
+ );
+ }
+
+ private setIntradayComparatorItems({
+ benchmarks,
+ chart
+ }: PortfolioIntradayResponse) {
+ this.performanceDataItemsInPercentage = chart;
+
+ this.benchmarkSeries = this.selectedBenchmarkIds.flatMap((id) => {
+ const series = benchmarks.find((benchmark) => {
+ return benchmark.id === id;
+ });
+
+ const profile = this.benchmarks.find((benchmark) => {
+ return benchmark.id === id;
+ });
+
+ return series
+ ? [
+ {
+ id,
+ // The daily benchmarks are in percent
+ items: series.items.map(({ date, value }) => {
+ return { date, value: value === null ? null : value * 100 };
+ }),
+ name: profile?.name ?? profile?.symbol ?? id
+ }
+ ]
+ : [];
+ });
+
+ this.changeDetectorRef.markForCheck();
+ }
+
private updateBenchmarkDataItems() {
// Discard the pending requests of a previous selection
this.benchmarkSeriesSubscription?.unsubscribe();
this.benchmarkSeries = [];
+ if (isIntradayDateRange(this.dateRange)) {
+ // The portfolio and the benchmarks are drawn from intraday prices
+ this.isLoadingBenchmarkComparator = true;
+
+ this.benchmarkSeriesSubscription = this.fetchIntraday()
+ .pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((intraday) => {
+ if (intraday) {
+ this.setIntradayComparatorItems(intraday);
+ }
+
+ this.isLoadingBenchmarkComparator = false;
+
+ this.changeDetectorRef.markForCheck();
+ });
+
+ return;
+ }
+
const selectedBenchmarks = this.benchmarks.filter(
(benchmark): benchmark is BenchmarkProfile => {
return (
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 547b315bc6..42d46fe261 100644
--- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html
+++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html
@@ -136,6 +136,7 @@
[benchmarks]="benchmarks"
[benchmarkSeries]="benchmarkSeries"
[colorScheme]="user?.settings?.colorScheme"
+ [dateRange]="user?.settings?.dateRange ?? defaultDateRange"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage"
diff --git a/libs/common/src/lib/interfaces/benchmark-series.interface.ts b/libs/common/src/lib/interfaces/benchmark-series.interface.ts
index 9926f22e5b..9fa654883b 100644
--- a/libs/common/src/lib/interfaces/benchmark-series.interface.ts
+++ b/libs/common/src/lib/interfaces/benchmark-series.interface.ts
@@ -1,7 +1,7 @@
-import { LineChartItem } from './line-chart-item.interface';
+import { NullableLineChartItem } from './line-chart-item.interface';
export interface BenchmarkSeries {
id: string;
- items: LineChartItem[];
+ items: NullableLineChartItem[];
name: string;
}