FIRE
-
diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.module.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.module.ts
index 86fb0a953..0887da8fb 100644
--- a/apps/client/src/app/pages/portfolio/fire/fire-page.module.ts
+++ b/apps/client/src/app/pages/portfolio/fire/fire-page.module.ts
@@ -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
],
diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts
index 2e45d40cd..351643732 100644
--- a/libs/common/src/lib/helper.ts
+++ b/libs/common/src/lib/helper.ts
@@ -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`;
+}
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html
new file mode 100644
index 000000000..99273da11
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html
@@ -0,0 +1,65 @@
+
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss b/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss
new file mode 100644
index 000000000..e02c91e3d
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss
@@ -0,0 +1,11 @@
+:host {
+ display: block;
+
+ .chart-container {
+ aspect-ratio: 16 / 9;
+
+ ngx-skeleton-loader {
+ height: 100%;
+ }
+ }
+}
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
new file mode 100644
index 000000000..cce5727a8
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
@@ -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
;
+
+const Template: Story = (
+ args: FireCalculatorComponent
+) => ({
+ props: args
+});
+
+export const Simple = Template.bind({});
+Simple.args = {
+ currency: baseCurrency,
+ fireWealth: 0,
+ locale: locale
+};
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
new file mode 100644
index 000000000..13fa76d2d
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts
@@ -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();
+
+ /**
+ * @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]
+ };
+ }
+}
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts
new file mode 100644
index 000000000..44fd48c2e
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/fire-calculator.module.ts
@@ -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 {}
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts
new file mode 100644
index 000000000..2a20b8a25
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts
@@ -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
+ };
+ }
+}
diff --git a/libs/ui/src/lib/fire-calculator/index.ts b/libs/ui/src/lib/fire-calculator/index.ts
new file mode 100644
index 000000000..aea6c656a
--- /dev/null
+++ b/libs/ui/src/lib/fire-calculator/index.ts
@@ -0,0 +1 @@
+export * from './fire-calculator.module';