Feature/add fire calculator (#822)
* Add fire calculator * Update changelog
This commit is contained in:
parent
d5ba624403
commit
23f2ac472e
@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added a calculator to the _FIRE_ section
|
||||
- Added support for the cryptocurrency _Terra_ (`LUNA1-USD`)
|
||||
- Added support for the cryptocurrency _THORChain_ (`RUNE-USD`)
|
||||
|
||||
|
@ -10,7 +10,10 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
parseDate,
|
||||
transformTickToAbbreviation
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
Chart,
|
||||
@ -148,19 +151,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
display: true,
|
||||
callback: (tickValue, index, ticks) => {
|
||||
if (index === 0 || index === ticks.length - 1) {
|
||||
// Only print last and first legend entry
|
||||
if (typeof tickValue === 'number') {
|
||||
return tickValue.toFixed(2);
|
||||
}
|
||||
|
||||
return tickValue;
|
||||
}
|
||||
|
||||
return '';
|
||||
callback: (value: number) => {
|
||||
return transformTickToAbbreviation(value);
|
||||
},
|
||||
display: true,
|
||||
mirror: true,
|
||||
z: 1
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import Big from 'big.js';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -14,6 +14,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './fire-page.html'
|
||||
})
|
||||
export class FirePageComponent implements OnDestroy, OnInit {
|
||||
public deviceType: string;
|
||||
public fireWealth: Big;
|
||||
public isLoading = false;
|
||||
public user: User;
|
||||
@ -28,7 +29,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -37,6 +38,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.isLoading = true;
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioSummary()
|
||||
|
@ -2,7 +2,7 @@
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
|
||||
<div class="mb-4">
|
||||
<div class="mb-5">
|
||||
<h4 i18n>4% Rule</h4>
|
||||
<div *ngIf="isLoading">
|
||||
<ngx-skeleton-loader
|
||||
@ -53,4 +53,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="mb-3" i18n>Calculator</h4>
|
||||
<gf-fire-calculator
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[fireWealth]="fireWealth?.toNumber()"
|
||||
[locale]="user?.settings?.locale"
|
||||
></gf-fire-calculator>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
@ -11,6 +12,7 @@ import { FirePageComponent } from './fire-page.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FirePageRoutingModule,
|
||||
GfFireCalculatorModule,
|
||||
GfValueModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
|
@ -176,3 +176,7 @@ export function parseDate(date: string) {
|
||||
export function prettifySymbol(aSymbol: string): string {
|
||||
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
|
||||
}
|
||||
|
||||
export function transformTickToAbbreviation(value: number) {
|
||||
return value < 1000000 ? `${value / 1000}K` : `${value / 1000000}M`;
|
||||
}
|
||||
|
@ -0,0 +1,65 @@
|
||||
<div class="container p-0">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<form class="" [formGroup]="calculatorForm">
|
||||
<!--<mat-form-field appearance="outline">
|
||||
<input formControlName="principalInvestmentAmount" matInput />
|
||||
</mat-form-field>-->
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Savings Rate</mat-label>
|
||||
<input
|
||||
formControlName="paymentPerPeriod"
|
||||
matInput
|
||||
step="100"
|
||||
type="number"
|
||||
/>
|
||||
<span class="ml-2" i18n matSuffix>{{ currency }} per month</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 matSuffix>years</span>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Annual Interest Rate</mat-label>
|
||||
<input
|
||||
formControlName="annualInterestRate"
|
||||
matInput
|
||||
step="0.25"
|
||||
type="number"
|
||||
/>
|
||||
<span class="ml-2" i18n matSuffix>%</span>
|
||||
</mat-form-field>
|
||||
|
||||
<gf-value
|
||||
label="Projected Total Amount"
|
||||
size="large"
|
||||
[currency]="currency"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="projectedTotalAmount"
|
||||
></gf-value>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-9 text-center">
|
||||
<div class="chart-container mb-4">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
<canvas
|
||||
#chartCanvas
|
||||
class="h-100"
|
||||
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.chart-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
ngx-skeleton-loader {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,48 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||
import { Meta, Story, moduleMetadata } from '@storybook/angular';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { GfValueModule } from '../value';
|
||||
|
||||
import { FireCalculatorComponent } from './fire-calculator.component';
|
||||
import { FireCalculatorService } from './fire-calculator.service';
|
||||
|
||||
export default {
|
||||
title: 'FIRE Calculator',
|
||||
component: FireCalculatorComponent,
|
||||
decorators: [
|
||||
moduleMetadata({
|
||||
declarations: [FireCalculatorComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
NoopAnimationsModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: [FireCalculatorService]
|
||||
})
|
||||
]
|
||||
} as Meta<FireCalculatorComponent>;
|
||||
|
||||
const Template: Story<FireCalculatorComponent> = (
|
||||
args: FireCalculatorComponent
|
||||
) => ({
|
||||
props: args
|
||||
});
|
||||
|
||||
export const Simple = Template.bind({});
|
||||
Simple.args = {
|
||||
currency: baseCurrency,
|
||||
fireWealth: 0,
|
||||
locale: locale
|
||||
};
|
247
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
Normal file
247
libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import 'chartjs-adapter-date-fns';
|
||||
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormControl } from '@angular/forms';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
Chart,
|
||||
LinearScale,
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
|
||||
import { FireCalculatorService } from './fire-calculator.service';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-fire-calculator',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './fire-calculator.component.html',
|
||||
styleUrls: ['./fire-calculator.component.scss']
|
||||
})
|
||||
export class FireCalculatorComponent
|
||||
implements AfterViewInit, OnChanges, OnDestroy
|
||||
{
|
||||
@Input() currency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() fireWealth: number;
|
||||
@Input() locale: string;
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public calculatorForm = this.formBuilder.group({
|
||||
annualInterestRate: new FormControl(),
|
||||
paymentPerPeriod: new FormControl(),
|
||||
principalInvestmentAmount: new FormControl(),
|
||||
time: new FormControl()
|
||||
});
|
||||
public chart: Chart;
|
||||
public isLoading = true;
|
||||
public projectedTotalAmount: number;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private fireCalculatorService: FireCalculatorService,
|
||||
private formBuilder: FormBuilder
|
||||
) {
|
||||
Chart.register(
|
||||
BarController,
|
||||
BarElement,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
Tooltip
|
||||
);
|
||||
|
||||
this.calculatorForm.setValue({
|
||||
annualInterestRate: 5,
|
||||
paymentPerPeriod: 500,
|
||||
principalInvestmentAmount: 0,
|
||||
time: 10
|
||||
});
|
||||
|
||||
this.calculatorForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.initialize();
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
if (this.fireWealth >= 0) {
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.calculatorForm.patchValue({
|
||||
principalInvestmentAmount: this.fireWealth
|
||||
});
|
||||
this.calculatorForm.get('principalInvestmentAmount').disable();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.fireWealth >= 0) {
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.calculatorForm.patchValue({
|
||||
principalInvestmentAmount: this.fireWealth
|
||||
});
|
||||
this.calculatorForm.get('principalInvestmentAmount').disable();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.chart?.destroy();
|
||||
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.isLoading = true;
|
||||
|
||||
const chartData = this.getChartData();
|
||||
|
||||
if (this.chartCanvas) {
|
||||
if (this.chart) {
|
||||
this.chart.data.labels = chartData.labels;
|
||||
this.chart.data.datasets[0].data = chartData.datasets[0].data;
|
||||
this.chart.data.datasets[1].data = chartData.datasets[1].data;
|
||||
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
data: chartData,
|
||||
options: {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
|
||||
if (context.parsed.y !== null) {
|
||||
label += new Intl.NumberFormat(this.locale, {
|
||||
currency: this.currency,
|
||||
currencyDisplay: 'code',
|
||||
style: 'currency'
|
||||
}).format(context.parsed.y);
|
||||
}
|
||||
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
stacked: true
|
||||
},
|
||||
y: {
|
||||
display: this.deviceType !== 'mobile',
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
stacked: true,
|
||||
ticks: {
|
||||
callback: (value: number) => {
|
||||
return transformTickToAbbreviation(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
type: 'bar'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private getChartData() {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const labels = [];
|
||||
|
||||
// Principal investment amount
|
||||
const P: number =
|
||||
this.calculatorForm.get('principalInvestmentAmount').value || 0;
|
||||
|
||||
// Payment per period
|
||||
const PMT: number = parseFloat(
|
||||
this.calculatorForm.get('paymentPerPeriod').value
|
||||
);
|
||||
|
||||
// Annual interest rate
|
||||
const r: number = this.calculatorForm.get('annualInterestRate').value / 100;
|
||||
|
||||
// Time
|
||||
const t: number = parseFloat(this.calculatorForm.get('time').value);
|
||||
|
||||
for (let year = currentYear; year < currentYear + t; year++) {
|
||||
labels.push(year);
|
||||
}
|
||||
|
||||
const datasetInterest = {
|
||||
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
data: [],
|
||||
label: 'Interest'
|
||||
};
|
||||
|
||||
const datasetPrincipal = {
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
data: [],
|
||||
label: 'Principal'
|
||||
};
|
||||
|
||||
for (let period = 1; period <= t; period++) {
|
||||
const { interest, principal, totalAmount } =
|
||||
this.fireCalculatorService.calculateCompoundInterest({
|
||||
P,
|
||||
period,
|
||||
PMT,
|
||||
r
|
||||
});
|
||||
|
||||
datasetPrincipal.data.push(principal.toNumber());
|
||||
datasetInterest.data.push(interest.toNumber());
|
||||
|
||||
if (period === t - 1) {
|
||||
this.projectedTotalAmount = totalAmount.toNumber();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [datasetPrincipal, datasetInterest]
|
||||
};
|
||||
}
|
||||
}
|
28
libs/ui/src/lib/fire-calculator/fire-calculator.module.ts
Normal file
28
libs/ui/src/lib/fire-calculator/fire-calculator.module.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
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';
|
||||
|
||||
@NgModule({
|
||||
declarations: [FireCalculatorComponent],
|
||||
exports: [FireCalculatorComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
providers: [FireCalculatorService]
|
||||
})
|
||||
export class GfFireCalculatorModule {}
|
49
libs/ui/src/lib/fire-calculator/fire-calculator.service.ts
Normal file
49
libs/ui/src/lib/fire-calculator/fire-calculator.service.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import Big from 'big.js';
|
||||
|
||||
@Injectable()
|
||||
export class FireCalculatorService {
|
||||
private readonly COMPOUND_PERIOD = 12;
|
||||
private readonly CONTRIBUTION_PERIOD = 12;
|
||||
|
||||
/**
|
||||
* @constructor
|
||||
*/
|
||||
public constructor() {}
|
||||
|
||||
public calculateCompoundInterest({
|
||||
P,
|
||||
period,
|
||||
PMT,
|
||||
r
|
||||
}: {
|
||||
P: number;
|
||||
period: number;
|
||||
PMT: number;
|
||||
r: number;
|
||||
}) {
|
||||
let interest = new Big(0);
|
||||
const principal = new Big(P).plus(
|
||||
new Big(PMT).mul(this.CONTRIBUTION_PERIOD).mul(period)
|
||||
);
|
||||
let totalAmount = principal;
|
||||
|
||||
if (r) {
|
||||
const compoundInterestForPrincipal = new Big(1)
|
||||
.plus(new Big(r).div(this.COMPOUND_PERIOD))
|
||||
.pow(new Big(this.COMPOUND_PERIOD).mul(period).toNumber());
|
||||
const compoundInterest = new Big(P).mul(compoundInterestForPrincipal);
|
||||
const contributionInterest = new Big(
|
||||
new Big(PMT).mul(compoundInterestForPrincipal.minus(1))
|
||||
).div(new Big(r).div(this.CONTRIBUTION_PERIOD));
|
||||
interest = compoundInterest.plus(contributionInterest).minus(principal);
|
||||
totalAmount = compoundInterest.plus(contributionInterest);
|
||||
}
|
||||
|
||||
return {
|
||||
interest,
|
||||
principal,
|
||||
totalAmount
|
||||
};
|
||||
}
|
||||
}
|
1
libs/ui/src/lib/fire-calculator/index.ts
Normal file
1
libs/ui/src/lib/fire-calculator/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './fire-calculator.module';
|
Loading…
x
Reference in New Issue
Block a user