Feature/improve allocations by etf holding (#3467)

* Improve allocations by ETF holding

* Update changelog
pull/3468/head
Thomas Kaul 4 months ago committed by GitHub
parent fdcf5fd396
commit 23e4d5454d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

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

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

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

@ -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);
}
}

@ -264,6 +264,30 @@
</div>
</div>
<div class="row">
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Country</span
><gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
/>
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries"
/>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
@ -306,57 +330,36 @@
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<div
class="col-md-12"
[ngClass]="{
'd-none': !user?.settings?.isExperimentalFeatures
}"
>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Country</span
><gf-premium-indicator
><span i18n>By ETF Holding</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
/>
</mat-card-title>
<mat-card-subtitle>
<ng-container i18n
>Approximation based on the Top 15 holdings per ETF</ng-container
>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
<gf-top-holdings
[baseCurrency]="user?.settings?.baseCurrency"
[colorScheme]="user?.settings?.colorScheme"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['name']"
[locale]="user?.settings?.locale"
[maxItems]="10"
[positions]="countries"
[pageSize]="10"
[topHoldings]="topHoldings"
/>
</mat-card-content>
</mat-card>
</div>
@if (topHoldings?.length > 0 && user?.settings?.isExperimentalFeatures) {
<div class="col-md-12">
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By ETF Holding</span>
<gf-premium-indicator
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1"
/>
</mat-card-title>
<mat-card-subtitle>
<ng-container i18n
>Approximation based on the Top 15 holdings per
ETF</ng-container
>
</mat-card-subtitle>
</mat-card-header>
<mat-card-content>
<gf-top-holdings
[baseCurrency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[topHoldings]="topHoldings"
/>
</mat-card-content>
</mat-card>
</div>
}
</div>
</div>

@ -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';

@ -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) {
<div class="my-3 text-center">
<button mat-stroked-button (click)="onShowAllPositions()">
<button mat-stroked-button (click)="onShowAllHoldings()">
<ng-container i18n>Show all</ng-container>
</button>
</div>

@ -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(() => {

@ -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)
};
});
}

@ -11,7 +11,7 @@
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
{{ element?.name }}
{{ element?.name | titlecase }}
</td>
</ng-container>
@ -59,3 +59,30 @@
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>
<mat-paginator class="d-none" [pageSize]="pageSize" />
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
/>
}
@if (dataSource.data.length > pageSize && !isLoading) {
<div class="my-3 text-center">
<button mat-stroked-button (click)="onShowAllHoldings()">
<ng-container i18n>Show all</ng-container>
</button>
</div>
}
@if (dataSource.data.length === 0 && !isLoading) {
<div class="p-3 text-center text-muted">
<small i18n>No data available</small>
</div>
}

@ -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<Holding> = new MatTableDataSource();
@ -40,6 +53,7 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy, OnInit {
'valueInBaseCurrency',
'allocationInPercentage'
];
public isLoading = true;
private unsubscribeSubject = new Subject<void>();
@ -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();

Loading…
Cancel
Save