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>
This commit is contained in:
parent
6d440eb777
commit
707c77f0cf
@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Changed
|
### 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 `countries-and-timezones` from version `3.4.1` to `3.7.2`
|
||||||
- Upgraded `Nx` from version `20.0.6` to `20.1.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 { prettifySymbol } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
AssetProfileIdentifier,
|
AssetProfileIdentifier,
|
||||||
Holding,
|
HoldingWithParents,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPosition,
|
PortfolioPosition,
|
||||||
User
|
User
|
||||||
@ -86,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
value: number;
|
value: number;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
public topHoldings: Holding[];
|
public topHoldings: HoldingWithParents[];
|
||||||
public topHoldingsMap: {
|
public topHoldingsMap: {
|
||||||
[name: string]: { name: string; value: number };
|
[name: string]: { name: string; value: number };
|
||||||
};
|
};
|
||||||
@ -490,6 +490,36 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
|||||||
name,
|
name,
|
||||||
allocationInPercentage:
|
allocationInPercentage:
|
||||||
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
|
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
|
valueInBaseCurrency: value
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
@ -347,6 +347,7 @@
|
|||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[pageSize]="10"
|
[pageSize]="10"
|
||||||
[topHoldings]="topHoldings"
|
[topHoldings]="topHoldings"
|
||||||
|
(holdingClicked)="onSymbolChartClicked($event)"
|
||||||
/>
|
/>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
@mixin gf-table($darkTheme: false) {
|
@mixin gf-table($darkTheme: false) {
|
||||||
--mat-table-background-color: var(--light-background);
|
--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-footer-row,
|
||||||
.mat-row {
|
.mat-row {
|
||||||
@ -21,16 +26,24 @@
|
|||||||
|
|
||||||
.mat-mdc-row {
|
.mat-mdc-row {
|
||||||
&:nth-child(even) {
|
&:nth-child(even) {
|
||||||
background-color: whitesmoke;
|
background-color: var(--mat-table-background-color-even);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: #e6e6e6 !important;
|
background-color: var(--mat-table-background-color-hover) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if $darkTheme {
|
@if $darkTheme {
|
||||||
--mat-table-background-color: var(--dark-background);
|
--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-row {
|
||||||
.mat-mdc-footer-cell {
|
.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 { FilterGroup } from './filter-group.interface';
|
||||||
import type { Filter } from './filter.interface';
|
import type { Filter } from './filter.interface';
|
||||||
import type { HistoricalDataItem } from './historical-data-item.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 { Holding } from './holding.interface';
|
||||||
import type { InfoItem } from './info-item.interface';
|
import type { InfoItem } from './info-item.interface';
|
||||||
import type { InvestmentItem } from './investment-item.interface';
|
import type { InvestmentItem } from './investment-item.interface';
|
||||||
@ -80,6 +81,7 @@ export {
|
|||||||
FilterGroup,
|
FilterGroup,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
Holding,
|
Holding,
|
||||||
|
HoldingWithParents,
|
||||||
ImportResponse,
|
ImportResponse,
|
||||||
InfoItem,
|
InfoItem,
|
||||||
InvestmentItem,
|
InvestmentItem,
|
||||||
|
@ -1,14 +1,18 @@
|
|||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table
|
<table
|
||||||
class="gf-table w-100"
|
class="gf-table holdings-table w-100"
|
||||||
mat-table
|
mat-table
|
||||||
matSort
|
multiTemplateDataRows
|
||||||
matSortActive="allocationInPercentage"
|
|
||||||
matSortDirection="desc"
|
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
|
<colgroup>
|
||||||
|
<col class="w-100" />
|
||||||
|
<col />
|
||||||
|
<col />
|
||||||
|
</colgroup>
|
||||||
|
|
||||||
<ng-container matColumnDef="name">
|
<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>
|
<ng-container i18n>Name</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-2" mat-cell>
|
<td *matCellDef="let element" class="px-2" mat-cell>
|
||||||
@ -17,12 +21,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="valueInBaseCurrency">
|
<ng-container matColumnDef="valueInBaseCurrency">
|
||||||
<th
|
<th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
|
||||||
*matHeaderCellDef
|
|
||||||
class="justify-content-end px-2"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
<ng-container i18n>Value</ng-container>
|
<ng-container i18n>Value</ng-container>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-2" mat-cell>
|
<td *matCellDef="let element" class="px-2" mat-cell>
|
||||||
@ -37,12 +36,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="allocationInPercentage" stickyEnd>
|
<ng-container matColumnDef="allocationInPercentage" stickyEnd>
|
||||||
<th
|
<th *matHeaderCellDef class="justify-content-end px-2" mat-header-cell>
|
||||||
*matHeaderCellDef
|
|
||||||
class="justify-content-end px-2"
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
<span class="d-none d-sm-block" i18n>Allocation</span>
|
<span class="d-none d-sm-block" i18n>Allocation</span>
|
||||||
<span class="d-block d-sm-none" title="Allocation">%</span>
|
<span class="d-block d-sm-none" title="Allocation">%</span>
|
||||||
</th>
|
</th>
|
||||||
@ -57,8 +51,107 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</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 *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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1,11 +1,33 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
.gf-table {
|
.holdings-table {
|
||||||
th {
|
table-layout: auto;
|
||||||
::ng-deep {
|
|
||||||
.mat-sort-header-container {
|
tr {
|
||||||
justify-content: inherit;
|
&: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 { 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 { GfValueComponent } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import {
|
||||||
|
animate,
|
||||||
|
state,
|
||||||
|
style,
|
||||||
|
transition,
|
||||||
|
trigger
|
||||||
|
} from '@angular/animations';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import {
|
import {
|
||||||
CUSTOM_ELEMENTS_SCHEMA,
|
CUSTOM_ELEMENTS_SCHEMA,
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
|
Output,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
|
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
|
||||||
import { MatSort, MatSortModule } from '@angular/material/sort';
|
|
||||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||||
import { get } from 'lodash';
|
import { DataSource } from '@prisma/client';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
@Component({
|
@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,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfSymbolModule,
|
||||||
GfValueComponent,
|
GfValueComponent,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatPaginatorModule,
|
MatPaginatorModule,
|
||||||
MatSortModule,
|
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
NgxSkeletonLoaderModule
|
NgxSkeletonLoaderModule
|
||||||
],
|
],
|
||||||
@ -41,12 +64,20 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy {
|
|||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() locale = getLocale();
|
@Input() locale = getLocale();
|
||||||
@Input() pageSize = Number.MAX_SAFE_INTEGER;
|
@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(MatPaginator) paginator: MatPaginator;
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
|
||||||
|
|
||||||
public dataSource = new MatTableDataSource<Holding>();
|
public dataSource = new MatTableDataSource<HoldingWithParents>();
|
||||||
public displayedColumns: string[] = [
|
public displayedColumns: string[] = [
|
||||||
'name',
|
'name',
|
||||||
'valueInBaseCurrency',
|
'valueInBaseCurrency',
|
||||||
@ -61,14 +92,16 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy {
|
|||||||
|
|
||||||
this.dataSource = new MatTableDataSource(this.topHoldings);
|
this.dataSource = new MatTableDataSource(this.topHoldings);
|
||||||
this.dataSource.paginator = this.paginator;
|
this.dataSource.paginator = this.paginator;
|
||||||
this.dataSource.sort = this.sort;
|
|
||||||
this.dataSource.sortingDataAccessor = get;
|
|
||||||
|
|
||||||
if (this.topHoldings) {
|
if (this.topHoldings) {
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onClickHolding(assetProfileIdentifier: AssetProfileIdentifier) {
|
||||||
|
this.holdingClicked.emit(assetProfileIdentifier);
|
||||||
|
}
|
||||||
|
|
||||||
public onShowAllHoldings() {
|
public onShowAllHoldings() {
|
||||||
this.pageSize = Number.MAX_SAFE_INTEGER;
|
this.pageSize = Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user