Browse Source

Feature/add coverage rule to emergency fund section on X-ray page (#7538)

* Add emergency fund coverage rule

* Update changelog
pull/7525/head
Thomas Kaul 19 hours ago
committed by GitHub
parent
commit
9b58da04d4
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      CHANGELOG.md
  2. 37
      apps/api/src/app/portfolio/portfolio.service.ts
  3. 9
      apps/api/src/app/user/user.service.ts
  4. 110
      apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts
  5. 5
      apps/api/src/services/i18n/i18n.service.ts
  6. 16
      apps/client/src/app/pages/i18n/i18n-page.html
  7. 1
      libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts

4
CHANGELOG.md

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added a _Coverage_ rule to the _Emergency Fund_ section on the _X-ray_ page
### Changed ### Changed
- Improved the check for duplicates in the preview step of the activities import (regardless of the account) - Improved the check for duplicates in the preview step of the activities import (regardless of the account)

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

@ -12,6 +12,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/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'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundCoverage } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-coverage';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
@ -1135,6 +1136,22 @@ export class PortfolioService {
withSummary: true withSummary: true
}); });
// The cash balance of the summary is split into the emergency fund and
// the remainder, both denominated in the base currency of the user
const cashBalanceInBaseCurrency = new Big(summary.cash)
.plus(summary.emergencyFund.cash)
.toNumber();
const emergencyFundInBaseCurrency = userSettings.emergencyFund ?? 0;
const emergencyFundHoldingsValueInBaseCurrency =
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings });
const totalEmergencyFundInBaseCurrency = this.getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings
}).toNumber();
const hasOpenHoldings = Object.keys(holdings).length > 0; const hasOpenHoldings = Object.keys(holdings).length > 0;
const marketsAdvancedTotalInBaseCurrency = getSum( const marketsAdvancedTotalInBaseCurrency = getSum(
@ -1180,12 +1197,22 @@ export class PortfolioService {
this.exchangeRateDataService, this.exchangeRateDataService,
this.i18nService, this.i18nService,
userSettings.language, userSettings.language,
this.getTotalEmergencyFund({ totalEmergencyFundInBaseCurrency
userSettings, ),
emergencyFundHoldingsValueInBaseCurrency: // The coverage is only meaningful once an emergency fund has been
this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) // set up, either by an amount or by the tagged holdings
}).toNumber() ...(totalEmergencyFundInBaseCurrency > 0
? [
new EmergencyFundCoverage(
this.exchangeRateDataService,
this.i18nService,
userSettings.language,
emergencyFundInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency,
cashBalanceInBaseCurrency
) )
]
: [])
], ],
userSettings userSettings
) )

9
apps/api/src/app/user/user.service.ts

@ -11,6 +11,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/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'; import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets';
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundCoverage } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-coverage';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume';
import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power';
@ -448,6 +449,14 @@ export class UserService {
undefined, undefined,
undefined undefined
).getSettings(user.settings.settings), ).getSettings(user.settings.settings),
EmergencyFundCoverage: new EmergencyFundCoverage(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined
).getSettings(user.settings.settings),
EmergencyFundSetup: new EmergencyFundSetup( EmergencyFundSetup: new EmergencyFundSetup(
undefined, undefined,
undefined, undefined,

110
apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts

@ -0,0 +1,110 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { DEFAULT_CURRENCY, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
export class EmergencyFundCoverage extends Rule<Settings> {
public constructor(
exchangeRateDataService: ExchangeRateDataService,
private i18nService: I18nService,
languageCode: string,
private emergencyFundInBaseCurrency: number,
private emergencyFundHoldingsValueInBaseCurrency: number,
private cashBalanceInBaseCurrency: number
) {
super(exchangeRateDataService, {
languageCode,
key: EmergencyFundCoverage.name
});
}
public evaluate(ruleSettings: Settings) {
if (!this.emergencyFundInBaseCurrency) {
return {
evaluation: this.i18nService.getTranslation({
id: 'rule.emergencyFundCoverage.false.unset',
languageCode: this.getLanguageCode()
}),
value: false
};
}
const placeholders = {
baseCurrency: ruleSettings.baseCurrency,
emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString(
ruleSettings.locale
)
};
// Only the holdings tagged as emergency fund are an explicit commitment,
// the cash balance covers the remainder
if (
new Big(this.emergencyFundHoldingsValueInBaseCurrency).gt(
this.emergencyFundInBaseCurrency
)
) {
return {
evaluation: this.i18nService.getTranslation({
placeholders,
id: 'rule.emergencyFundCoverage.false.over',
languageCode: this.getLanguageCode()
}),
value: false
};
}
const coverageInBaseCurrency = new Big(
this.emergencyFundHoldingsValueInBaseCurrency
).plus(this.cashBalanceInBaseCurrency);
if (coverageInBaseCurrency.lt(this.emergencyFundInBaseCurrency)) {
return {
evaluation: this.i18nService.getTranslation({
placeholders,
id: 'rule.emergencyFundCoverage.false.under',
languageCode: this.getLanguageCode()
}),
value: false
};
}
return {
evaluation: this.i18nService.getTranslation({
placeholders,
id: 'rule.emergencyFundCoverage.true',
languageCode: this.getLanguageCode()
}),
value: true
};
}
public getConfiguration() {
return undefined;
}
public getName() {
return this.i18nService.getTranslation({
id: 'rule.emergencyFundCoverage',
languageCode: this.getLanguageCode()
});
}
public getSettings({
baseCurrency = DEFAULT_CURRENCY,
locale = DEFAULT_LOCALE,
xRayRules
}: UserSettings): Settings {
return {
baseCurrency,
locale,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
}

5
apps/api/src/services/i18n/i18n.service.ts

@ -45,7 +45,10 @@ export class I18nService implements OnModuleInit {
if (placeholders) { if (placeholders) {
for (const [key, value] of Object.entries(placeholders)) { for (const [key, value] of Object.entries(placeholders)) {
translatedText = translatedText.replace(`\${${key}}`, String(value)); translatedText = translatedText.replace(
new RegExp(`\\$\\{${key}\\}`, 'g'),
String(value)
);
} }
} }

16
apps/client/src/app/pages/i18n/i18n-page.html

@ -142,6 +142,22 @@
$&#123;thresholdMin&#125;% and $&#123;thresholdMax&#125;% $&#123;thresholdMin&#125;% and $&#123;thresholdMax&#125;%
</li> </li>
<li i18n="@@rule.emergencyFund.category">Emergency Fund</li> <li i18n="@@rule.emergencyFund.category">Emergency Fund</li>
<li i18n="@@rule.emergencyFundCoverage">Coverage</li>
<li i18n="@@rule.emergencyFundCoverage.false.over">
The assets tagged as emergency fund exceed your emergency fund of
$&#123;emergencyFund&#125; $&#123;baseCurrency&#125;
</li>
<li i18n="@@rule.emergencyFundCoverage.false.under">
Your emergency fund of $&#123;emergencyFund&#125;
$&#123;baseCurrency&#125; is not covered
</li>
<li i18n="@@rule.emergencyFundCoverage.false.unset">
No emergency fund amount has been set to determine the coverage
</li>
<li i18n="@@rule.emergencyFundCoverage.true">
Your emergency fund of $&#123;emergencyFund&#125;
$&#123;baseCurrency&#125; is covered
</li>
<li i18n="@@rule.emergencyFundSetup">Setup</li> <li i18n="@@rule.emergencyFundSetup">Setup</li>
<li i18n="@@rule.emergencyFundSetup.false"> <li i18n="@@rule.emergencyFundSetup.false">
No emergency fund has been set up No emergency fund has been set up

1
libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts

@ -8,6 +8,7 @@ export interface XRayRulesSettings {
CurrencyClusterRiskCurrentInvestment?: RuleSettings; CurrencyClusterRiskCurrentInvestment?: RuleSettings;
EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings; EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings;
EconomicMarketClusterRiskEmergingMarkets?: RuleSettings; EconomicMarketClusterRiskEmergingMarkets?: RuleSettings;
EmergencyFundCoverage?: RuleSettings;
EmergencyFundSetup?: RuleSettings; EmergencyFundSetup?: RuleSettings;
FeeRatioTotalInvestmentVolume?: RuleSettings; FeeRatioTotalInvestmentVolume?: RuleSettings;
RegionalMarketClusterRiskAsiaPacific?: RuleSettings; RegionalMarketClusterRiskAsiaPacific?: RuleSettings;

Loading…
Cancel
Save