From 23f2ac472e1f342a601f0ee624071f3126b8e06e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Sun, 10 Apr 2022 20:02:31 +0200 Subject: [PATCH] Feature/add fire calculator (#822) * Add fire calculator * Update changelog --- CHANGELOG.md | 1 + .../investment-chart.component.ts | 20 +- .../portfolio/fire/fire-page.component.ts | 6 +- .../app/pages/portfolio/fire/fire-page.html | 11 +- .../pages/portfolio/fire/fire-page.module.ts | 2 + libs/common/src/lib/helper.ts | 4 + .../fire-calculator.component.html | 65 +++++ .../fire-calculator.component.scss | 11 + .../fire-calculator.component.stories.ts | 48 ++++ .../fire-calculator.component.ts | 247 ++++++++++++++++++ .../fire-calculator/fire-calculator.module.ts | 28 ++ .../fire-calculator.service.ts | 49 ++++ libs/ui/src/lib/fire-calculator/index.ts | 1 + 13 files changed, 477 insertions(+), 16 deletions(-) create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.component.html create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.component.scss create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.component.ts create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.module.ts create mode 100644 libs/ui/src/lib/fire-calculator/fire-calculator.service.ts create mode 100644 libs/ui/src/lib/fire-calculator/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1da5357aa..e438eba52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`) diff --git a/apps/client/src/app/components/investment-chart/investment-chart.component.ts b/apps/client/src/app/components/investment-chart/investment-chart.component.ts index d21d0d9cb..e77c5b362 100644 --- a/apps/client/src/app/components/investment-chart/investment-chart.component.ts +++ b/apps/client/src/app/components/investment-chart/investment-chart.component.ts @@ -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 } diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts index 1ca20dd11..30faa2230 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.component.ts @@ -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() diff --git a/apps/client/src/app/pages/portfolio/fire/fire-page.html b/apps/client/src/app/pages/portfolio/fire/fire-page.html index 4aa0f31ed..ebbfccb64 100644 --- a/apps/client/src/app/pages/portfolio/fire/fire-page.html +++ b/apps/client/src/app/pages/portfolio/fire/fire-page.html @@ -2,7 +2,7 @@

FIRE

-
+

4% Rule

+
+

Calculator

+ +
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 @@ +
+
+
+
+ + + + Savings Rate + + {{ currency }} per month + + + + Investment Horizon + + years + + + + Annual Interest Rate + + % + + + +
+
+
+
+ + +
+
+
+
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';