From 291be3e6051d89e50adb70ca49337dfa90703015 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 8 Dec 2024 08:05:08 +0100 Subject: [PATCH] Feature/extend X-ray page by summary (#4107) * Add summary to X-ray page * Update changelog --- CHANGELOG.md | 6 + .../src/app/portfolio/portfolio.controller.ts | 4 +- .../src/app/portfolio/portfolio.service.ts | 164 ++++++++++-------- .../portfolio/x-ray/x-ray-page.component.html | 31 +++- .../portfolio/x-ray/x-ray-page.component.ts | 52 +++--- apps/client/src/app/services/data.service.ts | 4 +- libs/common/src/lib/interfaces/index.ts | 4 +- .../interfaces/portfolio-report.interface.ts | 5 - .../responses/portfolio-report.interface.ts | 9 + 9 files changed, 162 insertions(+), 117 deletions(-) delete mode 100644 libs/common/src/lib/interfaces/portfolio-report.interface.ts create mode 100644 libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e8e4a73c..7c20027c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 + +### Added + +- Extended the _X-ray_ page by a summary + ## 2.126.1 - 2024-12-07 ### Added diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index f2415dff3..b15d22268 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -23,7 +23,7 @@ import { PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, - PortfolioReport + PortfolioReportResponse } from '@ghostfolio/common/interfaces'; import { hasReadRestrictedAccessPermission, @@ -611,7 +611,7 @@ export class PortfolioController { @UseGuards(AuthGuard('jwt'), HasPermissionGuard) public async getReport( @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string - ): Promise { + ): Promise { const report = await this.portfolioService.getReport(impersonationId); if ( diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 15ca227e9..d16a52544 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -37,7 +37,7 @@ import { PortfolioInvestments, PortfolioPerformanceResponse, PortfolioPosition, - PortfolioReport, + PortfolioReportResponse, PortfolioSummary, Position, UserSettings @@ -1162,7 +1162,9 @@ export class PortfolioService { }; } - public async getReport(impersonationId: string): Promise { + public async getReport( + impersonationId: string + ): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); const userSettings = this.request.user.Settings.settings as UserSettings; @@ -1179,79 +1181,79 @@ export class PortfolioService { }) ).toNumber(); - return { - rules: { - accountClusterRisk: - summary.ordersCount > 0 - ? await this.rulesService.evaluate( - [ - new AccountClusterRiskCurrentInvestment( - this.exchangeRateDataService, - accounts - ), - new AccountClusterRiskSingleAccount( - this.exchangeRateDataService, - accounts - ) - ], - userSettings - ) - : undefined, - economicMarketClusterRisk: - summary.ordersCount > 0 - ? await this.rulesService.evaluate( - [ - new EconomicMarketClusterRiskDevelopedMarkets( - this.exchangeRateDataService, - marketsTotalInBaseCurrency, - markets.developedMarkets.valueInBaseCurrency - ), - new EconomicMarketClusterRiskEmergingMarkets( - this.exchangeRateDataService, - marketsTotalInBaseCurrency, - markets.emergingMarkets.valueInBaseCurrency - ) - ], - userSettings - ) - : undefined, - currencyClusterRisk: - summary.ordersCount > 0 - ? await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - Object.values(holdings) - ), - new CurrencyClusterRiskCurrentInvestment( - this.exchangeRateDataService, - Object.values(holdings) - ) - ], - userSettings - ) - : undefined, - emergencyFund: await this.rulesService.evaluate( - [ - new EmergencyFundSetup( - this.exchangeRateDataService, - userSettings.emergencyFund + const rules: PortfolioReportResponse['rules'] = { + accountClusterRisk: + summary.ordersCount > 0 + ? await this.rulesService.evaluate( + [ + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + accounts + ) + ], + userSettings ) - ], - userSettings - ), - fees: await this.rulesService.evaluate( - [ - new FeeRatioInitialInvestment( - this.exchangeRateDataService, - summary.committedFunds, - summary.fees + : undefined, + economicMarketClusterRisk: + summary.ordersCount > 0 + ? await this.rulesService.evaluate( + [ + new EconomicMarketClusterRiskDevelopedMarkets( + this.exchangeRateDataService, + marketsTotalInBaseCurrency, + markets.developedMarkets.valueInBaseCurrency + ), + new EconomicMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + marketsTotalInBaseCurrency, + markets.emergingMarkets.valueInBaseCurrency + ) + ], + userSettings ) - ], - userSettings - ) - } + : undefined, + currencyClusterRisk: + summary.ordersCount > 0 + ? await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + Object.values(holdings) + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + Object.values(holdings) + ) + ], + userSettings + ) + : undefined, + emergencyFund: await this.rulesService.evaluate( + [ + new EmergencyFundSetup( + this.exchangeRateDataService, + userSettings.emergencyFund + ) + ], + userSettings + ), + fees: await this.rulesService.evaluate( + [ + new FeeRatioInitialInvestment( + this.exchangeRateDataService, + summary.committedFunds, + summary.fees + ) + ], + userSettings + ) }; + + return { rules, statistics: this.getReportStatistics(rules) }; } public async updateTags({ @@ -1670,6 +1672,24 @@ export class PortfolioService { return { markets, marketsAdvanced }; } + private getReportStatistics( + evaluatedRules: PortfolioReportResponse['rules'] + ): PortfolioReportResponse['statistics'] { + const rulesActiveCount = Object.values(evaluatedRules) + .flat() + .filter(({ isActive }) => { + return isActive === true; + }).length; + + const rulesFulfilledCount = Object.values(evaluatedRules) + .flat() + .filter(({ value }) => { + return value === true; + }).length; + + return { rulesActiveCount, rulesFulfilledCount }; + } + private getStreaks({ investments, savingsRate diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html index cd03b49bb..7a0a3512c 100644 --- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html +++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html @@ -2,11 +2,28 @@

X-ray

-

+

Ghostfolio X-ray uses static analysis to uncover potential issues and risks in your portfolio. Adjust the rules below and set custom thresholds to align with your personal investment strategy.

+

+ @if (isLoading) { + + } @else { + {{ statistics?.rulesFulfilledCount }} + of + {{ statistics?.rulesActiveCount }} + rules are currently fulfilled. + } +

Emergency Fund @@ -20,7 +37,7 @@ hasPermissionToUpdateUserSettings && user?.settings?.isExperimentalFeatures " - [isLoading]="isLoadingPortfolioReport" + [isLoading]="isLoading" [rules]="emergencyFundRules" [settings]="user?.settings?.xRayRules" (rulesUpdated)="onRulesUpdated($event)" @@ -39,7 +56,7 @@ hasPermissionToUpdateUserSettings && user?.settings?.isExperimentalFeatures " - [isLoading]="isLoadingPortfolioReport" + [isLoading]="isLoading" [rules]="currencyClusterRiskRules" [settings]="user?.settings?.xRayRules" (rulesUpdated)="onRulesUpdated($event)" @@ -58,7 +75,7 @@ hasPermissionToUpdateUserSettings && user?.settings?.isExperimentalFeatures " - [isLoading]="isLoadingPortfolioReport" + [isLoading]="isLoading" [rules]="accountClusterRiskRules" [settings]="user?.settings?.xRayRules" (rulesUpdated)="onRulesUpdated($event)" @@ -77,7 +94,7 @@ hasPermissionToUpdateUserSettings && user?.settings?.isExperimentalFeatures " - [isLoading]="isLoadingPortfolioReport" + [isLoading]="isLoading" [rules]="economicMarketClusterRiskRules" [settings]="user?.settings?.xRayRules" (rulesUpdated)="onRulesUpdated($event)" @@ -96,7 +113,7 @@ hasPermissionToUpdateUserSettings && user?.settings?.isExperimentalFeatures " - [isLoading]="isLoadingPortfolioReport" + [isLoading]="isLoading" [rules]="feeRules" [settings]="user?.settings?.xRayRules" (rulesUpdated)="onRulesUpdated($event)" @@ -111,7 +128,7 @@ hasPermissionToUpdateUserSettings && user?.settings?.isExperimentalFeatures " - [isLoading]="isLoadingPortfolioReport" + [isLoading]="isLoading" [rules]="inactiveRules" [settings]="user?.settings?.xRayRules" (rulesUpdated)="onRulesUpdated($event)" diff --git a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts index 36f42fc3e..86bc37737 100644 --- a/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts +++ b/apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts @@ -3,8 +3,8 @@ 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 { - PortfolioReportRule, - PortfolioReport + PortfolioReportResponse, + PortfolioReportRule } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces/user.interface'; import { hasPermission, permissions } from '@ghostfolio/common/permissions'; @@ -26,7 +26,8 @@ export class XRayPageComponent { public hasImpersonationId: boolean; public hasPermissionToUpdateUserSettings: boolean; public inactiveRules: PortfolioReportRule[]; - public isLoadingPortfolioReport = false; + public isLoading = false; + public statistics: PortfolioReportResponse['statistics']; public user: User; private unsubscribeSubject = new Subject(); @@ -87,56 +88,53 @@ export class XRayPageComponent { } private initializePortfolioReport() { - this.isLoadingPortfolioReport = true; + this.isLoading = true; this.dataService .fetchPortfolioReport() .pipe(takeUntil(this.unsubscribeSubject)) - .subscribe((portfolioReport) => { - this.inactiveRules = this.mergeInactiveRules(portfolioReport); + .subscribe(({ rules, statistics }) => { + this.inactiveRules = this.mergeInactiveRules(rules); + this.statistics = statistics; this.accountClusterRiskRules = - portfolioReport.rules['accountClusterRisk']?.filter( - ({ isActive }) => { - return isActive; - } - ) ?? null; + rules['accountClusterRisk']?.filter(({ isActive }) => { + return isActive; + }) ?? null; this.currencyClusterRiskRules = - portfolioReport.rules['currencyClusterRisk']?.filter( - ({ isActive }) => { - return isActive; - } - ) ?? null; + rules['currencyClusterRisk']?.filter(({ isActive }) => { + return isActive; + }) ?? null; this.economicMarketClusterRiskRules = - portfolioReport.rules['economicMarketClusterRisk']?.filter( - ({ isActive }) => { - return isActive; - } - ) ?? null; + rules['economicMarketClusterRisk']?.filter(({ isActive }) => { + return isActive; + }) ?? null; this.emergencyFundRules = - portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => { + rules['emergencyFund']?.filter(({ isActive }) => { return isActive; }) ?? null; this.feeRules = - portfolioReport.rules['fees']?.filter(({ isActive }) => { + rules['fees']?.filter(({ isActive }) => { return isActive; }) ?? null; - this.isLoadingPortfolioReport = false; + this.isLoading = false; this.changeDetectorRef.markForCheck(); }); } - private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] { + private mergeInactiveRules( + rules: PortfolioReportResponse['rules'] + ): PortfolioReportRule[] { let inactiveRules: PortfolioReportRule[] = []; - for (const category in report.rules) { - const rulesArray = report.rules[category]; + for (const category in rules) { + const rulesArray = rules[category]; inactiveRules = inactiveRules.concat( rulesArray.filter(({ isActive }) => { diff --git a/apps/client/src/app/services/data.service.ts b/apps/client/src/app/services/data.service.ts index 92d030827..eef258a5c 100644 --- a/apps/client/src/app/services/data.service.ts +++ b/apps/client/src/app/services/data.service.ts @@ -37,7 +37,7 @@ import { PortfolioHoldingsResponse, PortfolioInvestments, PortfolioPerformanceResponse, - PortfolioReport, + PortfolioReportResponse, PublicPortfolioResponse, User } from '@ghostfolio/common/interfaces'; @@ -613,7 +613,7 @@ export class DataService { } public fetchPortfolioReport() { - return this.http.get('/api/v1/portfolio/report'); + return this.http.get('/api/v1/portfolio/report'); } public fetchPublicPortfolio(aAccessId: string) { diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts index 344a1f965..ed8fd4f2a 100644 --- a/libs/common/src/lib/interfaces/index.ts +++ b/libs/common/src/lib/interfaces/index.ts @@ -34,7 +34,6 @@ import type { PortfolioOverview } from './portfolio-overview.interface'; import type { PortfolioPerformance } from './portfolio-performance.interface'; import type { PortfolioPosition } from './portfolio-position.interface'; import type { PortfolioReportRule } from './portfolio-report-rule.interface'; -import type { PortfolioReport } from './portfolio-report.interface'; import type { PortfolioSummary } from './portfolio-summary.interface'; import type { Position } from './position.interface'; import type { Product } from './product'; @@ -50,6 +49,7 @@ import type { LookupResponse } from './responses/lookup-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; +import type { PortfolioReportResponse } from './responses/portfolio-report.interface'; import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; import type { QuotesResponse } from './responses/quotes-response.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface'; @@ -108,7 +108,7 @@ export { PortfolioPerformance, PortfolioPerformanceResponse, PortfolioPosition, - PortfolioReport, + PortfolioReportResponse, PortfolioReportRule, PortfolioSummary, Position, diff --git a/libs/common/src/lib/interfaces/portfolio-report.interface.ts b/libs/common/src/lib/interfaces/portfolio-report.interface.ts deleted file mode 100644 index a33a0aae6..000000000 --- a/libs/common/src/lib/interfaces/portfolio-report.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { PortfolioReportRule } from './portfolio-report-rule.interface'; - -export interface PortfolioReport { - rules: { [group: string]: PortfolioReportRule[] }; -} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts new file mode 100644 index 000000000..35ff033eb --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts @@ -0,0 +1,9 @@ +import { PortfolioReportRule } from '../portfolio-report-rule.interface'; + +export interface PortfolioReportResponse { + rules: { [group: string]: PortfolioReportRule[] }; + statistics: { + rulesActiveCount: number; + rulesFulfilledCount: number; + }; +}