Browse Source

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.
pull/7452/head
Max Dietrich 3 weeks ago
parent
commit
2687229ca4
  1. 4
      CHANGELOG.md
  2. 22
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  3. 7
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  4. 4
      libs/common/src/lib/dtos/update-user-setting.dto.ts
  5. 1
      libs/common/src/lib/interfaces/user-settings.interface.ts
  6. 9
      libs/ui/src/lib/fire-calculator/fire-calculator.component.html
  7. 4
      libs/ui/src/lib/fire-calculator/fire-calculator.component.scss
  8. 101
      libs/ui/src/lib/fire-calculator/fire-calculator.component.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 an option to use the annualized performance as the annual interest rate in the _FIRE_ calculator
### Changed ### Changed
- Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0` - Upgraded `@openrouter/ai-sdk-provider` from version `2.9.1` to `3.0.0`

22
apps/client/src/app/pages/portfolio/fire/fire-page.component.ts

@ -50,6 +50,7 @@ export class GfFirePageComponent implements OnInit {
() => this.deviceDetectorService.deviceInfo().deviceType () => this.deviceDetectorService.deviceInfo().deviceType
); );
protected annualizedPerformancePercent: number | undefined;
protected fireWealth: FireWealth; protected fireWealth: FireWealth;
protected hasImpersonationId: boolean; protected hasImpersonationId: boolean;
protected hasPermissionToUpdateUserSettings: boolean; protected hasPermissionToUpdateUserSettings: boolean;
@ -99,6 +100,9 @@ export class GfFirePageComponent implements OnInit {
.fetchPortfolioDetails() .fetchPortfolioDetails()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ summary }) => { .subscribe(({ summary }) => {
this.annualizedPerformancePercent =
summary?.annualizedPerformancePercentWithCurrencyEffect;
this.fireWealth = { this.fireWealth = {
today: { today: {
valueInBaseCurrency: summary?.fireWealth 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() { private calculateWithdrawalRates() {
if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) { if (this.fireWealth && this.user?.settings?.safeWithdrawalRate) {
this.withdrawalRatePerYear = new Big( this.withdrawalRatePerYear = new Big(

7
apps/client/src/app/pages/portfolio/fire/fire-page.html

@ -11,6 +11,7 @@
</h4> </h4>
<gf-fire-calculator <gf-fire-calculator
[annualInterestRate]="user?.settings?.annualInterestRate" [annualInterestRate]="user?.settings?.annualInterestRate"
[annualizedPerformancePercent]="annualizedPerformancePercent"
[colorScheme]="user?.settings?.colorScheme" [colorScheme]="user?.settings?.colorScheme"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType()" [deviceType]="deviceType()"
@ -28,11 +29,17 @@
[style.pointer-events]=" [style.pointer-events]="
user?.subscription?.type === 'Basic' ? 'none' : 'initial' user?.subscription?.type === 'Basic' ? 'none' : 'initial'
" "
[useAnnualizedPerformanceRate]="
user?.settings?.useAnnualizedPerformanceRate
"
(annualInterestRateChanged)="onAnnualInterestRateChange($event)" (annualInterestRateChanged)="onAnnualInterestRateChange($event)"
(calculationCompleted)="onCalculationComplete($event)" (calculationCompleted)="onCalculationComplete($event)"
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)" (projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"
(retirementDateChanged)="onRetirementDateChange($event)" (retirementDateChanged)="onRetirementDateChange($event)"
(savingsRateChanged)="onSavingsRateChange($event)" (savingsRateChanged)="onSavingsRateChange($event)"
(useAnnualizedPerformanceRateChanged)="
onUseAnnualizedPerformanceRateChange($event)
"
/> />
</div> </div>
</div> </div>

4
libs/common/src/lib/dtos/update-user-setting.dto.ts

@ -120,6 +120,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
savingsRate?: number; savingsRate?: number;
@IsBoolean()
@IsOptional()
useAnnualizedPerformanceRate?: boolean;
@IsIn(['DEFAULT', 'ZEN'] as ViewMode[]) @IsIn(['DEFAULT', 'ZEN'] as ViewMode[])
@IsOptional() @IsOptional()
viewMode?: ViewMode; viewMode?: ViewMode;

1
libs/common/src/lib/interfaces/user-settings.interface.ts

@ -31,6 +31,7 @@ export interface UserSettings {
retirementDate?: string; retirementDate?: string;
safeWithdrawalRate?: number; safeWithdrawalRate?: number;
savingsRate?: number; savingsRate?: number;
useAnnualizedPerformanceRate?: boolean;
viewMode?: ViewMode; viewMode?: ViewMode;
xRayRules?: XRayRulesSettings; xRayRules?: XRayRulesSettings;
} }

9
libs/ui/src/lib/fire-calculator/fire-calculator.component.html

@ -28,6 +28,15 @@
<div class="ml-2" matTextSuffix>%</div> <div class="ml-2" matTextSuffix>%</div>
</mat-form-field> </mat-form-field>
<div class="mb-3 px-2 use-annualized-performance-rate">
<mat-checkbox
color="primary"
formControlName="useAnnualizedPerformanceRate"
i18n
>Use annualized performance</mat-checkbox
>
</div>
<mat-form-field appearance="outline" class="w-100"> <mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Retirement Date</mat-label> <mat-label i18n>Retirement Date</mat-label>
<div>{{ retirementDateLabel }}</div> <div>{{ retirementDateLabel }}</div>

4
libs/ui/src/lib/fire-calculator/fire-calculator.component.scss

@ -1,6 +1,10 @@
:host { :host {
display: block; display: block;
.use-annualized-performance-rate {
margin-top: -0.75rem;
}
.chart-container { .chart-container {
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;

101
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts

@ -28,6 +28,7 @@ import {
ReactiveFormsModule ReactiveFormsModule
} from '@angular/forms'; } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { import {
MatDatepicker, MatDatepicker,
MatDatepickerModule MatDatepickerModule
@ -72,6 +73,7 @@ import { FireCalculatorService } from './fire-calculator.service';
FormsModule, FormsModule,
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatCheckboxModule,
MatDatepickerModule, MatDatepickerModule,
MatFormFieldModule, MatFormFieldModule,
MatInputModule, MatInputModule,
@ -85,6 +87,7 @@ import { FireCalculatorService } from './fire-calculator.service';
}) })
export class GfFireCalculatorComponent implements OnChanges, OnDestroy { export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
@Input() annualInterestRate = 0; @Input() annualInterestRate = 0;
@Input() annualizedPerformancePercent: number;
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme;
@Input() currency: string; @Input() currency: string;
@Input() deviceType: string; @Input() deviceType: string;
@ -94,13 +97,15 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
@Input() projectedTotalAmount = 0; @Input() projectedTotalAmount = 0;
@Input() retirementDate: Date; @Input() retirementDate: Date;
@Input() savingsRate = 0; @Input() savingsRate = 0;
@Input() useAnnualizedPerformanceRate = false;
public calculatorForm = this.formBuilder.group({ public calculatorForm = this.formBuilder.group({
annualInterestRate: new FormControl<number | null>(null), annualInterestRate: new FormControl<number | null>(null),
paymentPerPeriod: new FormControl<number | null>(null), paymentPerPeriod: new FormControl<number | null>(null),
principalInvestmentAmount: new FormControl<number | null>(null), principalInvestmentAmount: new FormControl<number | null>(null),
projectedTotalAmount: new FormControl<number | null>(null), projectedTotalAmount: new FormControl<number | null>(null),
retirementDate: new FormControl<Date | null>(null) retirementDate: new FormControl<Date | null>(null),
useAnnualizedPerformanceRate: new FormControl<boolean>(false)
}); });
public chart: Chart<'bar'>; public chart: Chart<'bar'>;
@ -116,6 +121,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
protected readonly projectedTotalAmountChanged = output<number>(); protected readonly projectedTotalAmountChanged = output<number>();
protected readonly retirementDateChanged = output<Date>(); protected readonly retirementDateChanged = output<Date>();
protected readonly savingsRateChanged = output<number>(); protected readonly savingsRateChanged = output<number>();
protected readonly useAnnualizedPerformanceRateChanged = output<boolean>();
private readonly CONTRIBUTION_PERIOD = 12; private readonly CONTRIBUTION_PERIOD = 12;
@ -206,6 +212,27 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.retirementDateChanged.emit(retirementDate); 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 { protected get retirementDateLabel(): string {
@ -222,14 +249,24 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
} }
public ngOnChanges() { public ngOnChanges() {
const useAnnualizedPerformanceRate =
this.useAnnualizedPerformanceRate ?? false;
const annualInterestRate =
useAnnualizedPerformanceRate &&
this.isAnnualizedPerformancePercentAvailable()
? this.getAnnualizedInterestRatePercent()
: this.annualInterestRate;
if (isNumber(this.fireWealth) && this.fireWealth >= 0) { if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
this.calculatorForm.setValue( this.calculatorForm.setValue(
{ {
annualInterestRate: this.annualInterestRate, annualInterestRate,
paymentPerPeriod: this.savingsRate, paymentPerPeriod: this.savingsRate,
principalInvestmentAmount: this.fireWealth, principalInvestmentAmount: this.fireWealth,
projectedTotalAmount: this.projectedTotalAmount, projectedTotalAmount: this.projectedTotalAmount,
retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE,
useAnnualizedPerformanceRate
}, },
{ {
emitEvent: false emitEvent: false
@ -271,6 +308,16 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.calculatorForm this.calculatorForm
.get('projectedTotalAmount') .get('projectedTotalAmount')
?.enable({ emitEvent: false }); ?.enable({ emitEvent: false });
if (this.isAnnualizedPerformancePercentAvailable()) {
this.calculatorForm
.get('useAnnualizedPerformanceRate')
?.enable({ emitEvent: false });
} else {
this.calculatorForm
.get('useAnnualizedPerformanceRate')
?.disable({ emitEvent: false });
}
} else { } else {
this.calculatorForm this.calculatorForm
.get('annualInterestRate') .get('annualInterestRate')
@ -281,6 +328,15 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
this.calculatorForm this.calculatorForm
.get('projectedTotalAmount') .get('projectedTotalAmount')
?.disable({ emitEvent: false }); ?.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 }); 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() { private getP() {
return this.fireWealth || 0; return this.fireWealth || 0;
} }

Loading…
Cancel
Save