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