diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f342e405..509009e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Added a _Coverage_ rule to the _Emergency Fund_ section on the _X-ray_ page + ### Changed - Improved the check for duplicates in the preview step of the activities import (regardless of the account) diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 48ea66dac..b665b635a 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/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 { 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 { EmergencyFundCoverage } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-coverage'; 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 { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; @@ -1135,6 +1136,22 @@ export class PortfolioService { 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 marketsAdvancedTotalInBaseCurrency = getSum( @@ -1180,12 +1197,22 @@ export class PortfolioService { this.exchangeRateDataService, this.i18nService, userSettings.language, - this.getTotalEmergencyFund({ - userSettings, - emergencyFundHoldingsValueInBaseCurrency: - this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) - }).toNumber() - ) + totalEmergencyFundInBaseCurrency + ), + // The coverage is only meaningful once an emergency fund has been + // set up, either by an amount or by the tagged holdings + ...(totalEmergencyFundInBaseCurrency > 0 + ? [ + new EmergencyFundCoverage( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + emergencyFundInBaseCurrency, + emergencyFundHoldingsValueInBaseCurrency, + cashBalanceInBaseCurrency + ) + ] + : []) ], userSettings ) diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts index 8fb11ad34..516b68e8e 100644 --- a/apps/api/src/app/user/user.service.ts +++ b/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 { 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 { EmergencyFundCoverage } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-coverage'; 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 { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; @@ -448,6 +449,14 @@ export class UserService { undefined, undefined ).getSettings(user.settings.settings), + EmergencyFundCoverage: new EmergencyFundCoverage( + undefined, + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), EmergencyFundSetup: new EmergencyFundSetup( undefined, undefined, diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts new file mode 100644 index 000000000..7f9bbba09 --- /dev/null +++ b/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 { + 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; +} diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts index 65c51b2f0..83eb3a3ac 100644 --- a/apps/api/src/services/i18n/i18n.service.ts +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -45,7 +45,10 @@ export class I18nService implements OnModuleInit { if (placeholders) { for (const [key, value] of Object.entries(placeholders)) { - translatedText = translatedText.replace(`\${${key}}`, String(value)); + translatedText = translatedText.replace( + new RegExp(`\\$\\{${key}\\}`, 'g'), + String(value) + ); } } diff --git a/apps/client/src/app/pages/i18n/i18n-page.html b/apps/client/src/app/pages/i18n/i18n-page.html index 99ef3039e..a685074ee 100644 --- a/apps/client/src/app/pages/i18n/i18n-page.html +++ b/apps/client/src/app/pages/i18n/i18n-page.html @@ -142,7 +142,23 @@ ${thresholdMin}% and ${thresholdMax}%
  • Emergency Fund
  • -
  • Set up
  • +
  • Coverage
  • +
  • + The assets tagged as emergency fund exceed your emergency fund of + ${emergencyFund} ${baseCurrency} +
  • +
  • + Your emergency fund of ${emergencyFund} + ${baseCurrency} is not covered +
  • +
  • + No emergency fund amount has been set to determine the coverage +
  • +
  • + Your emergency fund of ${emergencyFund} + ${baseCurrency} is covered +
  • +
  • Setup
  • No emergency fund has been set up
  • diff --git a/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts b/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts index 60a76d468..cf0324f23 100644 --- a/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts +++ b/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts @@ -8,6 +8,7 @@ export interface XRayRulesSettings { CurrencyClusterRiskCurrentInvestment?: RuleSettings; EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings; EconomicMarketClusterRiskEmergingMarkets?: RuleSettings; + EmergencyFundCoverage?: RuleSettings; EmergencyFundSetup?: RuleSettings; FeeRatioTotalInvestmentVolume?: RuleSettings; RegionalMarketClusterRiskAsiaPacific?: RuleSettings;