Browse Source

Feature/draw the benchmark comparison of the current day, week and month from intraday prices

pull/8280/head
mister-ideas 1 day ago
parent
commit
07782f6f9f
  1. 1
      CHANGELOG.md
  2. 46
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  3. 92
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  4. 1
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  5. 4
      libs/common/src/lib/interfaces/benchmark-series.interface.ts

1
CHANGELOG.md

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- 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 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 time axis to the performance chart on the overview page which only contains the trading days, spans the whole current year and uses hours, days, months or years as unit according to the selected date range
- Added support for comparing the performance with multiple benchmarks in the benchmark comparator of the analysis page - Added support for comparing the performance with multiple benchmarks in the benchmark comparator of the analysis page

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

@ -3,20 +3,25 @@ import {
getChartBorderColor, getChartBorderColor,
getChartElementsOptions, getChartElementsOptions,
getTimeAxisOptions, getTimeAxisOptions,
getTimeAxisUnit,
getValueAxisOptions, getValueAxisOptions,
getVerticalHoverLinePlugin, getVerticalHoverLinePlugin,
getZeroLineAnnotation getZeroLineAnnotation
} from '@ghostfolio/common/chart-helper'; } from '@ghostfolio/common/chart-helper';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { getLocale, parseDate } from '@ghostfolio/common/helper'; import {
getLocale,
isIntradayDateRange,
parseDate
} from '@ghostfolio/common/helper';
import { import {
BenchmarkSeries, BenchmarkSeries,
LineChartItem, NullableLineChartItem,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes } from '@ghostfolio/common/routes/routes'; 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 type { SymbolProfile } from '@ghostfolio/prisma/browser';
import { import {
getTimeSeriesTooltipOptions, getTimeSeriesTooltipOptions,
@ -48,6 +53,7 @@ import {
LineElement, LineElement,
PointElement, PointElement,
TimeScale, TimeScale,
TimeSeriesScale,
Tooltip, Tooltip,
type TooltipOptions type TooltipOptions
} from 'chart.js'; } from 'chart.js';
@ -75,6 +81,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
public readonly benchmarkSeries = input<BenchmarkSeries[]>([]); public readonly benchmarkSeries = input<BenchmarkSeries[]>([]);
public readonly benchmarks = input<Partial<SymbolProfile>[]>(); public readonly benchmarks = input<Partial<SymbolProfile>[]>();
public readonly colorScheme = input<ColorScheme>(); public readonly colorScheme = input<ColorScheme>();
public readonly dateRange = input<DateRange>();
public readonly isLoading = input<boolean>(); public readonly isLoading = input<boolean>();
public readonly locale = input(getLocale(), { public readonly locale = input(getLocale(), {
@ -83,7 +90,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
} }
}); });
public readonly performanceDataItems = input.required<LineChartItem[]>(); public readonly performanceDataItems =
input.required<NullableLineChartItem[]>();
public readonly selectedBenchmarkIds = input<string[]>([]); public readonly selectedBenchmarkIds = input<string[]>([]);
public readonly user = input<User>(); public readonly user = input<User>();
@ -104,6 +112,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
LineElement, LineElement,
PointElement, PointElement,
TimeScale, TimeScale,
TimeSeriesScale,
Tooltip Tooltip
); );
@ -157,14 +166,14 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
data: this.performanceDataItems().map(({ date, value }) => { data: this.performanceDataItems().map(({ date, value }) => {
return { return {
x: parseDate(date)?.getTime() ?? null, x: parseDate(date)?.getTime() ?? null,
y: value * 100 y: value === null ? null : value * 100
}; };
}), }),
label: $localize`Portfolio` label: $localize`Portfolio`
}, },
...this.benchmarkSeries().map(({ items, name }, index) => { ...this.benchmarkSeries().map(({ items, name }, index) => {
const color = getBenchmarkColor(index); const color = getBenchmarkColor(index);
const valuesByDate: Record<string, number> = {}; const valuesByDate: Record<string, number | null> = {};
for (const { date, value } of items) { for (const { date, value } of items) {
valuesByDate[date] = value; valuesByDate[date] = value;
@ -177,7 +186,8 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
data: this.performanceDataItems().map(({ date }) => { data: this.performanceDataItems().map(({ date }) => {
return { return {
x: parseDate(date)?.getTime() ?? null, x: parseDate(date)?.getTime() ?? null,
y: valuesByDate[date] // The benchmarks are already in percent
y: valuesByDate[date] ?? null
}; };
}), }),
label: name ?? $localize`Benchmark` label: name ?? $localize`Benchmark`
@ -193,6 +203,10 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
this.chart.options.plugins.tooltip = this.chart.options.plugins.tooltip =
this.getTooltipPluginConfiguration(); 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(); this.chart.update();
} else { } else {
this.chart = new Chart<'line'>(this.chartCanvas().nativeElement, { this.chart = new Chart<'line'>(this.chartCanvas().nativeElement, {
@ -219,10 +233,7 @@ export class GfBenchmarkComparatorComponent implements OnChanges, OnDestroy {
}, },
responsive: true, responsive: true,
scales: { scales: {
x: getTimeAxisOptions({ x: this.getTimeAxisConfiguration(),
colorScheme: this.colorScheme(),
locale: this.locale()
}),
y: getValueAxisOptions({ y: getValueAxisOptions({
colorScheme: this.colorScheme(), colorScheme: this.colorScheme(),
tickCallback: (tickValue) => { 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<TooltipOptions<'line'>> { private getTooltipPluginConfiguration(): Partial<TooltipOptions<'line'>> {
return { return {
...getTimeSeriesTooltipOptions<'line'>({ ...getTimeSeriesTooltipOptions<'line'>({

92
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 { GfBenchmarkComparatorComponent } from '@ghostfolio/client/components/benchmark-comparator/benchmark-comparator.component';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getTradingDayItems } from '@ghostfolio/common/chart-helper';
import { import {
DEFAULT_DATE_RANGE, DEFAULT_DATE_RANGE,
NUMERICAL_PRECISION_THRESHOLD_6_FIGURES NUMERICAL_PRECISION_THRESHOLD_6_FIGURES
@ -7,12 +8,15 @@ import {
import { SubscriptionType } from '@ghostfolio/common/enums'; import { SubscriptionType } from '@ghostfolio/common/enums';
import { import {
canOpenHoldingDetail, canOpenHoldingDetail,
getSelectedBenchmarkIds getSelectedBenchmarkIds,
isIntradayDateRange
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
BenchmarkSeries, BenchmarkSeries,
InvestmentItem, InvestmentItem,
LineChartItem, LineChartItem,
NullableLineChartItem,
PortfolioIntradayResponse,
PortfolioInvestmentsResponse, PortfolioInvestmentsResponse,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
@ -22,6 +26,7 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { hasScope, scopes } from '@ghostfolio/common/scopes'; import { hasScope, scopes } from '@ghostfolio/common/scopes';
import type { import type {
AiPromptMode, AiPromptMode,
DateRange,
GroupBy, GroupBy,
ToggleOption ToggleOption
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -60,7 +65,7 @@ import { isNumber, keyBy, sortBy, union } from 'lodash-es';
import ms from 'ms'; import ms from 'ms';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; 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<SymbolProfile> & type BenchmarkProfile = Partial<SymbolProfile> &
Required<Pick<SymbolProfile, 'dataSource' | 'id' | 'symbol'>>; Required<Pick<SymbolProfile, 'dataSource' | 'id' | 'symbol'>>;
@ -110,7 +115,8 @@ export class GfAnalysisPageComponent implements OnInit {
protected performance: PortfolioPerformance; protected performance: PortfolioPerformance;
protected readonly PerformanceCalculationType = PerformanceCalculationType; protected readonly PerformanceCalculationType = PerformanceCalculationType;
protected performanceDataItems: LineChartItem[]; protected performanceDataItems: LineChartItem[];
protected performanceDataItemsInPercentage: LineChartItem[]; protected readonly defaultDateRange = DEFAULT_DATE_RANGE;
protected performanceDataItemsInPercentage: NullableLineChartItem[];
protected performancePercentagesByYear: InvestmentItem[]; protected performancePercentagesByYear: InvestmentItem[];
protected readonly performanceTimelineDataLabel = $localize`Net Performance`; protected readonly performanceTimelineDataLabel = $localize`Net Performance`;
protected readonly portfolioEvolutionDataLabel = $localize`Invested Capital`; protected readonly portfolioEvolutionDataLabel = $localize`Invested Capital`;
@ -419,6 +425,12 @@ export class GfAnalysisPageComponent implements OnInit {
this.precision = 0; 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.isLoadingInvestmentChart = false;
this.updateBenchmarkDataItems(); this.updateBenchmarkDataItems();
@ -476,12 +488,86 @@ export class GfAnalysisPageComponent implements OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
private get dateRange(): DateRange {
return this.user?.settings?.dateRange ?? DEFAULT_DATE_RANGE;
}
private fetchIntraday(): Observable<PortfolioIntradayResponse | null> {
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() { private updateBenchmarkDataItems() {
// Discard the pending requests of a previous selection // Discard the pending requests of a previous selection
this.benchmarkSeriesSubscription?.unsubscribe(); this.benchmarkSeriesSubscription?.unsubscribe();
this.benchmarkSeries = []; 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( const selectedBenchmarks = this.benchmarks.filter(
(benchmark): benchmark is BenchmarkProfile => { (benchmark): benchmark is BenchmarkProfile => {
return ( return (

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

@ -136,6 +136,7 @@
[benchmarks]="benchmarks" [benchmarks]="benchmarks"
[benchmarkSeries]="benchmarkSeries" [benchmarkSeries]="benchmarkSeries"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[dateRange]="user?.settings?.dateRange ?? defaultDateRange"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart" [isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage" [performanceDataItems]="performanceDataItemsInPercentage"

4
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 { export interface BenchmarkSeries {
id: string; id: string;
items: LineChartItem[]; items: NullableLineChartItem[];
name: string; name: string;
} }

Loading…
Cancel
Save