Browse Source

feat(fire): Add retirement date to the fire component

- Add targetNetWorth input to the fire component form
- Add targetNetWorth to the settings in the db
- Calculate retirement date based on targetNetWorth and 4% rule
pull/1748/head
Robbert Coeckelbergh 3 years ago
committed by Thomas
parent
commit
96288594c1
  1. 4
      apps/api/src/app/user/update-user-setting.dto.ts
  2. 22
      apps/client/src/app/pages/portfolio/fire/fire-page.component.ts
  3. 2
      apps/client/src/app/pages/portfolio/fire/fire-page.html
  4. 1
      libs/common/src/lib/interfaces/user-settings.interface.ts
  5. 19
      libs/ui/src/lib/fire-calculator/fire-calculator.component.html
  6. 57
      libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
  7. 73
      libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts
  8. 32
      libs/ui/src/lib/fire-calculator/fire-calculator.service.ts

4
apps/api/src/app/user/update-user-setting.dto.ts

@ -52,6 +52,10 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
savingsRate?: number; savingsRate?: number;
@IsNumber()
@IsOptional()
targetNetWorth?: number;
@IsIn(<ViewMode[]>['DEFAULT', 'ZEN']) @IsIn(<ViewMode[]>['DEFAULT', 'ZEN'])
@IsOptional() @IsOptional()
viewMode?: ViewMode; viewMode?: ViewMode;

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

@ -39,7 +39,6 @@ export class FirePageComponent implements OnDestroy, OnInit {
public ngOnInit() { public ngOnInit() {
this.isLoading = true; this.isLoading = true;
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService this.dataService
.fetchPortfolioDetails({}) .fetchPortfolioDetails({})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -92,6 +91,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
} }
public onSavingsRateChange(savingsRate: number) { public onSavingsRateChange(savingsRate: number) {
console.log(this.user.settings);
this.dataService this.dataService
.putUserSetting({ savingsRate }) .putUserSetting({ savingsRate })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -109,6 +109,26 @@ export class FirePageComponent implements OnDestroy, OnInit {
}); });
} }
public onTargetNetWorthChanged(targetNetWorth: number) {
console.log('onTargetNetWorthChanged: ' + targetNetWorth);
console.log(this.user.settings);
this.dataService
.putUserSetting({ targetNetWorth })
.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() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

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

@ -18,7 +18,9 @@
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings" [hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate" [savingsRate]="user?.settings?.savingsRate"
[targetNetWorth]="user?.settings?.targetNetWorth"
(savingsRateChanged)="onSavingsRateChange($event)" (savingsRateChanged)="onSavingsRateChange($event)"
(targetNetWorthChanged)="onTargetNetWorthChanged($event)"
></gf-fire-calculator> ></gf-fire-calculator>
</div> </div>
</div> </div>

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

@ -11,5 +11,6 @@ export interface UserSettings {
language?: string; language?: string;
locale?: string; locale?: string;
savingsRate?: number; savingsRate?: number;
targetNetWorth?: number;
viewMode?: ViewMode; viewMode?: ViewMode;
} }

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

@ -33,6 +33,16 @@
/> />
<div class="ml-2" matTextSuffix>%</div> <div class="ml-2" matTextSuffix>%</div>
</mat-form-field> </mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Target Net Worth</mat-label>
<input
formControlName="retirementNetWorth"
matInput
step="100"
type="number"
/>
<span class="ml-2" matTextSuffix>{{ currency }}</span>
</mat-form-field>
<gf-value <gf-value
i18n i18n
@ -43,6 +53,15 @@
[value]="projectedTotalAmount" [value]="projectedTotalAmount"
>Projected Total Amount</gf-value >Projected Total Amount</gf-value
> >
<gf-value
*ngIf="targetNetWorth"
i18n
size="large"
[isCurrency]="false"
[locale]="locale"
[value]="retirementDate"
>Retirement Date</gf-value
>
</form> </form>
</div> </div>
<div class="col-md-9 text-center"> <div class="col-md-9 text-center">

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

@ -28,11 +28,12 @@ import {
Tooltip Tooltip
} from 'chart.js'; } from 'chart.js';
import * as Color from 'color'; import * as Color from 'color';
import { getMonth } from 'date-fns'; import { add, format, getMonth } from 'date-fns';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
import { FireCalculatorService } from './fire-calculator.service'; import { FireCalculatorService } from './fire-calculator.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
@Component({ @Component({
selector: 'gf-fire-calculator', selector: 'gf-fire-calculator',
@ -50,8 +51,10 @@ export class FireCalculatorComponent
@Input() hasPermissionToUpdateUserSettings: boolean; @Input() hasPermissionToUpdateUserSettings: boolean;
@Input() locale: string; @Input() locale: string;
@Input() savingsRate = 0; @Input() savingsRate = 0;
@Input() targetNetWorth = 0;
@Output() savingsRateChanged = new EventEmitter<number>(); @Output() savingsRateChanged = new EventEmitter<number>();
@Output() targetNetWorthChanged = new EventEmitter<number>();
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -59,11 +62,13 @@ export class FireCalculatorComponent
annualInterestRate: new FormControl<number>(undefined), annualInterestRate: new FormControl<number>(undefined),
paymentPerPeriod: new FormControl<number>(undefined), paymentPerPeriod: new FormControl<number>(undefined),
principalInvestmentAmount: new FormControl<number>(undefined), principalInvestmentAmount: new FormControl<number>(undefined),
time: new FormControl<number>(undefined) time: new FormControl<number>(undefined),
retirementNetWorth: new FormControl<number>(undefined)
}); });
public chart: Chart<'bar'>; public chart: Chart<'bar'>;
public isLoading = true; public isLoading = true;
public projectedTotalAmount: number; public projectedTotalAmount: number;
public retirementDate: string;
private readonly CONTRIBUTION_PERIOD = 12; private readonly CONTRIBUTION_PERIOD = 12;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -86,7 +91,8 @@ export class FireCalculatorComponent
annualInterestRate: 5, annualInterestRate: 5,
paymentPerPeriod: this.savingsRate, paymentPerPeriod: this.savingsRate,
principalInvestmentAmount: 0, principalInvestmentAmount: 0,
time: 10 time: 10,
retirementNetWorth: this.targetNetWorth
}, },
{ {
emitEvent: false emitEvent: false
@ -105,6 +111,12 @@ export class FireCalculatorComponent
.subscribe((savingsRate) => { .subscribe((savingsRate) => {
this.savingsRateChanged.emit(savingsRate); this.savingsRateChanged.emit(savingsRate);
}); });
this.calculatorForm
.get('retirementNetWorth')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((targetNetWorth) => {
this.targetNetWorthChanged.emit(targetNetWorth);
});
} }
public ngAfterViewInit() { public ngAfterViewInit() {
@ -114,7 +126,8 @@ export class FireCalculatorComponent
this.calculatorForm.patchValue( this.calculatorForm.patchValue(
{ {
principalInvestmentAmount: this.fireWealth, principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0 paymentPerPeriod: this.savingsRate ?? 0,
retirementNetWorth: this.targetNetWorth ?? 0
}, },
{ {
emitEvent: false emitEvent: false
@ -128,8 +141,14 @@ export class FireCalculatorComponent
if (this.hasPermissionToUpdateUserSettings === true) { if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
this.calculatorForm
.get('retirementNetWorth')
.enable({ emitEvent: false });
} else { } else {
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
this.calculatorForm
.get('retirementNetWorth')
.disable({ emitEvent: false });
} }
} }
@ -140,7 +159,8 @@ export class FireCalculatorComponent
this.calculatorForm.patchValue( this.calculatorForm.patchValue(
{ {
principalInvestmentAmount: this.fireWealth, principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0 paymentPerPeriod: this.savingsRate ?? 0,
retirementNetWorth: this.targetNetWorth ?? 0
}, },
{ {
emitEvent: false emitEvent: false
@ -154,8 +174,14 @@ export class FireCalculatorComponent
if (this.hasPermissionToUpdateUserSettings === true) { if (this.hasPermissionToUpdateUserSettings === true) {
this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false });
this.calculatorForm
.get('retirementNetWorth')
.enable({ emitEvent: false });
} else { } else {
this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false });
this.calculatorForm
.get('retirementNetWorth')
.disable({ emitEvent: false });
} }
} }
@ -273,6 +299,8 @@ export class FireCalculatorComponent
// Time // Time
const t = this.calculatorForm.get('time').value; const t = this.calculatorForm.get('time').value;
const targetNetWorth = this.calculatorForm.get('retirementNetWorth').value;
for (let year = currentYear; year < currentYear + t; year++) { for (let year = currentYear; year < currentYear + t; year++) {
labels.push(year); labels.push(year);
} }
@ -325,6 +353,25 @@ export class FireCalculatorComponent
} }
} }
// Calculate retirement date
const periodsToRetire = this.fireCalculatorService.calculatePeriodsToRetire(
{
P,
totalAmount: targetNetWorth,
PMT,
r
}
);
const years = Math.floor(periodsToRetire / 12);
const months = periodsToRetire % 12;
const retirementDate = add(new Date(), {
years,
months
});
this.retirementDate = format(retirementDate, 'MMMM yyyy');
return { return {
labels, labels,
datasets: [datasetDeposit, datasetSavings, datasetInterest] datasets: [datasetDeposit, datasetSavings, datasetInterest]

73
libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts

@ -0,0 +1,73 @@
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
);
});
it('true', async () => {
expect(true).toBe(true);
});
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);
});
});
});

32
libs/ui/src/lib/fire-calculator/fire-calculator.service.ts

@ -40,4 +40,36 @@ export class FireCalculatorService {
totalAmount 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 <= 0) {
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