From 4b57c6f539e1eef33bede2f0dca7309044c302ed Mon Sep 17 00:00:00 2001 From: Thomas <4159106+dtslvr@users.noreply.github.com> Date: Mon, 10 Oct 2022 20:46:48 +0200 Subject: [PATCH] Add total amount chart --- .../src/app/portfolio/portfolio.controller.ts | 23 ++-- .../src/app/portfolio/portfolio.service.ts | 116 ++++-------------- .../home-market/home-market.component.ts | 4 +- .../components/home-market/home-market.html | 2 +- .../home-overview/home-overview.component.ts | 14 ++- .../investment-chart.component.ts | 87 +++++++++---- .../analysis/analysis-page.component.ts | 27 +++- .../portfolio/analysis/analysis-page.html | 5 +- apps/client/src/app/services/data.service.ts | 24 ++-- .../historical-data-item.interface.ts | 2 +- 10 files changed, 146 insertions(+), 158 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 680b4cd93..f8850cc60 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -68,7 +68,7 @@ export class PortfolioController { @Headers('impersonation-id') impersonationId: string, @Query('accounts') filterByAccounts?: string, @Query('assetClasses') filterByAssetClasses?: string, - @Query('range') range?: DateRange, + @Query('range') dateRange: DateRange = 'max', @Query('tags') filterByTags?: string ): Promise { let hasError = false; @@ -88,9 +88,9 @@ export class PortfolioController { summary, totalValueInBaseCurrency } = await this.portfolioService.getDetails({ + dateRange, filters, impersonationId, - dateRange: range, userId: this.request.user.id }); @@ -183,6 +183,7 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt')) public async getInvestments( @Headers('impersonation-id') impersonationId: string, + @Query('range') dateRange: DateRange = 'max', @Query('groupBy') groupBy?: GroupBy ): Promise { if ( @@ -198,12 +199,16 @@ export class PortfolioController { let investments: InvestmentItem[]; if (groupBy === 'month') { - investments = await this.portfolioService.getInvestments( + investments = await this.portfolioService.getInvestments({ + dateRange, impersonationId, - 'month' - ); + groupBy: 'month' + }); } else { - investments = await this.portfolioService.getInvestments(impersonationId); + investments = await this.portfolioService.getInvestments({ + dateRange, + impersonationId + }); } if ( @@ -230,7 +235,7 @@ export class PortfolioController { @Version('2') public async getPerformanceV2( @Headers('impersonation-id') impersonationId: string, - @Query('range') dateRange + @Query('range') dateRange: DateRange = 'max' ): Promise { const performanceInformation = await this.portfolioService.getPerformanceV2( { @@ -258,11 +263,11 @@ export class PortfolioController { @UseInterceptors(TransformDataSourceInResponseInterceptor) public async getPositions( @Headers('impersonation-id') impersonationId: string, - @Query('range') range + @Query('range') dateRange: DateRange = 'max' ): Promise { const result = await this.portfolioService.getPositions( impersonationId, - range + dateRange ); if ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 636b8f39c..5dec30ba6 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -207,11 +207,16 @@ export class PortfolioService { }; } - public async getInvestments( - aImpersonationId: string, - groupBy?: GroupBy - ): Promise { - const userId = await this.getUserId(aImpersonationId, this.request.user.id); + public async getInvestments({ + dateRange, + impersonationId, + groupBy + }: { + dateRange: DateRange; + impersonationId: string; + groupBy?: GroupBy; + }): Promise { + const userId = await this.getUserId(impersonationId, this.request.user.id); const { portfolioOrders, transactionPoints } = await this.getTransactionPoints({ @@ -283,98 +288,18 @@ export class PortfolioService { } } - return sortBy(investments, (investment) => { + investments = sortBy(investments, (investment) => { return investment.date; }); - } - - public async getChart( - aImpersonationId: string, - aDateRange: DateRange = 'max' - ): Promise { - const userId = await this.getUserId(aImpersonationId, this.request.user.id); - - const { portfolioOrders, transactionPoints } = - await this.getTransactionPoints({ - userId - }); - - const portfolioCalculator = new PortfolioCalculator({ - currency: this.request.user.Settings.settings.baseCurrency, - currentRateService: this.currentRateService, - orders: portfolioOrders - }); - - portfolioCalculator.setTransactionPoints(transactionPoints); - if (transactionPoints.length === 0) { - return { - isAllTimeHigh: false, - isAllTimeLow: false, - items: [] - }; - } - let portfolioStart = parse( - transactionPoints[0].date, - DATE_FORMAT, - new Date() - ); - - // Get start date for the full portfolio because of because of the - // min and max calculation - portfolioStart = this.getStartDate('max', portfolioStart); - - const timelineSpecification: TimelineSpecification[] = [ - { - start: format(portfolioStart, DATE_FORMAT), - accuracy: 'day' - } - ]; - - const timelineInfo = await portfolioCalculator.calculateTimeline( - timelineSpecification, - format(new Date(), DATE_FORMAT) - ); - - const timeline = timelineInfo.timelinePeriods; - - const items = timeline - .filter((timelineItem) => timelineItem !== null) - .map((timelineItem) => ({ - date: timelineItem.date, - value: timelineItem.netPerformance.toNumber() - })); - - let lastItem = null; - if (timeline.length > 0) { - lastItem = timeline[timeline.length - 1]; - } - - let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq( - lastItem?.netPerformance ?? 0 - ); - let isAllTimeLow = timelineInfo.minNetPerformance?.eq( - lastItem?.netPerformance ?? 0 - ); - if (isAllTimeHigh && isAllTimeLow) { - isAllTimeHigh = false; - isAllTimeLow = false; - } - portfolioStart = startOfDay( - this.getStartDate( - aDateRange, - parse(transactionPoints[0].date, DATE_FORMAT, new Date()) - ) + const startDate = this.getStartDate( + dateRange, + parseDate(investments[0]?.date) ); - return { - isAllTimeHigh, - isAllTimeLow, - items: items.filter((item) => { - // Filter items of date range - return !isAfter(portfolioStart, parseDate(item.date)); - }) - }; + return investments.filter(({ date }) => { + return !isBefore(parseDate(date), startDate); + }); } public async getChartV2({ @@ -441,7 +366,7 @@ export class PortfolioService { filters?: Filter[]; withExcludedAccounts?: boolean; }): Promise { - // TODO: + // TODO userId = await this.getUserId(impersonationId, userId); const user = await this.userService.user({ id: userId }); @@ -1035,10 +960,11 @@ export class PortfolioService { return { chart: historicalDataContainer.items.map( - ({ date, netPerformanceInPercentage }) => { + ({ date, netPerformance, netPerformanceInPercentage }) => { return { date, - value: netPerformanceInPercentage + netPerformance, + netPerformanceInPercentage }; } ), diff --git a/apps/client/src/app/components/home-market/home-market.component.ts b/apps/client/src/app/components/home-market/home-market.component.ts index 425ef0df7..2d2e1bffe 100644 --- a/apps/client/src/app/components/home-market/home-market.component.ts +++ b/apps/client/src/app/components/home-market/home-market.component.ts @@ -24,7 +24,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit { public fearLabel = $localize`Fear`; public greedLabel = $localize`Greed`; public hasPermissionToAccessFearAndGreedIndex: boolean; - public historicalData: HistoricalDataItem[]; + public historicalDataItems: HistoricalDataItem[]; public info: InfoItem; public isLoading = true; public readonly numberOfDays = 180; @@ -67,7 +67,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ historicalData, marketPrice }) => { this.fearAndGreedIndex = marketPrice; - this.historicalData = [ + this.historicalDataItems = [ ...historicalData, { date: resetHours(new Date()).toISOString(), diff --git a/apps/client/src/app/components/home-market/home-market.html b/apps/client/src/app/components/home-market/home-market.html index b71c45e09..331d6f83c 100644 --- a/apps/client/src/app/components/home-market/home-market.html +++ b/apps/client/src/app/components/home-market/home-market.html @@ -10,7 +10,7 @@ symbol="Fear & Greed Index" yMax="100" yMin="0" - [historicalDataItems]="historicalData" + [historicalDataItems]="historicalDataItems" [isAnimated]="true" [locale]="user?.settings?.locale" [showXAxis]="true" 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 72bbc4162..eb8f0c81c 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 @@ -116,12 +116,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { this.performance = response.performance; this.isLoadingPerformance = false; - this.historicalDataItems = response.chart.map(({ date, value }) => { - return { - date, - value - }; - }); + this.historicalDataItems = response.chart.map( + ({ date, netPerformanceInPercentage }) => { + return { + date, + value: netPerformanceInPercentage + }; + } + ); this.changeDetectorRef.markForCheck(); }); diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index 2c2b25172..4e2622828 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -15,6 +15,7 @@ import { } from '@ghostfolio/common/chart-helper'; import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; import { + DATE_FORMAT, getBackgroundColor, getDateFormatString, getTextColor, @@ -22,7 +23,7 @@ import { transformTickToAbbreviation } from '@ghostfolio/common/helper'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; -import { GroupBy } from '@ghostfolio/common/types'; +import { DateRange, GroupBy } from '@ghostfolio/common/types'; import { BarController, BarElement, @@ -35,7 +36,15 @@ import { Tooltip } from 'chart.js'; import annotationPlugin from 'chartjs-plugin-annotation'; -import { addDays, isAfter, parseISO, subDays } from 'date-fns'; +import { + addDays, + format, + isAfter, + isBefore, + parseISO, + subDays +} from 'date-fns'; +import { LineChartItem } from '@ghostfolio/common/interfaces'; @Component({ selector: 'gf-investment-chart', @@ -44,17 +53,19 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns'; styleUrls: ['./investment-chart.component.scss'] }) export class InvestmentChartComponent implements OnChanges, OnDestroy { + @Input() benchmarkDataItems: LineChartItem[] = []; @Input() currency: string; @Input() daysInMarket: number; @Input() groupBy: GroupBy; @Input() investments: InvestmentItem[]; @Input() isInPercent = false; @Input() locale: string; + @Input() range: DateRange = 'max'; @Input() savingsRate = 0; @ViewChild('chartCanvas') chartCanvas; - public chart: Chart; + public chart: Chart; public isLoading = true; private data: InvestmentItem[]; @@ -77,7 +88,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { } public ngOnChanges() { - if (this.investments) { + if (this.benchmarkDataItems && this.investments) { this.initialize(); } } @@ -93,41 +104,61 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { this.data = this.investments.map((a) => Object.assign({}, a)); if (!this.groupBy && this.data?.length > 0) { - // Extend chart by 5% of days in market (before) - const firstItem = this.data[0]; - this.data.unshift({ - ...firstItem, - date: subDays( - parseISO(firstItem.date), - this.daysInMarket * 0.05 || 90 - ).toISOString(), - investment: 0 - }); + if (this.range === 'max') { + // Extend chart by 5% of days in market (before) + const firstItem = this.data[0]; + this.data.unshift({ + ...firstItem, + date: format( + subDays(parseISO(firstItem.date), this.daysInMarket * 0.05 || 90), + DATE_FORMAT + ), + investment: 0 + }); + } // Extend chart by 5% of days in market (after) const lastItem = this.data[this.data.length - 1]; this.data.push({ ...lastItem, - date: addDays( - parseDate(lastItem.date), - this.daysInMarket * 0.05 || 90 - ).toISOString() + date: format( + addDays(parseDate(lastItem.date), this.daysInMarket * 0.05 || 90), + DATE_FORMAT + ) + }); + } + + let currentIndex = 0; + const totalAmountDataItems: { x: Date; y: number }[] = []; + + for (const { date, value } of this.benchmarkDataItems) { + // TODO: Improve total amount calculation + if ( + isBefore(parseDate(this.data?.[currentIndex]?.date), parseDate(date)) + ) { + currentIndex += 1; + } + + totalAmountDataItems.push({ + x: parseDate(date), + y: this.data?.[currentIndex]?.investment + value }); } const data = { - labels: this.data.map((investmentItem) => { - return investmentItem.date; + labels: this.benchmarkDataItems.map(({ date }) => { + return date; }), datasets: [ { backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, borderWidth: this.groupBy ? 0 : 2, - data: this.data.map((position) => { - return this.isInPercent - ? position.investment * 100 - : position.investment; + data: this.data.map(({ date, investment }) => { + return { + x: parseDate(date), + y: this.isInPercent ? investment * 100 : investment + }; }), label: $localize`Deposit`, segment: { @@ -139,6 +170,14 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { borderDash: (context: unknown) => this.isInFuture(context, [2, 2]) }, stepped: true + }, + { + borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, + borderWidth: 1, + data: totalAmountDataItems, + fill: false, + label: $localize`Total Amount`, + pointRadius: 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 f9640521c..770ab3f42 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 @@ -39,6 +39,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { { label: $localize`Accumulating`, value: undefined } ]; public performanceDataItems: HistoricalDataItem[]; + public performanceDataItemsInPercentage: HistoricalDataItem[]; public top3: Position[]; public user: User; @@ -131,7 +132,24 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ chart }) => { this.firstOrderDate = new Date(chart?.[0]?.date ?? new Date()); - this.performanceDataItems = chart; + + this.performanceDataItems = []; + this.performanceDataItemsInPercentage = []; + + for (const { + date, + netPerformance, + netPerformanceInPercentage + } of chart) { + this.performanceDataItems.push({ + date, + value: netPerformance + }); + this.performanceDataItemsInPercentage.push({ + date, + value: netPerformanceInPercentage + }); + } this.updateBenchmarkDataItems(); @@ -139,7 +157,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); this.dataService - .fetchInvestments() + .fetchInvestments({ range: this.user?.settings?.dateRange }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ firstOrderDate, investments }) => { this.daysInMarket = differenceInDays(new Date(), firstOrderDate); @@ -149,7 +167,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit { }); this.dataService - .fetchInvestmentsByMonth() + .fetchInvestments({ + groupBy: 'month', + range: this.user?.settings?.dateRange + }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe(({ investments }) => { this.investmentsByMonth = investments; 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 828fea343..852712a25 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -10,7 +10,7 @@ [daysInMarket]="daysInMarket" [isLoading]="isLoadingBenchmarkComparator" [locale]="user?.settings?.locale" - [performanceDataItems]="performanceDataItems" + [performanceDataItems]="performanceDataItemsInPercentage" [user]="user" (benchmarkChanged)="onChangeBenchmark($event)" (dateRangeChanged)="onChangeDateRange($event)" @@ -119,12 +119,14 @@
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 58b58e67b..ce223125f 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -163,23 +163,15 @@ export class DataService { return info; } - public fetchInvestments(): Observable { - return this.http.get('/api/v1/portfolio/investments').pipe( - map((response) => { - if (response.firstOrderDate) { - response.firstOrderDate = parseISO(response.firstOrderDate); - } - - return response; - }) - ); - } - - public fetchInvestmentsByMonth(): Observable { + public fetchInvestments({ + groupBy, + range + }: { + groupBy?: 'month'; + range: DateRange; + }): Observable { return this.http - .get('/api/v1/portfolio/investments', { - params: { groupBy: 'month' } - }) + .get('/api/v1/portfolio/investments', { params: { groupBy, range } }) .pipe( map((response) => { if (response.firstOrderDate) { diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts index 324957838..c78518388 100644 --- a/libs/common/src/lib/interfaces/historical-data-item.interface.ts +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -4,5 +4,5 @@ export interface HistoricalDataItem { grossPerformancePercent?: number; netPerformance?: number; netPerformanceInPercentage?: number; - value: number; + value?: number; }