From 3b2f13850cfe9207eb08e463c352f91cc829d85b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 3 Sep 2022 21:41:06 +0200 Subject: [PATCH] Feature/improve chart calculation (#1226) * Improve chart calculation * Update changelog --- CHANGELOG.md | 7 ++ .../src/app/portfolio/portfolio-calculator.ts | 25 +++--- .../src/app/portfolio/portfolio.controller.ts | 23 +++++- .../src/app/portfolio/portfolio.service.ts | 76 ++++++++++++++++++- .../src/app/user/update-user-setting.dto.ts | 4 + .../home-overview/home-overview.component.ts | 5 +- .../home-overview/home-overview.html | 2 +- .../investment-chart.component.ts | 8 +- .../position-detail-dialog.html | 2 +- .../pages/account/account-page.component.ts | 18 +++++ .../src/app/pages/account/account-page.html | 16 ++++ apps/client/src/app/services/data.service.ts | 4 +- apps/client/src/locales/messages.de.xlf | 34 +++++---- apps/client/src/locales/messages.xlf | 33 ++++---- libs/common/src/lib/chart-helper.ts | 12 ++- .../lib/interfaces/user-settings.interface.ts | 1 + .../lib/line-chart/line-chart.component.ts | 4 +- .../portfolio-proportion-chart.component.ts | 2 +- 18 files changed, 220 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59188bcaf..ea1b02abd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- Supported units in the line chart component +- Added a new chart calculation engine (experimental) + ## 1.186.2 - 03.09.2022 ### Changed diff --git a/apps/api/src/app/portfolio/portfolio-calculator.ts b/apps/api/src/app/portfolio/portfolio-calculator.ts index d3e036129..9905eadb2 100644 --- a/apps/api/src/app/portfolio/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/portfolio-calculator.ts @@ -172,15 +172,12 @@ export class PortfolioCalculator { start: Date, end = new Date(Date.now()) ): Promise { - const transactionPointsInRange = + const transactionPointsBeforeEndDate = this.transactionPoints?.filter((transactionPoint) => { - return isWithinInterval(parseDate(transactionPoint.date), { - start, - end - }); + return isBefore(parseDate(transactionPoint.date), end); }) ?? []; - if (!transactionPointsInRange.length) { + if (!transactionPointsBeforeEndDate.length) { return { currentValue: new Big(0), grossPerformance: new Big(0), @@ -194,32 +191,34 @@ export class PortfolioCalculator { } const lastTransactionPoint = - transactionPointsInRange[transactionPointsInRange.length - 1]; + transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1]; let firstTransactionPoint: TransactionPoint = null; - let firstIndex = transactionPointsInRange.length; + let firstIndex = transactionPointsBeforeEndDate.length; const dates = []; const dataGatheringItems: IDataGatheringItem[] = []; const currencies: { [symbol: string]: string } = {}; dates.push(resetHours(start)); - for (const item of transactionPointsInRange[firstIndex - 1].items) { + for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) { dataGatheringItems.push({ dataSource: item.dataSource, symbol: item.symbol }); currencies[item.symbol] = item.currency; } - for (let i = 0; i < transactionPointsInRange.length; i++) { + for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { if ( - !isBefore(parseDate(transactionPointsInRange[i].date), start) && + !isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && firstTransactionPoint === null ) { - firstTransactionPoint = transactionPointsInRange[i]; + firstTransactionPoint = transactionPointsBeforeEndDate[i]; firstIndex = i; } if (firstTransactionPoint !== null) { - dates.push(resetHours(parseDate(transactionPointsInRange[i].date))); + dates.push( + resetHours(parseDate(transactionPointsBeforeEndDate[i].date)) + ); } } diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5172b8071..2f2ae0841 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -35,7 +35,8 @@ import { Param, Query, UseGuards, - UseInterceptors + UseInterceptors, + Version } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; @@ -110,6 +111,26 @@ export class PortfolioController { }; } + @Get('chart') + @UseGuards(AuthGuard('jwt')) + @Version('2') + public async getChartV2( + @Headers('impersonation-id') impersonationId: string, + @Query('range') range + ): Promise { + const historicalDataContainer = await this.portfolioService.getChartV2( + impersonationId, + range + ); + + return { + chart: historicalDataContainer.items, + hasError: false, + isAllTimeHigh: false, + isAllTimeLow: false + }; + } + @Get('details') @UseGuards(AuthGuard('jwt')) @UseInterceptors(RedactValuesInResponseInterceptor) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8b925b4e0..cae6d7a24 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -57,6 +57,7 @@ import { } from '@prisma/client'; import Big from 'big.js'; import { + addDays, differenceInDays, endOfToday, format, @@ -71,7 +72,7 @@ import { subDays, subYears } from 'date-fns'; -import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; +import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash'; import { HistoricalDataContainer, @@ -85,6 +86,7 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json'); @Injectable() export class PortfolioService { + private static readonly MAX_CHART_ITEMS = 250; private baseCurrency: string; public constructor( @@ -354,6 +356,78 @@ export class PortfolioService { }; } + public async getChartV2( + 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.currency, + currentRateService: this.currentRateService, + orders: portfolioOrders + }); + + portfolioCalculator.setTransactionPoints(transactionPoints); + if (transactionPoints.length === 0) { + return { + isAllTimeHigh: false, + isAllTimeLow: false, + items: [] + }; + } + const endDate = new Date(); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(aDateRange, portfolioStart); + + const daysInMarket = differenceInDays(new Date(), startDate); + const step = Math.round( + daysInMarket / Math.min(daysInMarket, PortfolioService.MAX_CHART_ITEMS) + ); + + const items: HistoricalDataItem[] = []; + + let currentEndDate = startDate; + + while (isBefore(currentEndDate, endDate)) { + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate, + currentEndDate + ); + + items.push({ + date: format(currentEndDate, DATE_FORMAT), + value: currentPositions.netPerformancePercentage.toNumber() * 100 + }); + + currentEndDate = addDays(currentEndDate, step); + } + + const today = new Date(); + + if (last(items)?.date !== format(today, DATE_FORMAT)) { + // Add today + const { netPerformancePercentage } = + await portfolioCalculator.getCurrentPositions(startDate, today); + items.push({ + date: format(today, DATE_FORMAT), + value: netPerformancePercentage.toNumber() * 100 + }); + } + + return { + isAllTimeHigh: false, + isAllTimeLow: false, + items: items + }; + } + public async getDetails( aImpersonationId: string, aUserId: string, diff --git a/apps/api/src/app/user/update-user-setting.dto.ts b/apps/api/src/app/user/update-user-setting.dto.ts index f458294f8..1449bd64f 100644 --- a/apps/api/src/app/user/update-user-setting.dto.ts +++ b/apps/api/src/app/user/update-user-setting.dto.ts @@ -5,6 +5,10 @@ export class UpdateUserSettingDto { @IsOptional() emergencyFund?: number; + @IsBoolean() + @IsOptional() + isExperimentalFeatures?: boolean; + @IsBoolean() @IsOptional() isRestrictedView?: boolean; 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 f15099c2a..2017518e7 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 @@ -106,7 +106,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit { this.isLoadingPerformance = true; this.dataService - .fetchChart({ range: this.dateRange }) + .fetchChart({ + range: this.dateRange, + version: this.user?.settings?.isExperimentalFeatures ? 2 : 1 + }) .pipe(takeUntil(this.unsubscribeSubject)) .subscribe((chartData) => { this.historicalDataItems = chartData.chart.map((chartDataItem) => { 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 cce870b52..9071fdc22 100644 --- a/apps/client/src/app/components/home-overview/home-overview.html +++ b/apps/client/src/app/components/home-overview/home-overview.html @@ -15,7 +15,6 @@ 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 4cdb77ef8..3a758e43d 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 @@ -249,10 +249,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { private getTooltipPluginConfiguration() { return { - ...getTooltipOptions( - this.isInPercent ? undefined : this.currency, - this.isInPercent ? undefined : this.locale - ), + ...getTooltipOptions({ + locale: this.isInPercent ? undefined : this.locale, + unit: this.isInPercent ? undefined : this.currency + }), mode: 'index', position: 'top', xAlign: 'center', diff --git a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html index c910587ae..22dbf5ad0 100644 --- a/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html +++ b/apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html @@ -23,13 +23,13 @@ class="mb-4" benchmarkLabel="Average Unit Price" [benchmarkDataItems]="benchmarkDataItems" - [currency]="SymbolProfile?.currency" [historicalDataItems]="historicalDataItems" [locale]="data.locale" [showGradient]="true" [showXAxis]="true" [showYAxis]="true" [symbol]="data.symbol" + [unit]="SymbolProfile?.currency" >
diff --git a/apps/client/src/app/pages/account/account-page.component.ts b/apps/client/src/app/pages/account/account-page.component.ts index 99900e15a..03d95a7e4 100644 --- a/apps/client/src/app/pages/account/account-page.component.ts +++ b/apps/client/src/app/pages/account/account-page.component.ts @@ -226,6 +226,24 @@ export class AccountPageComponent implements OnDestroy, OnInit { }); } + public onExperimentalFeaturesChange(aEvent: MatSlideToggleChange) { + this.dataService + .putUserSetting({ isExperimentalFeatures: aEvent.checked }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.userService.remove(); + + this.userService + .get() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + public onRedeemCoupon() { let couponCode = prompt($localize`Please enter your coupon code:`); couponCode = couponCode?.trim(); diff --git a/apps/client/src/app/pages/account/account-page.html b/apps/client/src/app/pages/account/account-page.html index b3f59d3a9..c0efac59a 100644 --- a/apps/client/src/app/pages/account/account-page.html +++ b/apps/client/src/app/pages/account/account-page.html @@ -188,6 +188,22 @@ >
+
+
+
Experimental Features
+
+
+ +
+
User ID
{{ user?.id }}
diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 15e3ff115..f21ebb25f 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -185,8 +185,8 @@ export class DataService { return this.http.get('/api/v1/benchmark'); } - public fetchChart({ range }: { range: DateRange }) { - return this.http.get('/api/v1/portfolio/chart', { + public fetchChart({ range, version }: { range: DateRange; version: number }) { + return this.http.get(`/api/v${version}/portfolio/chart`, { params: { range } }); } diff --git a/apps/client/src/locales/messages.de.xlf b/apps/client/src/locales/messages.de.xlf index 1bdce8dfe..017afc27f 100644 --- a/apps/client/src/locales/messages.de.xlf +++ b/apps/client/src/locales/messages.de.xlf @@ -642,7 +642,7 @@ Aktuelle Marktstimmung apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html - 11 + 12 @@ -1058,7 +1058,7 @@ Bitte gib den Betrag deines Notfallfonds ein: apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts - 48 + 52 @@ -1262,7 +1262,7 @@ Bitte gebe deinen Gutscheincode ein: apps/client/src/app/pages/account/account-page.component.ts - 230 + 248 @@ -1270,7 +1270,7 @@ Gutscheincode konnte nicht eingelöst werden apps/client/src/app/pages/account/account-page.component.ts - 240 + 258 @@ -1278,7 +1278,7 @@ Gutscheincode wurde eingelöst apps/client/src/app/pages/account/account-page.component.ts - 252 + 270 @@ -1286,7 +1286,7 @@ Neu laden apps/client/src/app/pages/account/account-page.component.ts - 253 + 271 @@ -1294,7 +1294,7 @@ Möchtest du diese Anmeldemethode wirklich löschen? apps/client/src/app/pages/account/account-page.component.ts - 299 + 317 @@ -1382,7 +1382,7 @@ Locale apps/client/src/app/pages/account/account-page.html - 134 + 135 @@ -1390,7 +1390,7 @@ Datums- und Zahlenformat apps/client/src/app/pages/account/account-page.html - 136 + 137 @@ -1398,7 +1398,7 @@ Ansicht apps/client/src/app/pages/account/account-page.html - 159 + 160 @@ -1406,7 +1406,7 @@ Einloggen mit Fingerabdruck apps/client/src/app/pages/account/account-page.html - 180 + 181 @@ -1414,7 +1414,7 @@ Benutzer ID apps/client/src/app/pages/account/account-page.html - 191 + 208 @@ -1422,7 +1422,7 @@ Zugangsberechtigung apps/client/src/app/pages/account/account-page.html - 200 + 217 @@ -2641,6 +2641,14 @@ 4,7 + + Experimental Features + Experimentelle Funktionen + + apps/client/src/app/pages/account/account-page.html + 196 + + diff --git a/apps/client/src/locales/messages.xlf b/apps/client/src/locales/messages.xlf index 90471608c..83376a958 100644 --- a/apps/client/src/locales/messages.xlf +++ b/apps/client/src/locales/messages.xlf @@ -583,7 +583,7 @@ Current Market Mood apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html - 11 + 12 @@ -957,7 +957,7 @@ Please enter the amount of your emergency fund: apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts - 48 + 52 @@ -1137,35 +1137,35 @@ Please enter your coupon code: apps/client/src/app/pages/account/account-page.component.ts - 230 + 248 Could not redeem coupon code apps/client/src/app/pages/account/account-page.component.ts - 240 + 258 Coupon code has been redeemed apps/client/src/app/pages/account/account-page.component.ts - 252 + 270 Reload apps/client/src/app/pages/account/account-page.component.ts - 253 + 271 Do you really want to remove this sign in method? apps/client/src/app/pages/account/account-page.component.ts - 299 + 317 @@ -1243,42 +1243,42 @@ Locale apps/client/src/app/pages/account/account-page.html - 134 + 135 Date and number format apps/client/src/app/pages/account/account-page.html - 136 + 137 View Mode apps/client/src/app/pages/account/account-page.html - 159 + 160 Sign in with fingerprint apps/client/src/app/pages/account/account-page.html - 180 + 181 User ID apps/client/src/app/pages/account/account-page.html - 191 + 208 Granted Access apps/client/src/app/pages/account/account-page.html - 200 + 217 @@ -2359,6 +2359,13 @@ 6 + + Experimental Features + + apps/client/src/app/pages/account/account-page.html + 196 + + \ No newline at end of file diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index 59be475c0..dad86ed1f 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -2,7 +2,13 @@ import { Chart, TooltipPosition } from 'chart.js'; import { getBackgroundColor, getTextColor } from './helper'; -export function getTooltipOptions(currency = '', locale = '') { +export function getTooltipOptions({ + locale = '', + unit = '' +}: { + locale?: string; + unit?: string; +} = {}) { return { backgroundColor: getBackgroundColor(), bodyColor: `rgb(${getTextColor()})`, @@ -15,11 +21,11 @@ export function getTooltipOptions(currency = '', locale = '') { label += ': '; } if (context.parsed.y !== null) { - if (currency) { + if (unit) { label += `${context.parsed.y.toLocaleString(locale, { maximumFractionDigits: 2, minimumFractionDigits: 2 - })} ${currency}`; + })} ${unit}`; } else { label += context.parsed.y.toFixed(2); } diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 2fd2bbb2d..d6f6a66e6 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -2,6 +2,7 @@ import { ViewMode } from '@prisma/client'; export interface UserSettings { baseCurrency?: string; + isExperimentalFeatures?: boolean; isRestrictedView?: boolean; language?: string; locale: string; 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 a36530ef1..4d0ceb218 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -47,7 +47,6 @@ import { LineChartItem } from './interfaces/line-chart.interface'; export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() benchmarkDataItems: LineChartItem[] = []; @Input() benchmarkLabel = ''; - @Input() currency: string; @Input() historicalDataItems: LineChartItem[]; @Input() locale: string; @Input() showGradient = false; @@ -56,6 +55,7 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() showXAxis = false; @Input() showYAxis = false; @Input() symbol: string; + @Input() unit: string; @Input() yMax: number; @Input() yMaxLabel: string; @Input() yMin: number; @@ -259,7 +259,7 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy { private getTooltipPluginConfiguration() { return { - ...getTooltipOptions(this.currency, this.locale), + ...getTooltipOptions({ locale: this.locale, unit: this.unit }), mode: 'index', position: 'top', xAlign: 'center', diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index 905cfd91d..40397f402 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -349,7 +349,7 @@ export class PortfolioProportionChartComponent private getTooltipPluginConfiguration(data: ChartConfiguration['data']) { return { - ...getTooltipOptions(this.baseCurrency, this.locale), + ...getTooltipOptions({ locale: this.locale, unit: this.baseCurrency }), callbacks: { label: (context) => { const labelIndex =