mirror of https://github.com/ghostfolio/ghostfolio
Thomas Kaul
3 years ago
committed by
GitHub
13 changed files with 477 additions and 16 deletions
@ -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 |
||||
|
}; |
@ -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] |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -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 {} |
@ -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 |
||||
|
}; |
||||
|
} |
||||
|
} |
@ -0,0 +1 @@ |
|||||
|
export * from './fire-calculator.module'; |
Loading…
Reference in new issue