Feature/set up asset class cluster risk x-ray rule (#4128)

* Set up Asset Class Cluster Risk rule (equity)

* Set up Asset Class Cluster Risk rule (fixed income)

* Update changelog
pull/4117/head^2
Thomas Kaul 5 days ago committed by GitHub
parent c3bd433ac9
commit 6ee6121317
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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

@ -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(

@ -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),

@ -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<Settings> {
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;
}

@ -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<Settings> {
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;
}

@ -28,7 +28,12 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
let maxItem = holdingsGroupedByCurrency[0];
let totalValue = 0;
holdingsGroupedByCurrency.forEach((groupItem) => {
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<Setti
if (groupItem.investment > 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 {

@ -72,6 +72,30 @@
(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 &&
user?.settings?.isExperimentalFeatures
"
[isLoading]="isLoading"
[rules]="assetClassClusterRiskRules"
[settings]="user?.settings?.xRayRules"
(rulesUpdated)="onRulesUpdated($event)"
/>
</div>
<div
class="mb-4"
[ngClass]="{

@ -19,6 +19,7 @@ import { Subject, takeUntil } from 'rxjs';
})
export class XRayPageComponent {
public accountClusterRiskRules: PortfolioReportRule[];
public assetClassClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[];
public economicMarketClusterRiskRules: PortfolioReportRule[];
public emergencyFundRules: PortfolioReportRule[];
@ -102,6 +103,11 @@ export class XRayPageComponent {
return isActive;
}) ?? null;
this.assetClassClusterRiskRules =
rules['assetClassClusterRisk']?.filter(({ isActive }) => {
return isActive;
}) ?? null;
this.currencyClusterRiskRules =
rules['currencyClusterRisk']?.filter(({ isActive }) => {
return isActive;

@ -1,6 +1,8 @@
export interface XRayRulesSettings {
AccountClusterRiskCurrentInvestment?: RuleSettings;
AccountClusterRiskSingleAccount?: RuleSettings;
AssetClassClusterRiskEquity?: RuleSettings;
AssetClassClusterRiskFixedIncome?: RuleSettings;
CurrencyClusterRiskBaseCurrencyCurrentInvestment?: RuleSettings;
CurrencyClusterRiskCurrentInvestment?: RuleSettings;
EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings;

Loading…
Cancel
Save