Feature/respect cash balance in analysis (#203)

* Respect cash balance in in analysis

* Update changelog
pull/205/head
Thomas 3 years ago committed by GitHub
parent 1135a5b335
commit f22991b090
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Changed
- Respected the cash balance on the analysis page
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)

@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
@ -55,24 +56,6 @@ export class AccountService {
});
}
public async calculateCashBalance(aUserId: string, aCurrency: Currency) {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return totalCashBalance;
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
@ -93,6 +76,27 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;

@ -0,0 +1,6 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

@ -1,3 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
imports: [RedisCacheModule],
controllers: [ExperimentalController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
@ -14,6 +15,7 @@ import { Data } from './interfaces/data.interface';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
@ -52,6 +54,7 @@ export class ExperimentalService {
});
const portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService

@ -70,6 +70,7 @@ export class PortfolioService {
JSON.parse(stringifiedPortfolio);
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -86,6 +87,7 @@ export class PortfolioService {
});
portfolio = new Portfolio(
this.accountService,
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
@ -194,7 +196,7 @@ export class PortfolioService {
impersonationUserId || this.request.user.id
);
const cash = await this.accountService.calculateCashBalance(
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
@ -202,9 +204,9 @@ export class PortfolioService {
const fees = portfolio.getFees();
return {
cash,
committedFunds,
fees,
cash: balance,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
@ -350,13 +352,13 @@ export class PortfolioService {
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
@ -16,6 +17,16 @@ import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../app/account/account.service', () => {
return {
AccountService: jest.fn().mockImplementation(() => {
return {
getCashDetails: () => Promise.resolve({ accounts: [], balance: 0 })
};
})
};
});
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
@ -81,12 +92,14 @@ const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let accountService: AccountService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let rulesService: RulesService;
beforeAll(async () => {
accountService = new AccountService(null, null, null);
dataProviderService = new DataProviderService(
null,
null,
@ -101,6 +114,7 @@ describe('Portfolio', () => {
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
accountService,
dataProviderService,
exchangeRateDataService,
rulesService
@ -147,12 +161,52 @@ describe('Portfolio', () => {
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
expect(details).toMatchObject({
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
});
it('should return zero performance for 1d', async () => {

@ -1,4 +1,6 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
@ -11,7 +13,7 @@ import {
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { Currency, Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
@ -34,7 +36,7 @@ import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { IOrder, MarketState, Type } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
@ -54,6 +56,7 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings;
public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
@ -232,10 +235,14 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const cashDetails = await this.accountService.getCashDetails(
this.user.id,
this.user.Settings.currency
);
const investment = this.getInvestment(new Date()) + cashDetails.balance;
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const value = this.getValue() + cashDetails.balance;
const details: { [symbol: string]: PortfolioPosition } = {};
@ -372,6 +379,12 @@ export class Portfolio implements PortfolioInterface {
};
});
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details;
}
@ -644,6 +657,46 @@ export class Portfolio implements PortfolioInterface {
return this;
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: number;
value: number;
}) {
const accounts = {};
const cashValue = cashDetails.balance;
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue / value,
allocationInvestment: cashValue / investment,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue,
marketPrice: 0,
marketState: MarketState.open,
name: Type.Cash,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
type: Type.Cash,
transactionCount: 0,
value: cashValue
};
}
/**
* TODO: Refactor
*/

@ -10,6 +10,7 @@ export const MarketState = {
};
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',

@ -82,7 +82,13 @@
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="onOpenPositionDialog({ symbol: row.symbol, title: row.name })"
[ngClass]="{
'cursor-pointer': !this.ignoreTypes.includes(row.type)
}"
(click)="
!this.ignoreTypes.includes(row.type) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
"
></tr>
</table>

@ -19,7 +19,9 @@
}
.mat-row {
cursor: pointer;
&.cursor-pointer {
cursor: pointer;
}
}
}
}

@ -14,6 +14,7 @@ import { MatPaginator } from '@angular/material/paginator';
import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
@ -42,6 +43,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreTypes = [Type.Cash];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;

@ -5,7 +5,10 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import {
MarketState,
Type
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
@Component({
@ -25,6 +28,8 @@ export class PositionsComponent implements OnChanges, OnInit {
public positionsRest: PortfolioPosition[] = [];
public positionsWithPriority: PortfolioPosition[] = [];
private ignoreTypes = [Type.Cash];
public constructor() {}
public ngOnInit() {}
@ -41,6 +46,10 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = [];
for (const [, portfolioPosition] of Object.entries(this.positions)) {
if (this.ignoreTypes.includes(portfolioPosition.type)) {
continue;
}
if (
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'

@ -9,7 +9,6 @@ import {
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -30,12 +29,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public hasImpersonationId: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public hasImpersonationId: boolean;
public portfolioItems: PortfolioItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };

@ -15,6 +15,7 @@ export const currencyPairs: Partial<IDataGatheringItem>[] = [
];
export const ghostfolioScraperApiSymbolPrefix = '_GF_';
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const locale = 'de-CH';

Loading…
Cancel
Save