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
### 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`

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
);
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(

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

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

4
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;

1
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;
}

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

@ -28,6 +28,15 @@
<div class="ml-2" matTextSuffix>%</div>
</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-label i18n>Retirement Date</mat-label>
<div>{{ retirementDateLabel }}</div>

4
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;

101
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<number | null>(null),
paymentPerPeriod: new FormControl<number | null>(null),
principalInvestmentAmount: 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'>;
@ -116,6 +121,7 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy {
protected readonly projectedTotalAmountChanged = output<number>();
protected readonly retirementDateChanged = output<Date>();
protected readonly savingsRateChanged = output<number>();
protected readonly useAnnualizedPerformanceRateChanged = output<boolean>();
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;
}

Loading…
Cancel
Save