diff --git a/CHANGELOG.md b/CHANGELOG.md index 1803c2c8d..4f702c0c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts index 9e4b6a6c6..3323e775d 100644 --- a/apps/api/src/app/admin/admin.service.ts +++ b/apps/api/src/app/admin/admin.service.ts @@ -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, diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts index a1ddeb482..9b8668158 100644 --- a/apps/api/src/app/import/import.service.ts +++ b/apps/api/src/app/import/import.service.ts @@ -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 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts index 51ad40c31..d458be708 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -20,6 +20,7 @@ export const symbolProfileDummyData = { assetSubClass: undefined, countries: [], createdAt: undefined, + holdings: [], id: undefined, sectors: [], updatedAt: undefined diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index b7c7bd0ae..f088fa7c7 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -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', diff --git a/apps/api/src/services/data-gathering/data-gathering.service.ts b/apps/api/src/services/data-gathering/data-gathering.service.ts index 31a0040a5..a80d68d6b 100644 --- a/apps/api/src/services/data-gathering/data-gathering.service.ts +++ b/apps/api/src/services/data-gathering/data-gathering.service.ts @@ -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, diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts index 5d79ea90a..aa0b3c597 100644 --- a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts @@ -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; diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts index ddc3d79f8..284caed4f 100644 --- a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -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( holdings?.sectors ?? {} )) { diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts index 01901d4f3..ae3ecafcd 100644 --- a/apps/api/src/services/symbol-profile/symbol-profile.service.ts +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -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: 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 { diff --git a/apps/client/src/app/components/accounts-table/accounts-table.component.scss b/apps/client/src/app/components/accounts-table/accounts-table.component.scss index e934483db..990b8b294 100644 --- a/apps/client/src/app/components/accounts-table/accounts-table.component.scss +++ b/apps/client/src/app/components/accounts-table/accounts-table.component.scss @@ -1,7 +1,7 @@ :host { display: block; - .mat-mdc-table { + .gf-table { th { ::ng-deep { .mat-sort-header-container { diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts index 092e918cf..f8ad447de 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts @@ -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) { diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html index 04bf96f39..3a464b2f7 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.html +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.html @@ -330,5 +330,33 @@ + @if (topHoldings?.length > 0 && user?.settings?.isExperimentalFeatures) { +
+ + + By ETF Holding + + + + Approximation based on the Top 15 holdings per + ETF + + + + + + +
+ } diff --git a/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts b/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts index 0f500ab35..263c34e49 100644 --- a/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts +++ b/apps/client/src/app/pages/portfolio/allocations/allocations-page.module.ts @@ -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 diff --git a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts index dfdd223c2..e7fc4c5b5 100644 --- a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts +++ b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts @@ -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; diff --git a/libs/common/src/lib/interfaces/holding.interface.ts b/libs/common/src/lib/interfaces/holding.interface.ts new file mode 100644 index 000000000..5a0df62f6 --- /dev/null +++ b/libs/common/src/lib/interfaces/holding.interface.ts @@ -0,0 +1,5 @@ +export interface Holding { + allocationInPercentage?: number; + name: string; + valueInBaseCurrency: number; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 1f6bf99eb..bf3f6fd19 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -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, diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts index a47a1ebc7..47b3a821d 100644 --- a/libs/common/src/lib/interfaces/portfolio-position.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -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; diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.scss b/libs/ui/src/lib/holdings-table/holdings-table.component.scss index 8e321bcf1..89d62d59d 100644 --- a/libs/ui/src/lib/holdings-table/holdings-table.component.scss +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.scss @@ -4,7 +4,7 @@ .holdings { overflow-x: auto; - .mat-mdc-table { + .gf-table { th { ::ng-deep { .mat-sort-header-container { diff --git a/libs/ui/src/lib/top-holdings/index.ts b/libs/ui/src/lib/top-holdings/index.ts new file mode 100644 index 000000000..a5bc960a9 --- /dev/null +++ b/libs/ui/src/lib/top-holdings/index.ts @@ -0,0 +1 @@ +export * from './top-holdings.component'; diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.html b/libs/ui/src/lib/top-holdings/top-holdings.component.html new file mode 100644 index 000000000..b3ce98a14 --- /dev/null +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + +
+ Name + + {{ element?.name }} + + Value + +
+ +
+
+ Allocation + % + +
+ +
+
diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.scss b/libs/ui/src/lib/top-holdings/top-holdings.component.scss new file mode 100644 index 000000000..990b8b294 --- /dev/null +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + + .gf-table { + th { + ::ng-deep { + .mat-sort-header-container { + justify-content: inherit; + } + } + } + } +} diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts new file mode 100644 index 000000000..aa2b85e2e --- /dev/null +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -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 = new MatTableDataSource(); + public displayedColumns: string[] = [ + 'name', + 'valueInBaseCurrency', + 'allocationInPercentage' + ]; + + private unsubscribeSubject = new Subject(); + + 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(); + } +} diff --git a/prisma/migrations/20240607122659_added_holdings_to_symbol_profile/migration.sql b/prisma/migrations/20240607122659_added_holdings_to_symbol_profile/migration.sql new file mode 100644 index 000000000..8280fe589 --- /dev/null +++ b/prisma/migrations/20240607122659_added_holdings_to_symbol_profile/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "SymbolProfile" ADD COLUMN "holdings" JSONB DEFAULT '[]'; + +-- AlterTable +ALTER TABLE "SymbolProfileOverrides" ADD COLUMN "holdings" JSONB DEFAULT '[]'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6293cfc18..9385cb323 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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?