Browse Source

Task/restructure portfolio report response (#5454)

* Restructure portfolio report response

* Update changelog
pull/5474/head
Tobias Kugel 2 weeks ago
committed by GitHub
parent
commit
cc9346f3de
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      CHANGELOG.md
  2. 4
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 187
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 2
      apps/api/src/app/portfolio/rules.service.ts
  5. 1
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  6. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  7. 2
      apps/client/src/app/components/rule/rule.component.ts
  8. 1
      apps/client/src/app/components/rules/rules.component.html
  9. 1
      apps/client/src/app/components/rules/rules.component.ts
  10. 163
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html
  11. 76
      apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts
  12. 1
      libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts
  13. 6
      libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts

1
CHANGELOG.md

@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Restructured the response of the portfolio report endpoint (_X-ray_)
- Refactored the create or update access dialog component to standalone
- Upgraded `envalid` from version `8.0.0` to `8.1.0`
- Upgraded `prisma` from version `6.14.0` to `6.15.0`

4
apps/api/src/app/portfolio/portfolio.controller.ts

@ -655,8 +655,8 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
for (const rule in report.xRay.rules) {
report.xRay.rules[rule] = null;
for (const category of report.xRay.categories) {
category.rules = null;
}
report.xRay.statistics = {

187
apps/api/src/app/portfolio/portfolio.service.ts

@ -50,6 +50,7 @@ import {
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioReportResponse,
PortfolioReportRule,
PortfolioSummary,
UserSettings
} from '@ghostfolio/common/interfaces';
@ -1231,48 +1232,54 @@ export class PortfolioService {
})
).toNumber();
const rules: PortfolioReportResponse['xRay']['rules'] = {
accountClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
const categories: PortfolioReportResponse['xRay']['categories'] = [
{
key: 'liquidity',
name: this.i18nService.getTranslation({
id: 'rule.liquidity.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
),
new AccountClusterRiskSingleAccount(
new BuyingPower(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
accounts
summary.cash,
userSettings.language
)
],
userSettings
)
: undefined,
assetClassClusterRisk:
summary.activityCount > 0
? await this.rulesService.evaluate(
},
{
key: 'emergencyFund',
name: this.i18nService.getTranslation({
id: 'rule.emergencyFund.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
),
new AssetClassClusterRiskFixedIncome(
new EmergencyFundSetup(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
Object.values(holdings)
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
)
: undefined,
currencyClusterRisk:
},
{
key: 'currencyClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.currencyClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
@ -1291,68 +1298,98 @@ export class PortfolioService {
],
userSettings
)
: undefined,
economicMarketClusterRisk:
: undefined
},
{
key: 'assetClassClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.assetClassClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EconomicMarketClusterRiskDevelopedMarkets(
new AssetClassClusterRiskEquity(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
userSettings.language,
Object.values(holdings)
),
new EconomicMarketClusterRiskEmergingMarkets(
new AssetClassClusterRiskFixedIncome(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language
userSettings.language,
Object.values(holdings)
)
],
userSettings
)
: undefined,
emergencyFund: await this.rulesService.evaluate(
: undefined
},
{
key: 'accountClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.accountClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new EmergencyFundSetup(
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
this.getTotalEmergencyFund({
userSettings,
emergencyFundHoldingsValueInBaseCurrency:
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings })
}).toNumber()
)
],
userSettings
accounts
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
accounts
)
],
userSettings
),
liquidity: await this.rulesService.evaluate(
)
: undefined
},
{
key: 'economicMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.economicMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
new BuyingPower(
new EconomicMarketClusterRiskDevelopedMarkets(
this.exchangeRateDataService,
this.i18nService,
summary.cash,
marketsTotalInBaseCurrency,
markets.developedMarkets.valueInBaseCurrency,
userSettings.language
),
new EconomicMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
this.i18nService,
marketsTotalInBaseCurrency,
markets.emergingMarkets.valueInBaseCurrency,
userSettings.language
)
],
userSettings
),
regionalMarketClusterRisk:
)
: undefined
},
{
key: 'regionalMarketClusterRisk',
name: this.i18nService.getTranslation({
id: 'rule.regionalMarketClusterRisk.category',
languageCode: userSettings.language
}),
rules:
summary.activityCount > 0
? await this.rulesService.evaluate(
[
@ -1395,12 +1432,36 @@ export class PortfolioService {
userSettings
)
: undefined
};
},
{
key: 'fees',
name: this.i18nService.getTranslation({
id: 'rule.fees.category',
languageCode: userSettings.language
}),
rules: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
summary.committedFunds,
summary.fees
)
],
userSettings
)
}
];
return {
xRay: {
rules,
statistics: this.getReportStatistics(rules)
categories,
statistics: this.getReportStatistics(
categories.flatMap(({ rules }) => {
return rules ?? [];
})
)
}
};
}
@ -1822,7 +1883,7 @@ export class PortfolioService {
}
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['xRay']['rules']
evaluatedRules: PortfolioReportRule[]
): PortfolioReportResponse['xRay']['statistics'] {
const rulesActiveCount = Object.values(evaluatedRules)
.flat()

2
apps/api/src/app/portfolio/rules.service.ts

@ -22,7 +22,6 @@ export class RulesService {
return {
evaluation,
value,
categoryName: rule.getCategoryName(),
configuration: rule.getConfiguration(),
isActive: true,
key: rule.getKey(),
@ -30,7 +29,6 @@ export class RulesService {
};
} else {
return {
categoryName: rule.getCategoryName(),
isActive: false,
key: rule.getKey(),
name: rule.getName()

1
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -4,6 +4,7 @@ import {
} from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams {
categoryName: string;
rule: PortfolioReportRule;
settings: XRayRulesSettings['AccountClusterRiskCurrentInvestment'];
}

2
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,4 +1,4 @@
<div mat-dialog-title>{{ data.rule.categoryName }} › {{ data.rule.name }}</div>
<div mat-dialog-title>{{ data.categoryName }} › {{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
@if (

2
apps/client/src/app/components/rule/rule.component.ts

@ -37,6 +37,7 @@ import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-setti
standalone: false
})
export class RuleComponent implements OnInit {
@Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() rule: PortfolioReportRule;
@ -69,6 +70,7 @@ export class RuleComponent implements OnInit {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: {
rule,
categoryName: this.categoryName,
settings: this.settings
} as IRuleSettingsDialogParams,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

1
apps/client/src/app/components/rules/rules.component.html

@ -8,6 +8,7 @@
@if (rules !== null && rules !== undefined) {
@for (rule of rules; track rule.key) {
<gf-rule
[categoryName]="categoryName"
[hasPermissionToUpdateUserSettings]="
hasPermissionToUpdateUserSettings
"

1
apps/client/src/app/components/rules/rules.component.ts

@ -20,6 +20,7 @@ import {
standalone: false
})
export class RulesComponent {
@Input() categoryName: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() rules: PortfolioReportRule[];

163
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.html

@ -59,182 +59,29 @@
</div>
}
</div>
@for (category of categories; track category.key) {
<div
class="mb-4"
[ngClass]="{
'd-none': liquidityRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Liquidity</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="liquidityRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': emergencyFundRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Emergency Fund</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="emergencyFundRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': currencyClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Currency Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="currencyClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': assetClassClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Asset Class Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="assetClassClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': accountClusterRiskRules?.length === 0
}"
[ngClass]="{ 'd-none': category.rules?.length === 0 }"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Account Cluster Risks</span>
<span>{{ category.name }}</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[categoryName]="category.name"
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="accountClusterRiskRules"
[rules]="category.rules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': economicMarketClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Economic Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="economicMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': regionalMarketClusterRiskRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Regional Market Cluster Risks</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="regionalMarketClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{
'd-none': feeRules?.length === 0
}"
>
<h4 class="align-items-center d-flex m-0">
<span i18n>Fees</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</h4>
<gf-rules
[hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading"
[rules]="feeRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
@if (inactiveRules?.length > 0) {
<div>
<h4 class="m-0" i18n>Inactive</h4>

76
apps/client/src/app/pages/portfolio/x-ray/x-ray-page.component.ts

@ -36,18 +36,15 @@ import { Subject, takeUntil } from 'rxjs';
templateUrl: './x-ray-page.component.html'
})
export class GfXRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
public assetClassClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[];
public categories: {
key: string;
name: string;
rules: PortfolioReportRule[];
}[];
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public inactiveRules: PortfolioReportRule[];
public isLoading = false;
public liquidityRules: PortfolioReportRule[];
public regionalMarketClusterRiskRules: PortfolioReportRule[];
public statistics: PortfolioReportResponse['xRay']['statistics'];
public user: User;
@ -116,50 +113,11 @@ export class GfXRayPageComponent {
this.dataService
.fetchPortfolioReport()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ xRay: { rules, statistics } }) => {
this.inactiveRules = this.mergeInactiveRules(rules);
.subscribe(({ xRay: { categories, statistics } }) => {
this.categories = categories;
this.inactiveRules = this.mergeInactiveRules(categories);
this.statistics = statistics;
this.accountClusterRiskRules =
rules['accountClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.assetClassClusterRiskRules =
rules['assetClassClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.currencyClusterRiskRules =
rules['currencyClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.economicMarketClusterRiskRules =
rules['economicMarketClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.emergencyFundRules =
rules['emergencyFund']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.feeRules =
rules['fees']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.liquidityRules =
rules['liquidity']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.regionalMarketClusterRiskRules =
rules['regionalMarketClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
@ -167,20 +125,14 @@ export class GfXRayPageComponent {
}
private mergeInactiveRules(
rules: PortfolioReportResponse['xRay']['rules']
categories: PortfolioReportResponse['xRay']['categories']
): PortfolioReportRule[] {
let inactiveRules: PortfolioReportRule[] = [];
for (const category in rules) {
const rulesArray = rules[category] || [];
inactiveRules = inactiveRules.concat(
rulesArray.filter(({ isActive }) => {
return categories.flatMap(({ rules }) => {
return (
rules?.filter(({ isActive }) => {
return !isActive;
})
}) ?? []
);
}
return inactiveRules;
});
}
}

1
libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts

@ -1,5 +1,4 @@
export interface PortfolioReportRule {
categoryName: string;
configuration?: {
threshold?: {
max: number;

6
libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts

@ -2,7 +2,11 @@ import { PortfolioReportRule } from '../portfolio-report-rule.interface';
export interface PortfolioReportResponse {
xRay: {
rules: { [group: string]: PortfolioReportRule[] };
categories: {
key: string;
name: string;
rules: PortfolioReportRule[];
}[];
statistics: {
rulesActiveCount: number;
rulesFulfilledCount: number;

Loading…
Cancel
Save