Feature/extend fire calculator by retirement date (#1748)

* Extend fire calculator by retirement date

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
pull/1778/head
Robbert Coeckelbergh 2 years ago committed by GitHub
parent 6301c0c21c
commit fce9e7fb0c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Extended the _FIRE_ calculator by a retirement date setting
## 1.243.0 - 2023-03-08
### Added

@ -3,9 +3,11 @@ import type {
DateRange,
ViewMode
} from '@ghostfolio/common/types';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsIn,
IsISO8601,
IsNumber,
IsOptional,
IsString
@ -48,6 +50,14 @@ export class UpdateUserSettingDto {
@IsOptional()
locale?: string;
@IsNumber()
@IsOptional()
projectedTotalAmount?: number;
@IsISO8601()
@IsOptional()
retirementDate?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;

@ -91,6 +91,27 @@ export class FirePageComponent implements OnDestroy, OnInit {
});
}
public onRetirementDateChange(retirementDate: Date) {
this.dataService
.putUserSetting({
retirementDate: retirementDate.toISOString(),
projectedTotalAmount: null
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
@ -109,6 +130,27 @@ export class FirePageComponent implements OnDestroy, OnInit {
});
}
public onProjectedTotalAmountChange(projectedTotalAmount: number) {
this.dataService
.putUserSetting({
projectedTotalAmount,
retirementDate: null
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

@ -17,7 +17,11 @@
[fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale"
[projectedTotalAmount]="user?.settings?.projectedTotalAmount"
[retirementDate]="user?.settings?.retirementDate"
[savingsRate]="user?.settings?.savingsRate"
(projectedTotalAmountChanged)="onProjectedTotalAmountChange($event)"
(retirementDateChanged)="onRetirementDateChange($event)"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
</div>

@ -6,8 +6,9 @@ import { SubscriptionInterstitialDialogParams } from '@ghostfolio/client/compone
import { SubscriptionInterstitialDialog } from '@ghostfolio/client/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { parseISO } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, of } from 'rxjs';
import { Subject, of, Observable } from 'rxjs';
import { throwError } from 'rxjs';
import { catchError, map, takeUntil } from 'rxjs/operators';
@ -49,9 +50,13 @@ export class UserService extends ObservableStore<UserStoreState> {
this.setState({ user: null }, UserStoreActions.RemoveUser);
}
private fetchUser() {
return this.http.get<User>('/api/v1/user').pipe(
private fetchUser(): Observable<User> {
return this.http.get<any>('/api/v1/user').pipe(
map((user) => {
if (user.settings?.retirementDate) {
user.settings.retirementDate = parseISO(user.settings.retirementDate);
}
this.setState({ user }, UserStoreActions.GetUser);
if (

@ -3505,6 +3505,22 @@
<context context-type="linenumber">199,201</context>
</context-group>
</trans-unit>
<trans-unit id="46d3a0f17b741c93f9e61aa7157820da41506f53" datatype="html">
<source>Target Net Worth</source>
<target state="new">Angestrebtes Nettovermögen</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="7383cd391b1967e03f0636c231d20f036d5c37ee" datatype="html">
<source>Retirement Date</source>
<target state="translated">Pensionierungsdatum</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

@ -2434,7 +2434,7 @@
<target state="translated">Verwacht totaalbedrag</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">44</context>
<context context-type="linenumber">52</context>
</context-group>
</trans-unit>
<trans-unit id="1054498214311181686" datatype="html">
@ -3505,6 +3505,22 @@
<context context-type="linenumber">199,201</context>
</context-group>
</trans-unit>
<trans-unit id="46d3a0f17b741c93f9e61aa7157820da41506f53" datatype="html">
<source>Target Net Worth</source>
<target state="translated">Beoogd Netto Vermogen</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="7383cd391b1967e03f0636c231d20f036d5c37ee" datatype="html">
<source>Retirement Date</source>
<target state="translated">Pensioen Datum</target>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

@ -2193,7 +2193,7 @@
<source>Projected Total Amount</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">44</context>
<context context-type="linenumber">52</context>
</context-group>
</trans-unit>
<trans-unit id="1054498214311181686" datatype="html">
@ -3146,6 +3146,20 @@
<context context-type="linenumber">199,201</context>
</context-group>
</trans-unit>
<trans-unit id="46d3a0f17b741c93f9e61aa7157820da41506f53" datatype="html">
<source>Target Net Worth</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">38</context>
</context-group>
</trans-unit>
<trans-unit id="7383cd391b1967e03f0636c231d20f036d5c37ee" datatype="html">
<source>Retirement Date</source>
<context-group purpose="location">
<context context-type="sourcefile">libs/ui/src/lib/fire-calculator/fire-calculator.component.html</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>

@ -10,6 +10,8 @@ export interface UserSettings {
isRestrictedView?: boolean;
language?: string;
locale?: string;
projectedTotalAmount?: number;
retirementDate?: string;
savingsRate?: number;
viewMode?: ViewMode;
}

@ -17,12 +17,6 @@
<span class="ml-2" matTextSuffix>{{ currency }}</span>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Investment Horizon</mat-label>
<input formControlName="time" matInput type="number" />
<span class="ml-2" i18n matTextSuffix>years</span>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Annual Interest Rate</mat-label>
<input
@ -34,15 +28,35 @@
<div class="ml-2" matTextSuffix>%</div>
</mat-form-field>
<gf-value
i18n
size="large"
[currency]="currency"
[isCurrency]="true"
[locale]="locale"
[value]="projectedTotalAmount"
>Projected Total Amount</gf-value
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Retirement Date</mat-label>
<input
formControlName="retirementDate"
matInput
[matDatepicker]="datepicker"
/>
<mat-datepicker-toggle
matIconSuffix
[for]="datepicker"
></mat-datepicker-toggle>
<mat-datepicker
#datepicker
startView="multi-year"
(monthSelected)="setMonthAndYear($event, datepicker)"
>
</mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Projected Total Amount</mat-label>
<input
formControlName="projectedTotalAmount"
matInput
step="100"
type="number"
/>
<span class="ml-2" matTextSuffix>{{ currency }}</span>
</mat-form-field>
</form>
</div>
<div class="col-md-9 text-center">

@ -13,6 +13,7 @@ import {
ViewChild
} from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { MatDatepicker } from '@angular/material/datepicker';
import {
getTooltipOptions,
transformTickToAbbreviation
@ -28,17 +29,25 @@ import {
Tooltip
} from 'chart.js';
import * as Color from 'color';
import { getMonth } from 'date-fns';
import {
add,
addYears,
getMonth,
setMonth,
setYear,
startOfMonth,
sub
} from 'date-fns';
import { isNumber } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
import { FireCalculatorService } from './fire-calculator.service';
@Component({
selector: 'gf-fire-calculator',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './fire-calculator.component.html',
styleUrls: ['./fire-calculator.component.scss']
selector: 'gf-fire-calculator',
styleUrls: ['./fire-calculator.component.scss'],
templateUrl: './fire-calculator.component.html'
})
export class FireCalculatorComponent
implements AfterViewInit, OnChanges, OnDestroy
@ -49,8 +58,12 @@ export class FireCalculatorComponent
@Input() fireWealth: number;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() locale: string;
@Input() projectedTotalAmount = 0;
@Input() retirementDate: Date;
@Input() savingsRate = 0;
@Output() projectedTotalAmountChanged = new EventEmitter<number>();
@Output() retirementDateChanged = new EventEmitter<Date>();
@Output() savingsRateChanged = new EventEmitter<number>();
@ViewChild('chartCanvas') chartCanvas;
@ -59,13 +72,17 @@ export class FireCalculatorComponent
annualInterestRate: new FormControl<number>(undefined),
paymentPerPeriod: new FormControl<number>(undefined),
principalInvestmentAmount: new FormControl<number>(undefined),
time: new FormControl<number>(undefined)
projectedTotalAmount: new FormControl<number>(undefined),
retirementDate: new FormControl<Date>(undefined)
});
public chart: Chart<'bar'>;
public isLoading = true;
public projectedTotalAmount: number;
public periodsToRetire = 0;
private readonly CONTRIBUTION_PERIOD = 12;
private readonly DEFAULT_RETIREMENT_DATE = startOfMonth(
addYears(new Date(), 10)
);
private unsubscribeSubject = new Subject<void>();
public constructor(
@ -86,7 +103,8 @@ export class FireCalculatorComponent
annualInterestRate: 5,
paymentPerPeriod: this.savingsRate,
principalInvestmentAmount: 0,
time: 10
projectedTotalAmount: this.projectedTotalAmount,
retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE
},
{
emitEvent: false
@ -105,6 +123,18 @@ export class FireCalculatorComponent
.subscribe((savingsRate) => {
this.savingsRateChanged.emit(savingsRate);
});
this.calculatorForm
.get('projectedTotalAmount')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((projectedTotalAmount) => {
this.projectedTotalAmountChanged.emit(projectedTotalAmount);
});
this.calculatorForm
.get('retirementDate')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((retirementDate) => {
this.retirementDateChanged.emit(retirementDate);
});
}
public ngAfterViewInit() {
@ -113,8 +143,12 @@ export class FireCalculatorComponent
// Wait for the chartCanvas
this.calculatorForm.patchValue(
{
principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0
paymentPerPeriod: this.getPMT(),
principalInvestmentAmount: this.getP(),
projectedTotalAmount:
Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0,
retirementDate:
this.getRetirementDate() ?? this.DEFAULT_RETIREMENT_DATE
},
{
emitEvent: false
@ -128,19 +162,32 @@ export class FireCalculatorComponent
if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
this.calculatorForm
.get('projectedTotalAmount')
.enable({ emitEvent: false });
this.calculatorForm.get('retirementDate').enable({ emitEvent: false });
} else {
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
this.calculatorForm
.get('projectedTotalAmount')
.disable({ emitEvent: false });
this.calculatorForm.get('retirementDate').disable({ emitEvent: false });
}
}
public ngOnChanges() {
this.periodsToRetire = this.getPeriodsToRetire();
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => {
// Wait for the chartCanvas
this.calculatorForm.patchValue(
{
principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0
paymentPerPeriod: this.savingsRate ?? 0,
projectedTotalAmount:
Number(this.getProjectedTotalAmount().toFixed(2)) ?? 0,
retirementDate:
this.getRetirementDate() ?? this.DEFAULT_RETIREMENT_DATE
},
{
emitEvent: false
@ -154,11 +201,32 @@ export class FireCalculatorComponent
if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
this.calculatorForm
.get('projectedTotalAmount')
.enable({ emitEvent: false });
this.calculatorForm.get('retirementDate').enable({ emitEvent: false });
} else {
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
this.calculatorForm
.get('projectedTotalAmount')
.disable({ emitEvent: false });
this.calculatorForm.get('retirementDate').disable({ emitEvent: false });
}
}
public setMonthAndYear(
normalizedMonthAndYear: Date,
datepicker: MatDatepicker<Date>
) {
const retirementDate = this.calculatorForm.get('retirementDate').value;
const newRetirementDate = setMonth(
setYear(retirementDate, normalizedMonthAndYear.getFullYear()),
normalizedMonthAndYear.getMonth()
);
this.calculatorForm.get('retirementDate').setValue(newRetirementDate);
datepicker.close();
}
public ngOnDestroy() {
this.chart?.destroy();
@ -261,17 +329,22 @@ export class FireCalculatorComponent
const labels = [];
// Principal investment amount
const P: number =
this.calculatorForm.get('principalInvestmentAmount').value || 0;
const P: number = this.getP();
// Payment per period
const PMT = this.calculatorForm.get('paymentPerPeriod').value;
const PMT = this.getPMT();
// Annual interest rate
const r: number = this.calculatorForm.get('annualInterestRate').value / 100;
const r: number = this.getR();
// Calculate retirement date
// if we want to retire at month x, we need the projectedTotalAmount at month x-1
const lastPeriodDate = sub(this.getRetirementDate(), { months: 1 });
const yearsToRetire = lastPeriodDate.getFullYear() - currentYear;
// Time
const t = this.calculatorForm.get('time').value;
// +1 to take into account the current year
const t = yearsToRetire + 1;
for (let year = currentYear; year < currentYear + t; year++) {
labels.push(year);
@ -308,7 +381,7 @@ export class FireCalculatorComponent
for (let period = 1; period <= t; period++) {
const periodInMonths =
period * this.CONTRIBUTION_PERIOD - monthsPassedInCurrentYear;
const { interest, principal, totalAmount } =
const { interest, principal } =
this.fireCalculatorService.calculateCompoundInterest({
P,
periodInMonths,
@ -319,10 +392,6 @@ export class FireCalculatorComponent
datasetDeposit.data.push(this.fireWealth);
datasetInterest.data.push(interest.toNumber());
datasetSavings.data.push(principal.minus(this.fireWealth).toNumber());
if (period === t) {
this.projectedTotalAmount = totalAmount.toNumber();
}
}
return {
@ -330,4 +399,67 @@ export class FireCalculatorComponent
datasets: [datasetDeposit, datasetSavings, datasetInterest]
};
}
private getP() {
return this.fireWealth || 0;
}
private getPeriodsToRetire(): number {
if (this.projectedTotalAmount) {
const periods = this.fireCalculatorService.calculatePeriodsToRetire({
P: this.getP(),
totalAmount: this.projectedTotalAmount,
PMT: this.getPMT(),
r: this.getR()
});
return periods;
} else {
const today = new Date();
const retirementDate =
this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE;
return (
12 * (retirementDate.getFullYear() - today.getFullYear()) +
retirementDate.getMonth() -
today.getMonth()
);
}
}
private getPMT() {
return this.savingsRate ?? 0;
}
private getProjectedTotalAmount() {
if (this.projectedTotalAmount) {
return this.projectedTotalAmount || 0;
} else {
const { totalAmount } =
this.fireCalculatorService.calculateCompoundInterest({
P: this.getP(),
periodInMonths: this.periodsToRetire,
PMT: this.getPMT(),
r: this.getR()
});
return totalAmount.toNumber();
}
}
private getR() {
return this.calculatorForm.get('annualInterestRate').value / 100;
}
private getRetirementDate(): Date {
const monthsToRetire = this.periodsToRetire % 12;
const yearsToRetire = Math.floor(this.periodsToRetire / 12);
return startOfMonth(
add(new Date(), {
months: monthsToRetire,
years: yearsToRetire
})
);
}
}

@ -2,11 +2,11 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value';
import { FireCalculatorComponent } from './fire-calculator.component';
import { FireCalculatorService } from './fire-calculator.service';
@ -16,8 +16,8 @@ import { FireCalculatorService } from './fire-calculator.service';
imports: [
CommonModule,
FormsModule,
GfValueModule,
MatButtonModule,
MatDatepickerModule,
MatFormFieldModule,
MatInputModule,
NgxSkeletonLoaderModule,

@ -0,0 +1,69 @@
import { Test, TestingModule } from '@nestjs/testing';
import Big from 'big.js';
import { FireCalculatorService } from './fire-calculator.service';
describe('FireCalculatorService', () => {
let fireCalculatorService: FireCalculatorService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [FireCalculatorService]
}).compile();
fireCalculatorService = module.get<FireCalculatorService>(
FireCalculatorService
);
});
describe('Test periods to retire', () => {
it('should return the correct amount of periods to retire with no interst rate', async () => {
const r = 0;
const P = 1000;
const totalAmount = 1900;
const PMT = 100;
const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({
P,
r,
PMT,
totalAmount
});
expect(periodsToRetire).toBe(9);
});
it('should return the 0 when total amount is 0', async () => {
const r = 0.05;
const P = 100000;
const totalAmount = 0;
const PMT = 10000;
const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({
P,
r,
PMT,
totalAmount
});
expect(periodsToRetire).toBe(0);
});
it('should return the correct amount of periods to retire with interst rate', async () => {
const r = 0.05;
const P = 598478.96;
const totalAmount = 812399.66;
const PMT = 6000;
const expectedPeriods = 24;
const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({
P,
r,
PMT,
totalAmount
});
expect(Math.round(periodsToRetire)).toBe(expectedPeriods);
});
});
});

@ -40,4 +40,36 @@ export class FireCalculatorService {
totalAmount
};
}
public calculatePeriodsToRetire({
P,
PMT,
r,
totalAmount
}: {
P: number;
PMT: number;
r: number;
totalAmount: number;
}) {
if (r == 0) {
// No compound interest
return (totalAmount - P) / PMT;
} else if (totalAmount <= P) {
return 0;
}
const periodInterest = new Big(r).div(this.COMPOUND_PERIOD);
const numerator1: number = Math.log10(
new Big(totalAmount).plus(new Big(PMT).div(periodInterest)).toNumber()
);
const numerator2: number = Math.log10(
new Big(P).plus(new Big(PMT).div(periodInterest)).toNumber()
);
const denominator: number = Math.log10(
new Big(1).plus(periodInterest).toNumber()
);
return (numerator1 - numerator2) / denominator;
}
}

Loading…
Cancel
Save