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