Feature/extend allocations by ETF holding with parent ETFs (#4044)

* Extend allocations by ETF holding with parent ETFs

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
pull/4063/head
Jory Hogeveen 1 month ago committed by GitHub
parent 6d440eb777
commit 707c77f0cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Extended the allocations by ETF holding on the allocations page by the parent ETFs (experimental)
- Upgraded `countries-and-timezones` from version `3.4.1` to `3.7.2`
- Upgraded `Nx` from version `20.0.6` to `20.1.2`

@ -7,7 +7,7 @@ import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Holding,
HoldingWithParents,
PortfolioDetails,
PortfolioPosition,
User
@ -86,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public topHoldings: Holding[];
public topHoldings: HoldingWithParents[];
public topHoldingsMap: {
[name: string]: { name: string; value: number };
};
@ -490,6 +490,36 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
name,
allocationInPercentage:
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => {
if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find(
(parentHolding) => {
return parentHolding.name === name;
}
);
return currentParentHolding
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.name,
position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency:
currentParentHolding.valueInBaseCurrency
}
: null;
}
return null;
})
.filter((item) => {
return item !== null;
})
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
}),
valueInBaseCurrency: value
};
})

@ -347,6 +347,7 @@
[locale]="user?.settings?.locale"
[pageSize]="10"
[topHoldings]="topHoldings"
(holdingClicked)="onSymbolChartClicked($event)"
/>
</mat-card-content>
</mat-card>

@ -1,5 +1,10 @@
@mixin gf-table($darkTheme: false) {
--mat-table-background-color: var(--light-background);
--mat-table-background-color-even: rgba(var(--palette-foreground-base), 0.02);
--mat-table-background-color-hover: rgba(
var(--palette-foreground-base),
0.04
);
.mat-footer-row,
.mat-row {
@ -21,16 +26,24 @@
.mat-mdc-row {
&:nth-child(even) {
background-color: whitesmoke;
background-color: var(--mat-table-background-color-even);
}
&:hover {
background-color: #e6e6e6 !important;
background-color: var(--mat-table-background-color-hover) !important;
}
}
@if $darkTheme {
--mat-table-background-color: var(--dark-background);
--mat-table-background-color-even: rgba(
var(--palette-foreground-base-dark),
0.02
);
--mat-table-background-color-hover: rgba(
var(--palette-foreground-base-dark),
0.04
);
.mat-mdc-footer-row {
.mat-mdc-footer-cell {
@ -40,15 +53,5 @@
);
}
}
.mat-mdc-row {
&:nth-child(even) {
background-color: #222222;
}
&:hover {
background-color: #303030 !important;
}
}
}
}

@ -0,0 +1,5 @@
import { Holding } from './holding.interface';
export interface HoldingWithParents extends Holding {
parents?: Holding[];
}

@ -19,6 +19,7 @@ import type { Export } from './export.interface';
import type { FilterGroup } from './filter-group.interface';
import type { Filter } from './filter.interface';
import type { HistoricalDataItem } from './historical-data-item.interface';
import type { HoldingWithParents } from './holding-with-parents.interface';
import type { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface';
@ -80,6 +81,7 @@ export {
FilterGroup,
HistoricalDataItem,
Holding,
HoldingWithParents,
ImportResponse,
InfoItem,
InvestmentItem,

@ -1,14 +1,18 @@
<div class="overflow-x-auto">
<table
class="gf-table w-100"
class="gf-table holdings-table w-100"
mat-table
matSort
matSortActive="allocationInPercentage"
matSortDirection="desc"
multiTemplateDataRows
[dataSource]="dataSource"
>
<colgroup>
<col class="w-100" />
<col />
<col />
</colgroup>
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-2" mat-header-cell>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
@ -17,12 +21,7 @@
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="justify-content-end px-2"
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
<ng-container i18n>Value</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
@ -37,12 +36,7 @@
</ng-container>
<ng-container matColumnDef="allocationInPercentage" stickyEnd>
<th
*matHeaderCellDef
class="justify-content-end px-2"
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="justify-content-end px-2" mat-header-cell>
<span class="d-none d-sm-block" i18n>Allocation</span>
<span class="d-block d-sm-none" title="Allocation">%</span>
</th>
@ -57,8 +51,107 @@
</td>
</ng-container>
<ng-container matColumnDef="expandedDetail">
<td
*matCellDef="let element"
class="p-0"
mat-cell
[attr.colspan]="displayedColumns.length"
>
<div [@detailExpand]="element.expand ? 'expanded' : 'collapsed'">
<div class="holding-parents-table">
<table
class="gf-table w-100"
mat-table
[dataSource]="element.parents"
>
<colgroup>
<col class="w-100" />
<col />
<col />
</colgroup>
<ng-container matColumnDef="name">
<td *matCellDef="let parentHolding" class="px-2" mat-cell>
<div
class="align-items-center d-flex line-height-1 text-nowrap"
>
<div>{{ parentHolding?.name }}</div>
</div>
<div>
<small class="text-muted">{{
parentHolding?.symbol | gfSymbol
}}</small>
</div>
</td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<ng-container i18n>Name</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<td *matCellDef="let parentHolding" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="parentHolding?.valueInBaseCurrency"
/>
</div>
</td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<ng-container i18n>Value</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="allocationInPercentage" stickyEnd>
<td *matCellDef="let parentHolding" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isPercent]="true"
[locale]="locale"
[value]="parentHolding?.allocationInPercentage"
/>
</div>
</td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<span class="d-none d-sm-block" i18n>Allocation</span>
<span class="d-block d-sm-none">%</span>
</td>
</ng-container>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{ 'cursor-pointer': row.position }"
(click)="onClickHolding(row.position)"
></tr>
<tr
*matFooterRowDef="displayedColumns"
class="hidden"
mat-footer-row
></tr>
</table>
</div>
</div>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matRowDef="let element; columns: displayedColumns"
mat-row
[ngClass]="{
'cursor-pointer': element.parents?.length > 0,
expanded: element.expand ?? false
}"
(click)="
element.expand ? (element.expand = false) : (element.expand = true)
"
></tr>
<tr
*matRowDef="let row; columns: ['expandedDetail']"
class="holding-detail"
mat-row
[ngClass]="{ 'd-none': !row.parents?.length }"
></tr>
</table>
</div>

@ -1,11 +1,33 @@
:host {
display: block;
.gf-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
.holdings-table {
table-layout: auto;
tr {
&:not(.expanded) + tr.holding-detail td {
border-bottom: 0;
}
&.expanded {
> td {
font-weight: bold;
}
}
&.holding-detail {
height: 0;
}
.holding-parents-table {
--table-padding: 0.5em;
tr {
height: auto;
td {
padding: var(--table-padding);
}
}
}
}

@ -1,33 +1,56 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { getLocale } from '@ghostfolio/common/helper';
import { Holding } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HoldingWithParents,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
animate,
state,
style,
transition,
trigger
} from '@angular/animations';
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
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 { DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
@Component({
animations: [
trigger('detailExpand', [
state('collapsed,void', style({ height: '0px', minHeight: '0' })),
state('expanded', style({ height: '*' })),
transition(
'expanded <=> collapsed',
animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')
)
])
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfSymbolModule,
GfValueComponent,
MatButtonModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule
],
@ -41,12 +64,20 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy {
@Input() baseCurrency: string;
@Input() locale = getLocale();
@Input() pageSize = Number.MAX_SAFE_INTEGER;
@Input() topHoldings: Holding[];
@Input() topHoldings: HoldingWithParents[];
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & {
dataSource?: DataSource;
name: string;
value: number;
};
} = {};
@Output() holdingClicked = new EventEmitter<AssetProfileIdentifier>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Holding>();
public dataSource = new MatTableDataSource<HoldingWithParents>();
public displayedColumns: string[] = [
'name',
'valueInBaseCurrency',
@ -61,14 +92,16 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy {
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 onClickHolding(assetProfileIdentifier: AssetProfileIdentifier) {
this.holdingClicked.emit(assetProfileIdentifier);
}
public onShowAllHoldings() {
this.pageSize = Number.MAX_SAFE_INTEGER;

Loading…
Cancel
Save