From c34959896c353ed220da94352348edb5339168fc Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 10 Aug 2024 09:01:28 +0200 Subject: [PATCH] Feature/improve color assignment with annualized performance in treemap chart (#3657) * Improve color assignment * Update changelog --- CHANGELOG.md | 1 + .../src/app/benchmark/benchmark.controller.ts | 4 +- apps/api/src/app/order/order.controller.ts | 4 +- .../calculator/portfolio-calculator.ts | 15 ++-- .../src/app/portfolio/portfolio.controller.ts | 4 +- .../src/app/portfolio/portfolio.service.ts | 14 ++-- apps/api/src/helper/portfolio.helper.ts | 71 ------------------- .../home-holdings/home-holdings.html | 1 + .../investment-chart.component.ts | 3 +- .../portfolio/analysis/analysis-page.html | 3 - libs/common/src/lib/calculation-helper.ts | 71 +++++++++++++++++++ .../treemap-chart/treemap-chart.component.ts | 18 +++-- .../trend-indicator.component.html | 4 +- .../trend-indicator.component.ts | 2 +- 14 files changed, 112 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34846d69..74600ab0 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 ### Changed +- Improved the color assignment in the chart of the holdings tab on the home page (experimental) - Enabled Catalan (`ca`) as an option in the user settings (experimental) - Enabled Polish (`pl`) as an option in the user settings (experimental) - Improved the language localization for Portuguese (`pt`) diff --git a/apps/api/src/app/benchmark/benchmark.controller.ts b/apps/api/src/app/benchmark/benchmark.controller.ts index ea9ba802..66c268b9 100644 --- a/apps/api/src/app/benchmark/benchmark.controller.ts +++ b/apps/api/src/app/benchmark/benchmark.controller.ts @@ -1,8 +1,8 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import type { AssetProfileIdentifier, BenchmarkMarketDataDetails, @@ -113,7 +113,7 @@ export class BenchmarkController { @Param('symbol') symbol: string, @Query('range') dateRange: DateRange = 'max' ): Promise { - const { endDate, startDate } = getInterval( + const { endDate, startDate } = getIntervalFromDateRange( dateRange, new Date(startDateString) ); diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts index af8a1e29..7a9cf3d1 100644 --- a/apps/api/src/app/order/order.controller.ts +++ b/apps/api/src/app/order/order.controller.ts @@ -1,12 +1,12 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; -import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DATA_GATHERING_QUEUE_PRIORITY_HIGH, HEADER_KEY_IMPERSONATION @@ -110,7 +110,7 @@ export class OrderController { let startDate: Date; if (dateRange) { - ({ endDate, startDate } = getInterval(dateRange)); + ({ endDate, startDate } = getIntervalFromDateRange(dateRange)); } const filters = this.apiService.buildFiltersFromQueryParams({ diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index 8c9342e3..697496a1 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -4,13 +4,11 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { - getFactor, - getInterval -} from '@ghostfolio/api/helper/portfolio.helper'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { MAX_CHART_ITEMS } from '@ghostfolio/common/config'; import { DATE_FORMAT, @@ -133,7 +131,7 @@ export abstract class PortfolioCalculator { this.useCache = useCache; this.userId = userId; - const { endDate, startDate } = getInterval(dateRange); + const { endDate, startDate } = getIntervalFromDateRange(dateRange); this.endDate = endDate; this.startDate = startDate; @@ -303,7 +301,7 @@ export abstract class PortfolioCalculator { const feeInBaseCurrency = item.fee.mul( exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ lastTransactionPoint.date - ] + ] ?? 1 ); const marketPriceInBaseCurrency = ( @@ -433,7 +431,10 @@ export abstract class PortfolioCalculator { dateRange?: DateRange; withDataDecimation?: boolean; }): Promise { - const { endDate, startDate } = getInterval(dateRange, this.getStartDate()); + const { endDate, startDate } = getIntervalFromDateRange( + dateRange, + this.getStartDate() + ); const daysInMarket = differenceInDays(endDate, startDate) + 1; const step = withDataDecimation diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 3c7993c6..7ce0b084 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -7,7 +7,6 @@ import { hasNotDefinedValuesInObject, nullifyValuesInObject } from '@ghostfolio/api/helper/object.helper'; -import { getInterval } from '@ghostfolio/api/helper/portfolio.helper'; import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; @@ -15,6 +14,7 @@ import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, HEADER_KEY_IMPERSONATION @@ -247,7 +247,7 @@ export class PortfolioController { await this.impersonationService.validateImpersonationId(impersonationId); const userCurrency = this.request.user.Settings.settings.baseCurrency; - const { endDate, startDate } = getInterval(dateRange); + const { endDate, startDate } = getIntervalFromDateRange(dateRange); const { activities } = await this.orderService.getOrders({ endDate, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 67529cc6..52543c16 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -4,10 +4,7 @@ import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { UserService } from '@ghostfolio/api/app/user/user.service'; -import { - getFactor, - getInterval -} from '@ghostfolio/api/helper/portfolio.helper'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; @@ -18,7 +15,10 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; -import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; import { DEFAULT_CURRENCY, EMERGENCY_FUND_TAG_ID, @@ -912,7 +912,7 @@ export class PortfolioService { const userId = await this.getUserId(impersonationId, this.request.user.id); const user = await this.userService.user({ id: userId }); - const { endDate } = getInterval(dateRange); + const { endDate } = getIntervalFromDateRange(dateRange); const { activities } = await this.orderService.getOrders({ endDate, @@ -1089,7 +1089,7 @@ export class PortfolioService { ) ); - const { endDate } = getInterval(dateRange); + const { endDate } = getIntervalFromDateRange(dateRange); const { activities } = await this.orderService.getOrders({ endDate, diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts index 21b11139..6ebe48d3 100644 --- a/apps/api/src/helper/portfolio.helper.ts +++ b/apps/api/src/helper/portfolio.helper.ts @@ -1,17 +1,4 @@ -import { resetHours } from '@ghostfolio/common/helper'; -import { DateRange } from '@ghostfolio/common/types'; - import { Type as ActivityType } from '@prisma/client'; -import { - endOfDay, - max, - subDays, - startOfMonth, - startOfWeek, - startOfYear, - subYears, - endOfYear -} from 'date-fns'; export function getFactor(activityType: ActivityType) { let factor: number; @@ -30,61 +17,3 @@ export function getFactor(activityType: ActivityType) { return factor; } - -export function getInterval( - aDateRange: DateRange, - portfolioStart = new Date(0) -) { - let endDate = endOfDay(new Date(Date.now())); - let startDate = portfolioStart; - - switch (aDateRange) { - case '1d': - startDate = max([ - startDate, - subDays(resetHours(new Date(Date.now())), 1) - ]); - break; - case 'mtd': - startDate = max([ - startDate, - subDays(startOfMonth(resetHours(new Date(Date.now()))), 1) - ]); - break; - case 'wtd': - startDate = max([ - startDate, - subDays( - startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }), - 1 - ) - ]); - break; - case 'ytd': - startDate = max([ - startDate, - subDays(startOfYear(resetHours(new Date(Date.now()))), 1) - ]); - break; - case '1y': - startDate = max([ - startDate, - subYears(resetHours(new Date(Date.now())), 1) - ]); - break; - case '5y': - startDate = max([ - startDate, - subYears(resetHours(new Date(Date.now())), 5) - ]); - break; - case 'max': - break; - default: - // '2024', '2023', '2022', etc. - endDate = endOfYear(new Date(aDateRange)); - startDate = max([startDate, new Date(aDateRange)]); - } - - return { endDate, startDate }; -} diff --git a/apps/client/src/app/components/home-holdings/home-holdings.html b/apps/client/src/app/components/home-holdings/home-holdings.html index b3ebe941..bd9e57bb 100644 --- a/apps/client/src/app/components/home-holdings/home-holdings.html +++ b/apps/client/src/app/components/home-holdings/home-holdings.html @@ -38,6 +38,7 @@ 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 429eaae6..15a4a6f9 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 @@ -14,7 +14,7 @@ import { } from '@ghostfolio/common/helper'; import { LineChartItem } from '@ghostfolio/common/interfaces'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; -import { ColorScheme, DateRange, GroupBy } from '@ghostfolio/common/types'; +import { ColorScheme, GroupBy } from '@ghostfolio/common/types'; import { ChangeDetectionStrategy, @@ -58,7 +58,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy { @Input() isInPercent = false; @Input() isLoading = false; @Input() locale = getLocale(); - @Input() range: DateRange = 'max'; @Input() savingsRate = 0; @ViewChild('chartCanvas') chartCanvas; 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 970a89f7..73817bdc 100644 --- a/apps/client/src/app/pages/portfolio/analysis/analysis-page.html +++ b/apps/client/src/app/pages/portfolio/analysis/analysis-page.html @@ -282,7 +282,6 @@ [isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isLoading]="isLoadingInvestmentChart" [locale]="user?.settings?.locale" - [range]="user?.settings?.dateRange" /> @@ -340,7 +339,6 @@ [isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isLoading]="isLoadingInvestmentTimelineChart" [locale]="user?.settings?.locale" - [range]="user?.settings?.dateRange" [savingsRate]="savingsRate" /> @@ -377,7 +375,6 @@ [isInPercent]="hasImpersonationId || user.settings.isRestrictedView" [isLoading]="isLoadingDividendTimelineChart" [locale]="user?.settings?.locale" - [range]="user?.settings?.dateRange" /> diff --git a/libs/common/src/lib/calculation-helper.ts b/libs/common/src/lib/calculation-helper.ts index 7d2ec909..82528257 100644 --- a/libs/common/src/lib/calculation-helper.ts +++ b/libs/common/src/lib/calculation-helper.ts @@ -1,6 +1,19 @@ import { Big } from 'big.js'; +import { + endOfDay, + endOfYear, + max, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subYears +} from 'date-fns'; import { isNumber } from 'lodash'; +import { resetHours } from './helper'; +import { DateRange } from './types'; + export function getAnnualizedPerformancePercent({ daysInMarket, netPerformancePercentage @@ -18,3 +31,61 @@ export function getAnnualizedPerformancePercent({ return new Big(0); } + +export function getIntervalFromDateRange( + aDateRange: DateRange, + portfolioStart = new Date(0) +) { + let endDate = endOfDay(new Date(Date.now())); + let startDate = portfolioStart; + + switch (aDateRange) { + case '1d': + startDate = max([ + startDate, + subDays(resetHours(new Date(Date.now())), 1) + ]); + break; + case 'mtd': + startDate = max([ + startDate, + subDays(startOfMonth(resetHours(new Date(Date.now()))), 1) + ]); + break; + case 'wtd': + startDate = max([ + startDate, + subDays( + startOfWeek(resetHours(new Date(Date.now())), { weekStartsOn: 1 }), + 1 + ) + ]); + break; + case 'ytd': + startDate = max([ + startDate, + subDays(startOfYear(resetHours(new Date(Date.now()))), 1) + ]); + break; + case '1y': + startDate = max([ + startDate, + subYears(resetHours(new Date(Date.now())), 1) + ]); + break; + case '5y': + startDate = max([ + startDate, + subYears(resetHours(new Date(Date.now())), 5) + ]); + break; + case 'max': + break; + default: + // '2024', '2023', '2022', etc. + endDate = endOfYear(new Date(aDateRange)); + startDate = max([startDate, new Date(aDateRange)]); + } + + return { endDate, startDate }; +} diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index f65894da..8915707f 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -1,8 +1,12 @@ -import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; import { AssetProfileIdentifier, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { DateRange } from '@ghostfolio/common/types'; import { CommonModule } from '@angular/common'; import { @@ -23,7 +27,7 @@ import { ChartConfiguration } from 'chart.js'; import { LinearScale } from 'chart.js'; import { Chart } from 'chart.js'; import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; -import { differenceInDays } from 'date-fns'; +import { differenceInDays, max } from 'date-fns'; import { orderBy } from 'lodash'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @@ -41,6 +45,7 @@ export class GfTreemapChartComponent implements AfterViewInit, OnChanges, OnDestroy { @Input() cursor: string; + @Input() dateRange: DateRange; @Input() holdings: PortfolioPosition[]; @Output() treemapChartClicked = new EventEmitter(); @@ -75,6 +80,8 @@ export class GfTreemapChartComponent private initialize() { this.isLoading = true; + const { endDate, startDate } = getIntervalFromDateRange(this.dateRange); + const data: ChartConfiguration['data'] = { datasets: [ { @@ -82,8 +89,11 @@ export class GfTreemapChartComponent const annualizedNetPerformancePercentWithCurrencyEffect = getAnnualizedPerformancePercent({ daysInMarket: differenceInDays( - new Date(), - ctx.raw._data.dateOfFirstActivity + endDate, + max([ + ctx.raw._data.dateOfFirstActivity ?? new Date(0), + startDate + ]) ), netPerformancePercentage: new Big( ctx.raw._data.netPerformancePercentWithCurrencyEffect diff --git a/libs/ui/src/lib/trend-indicator/trend-indicator.component.html b/libs/ui/src/lib/trend-indicator/trend-indicator.component.html index 761b3f23..b9f65a2e 100644 --- a/libs/ui/src/lib/trend-indicator/trend-indicator.component.html +++ b/libs/ui/src/lib/trend-indicator/trend-indicator.component.html @@ -8,9 +8,9 @@ }" /> } @else { - @if (marketState === 'closed' && range === '1d') { + @if (marketState === 'closed' && dateRange === '1d') { - } @else if (marketState === 'delayed' && range === '1d') { + } @else if (marketState === 'delayed' && dateRange === '1d') { } @else if (value <= -0.0005) {