Feature/add support for emergency fund (#749)

* Add support for emergency fund

* Update changelog
pull/752/head
Thomas Kaul 2 years ago committed by GitHub
parent 07799573cb
commit 7b6893b5ed
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for an emergency fund
- Added the contexts to the logger commands
### Changed

@ -334,6 +334,7 @@ export class PortfolioController {
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'fees',
'items',
'netWorth',

@ -5,6 +5,8 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -19,7 +21,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
baseCurrency
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
@ -76,7 +82,8 @@ export class PortfolioServiceNew {
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
@ -295,7 +302,11 @@ export class PortfolioServiceNew {
aDateRange: DateRange = 'max'
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId });
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const { orders, portfolioOrders, transactionPoints } =
@ -393,6 +404,7 @@ export class PortfolioServiceNew {
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestment,
value: totalValue
@ -883,6 +895,7 @@ export class PortfolioServiceNew {
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance(aImpersonationId);
@ -895,6 +908,9 @@ export class PortfolioServiceNew {
userId
});
const dividend = this.getDividend(orders).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
@ -902,6 +918,7 @@ export class PortfolioServiceNew {
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balanceInBaseCurrency)
@ -927,6 +944,7 @@ export class PortfolioServiceNew {
return {
...performanceInformation.performance,
annualizedPerformancePercent,
cash,
dividend,
fees,
firstOrderDate,
@ -934,8 +952,8 @@ export class PortfolioServiceNew {
netWorth,
totalBuy,
totalSell,
cash: balanceInBaseCurrency,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
}).length
@ -944,16 +962,18 @@ export class PortfolioServiceNew {
private async getCashPositions({
cashDetails,
emergencyFund,
investment,
userCurrency,
value
}: {
cashDetails: CashDetails;
emergencyFund: Big;
investment: Big;
value: Big;
userCurrency: string;
}) {
const cashPositions = {};
const cashPositions: PortfolioDetails['holdings'] = {};
for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency(
@ -977,6 +997,7 @@ export class PortfolioServiceNew {
assetSubClass: AssetClass.CASH,
countries: [],
currency: account.currency,
dataSource: undefined,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
@ -994,6 +1015,28 @@ export class PortfolioServiceNew {
}
}
if (emergencyFund.gt(0)) {
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
...cashPositions[userCurrency],
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
investment: emergencyFund.toNumber(),
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
value: emergencyFund.toNumber()
};
cashPositions[userCurrency].investment = new Big(
cashPositions[userCurrency].investment
)
.minus(emergencyFund)
.toNumber();
cashPositions[userCurrency].value = new Big(
cashPositions[userCurrency].value
)
.minus(emergencyFund)
.toNumber();
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(

@ -6,6 +6,8 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
@ -20,7 +22,11 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
baseCurrency
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
@ -75,7 +81,8 @@ export class PortfolioService {
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService
private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
@ -286,7 +293,11 @@ export class PortfolioService {
aDateRange: DateRange = 'max'
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId });
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
@ -381,6 +392,7 @@ export class PortfolioService {
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestment,
value: totalValue
@ -861,6 +873,7 @@ export class PortfolioService {
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance(aImpersonationId);
@ -873,6 +886,9 @@ export class PortfolioService {
userId
});
const dividend = this.getDividend(orders).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
@ -880,6 +896,7 @@ export class PortfolioService {
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balanceInBaseCurrency)
@ -889,6 +906,7 @@ export class PortfolioService {
return {
...performanceInformation.performance,
cash,
dividend,
fees,
firstOrderDate,
@ -898,8 +916,8 @@ export class PortfolioService {
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balanceInBaseCurrency,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
}).length
@ -908,16 +926,18 @@ export class PortfolioService {
private async getCashPositions({
cashDetails,
emergencyFund,
investment,
userCurrency,
value
}: {
cashDetails: CashDetails;
emergencyFund: Big;
investment: Big;
userCurrency: string;
value: Big;
}) {
const cashPositions = {};
const cashPositions: PortfolioDetails['holdings'] = {};
for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency(
@ -941,6 +961,7 @@ export class PortfolioService {
assetSubClass: AssetClass.CASH,
countries: [],
currency: account.currency,
dataSource: undefined,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: convertedBalance,
@ -958,6 +979,28 @@ export class PortfolioService {
}
}
if (emergencyFund.gt(0)) {
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
...cashPositions[userCurrency],
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
investment: emergencyFund.toNumber(),
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
value: emergencyFund.toNumber()
};
cashPositions[userCurrency].investment = new Big(
cashPositions[userCurrency].investment
)
.minus(emergencyFund)
.toNumber();
cashPositions[userCurrency].value = new Big(
cashPositions[userCurrency].value
)
.minus(emergencyFund)
.toNumber();
}
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big(

@ -1,3 +1,5 @@
export interface UserSettings {
emergencyFund?: number;
isNewCalculationEngine?: boolean;
isRestrictedView?: boolean;
}

@ -1,6 +1,10 @@
import { IsBoolean, IsOptional } from 'class-validator';
import { IsBoolean, IsNumber, IsOptional } from 'class-validator';
export class UpdateUserSettingDto {
@IsNumber()
@IsOptional()
emergencyFund?: number;
@IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;

@ -23,7 +23,6 @@ import {
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport';
import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';

@ -15,7 +15,7 @@ import {
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client';
import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';

@ -1,7 +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 { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -11,6 +13,8 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-summary.html'
})
export class HomeSummaryComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = true;
public summary: PortfolioSummary;
public user: User;
@ -23,6 +27,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {
this.userService.stateChanged
@ -31,6 +36,11 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}
});
@ -40,9 +50,25 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
* Initializes the controller
*/
public ngOnInit() {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.update();
}
public onChangeEmergencyFund(emergencyFund: number) {
this.dataService
.putUserSetting({ emergencyFund })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.update();
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

@ -8,9 +8,11 @@
<mat-card-content>
<gf-portfolio-summary
[baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
[isLoading]="isLoading"
[locale]="user?.settings?.locale"
[summary]="summary"
(emergencyFundChanged)="onChangeEmergencyFund($event)"
></gf-portfolio-summary>
</mat-card-content>
</mat-card>

@ -130,6 +130,26 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Emergency Fund</div>
<div
class="align-items-center d-flex justify-content-end"
[ngClass]="{ 'cursor-pointer': hasPermissionToUpdateUserSettings }"
(click)="hasPermissionToUpdateUserSettings && onEditEmergencyFund()"
>
<ion-icon
*ngIf="hasPermissionToUpdateUserSettings && !isLoading"
class="mr-1 text-muted"
name="ellipsis-horizontal-circle-outline"
></ion-icon>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.emergencyFund"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash (Buying Power)</div>
<div class="d-flex justify-content-end">

@ -1,9 +1,11 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnInit
OnInit,
Output
} from '@angular/core';
import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { formatDistanceToNow } from 'date-fns';
@ -16,10 +18,13 @@ import { formatDistanceToNow } from 'date-fns';
})
export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() hasPermissionToUpdateUserSettings: boolean;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() summary: PortfolioSummary;
@Output() emergencyFundChanged = new EventEmitter<number>();
public timeInMarket: string;
public constructor() {}
@ -37,4 +42,16 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
this.timeInMarket = undefined;
}
}
public onEditEmergencyFund() {
const emergencyFundInput = prompt(
'Please enter the amount of your emergency fund:',
this.summary.emergencyFund.toString()
);
const emergencyFund = parseFloat(emergencyFundInput?.trim());
if (emergencyFund >= 0) {
this.emergencyFundChanged.emit(emergencyFund);
}
}
}

@ -1,5 +1,5 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { PortfolioSummaryComponent } from './portfolio-summary.component';
@ -8,6 +8,6 @@ import { PortfolioSummaryComponent } from './portfolio-summary.component';
declarations: [PortfolioSummaryComponent],
exports: [PortfolioSummaryComponent],
imports: [CommonModule, GfValueModule],
providers: []
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfPortfolioSummaryModule {}

@ -13,6 +13,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router';
import { ASSET_SUB_CLASS_EMERGENCY_FUND } from '@ghostfolio/common/config';
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
import { AssetClass, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -39,7 +40,10 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreAssetSubClasses = [AssetClass.CASH.toString()];
public ignoreAssetSubClasses = [
AssetClass.CASH.toString(),
ASSET_SUB_CLASS_EMERGENCY_FUND
];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;

@ -42,6 +42,8 @@ export const warnColorRgb = {
b: 69
};
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';

@ -8,7 +8,7 @@ export interface PortfolioPosition {
allocationCurrent: number;
allocationInvestment: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass | 'CASH';
assetSubClass?: AssetSubClass | 'CASH' | 'EMERGENCY_FUND';
countries: Country[];
currency: string;
dataSource: DataSource;

@ -5,6 +5,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
cash: number;
dividend: number;
committedFunds: number;
emergencyFund: number;
fees: number;
firstOrderDate: Date;
items: number;

@ -122,7 +122,7 @@ export class PortfolioProportionChartComponent
chartData[this.positions[symbol][this.keys[0]]] = {
name: this.positions[symbol].name,
subCategory: {},
value: new Big(this.positions[symbol].value)
value: new Big(this.positions[symbol].value ?? 0)
};
if (this.positions[symbol][this.keys[1]]) {

Loading…
Cancel
Save