Feature/extend X-ray page by summary (#4107)

* Add summary to X-ray page

* Update changelog
This commit is contained in:
Thomas Kaul 2024-12-08 08:05:08 +01:00 committed by GitHub
parent d6357487ea
commit 291be3e605
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 162 additions and 117 deletions

View File

@ -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/), 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). 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 ## 2.126.1 - 2024-12-07
### Added ### Added

View File

@ -23,7 +23,7 @@ import {
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport PortfolioReportResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
hasReadRestrictedAccessPermission, hasReadRestrictedAccessPermission,
@ -611,7 +611,7 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getReport( public async getReport(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string
): Promise<PortfolioReport> { ): Promise<PortfolioReportResponse> {
const report = await this.portfolioService.getReport(impersonationId); const report = await this.portfolioService.getReport(impersonationId);
if ( if (

View File

@ -37,7 +37,7 @@ import {
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReportResponse,
PortfolioSummary, PortfolioSummary,
Position, Position,
UserSettings UserSettings
@ -1162,7 +1162,9 @@ export class PortfolioService {
}; };
} }
public async getReport(impersonationId: string): Promise<PortfolioReport> { public async getReport(
impersonationId: string
): Promise<PortfolioReportResponse> {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = this.request.user.Settings.settings as UserSettings; const userSettings = this.request.user.Settings.settings as UserSettings;
@ -1179,79 +1181,79 @@ export class PortfolioService {
}) })
).toNumber(); ).toNumber();
return { const rules: PortfolioReportResponse['rules'] = {
rules: { accountClusterRisk:
accountClusterRisk: summary.ordersCount > 0
summary.ordersCount > 0 ? await this.rulesService.evaluate(
? await this.rulesService.evaluate( [
[ new AccountClusterRiskCurrentInvestment(
new AccountClusterRiskCurrentInvestment( this.exchangeRateDataService,
this.exchangeRateDataService, accounts
accounts ),
), new AccountClusterRiskSingleAccount(
new AccountClusterRiskSingleAccount( this.exchangeRateDataService,
this.exchangeRateDataService, accounts
accounts )
) ],
], userSettings
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
) )
], : undefined,
userSettings economicMarketClusterRisk:
), summary.ordersCount > 0
fees: await this.rulesService.evaluate( ? await this.rulesService.evaluate(
[ [
new FeeRatioInitialInvestment( new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService, this.exchangeRateDataService,
summary.committedFunds, marketsTotalInBaseCurrency,
summary.fees markets.developedMarkets.valueInBaseCurrency
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency
)
],
userSettings
) )
], : undefined,
userSettings 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({ public async updateTags({
@ -1670,6 +1672,24 @@ export class PortfolioService {
return { markets, marketsAdvanced }; 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({ private getStreaks({
investments, investments,
savingsRate savingsRate

View File

@ -2,11 +2,28 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2> <h2 class="d-none d-sm-block h3 mb-3 text-center">X-ray</h2>
<p class="mb-4" i18n> <p i18n>
Ghostfolio X-ray uses static analysis to uncover potential issues and Ghostfolio X-ray uses static analysis to uncover potential issues and
risks in your portfolio. Adjust the rules below and set custom risks in your portfolio. Adjust the rules below and set custom
thresholds to align with your personal investment strategy. thresholds to align with your personal investment strategy.
</p> </p>
<p class="mb-4">
@if (isLoading) {
<ngx-skeleton-loader
animation="pulse"
class="w-100"
[theme]="{
height: '1rem',
width: '100%'
}"
/>
} @else {
{{ statistics?.rulesFulfilledCount }}
<ng-container i18n>of</ng-container>
{{ statistics?.rulesActiveCount }}
<ng-container i18n>rules are currently fulfilled.</ng-container>
}
</p>
<div class="mb-4"> <div class="mb-4">
<h4 class="align-items-center d-flex m-0"> <h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span> <span i18n>Emergency Fund</span>
@ -20,7 +37,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="emergencyFundRules" [rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -39,7 +56,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="currencyClusterRiskRules" [rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -58,7 +75,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="accountClusterRiskRules" [rules]="accountClusterRiskRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -77,7 +94,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules" [rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -96,7 +113,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="feeRules" [rules]="feeRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"
@ -111,7 +128,7 @@
hasPermissionToUpdateUserSettings && hasPermissionToUpdateUserSettings &&
user?.settings?.isExperimentalFeatures user?.settings?.isExperimentalFeatures
" "
[isLoading]="isLoadingPortfolioReport" [isLoading]="isLoading"
[rules]="inactiveRules" [rules]="inactiveRules"
[settings]="user?.settings?.xRayRules" [settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)" (rulesUpdated)="onRulesUpdated($event)"

View File

@ -3,8 +3,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
PortfolioReportRule, PortfolioReportResponse,
PortfolioReport PortfolioReportRule
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { User } from '@ghostfolio/common/interfaces/user.interface'; import { User } from '@ghostfolio/common/interfaces/user.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -26,7 +26,8 @@ export class XRayPageComponent {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[]; public inactiveRules: PortfolioReportRule[];
public isLoadingPortfolioReport = false; public isLoading = false;
public statistics: PortfolioReportResponse['statistics'];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -87,56 +88,53 @@ export class XRayPageComponent {
} }
private initializePortfolioReport() { private initializePortfolioReport() {
this.isLoadingPortfolioReport = true; this.isLoading = true;
this.dataService this.dataService
.fetchPortfolioReport() .fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioReport) => { .subscribe(({ rules, statistics }) => {
this.inactiveRules = this.mergeInactiveRules(portfolioReport); this.inactiveRules = this.mergeInactiveRules(rules);
this.statistics = statistics;
this.accountClusterRiskRules = this.accountClusterRiskRules =
portfolioReport.rules['accountClusterRisk']?.filter( rules['accountClusterRisk']?.filter(({ isActive }) => {
({ isActive }) => { return isActive;
return isActive; }) ?? null;
}
) ?? null;
this.currencyClusterRiskRules = this.currencyClusterRiskRules =
portfolioReport.rules['currencyClusterRisk']?.filter( rules['currencyClusterRisk']?.filter(({ isActive }) => {
({ isActive }) => { return isActive;
return isActive; }) ?? null;
}
) ?? null;
this.economicMarketClusterRiskRules = this.economicMarketClusterRiskRules =
portfolioReport.rules['economicMarketClusterRisk']?.filter( rules['economicMarketClusterRisk']?.filter(({ isActive }) => {
({ isActive }) => { return isActive;
return isActive; }) ?? null;
}
) ?? null;
this.emergencyFundRules = this.emergencyFundRules =
portfolioReport.rules['emergencyFund']?.filter(({ isActive }) => { rules['emergencyFund']?.filter(({ isActive }) => {
return isActive; return isActive;
}) ?? null; }) ?? null;
this.feeRules = this.feeRules =
portfolioReport.rules['fees']?.filter(({ isActive }) => { rules['fees']?.filter(({ isActive }) => {
return isActive; return isActive;
}) ?? null; }) ?? null;
this.isLoadingPortfolioReport = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
private mergeInactiveRules(report: PortfolioReport): PortfolioReportRule[] { private mergeInactiveRules(
rules: PortfolioReportResponse['rules']
): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = []; let inactiveRules: PortfolioReportRule[] = [];
for (const category in report.rules) { for (const category in rules) {
const rulesArray = report.rules[category]; const rulesArray = rules[category];
inactiveRules = inactiveRules.concat( inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => { rulesArray.filter(({ isActive }) => {

View File

@ -37,7 +37,7 @@ import {
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReportResponse,
PublicPortfolioResponse, PublicPortfolioResponse,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -613,7 +613,7 @@ export class DataService {
} }
public fetchPortfolioReport() { public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/v1/portfolio/report'); return this.http.get<PortfolioReportResponse>('/api/v1/portfolio/report');
} }
public fetchPublicPortfolio(aAccessId: string) { public fetchPublicPortfolio(aAccessId: string) {

View File

@ -34,7 +34,6 @@ import type { PortfolioOverview } from './portfolio-overview.interface';
import type { PortfolioPerformance } from './portfolio-performance.interface'; import type { PortfolioPerformance } from './portfolio-performance.interface';
import type { PortfolioPosition } from './portfolio-position.interface'; import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.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 { PortfolioSummary } from './portfolio-summary.interface';
import type { Position } from './position.interface'; import type { Position } from './position.interface';
import type { Product } from './product'; 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 { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-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 { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { QuotesResponse } from './responses/quotes-response.interface'; import type { QuotesResponse } from './responses/quotes-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface';
@ -108,7 +108,7 @@ export {
PortfolioPerformance, PortfolioPerformance,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReportResponse,
PortfolioReportRule, PortfolioReportRule,
PortfolioSummary, PortfolioSummary,
Position, Position,

View File

@ -1,5 +0,0 @@
import { PortfolioReportRule } from './portfolio-report-rule.interface';
export interface PortfolioReport {
rules: { [group: string]: PortfolioReportRule[] };
}

View File

@ -0,0 +1,9 @@
import { PortfolioReportRule } from '../portfolio-report-rule.interface';
export interface PortfolioReportResponse {
rules: { [group: string]: PortfolioReportRule[] };
statistics: {
rulesActiveCount: number;
rulesFulfilledCount: number;
};
}