From 23e4d5454dbe6ed903fcb31094d27d14374ab308 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sat, 8 Jun 2024 11:17:27 +0200 Subject: [PATCH] Feature/improve allocations by etf holding (#3467) * Improve allocations by ETF holding * Update changelog --- CHANGELOG.md | 6 ++ .../src/app/portfolio/portfolio.controller.ts | 1 + .../trackinsight/trackinsight.service.ts | 4 + .../allocations/allocations-page.component.ts | 71 +++++++++-------- .../allocations/allocations-page.html | 77 ++++++++++--------- libs/common/src/lib/config.ts | 1 + .../holdings-table.component.html | 4 +- .../holdings-table.component.ts | 4 +- .../portfolio-proportion-chart.component.ts | 16 ++-- .../top-holdings/top-holdings.component.html | 29 ++++++- .../top-holdings/top-holdings.component.ts | 36 +++++++-- 11 files changed, 161 insertions(+), 88 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45a2b7c89..3cf870e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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 + +### Changed + +- Improved the allocations by ETF holding on the allocations page (experimental) + ## 2.86.0 - 2024-06-07 ### Added diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 5cdaa1641..19f48fa86 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -204,6 +204,7 @@ export class PortfolioController { : undefined, countries: hasDetails ? portfolioPosition.countries : [], currency: hasDetails ? portfolioPosition.currency : undefined, + holdings: hasDetails ? portfolioPosition.holdings : [], markets: hasDetails ? portfolioPosition.markets : undefined, marketsAdvanced: hasDetails ? portfolioPosition.marketsAdvanced diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index 284caed4f..0e12b8f02 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -163,6 +163,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface { response.holdings = []; for (const { label, weight } of holdings?.topHoldings ?? []) { + if (label?.toLowerCase() === 'other') { + continue; + } + response.holdings.push({ weight, name: label diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index f8ad447de..18c839d10 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -3,7 +3,7 @@ import { AccountDetailDialogParams } from '@ghostfolio/client/components/account import { DataService } from '@ghostfolio/client/services/data.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { UserService } from '@ghostfolio/client/services/user/user.service'; -import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { prettifySymbol } from '@ghostfolio/common/helper'; import { Holding, @@ -85,7 +85,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { value: number; }; }; - public topHoldings: Holding[] = []; + public topHoldings: Holding[]; public topHoldingsMap: { [name: string]: { name: string; value: number }; }; @@ -456,21 +456,28 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { for (const holding of position.holdings) { const { name, valueInBaseCurrency } = holding; - if (this.topHoldingsMap[name]?.value) { - this.topHoldingsMap[name].value += - valueInBaseCurrency * - (isNumber(position.valueInBaseCurrency) - ? position.valueInBaseCurrency - : position.valueInPercentage); - } else { - this.topHoldingsMap[name] = { - name, - value: + if ( + !this.hasImpersonationId && + !this.user.settings.isRestrictedView + ) { + if (this.topHoldingsMap[name]?.value) { + this.topHoldingsMap[name].value += valueInBaseCurrency * (isNumber(position.valueInBaseCurrency) - ? this.portfolioDetails.holdings[symbol].valueInBaseCurrency - : this.portfolioDetails.holdings[symbol].valueInPercentage) - }; + ? position.valueInBaseCurrency + : position.valueInPercentage); + } else { + this.topHoldingsMap[name] = { + name, + value: + valueInBaseCurrency * + (isNumber(position.valueInBaseCurrency) + ? this.portfolioDetails.holdings[symbol] + .valueInBaseCurrency + : this.portfolioDetails.holdings[symbol] + .valueInPercentage) + }; + } } } } @@ -505,11 +512,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { } } - if ( - this.positions[symbol].assetSubClass === 'ETF' && - !this.hasImpersonationId && - !this.user.settings.isRestrictedView - ) { + if (this.positions[symbol].assetSubClass === 'ETF') { this.totalValueInEtf += this.positions[symbol].value; } @@ -557,19 +560,21 @@ export class AllocationsPageComponent implements OnDestroy, OnInit { this.markets[UNKNOWN_KEY].value = this.markets[UNKNOWN_KEY].value / marketsTotal; - if (!this.hasImpersonationId && !this.user.settings.isRestrictedView) { - this.topHoldings = Object.values(this.topHoldingsMap) - .map(({ name, value }) => { - return { - name, - allocationInPercentage: - this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, - valueInBaseCurrency: value - }; - }) - .sort((a, b) => { - return b.valueInBaseCurrency - a.valueInBaseCurrency; - }); + this.topHoldings = Object.values(this.topHoldingsMap) + .map(({ name, value }) => { + return { + name, + allocationInPercentage: + this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0, + valueInBaseCurrency: value + }; + }) + .sort((a, b) => { + return b.valueInBaseCurrency - a.valueInBaseCurrency; + }); + + if (this.topHoldings.length > MAX_TOP_HOLDINGS) { + this.topHoldings = this.topHoldings.slice(0, MAX_TOP_HOLDINGS); } } diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index 3a464b2f7..bf26e1579 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -264,6 +264,30 @@
+
+ + + By Country + + + + + + +
@@ -306,57 +330,36 @@
-
+
By CountryBy ETF Holding + + + Approximation based on the Top 15 holdings per ETF + -
- @if (topHoldings?.length > 0 && user?.settings?.isExperimentalFeatures) { -
- - - By ETF Holding - - - - Approximation based on the Top 15 holdings per - ETF - - - - - - -
- }
diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index c89143d9d..cd0f207da 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -89,6 +89,7 @@ export const HEADER_KEY_TIMEZONE = 'Timezone'; export const HEADER_KEY_TOKEN = 'Authorization'; export const MAX_CHART_ITEMS = 365; +export const MAX_TOP_HOLDINGS = 50; export const PROPERTY_BENCHMARKS = 'BENCHMARKS'; export const PROPERTY_BETTER_UPTIME_MONITOR_ID = 'BETTER_UPTIME_MONITOR_ID'; diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html index 680154dc9..2aa01580d 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.html +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -169,7 +169,7 @@ }" (click)=" !ignoreAssetSubClasses.includes(row.assetSubClass) && - onOpenPositionDialog({ + onOpenHoldingDialog({ dataSource: row.dataSource, symbol: row.symbol }) @@ -193,7 +193,7 @@ @if (dataSource.data.length > pageSize && !isLoading) {
-
diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts index d1964b06f..12f071ef1 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.ts +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -102,7 +102,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy, OnInit { } } - public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset) { + public onOpenHoldingDialog({ dataSource, symbol }: UniqueAsset) { if (this.hasPermissionToOpenDetails) { this.router.navigate([], { queryParams: { dataSource, symbol, holdingDetailDialog: true } @@ -110,7 +110,7 @@ export class GfHoldingsTableComponent implements OnChanges, OnDestroy, OnInit { } } - public onShowAllPositions() { + public onShowAllHoldings() { this.pageSize = Number.MAX_SAFE_INTEGER; setTimeout(() => { 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 f243f888a..aa0a6cacd 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 @@ -112,7 +112,7 @@ export class GfPortfolioProportionChartComponent this.positions[symbol][this.keys[0]].toUpperCase() ].value = chartData[ this.positions[symbol][this.keys[0]].toUpperCase() - ].value.plus(this.positions[symbol].value); + ].value.plus(this.positions[symbol].value || 0); if ( chartData[this.positions[symbol][this.keys[0]].toUpperCase()] @@ -124,20 +124,20 @@ export class GfPortfolioProportionChartComponent chartData[ this.positions[symbol][this.keys[0]].toUpperCase() ].subCategory[this.positions[symbol][this.keys[1]]].value.plus( - this.positions[symbol].value + this.positions[symbol].value || 0 ); } else { chartData[ this.positions[symbol][this.keys[0]].toUpperCase() ].subCategory[ this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY - ] = { value: new Big(this.positions[symbol].value) }; + ] = { value: new Big(this.positions[symbol].value || 0) }; } } else { chartData[this.positions[symbol][this.keys[0]].toUpperCase()] = { name: this.positions[symbol][this.keys[0]], subCategory: {}, - value: new Big(this.positions[symbol].value ?? 0) + value: new Big(this.positions[symbol].value || 0) }; if (this.positions[symbol][this.keys[1]]) { @@ -145,7 +145,7 @@ export class GfPortfolioProportionChartComponent this.positions[symbol][this.keys[0]].toUpperCase() ].subCategory = { [this.positions[symbol][this.keys[1]]]: { - value: new Big(this.positions[symbol].value) + value: new Big(this.positions[symbol].value || 0) } }; } @@ -153,7 +153,7 @@ export class GfPortfolioProportionChartComponent } else { if (chartData[UNKNOWN_KEY]) { chartData[UNKNOWN_KEY].value = chartData[UNKNOWN_KEY].value.plus( - this.positions[symbol].value + this.positions[symbol].value || 0 ); } else { chartData[UNKNOWN_KEY] = { @@ -161,7 +161,7 @@ export class GfPortfolioProportionChartComponent subCategory: this.keys[1] ? { [this.keys[1]]: { value: new Big(0) } } : undefined, - value: new Big(this.positions[symbol].value) + value: new Big(this.positions[symbol].value || 0) }; } } @@ -170,7 +170,7 @@ export class GfPortfolioProportionChartComponent Object.keys(this.positions).forEach((symbol) => { chartData[symbol] = { name: this.positions[symbol].name, - value: new Big(this.positions[symbol].value) + value: new Big(this.positions[symbol].value || 0) }; }); } diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.html b/libs/ui/src/lib/top-holdings/top-holdings.component.html index b3ce98a14..ec95bddcb 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.html +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -11,7 +11,7 @@ Name - {{ element?.name }} + {{ element?.name | titlecase }} @@ -59,3 +59,30 @@ + + + +@if (isLoading) { + +} + +@if (dataSource.data.length > pageSize && !isLoading) { +
+ +
+} + +@if (dataSource.data.length === 0 && !isLoading) { +
+ No data available +
+} diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts index aa2b85e2e..6f7695687 100644 --- a/libs/ui/src/lib/top-holdings/top-holdings.component.ts +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -2,6 +2,7 @@ import { getLocale } from '@ghostfolio/common/helper'; import { Holding } from '@ghostfolio/common/interfaces'; import { GfValueComponent } from '@ghostfolio/ui/value'; +import { CommonModule } from '@angular/common'; import { CUSTOM_ELEMENTS_SCHEMA, ChangeDetectionStrategy, @@ -13,14 +14,24 @@ import { ViewChild } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; import { MatSort, MatSortModule } from '@angular/material/sort'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { get } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { Subject } from 'rxjs'; @Component({ changeDetection: ChangeDetectionStrategy.OnPush, - imports: [GfValueComponent, MatButtonModule, MatSortModule, MatTableModule], + imports: [ + CommonModule, + GfValueComponent, + MatButtonModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + NgxSkeletonLoaderModule + ], schemas: [CUSTOM_ELEMENTS_SCHEMA], selector: 'gf-top-holdings', standalone: true, @@ -30,8 +41,10 @@ import { Subject } from 'rxjs'; export class GfTopHoldingsComponent implements OnChanges, OnDestroy, OnInit { @Input() baseCurrency: string; @Input() locale = getLocale(); + @Input() pageSize = Number.MAX_SAFE_INTEGER; @Input() topHoldings: Holding[]; + @ViewChild(MatPaginator) paginator: MatPaginator; @ViewChild(MatSort) sort: MatSort; public dataSource: MatTableDataSource = new MatTableDataSource(); @@ -40,6 +53,7 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy, OnInit { 'valueInBaseCurrency', 'allocationInPercentage' ]; + public isLoading = true; private unsubscribeSubject = new Subject(); @@ -48,14 +62,26 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy, OnInit { public ngOnInit() {} public ngOnChanges() { - if (this.topHoldings) { - this.dataSource = new MatTableDataSource(this.topHoldings); + this.isLoading = true; + + this.dataSource = new MatTableDataSource(this.topHoldings); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; + this.dataSource.sortingDataAccessor = get; - this.dataSource.sort = this.sort; - this.dataSource.sortingDataAccessor = get; + if (this.topHoldings) { + this.isLoading = false; } } + public onShowAllHoldings() { + this.pageSize = Number.MAX_SAFE_INTEGER; + + setTimeout(() => { + this.dataSource.paginator = this.paginator; + }); + } + public ngOnDestroy() { this.unsubscribeSubject.next(); this.unsubscribeSubject.complete();