From f426d0a1869239a9fd384e52d6a93a6fb363b4db Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Tue, 4 Aug 2026 20:16:42 +0200 Subject: [PATCH] Add emergency fund coverage rule --- .../src/app/portfolio/portfolio.service.ts | 33 ++++- apps/api/src/app/user/user.service.ts | 9 ++ .../emergency-fund/emergency-fund-coverage.ts | 121 ++++++++++++++++++ apps/client/src/app/pages/i18n/i18n-page.html | 16 +++ .../x-ray-rules-settings.interface.ts | 1 + 5 files changed, 176 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 48ea66dac..e4b0ca60a 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'; @@ -1137,6 +1138,17 @@ export class PortfolioService { const hasOpenHoldings = Object.keys(holdings).length > 0; + const emergencyFundHoldingsValueInBaseCurrency = + this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }); + + const emergencyFundInBaseCurrency = userSettings.emergencyFund ?? 0; + + const { balanceInBaseCurrency: cashBalanceInBaseCurrency } = + await this.accountService.getCashDetails({ + userId, + currency: this.getUserCurrency() + }); + const marketsAdvancedTotalInBaseCurrency = getSum( Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { return new Big(valueInBaseCurrency); @@ -1181,11 +1193,24 @@ export class PortfolioService { this.i18nService, userSettings.language, this.getTotalEmergencyFund({ - userSettings, - emergencyFundHoldingsValueInBaseCurrency: - this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) + emergencyFundHoldingsValueInBaseCurrency, + userSettings }).toNumber() - ) + ), + // The coverage is only meaningful once a target has been set, + // otherwise the set up rule already reports the missing setup + ...(emergencyFundInBaseCurrency > 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..8505ef991 --- /dev/null +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts @@ -0,0 +1,121 @@ +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'; + +export class EmergencyFundCoverage extends Rule { + private cashBalanceInBaseCurrency: number; + private emergencyFundHoldingsValueInBaseCurrency: number; + private emergencyFundInBaseCurrency: number; + + public constructor( + exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + emergencyFundInBaseCurrency: number, + emergencyFundHoldingsValueInBaseCurrency: number, + cashBalanceInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: EmergencyFundCoverage.name + }); + + this.cashBalanceInBaseCurrency = cashBalanceInBaseCurrency; + this.emergencyFundHoldingsValueInBaseCurrency = + emergencyFundHoldingsValueInBaseCurrency; + this.emergencyFundInBaseCurrency = emergencyFundInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + // Only the holdings tagged as emergency fund are an explicit commitment, + // the cash balance covers the remainder + if ( + this.emergencyFundHoldingsValueInBaseCurrency > + this.emergencyFundInBaseCurrency + ) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.emergencyFundCoverage.false.over', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( + ruleSettings.locale + ), + emergencyFundHoldingsValue: + this.emergencyFundHoldingsValueInBaseCurrency.toLocaleString( + ruleSettings.locale + ) + } + }), + value: false + }; + } + + const coverageInBaseCurrency = + this.emergencyFundHoldingsValueInBaseCurrency + + this.cashBalanceInBaseCurrency; + + if (coverageInBaseCurrency < this.emergencyFundInBaseCurrency) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.emergencyFundCoverage.false.under', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + coverage: coverageInBaseCurrency.toLocaleString( + ruleSettings.locale + ), + emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( + ruleSettings.locale + ) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.emergencyFundCoverage.true', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( + ruleSettings.locale + ) + } + }), + 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/client/src/app/pages/i18n/i18n-page.html b/apps/client/src/app/pages/i18n/i18n-page.html index 99ef3039e..15c58fbf2 100644 --- a/apps/client/src/app/pages/i18n/i18n-page.html +++ b/apps/client/src/app/pages/i18n/i18n-page.html @@ -142,6 +142,22 @@ ${thresholdMin}% and ${thresholdMax}%
  • Emergency Fund
  • +
  • Coverage
  • +
  • + The assets tagged as emergency fund + (${emergencyFundHoldingsValue} ${baseCurrency}) + exceed your emergency fund of ${emergencyFund} + ${baseCurrency} +
  • +
  • + Your emergency fund of ${emergencyFund} + ${baseCurrency} is not covered (${coverage} + ${baseCurrency} available) +
  • +
  • + Your emergency fund of ${emergencyFund} + ${baseCurrency} is covered +
  • Set up
  • 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;