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

46
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<BenchmarkSeries[]>([]);
public readonly benchmarks = input<Partial<SymbolProfile>[]>();
public readonly colorScheme = input<ColorScheme>();
public readonly dateRange = input<DateRange>();
public readonly isLoading = input<boolean>();
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 user = input<User>();
@ -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<string, number> = {};
const valuesByDate: Record<string, number | null> = {};
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<TooltipOptions<'line'>> {
return {
...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 { 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<SymbolProfile> &
Required<Pick<SymbolProfile, 'dataSource' | 'id' | 'symbol'>>;
@ -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<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() {
// 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 (

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

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

Loading…
Cancel
Save