diff --git a/CHANGELOG.md b/CHANGELOG.md index 44fd0c3a8..35c78e959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a new static portfolio analysis rule: _Asset Class Cluster Risk_ (Equity) +- Added a new static portfolio analysis rule: _Asset Class Cluster Risk_ (Fixed Income) + ### Changed - Extracted the market data management from the admin control panel endpoint to a dedicated endpoint diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8ead98a58..400b0c3a9 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -7,6 +7,8 @@ import { UserService } from '@ghostfolio/api/app/user/user.service'; import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; +import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity'; +import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; @@ -1198,19 +1200,17 @@ export class PortfolioService { userSettings ) : undefined, - economicMarketClusterRisk: + assetClassClusterRisk: summary.ordersCount > 0 ? await this.rulesService.evaluate( [ - new EconomicMarketClusterRiskDevelopedMarkets( + new AssetClassClusterRiskEquity( this.exchangeRateDataService, - marketsTotalInBaseCurrency, - markets.developedMarkets.valueInBaseCurrency + Object.values(holdings) ), - new EconomicMarketClusterRiskEmergingMarkets( + new AssetClassClusterRiskFixedIncome( this.exchangeRateDataService, - marketsTotalInBaseCurrency, - markets.emergingMarkets.valueInBaseCurrency + Object.values(holdings) ) ], userSettings @@ -1232,6 +1232,24 @@ export class PortfolioService { 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, emergencyFund: await this.rulesService.evaluate( [ new EmergencyFundSetup( diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 6676a00b6..bac6ed19b 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/apps/api/src/app/user/user.service.ts @@ -5,6 +5,8 @@ import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed. import { getRandomString } from '@ghostfolio/api/helper/string.helper'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; +import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity'; +import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; @@ -226,25 +228,33 @@ export class UserService { undefined, {} ).getSettings(user.Settings.settings), - EconomicMarketClusterRiskDevelopedMarkets: - new EconomicMarketClusterRiskDevelopedMarkets( - undefined, + AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity( + undefined, + undefined + ).getSettings(user.Settings.settings), + AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome( + undefined, + undefined + ).getSettings(user.Settings.settings), + CurrencyClusterRiskBaseCurrencyCurrentInvestment: + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( undefined, undefined ).getSettings(user.Settings.settings), - EconomicMarketClusterRiskEmergingMarkets: - new EconomicMarketClusterRiskEmergingMarkets( - undefined, + CurrencyClusterRiskCurrentInvestment: + new CurrencyClusterRiskCurrentInvestment( undefined, undefined ).getSettings(user.Settings.settings), - CurrencyClusterRiskBaseCurrencyCurrentInvestment: - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + EconomicMarketClusterRiskDevelopedMarkets: + new EconomicMarketClusterRiskDevelopedMarkets( + undefined, undefined, undefined ).getSettings(user.Settings.settings), - CurrencyClusterRiskCurrentInvestment: - new CurrencyClusterRiskCurrentInvestment( + EconomicMarketClusterRiskEmergingMarkets: + new EconomicMarketClusterRiskEmergingMarkets( + undefined, undefined, undefined ).getSettings(user.Settings.settings), diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts new file mode 100644 index 000000000..b67e01e61 --- /dev/null +++ b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts @@ -0,0 +1,95 @@ +import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; + +export class AssetClassClusterRiskEquity extends Rule { + private holdings: PortfolioPosition[]; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + holdings: PortfolioPosition[] + ) { + super(exchangeRateDataService, { + key: AssetClassClusterRiskEquity.name, + name: 'Equity' + }); + + this.holdings = holdings; + } + + public evaluate(ruleSettings: Settings) { + const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( + this.holdings, + 'assetClass', + ruleSettings.baseCurrency + ); + let totalValue = 0; + + const equityValueInBaseCurrency = + holdingsGroupedByAssetClass.find(({ groupKey }) => { + return groupKey === 'EQUITY'; + })?.value ?? 0; + + for (const { value } of holdingsGroupedByAssetClass) { + totalValue += value; + } + + const equityValueRatio = totalValue + ? equityValueInBaseCurrency / totalValue + : 0; + + if (equityValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) exceeds ${( + ruleSettings.thresholdMax * 100 + ).toPrecision(3)}%`, + value: false + }; + } else if (equityValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is below ${( + ruleSettings.thresholdMin * 100 + ).toPrecision(3)}%`, + value: false + }; + } + + return { + evaluation: `The equity contribution of your current investment (${(equityValueRatio * 100).toPrecision(3)}%) is within the range of ${( + ruleSettings.thresholdMin * 100 + ).toPrecision( + 3 + )}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`, + value: true + }; + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { + return { + baseCurrency, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.82, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.78 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts new file mode 100644 index 000000000..eb744a143 --- /dev/null +++ b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts @@ -0,0 +1,95 @@ +import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; + +export class AssetClassClusterRiskFixedIncome extends Rule { + private holdings: PortfolioPosition[]; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + holdings: PortfolioPosition[] + ) { + super(exchangeRateDataService, { + key: AssetClassClusterRiskFixedIncome.name, + name: 'Fixed Income' + }); + + this.holdings = holdings; + } + + public evaluate(ruleSettings: Settings) { + const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( + this.holdings, + 'assetClass', + ruleSettings.baseCurrency + ); + let totalValue = 0; + + const fixedIncomeValueInBaseCurrency = + holdingsGroupedByAssetClass.find(({ groupKey }) => { + return groupKey === 'FIXED_INCOME'; + })?.value ?? 0; + + for (const { value } of holdingsGroupedByAssetClass) { + totalValue += value; + } + + const fixedIncomeValueRatio = totalValue + ? fixedIncomeValueInBaseCurrency / totalValue + : 0; + + if (fixedIncomeValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) exceeds ${( + ruleSettings.thresholdMax * 100 + ).toPrecision(3)}%`, + value: false + }; + } else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is below ${( + ruleSettings.thresholdMin * 100 + ).toPrecision(3)}%`, + value: false + }; + } + + return { + evaluation: `The fixed income contribution of your current investment (${(fixedIncomeValueRatio * 100).toPrecision(3)}%) is within the range of ${( + ruleSettings.thresholdMin * 100 + ).toPrecision( + 3 + )}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`, + value: true + }; + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings { + return { + baseCurrency, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.22, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.18 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index 573795799..90ee29c54 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -28,7 +28,12 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { + const baseCurrencyValue = + holdingsGroupedByCurrency.find(({ groupKey }) => { + return groupKey === ruleSettings.baseCurrency; + })?.value ?? 0; + + for (const groupItem of holdingsGroupedByCurrency) { // Calculate total value totalValue += groupItem.value; @@ -36,13 +41,11 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule maxItem.investment) { maxItem = groupItem; } - }); - - const baseCurrencyItem = holdingsGroupedByCurrency.find((item) => { - return item.groupKey === ruleSettings.baseCurrency; - }); + } - const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0; + const baseCurrencyValueRatio = totalValue + ? baseCurrencyValue / totalValue + : 0; if (maxItem?.groupKey !== ruleSettings.baseCurrency) { return { 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 4af225c49..ceba5f52c 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 @@ -72,6 +72,30 @@ (rulesUpdated)="onRulesUpdated($event)" /> +
+

+ Asset Class Cluster Risks + @if (user?.subscription?.type === 'Basic') { + + } +

+ +