Feature/improve allocations by etf holding (#3467)
* Improve allocations by ETF holding * Update changelog
This commit is contained in:
parent
fdcf5fd396
commit
23e4d5454d
@ -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.sort = this.sort;
|
||||
this.dataSource.sortingDataAccessor = get;
|
||||
this.dataSource = new MatTableDataSource(this.topHoldings);
|
||||
this.dataSource.paginator = this.paginator;
|
||||
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…
x
Reference in New Issue
Block a user