From 2687229ca4f2b5439a3e8372fc27f5ef92137c8b Mon Sep 17 00:00:00 2001 From: Max Dietrich Date: Tue, 28 Jul 2026 18:30:07 +0200 Subject: [PATCH] Add annualized performance option to FIRE calculator Lets users use their portfolio's annualized performance as the annual interest rate in the FIRE calculator instead of manually entering one. --- CHANGELOG.md | 4 + .../portfolio/fire/fire-page.component.ts | 22 ++++ .../app/pages/portfolio/fire/fire-page.html | 7 ++ .../src/lib/dtos/update-user-setting.dto.ts | 4 + .../lib/interfaces/user-settings.interface.ts | 1 + .../fire-calculator.component.html | 9 ++ .../fire-calculator.component.scss | 4 + .../fire-calculator.component.ts | 101 +++++++++++++++++- 8 files changed, 149 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5bf323e8..0953efcfa 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 an option to use the annualized performance as the annual interest rate in the _FIRE_ calculator + ### Changed - Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0` diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index b7ef8b302..6f065aaf0 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -50,6 +50,7 @@ export class GfFirePageComponent implements OnInit { () => this.deviceDetectorService.deviceInfo().deviceType ); + protected annualizedPerformancePercent: number | undefined; protected fireWealth: FireWealth; protected hasImpersonationId: boolean; protected hasPermissionToUpdateUserSettings: boolean; @@ -99,6 +100,9 @@ export class GfFirePageComponent implements OnInit { .fetchPortfolioDetails() .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(({ summary }) => { + this.annualizedPerformancePercent = + summary?.annualizedPerformancePercentWithCurrencyEffect; + this.fireWealth = { today: { valueInBaseCurrency: summary?.fireWealth @@ -243,6 +247,24 @@ export class GfFirePageComponent implements OnInit { }); } + protected onUseAnnualizedPerformanceRateChange( + useAnnualizedPerformanceRate: boolean + ) { + this.dataService + .putUserSetting({ useAnnualizedPerformanceRate }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.userService + .get(true) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((user) => { + this.user = user; + + this.changeDetectorRef.markForCheck(); + }); + }); + } + private calculateWithdrawalRates() { if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) { this.withdrawalRatePerYear = new Big( diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 7315f10cb..1d5d8ff5e 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -11,6 +11,7 @@ diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts index d46982e70..daf1430a8 100644 --- a/libs/common/src/lib/dtos/update-user-setting.dto.ts +++ b/libs/common/src/lib/dtos/update-user-setting.dto.ts @@ -120,6 +120,10 @@ export class UpdateUserSettingDto { @IsOptional() savingsRate?: number; + @IsBoolean() + @IsOptional() + useAnnualizedPerformanceRate?: boolean; + @IsIn(['DEFAULT', 'ZEN'] as ViewMode[]) @IsOptional() viewMode?: ViewMode; diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts index 65325a42f..f4377f693 100644 --- a/libs/common/src/lib/interfaces/user-settings.interface.ts +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -31,6 +31,7 @@ export interface UserSettings { retirementDate?: string; safeWithdrawalRate?: number; savingsRate?: number; + useAnnualizedPerformanceRate?: boolean; viewMode?: ViewMode; xRayRules?: XRayRulesSettings; } diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html index b2162a764..80a8b7870 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html @@ -28,6 +28,15 @@
%
+
+ Use annualized performance +
+ Retirement Date
{{ retirementDateLabel }}
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss b/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss index 5662415da..8e3a84454 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss @@ -1,6 +1,10 @@ :host { display: block; + .use-annualized-performance-rate { + margin-top: -0.75rem; + } + .chart-container { aspect-ratio: 16 / 9; diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index cfa2ee5cd..db69e9266 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -28,6 +28,7 @@ import { ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatDatepicker, MatDatepickerModule @@ -72,6 +73,7 @@ import { FireCalculatorService } from './fire-calculator.service'; FormsModule, IonIcon, MatButtonModule, + MatCheckboxModule, MatDatepickerModule, MatFormFieldModule, MatInputModule, @@ -85,6 +87,7 @@ import { FireCalculatorService } from './fire-calculator.service'; }) export class GfFireCalculatorComponent implements OnChanges, OnDestroy { @Input() annualInterestRate = 0; + @Input() annualizedPerformancePercent: number; @Input() colorScheme: ColorScheme; @Input() currency: string; @Input() deviceType: string; @@ -94,13 +97,15 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { @Input() projectedTotalAmount = 0; @Input() retirementDate: Date; @Input() savingsRate = 0; + @Input() useAnnualizedPerformanceRate = false; public calculatorForm = this.formBuilder.group({ annualInterestRate: new FormControl(null), paymentPerPeriod: new FormControl(null), principalInvestmentAmount: new FormControl(null), projectedTotalAmount: new FormControl(null), - retirementDate: new FormControl(null) + retirementDate: new FormControl(null), + useAnnualizedPerformanceRate: new FormControl(false) }); public chart: Chart<'bar'>; @@ -116,6 +121,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { protected readonly projectedTotalAmountChanged = output(); protected readonly retirementDateChanged = output(); protected readonly savingsRateChanged = output(); + protected readonly useAnnualizedPerformanceRateChanged = output(); private readonly CONTRIBUTION_PERIOD = 12; @@ -206,6 +212,27 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { this.retirementDateChanged.emit(retirementDate); } }); + this.calculatorForm + .get('useAnnualizedPerformanceRate') + ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((useAnnualizedPerformanceRate) => { + if (useAnnualizedPerformanceRate !== null) { + this.applyAnnualizedPerformanceRate(useAnnualizedPerformanceRate); + } + }); + this.calculatorForm + .get('useAnnualizedPerformanceRate') + ?.valueChanges.pipe( + debounceTime(500), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((useAnnualizedPerformanceRate) => { + if (useAnnualizedPerformanceRate !== null) { + this.useAnnualizedPerformanceRateChanged.emit( + useAnnualizedPerformanceRate + ); + } + }); } protected get retirementDateLabel(): string { @@ -222,14 +249,24 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { } public ngOnChanges() { + const useAnnualizedPerformanceRate = + this.useAnnualizedPerformanceRate ?? false; + + const annualInterestRate = + useAnnualizedPerformanceRate && + this.isAnnualizedPerformancePercentAvailable() + ? this.getAnnualizedInterestRatePercent() + : this.annualInterestRate; + if (isNumber(this.fireWealth) && this.fireWealth >= 0) { this.calculatorForm.setValue( { - annualInterestRate: this.annualInterestRate, + annualInterestRate, paymentPerPeriod: this.savingsRate, principalInvestmentAmount: this.fireWealth, projectedTotalAmount: this.projectedTotalAmount, - retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE + retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE, + useAnnualizedPerformanceRate }, { emitEvent: false @@ -271,6 +308,16 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { this.calculatorForm .get('projectedTotalAmount') ?.enable({ emitEvent: false }); + + if (this.isAnnualizedPerformancePercentAvailable()) { + this.calculatorForm + .get('useAnnualizedPerformanceRate') + ?.enable({ emitEvent: false }); + } else { + this.calculatorForm + .get('useAnnualizedPerformanceRate') + ?.disable({ emitEvent: false }); + } } else { this.calculatorForm .get('annualInterestRate') @@ -281,6 +328,15 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { this.calculatorForm .get('projectedTotalAmount') ?.disable({ emitEvent: false }); + this.calculatorForm + .get('useAnnualizedPerformanceRate') + ?.disable({ emitEvent: false }); + } + + if (useAnnualizedPerformanceRate) { + this.calculatorForm + .get('annualInterestRate') + ?.disable({ emitEvent: false }); } this.calculatorForm.get('retirementDate')?.disable({ emitEvent: false }); @@ -467,6 +523,45 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { }; } + private applyAnnualizedPerformanceRate( + useAnnualizedPerformanceRate: boolean + ) { + const annualInterestRateControl = + this.calculatorForm.get('annualInterestRate'); + + if ( + useAnnualizedPerformanceRate && + this.isAnnualizedPerformancePercentAvailable() + ) { + annualInterestRateControl?.setValue( + this.getAnnualizedInterestRatePercent(), + { emitEvent: false } + ); + annualInterestRateControl?.disable({ emitEvent: false }); + } else { + annualInterestRateControl?.setValue(this.annualInterestRate, { + emitEvent: false + }); + + if (this.hasPermissionToUpdateUserSettings === true) { + annualInterestRateControl?.enable({ emitEvent: false }); + } + } + + this.initialize(); + } + + private isAnnualizedPerformancePercentAvailable(): boolean { + return ( + isNumber(this.annualizedPerformancePercent) && + !Number.isNaN(this.annualizedPerformancePercent) + ); + } + + private getAnnualizedInterestRatePercent(): number { + return Math.round(this.annualizedPerformancePercent * 10000) / 100; + } + private getP() { return this.fireWealth || 0; }