Feature/allocations by etf holding (#3464)

* Setup allocations by ETF holding

* Update changelog
pull/3465/head
Thomas Kaul 7 months ago committed by GitHub
parent 3fb7e746df
commit 8a9ae9bb33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Introduced the allocations by ETF holding on the allocations page (experimental)
### Changed
- Upgraded `prettier` from version `3.2.5` to `3.3.1`

@ -335,6 +335,7 @@ export class AdminService {
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
@ -355,6 +356,7 @@ export class AdminService {
countries,
currency,
dataSource,
holdings,
scraperConfiguration,
sectors,
symbol,

@ -13,10 +13,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
} from '@ghostfolio/common/config';
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
@ -295,6 +292,7 @@ export class ImportService {
figi,
figiComposite,
figiShareClass,
holdings,
id,
isin,
name,
@ -367,6 +365,7 @@ export class ImportService {
figi,
figiComposite,
figiShareClass,
holdings,
id,
isin,
name,
@ -538,6 +537,7 @@ export class ImportService {
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
holdings: undefined,
id: undefined,
sectors: undefined,
updatedAt: undefined

@ -20,6 +20,7 @@ export const symbolProfileDummyData = {
assetSubClass: undefined,
countries: [],
createdAt: undefined,
holdings: [],
id: undefined,
sectors: [],
updatedAt: undefined

@ -499,6 +499,7 @@ export class PortfolioService {
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
holdings: assetProfile.holdings,
investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed',
name: assetProfile.name,
@ -1465,6 +1466,7 @@ export class PortfolioService {
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
marketState: 'open',

@ -181,6 +181,7 @@ export class DataGatheringService {
figi,
figiComposite,
figiShareClass,
holdings,
isin,
name,
sectors,
@ -198,6 +199,7 @@ export class DataGatheringService {
figi,
figiComposite,
figiShareClass,
holdings,
isin,
name,
sectors,
@ -212,6 +214,7 @@ export class DataGatheringService {
figi,
figiComposite,
figiShareClass,
holdings,
isin,
name,
sectors,

@ -36,6 +36,7 @@ export class DataEnhancerService {
if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.holdings as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) {
return true;

@ -1,5 +1,6 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { Holding } from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
@ -155,11 +156,26 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
}
}
if (
!response.holdings ||
(response.holdings as unknown as Holding[]).length === 0
) {
response.holdings = [];
for (const { label, weight } of holdings?.topHoldings ?? []) {
response.holdings.push({
weight,
name: label
});
}
}
if (
!response.sectors ||
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(
holdings?.sectors ?? {}
)) {

@ -2,6 +2,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
EnhancedSymbolProfile,
Holding,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
@ -97,6 +98,7 @@ export class SymbolProfileService {
countries,
currency,
dataSource,
holdings,
name,
scraperConfiguration,
sectors,
@ -112,6 +114,7 @@ export class SymbolProfileService {
comment,
countries,
currency,
holdings,
name,
scraperConfiguration,
sectors,
@ -140,6 +143,7 @@ export class SymbolProfileService {
symbolProfile?.countries as unknown as Prisma.JsonArray
),
dateOfFirstActivity: <Date>undefined,
holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
@ -167,6 +171,14 @@ export class SymbolProfileService {
);
}
if (
(item.SymbolProfileOverrides.holdings as unknown as Holding[])
?.length > 0
) {
item.holdings = item.SymbolProfileOverrides
.holdings as unknown as Holding[];
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
if (
@ -203,6 +215,19 @@ export class SymbolProfileService {
});
}
private getHoldings(symbolProfile: SymbolProfile): Holding[] {
return ((symbolProfile?.holdings as Prisma.JsonArray) ?? []).map(
(holding) => {
const { name, weight } = holding as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
valueInBaseCurrency: weight as number
};
}
);
}
private getScraperConfiguration(
symbolProfile: SymbolProfile
): ScraperConfiguration {

@ -1,7 +1,7 @@
:host {
display: block;
.mat-mdc-table {
.gf-table {
th {
::ng-deep {
.mat-sort-header-container {

@ -6,6 +6,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
Holding,
PortfolioDetails,
PortfolioPosition,
UniqueAsset,
@ -84,6 +85,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public topHoldings: Holding[] = [];
public topHoldingsMap: {
[name: string]: { name: string; value: number };
};
public totalValueInEtf = 0;
public UNKNOWN_KEY = UNKNOWN_KEY;
public user: User;
public worldMapChartFormat: string;
@ -288,6 +294,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0
}
};
this.topHoldingsMap = {};
}
private initializeAllocationsData() {
@ -337,7 +344,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
};
if (position.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries and sectors except for liquidity
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
if (position.countries.length > 0) {
this.markets.developedMarkets.value +=
@ -445,6 +452,29 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
: this.portfolioDetails.holdings[symbol].valueInPercentage;
}
if (position.holdings.length > 0) {
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:
valueInBaseCurrency *
(isNumber(position.valueInBaseCurrency)
? this.portfolioDetails.holdings[symbol].valueInBaseCurrency
: this.portfolioDetails.holdings[symbol].valueInPercentage)
};
}
}
}
if (position.sectors.length > 0) {
for (const sector of position.sectors) {
const { name, weight } = sector;
@ -475,6 +505,14 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
}
if (
this.positions[symbol].assetSubClass === 'ETF' &&
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView
) {
this.totalValueInEtf += this.positions[symbol].value;
}
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource,
name: position.name,
@ -518,6 +556,21 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.markets.otherMarkets.value / marketsTotal;
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;
});
}
}
private openAccountDetailDialog(aAccountId: string) {

@ -330,5 +330,33 @@
</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>

@ -1,6 +1,7 @@
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfTopHoldingsComponent } from '@ghostfolio/ui/top-holdings';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
@ -19,8 +20,9 @@ import { AllocationsPageComponent } from './allocations-page.component';
CommonModule,
GfPortfolioProportionChartComponent,
GfPremiumIndicatorComponent,
GfWorldMapChartModule,
GfTopHoldingsComponent,
GfValueComponent,
GfWorldMapChartModule,
MatCardModule,
MatDialogModule,
MatProgressBarModule

@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Country } from './country.interface';
import { DataProviderInfo } from './data-provider-info.interface';
import { Holding } from './holding.interface';
import { ScraperConfiguration } from './scraper-configuration.interface';
import { Sector } from './sector.interface';
@ -16,10 +17,11 @@ export interface EnhancedSymbolProfile {
dataProviderInfo?: DataProviderInfo;
dataSource: DataSource;
dateOfFirstActivity?: Date;
id: string;
figi?: string;
figiComposite?: string;
figiShareClass?: string;
holdings: Holding[];
id: string;
isin?: string;
name?: string;
scraperConfiguration?: ScraperConfiguration;

@ -0,0 +1,5 @@
export interface Holding {
allocationInPercentage?: number;
name: string;
valueInBaseCurrency: number;
}

@ -17,6 +17,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 { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface';
import type { LineChartItem } from './line-chart-item.interface';
@ -71,6 +72,7 @@ export {
Filter,
FilterGroup,
HistoricalDataItem,
Holding,
ImportResponse,
InfoItem,
InvestmentItem,

@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Market, MarketAdvanced, MarketState } from '../types';
import { Country } from './country.interface';
import { Holding } from './holding.interface';
import { Sector } from './sector.interface';
export interface PortfolioPosition {
@ -20,6 +21,7 @@ export interface PortfolioPosition {
grossPerformancePercent: number;
grossPerformancePercentWithCurrencyEffect: number;
grossPerformanceWithCurrencyEffect: number;
holdings: Holding[];
investment: number;
marketChange?: number;
marketChangePercent?: number;

@ -4,7 +4,7 @@
.holdings {
overflow-x: auto;
.mat-mdc-table {
.gf-table {
th {
::ng-deep {
.mat-sort-header-container {

@ -0,0 +1 @@
export * from './top-holdings.component';

@ -0,0 +1,61 @@
<table
class="gf-table w-100"
mat-table
matSort
matSortActive="allocationInPercentage"
matSortDirection="desc"
[dataSource]="dataSource"
>
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
{{ element?.name }}
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="justify-content-end px-2"
mat-header-cell
mat-sort-header
>
<ng-container i18n>Value</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="element?.valueInBaseCurrency"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="allocationInPercentage">
<th
*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-block d-sm-none" title="Allocation">%</span>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isPercent]="true"
[locale]="locale"
[value]="element?.allocationInPercentage"
/>
</div>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

@ -0,0 +1,13 @@
:host {
display: block;
.gf-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
}
}

@ -0,0 +1,63 @@
import { getLocale } from '@ghostfolio/common/helper';
import { Holding } from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { get } from 'lodash';
import { Subject } from 'rxjs';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [GfValueComponent, MatButtonModule, MatSortModule, MatTableModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-top-holdings',
standalone: true,
styleUrls: ['./top-holdings.component.scss'],
templateUrl: './top-holdings.component.html'
})
export class GfTopHoldingsComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() locale = getLocale();
@Input() topHoldings: Holding[];
@ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Holding> = new MatTableDataSource();
public displayedColumns: string[] = [
'name',
'valueInBaseCurrency',
'allocationInPercentage'
];
private unsubscribeSubject = new Subject<void>();
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.topHoldings) {
this.dataSource = new MatTableDataSource(this.topHoldings);
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
}
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "holdings" JSONB DEFAULT '[]';
-- AlterTable
ALTER TABLE "SymbolProfileOverrides" ADD COLUMN "holdings" JSONB DEFAULT '[]';

@ -164,6 +164,7 @@ model SymbolProfile {
figi String?
figiComposite String?
figiShareClass String?
holdings Json? @default("[]")
id String @id @default(uuid())
isin String?
name String?
@ -189,6 +190,7 @@ model SymbolProfileOverrides {
assetClass AssetClass?
assetSubClass AssetSubClass?
countries Json? @default("[]")
holdings Json? @default("[]")
name String?
sectors Json? @default("[]")
url String?

Loading…
Cancel
Save