diff --git a/CHANGELOG.md b/CHANGELOG.md index 142bfc6c5..7ff12bfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts index 26f85268e..750ef1fc9 100644 --- a/apps/api/src/app/account/account.service.ts +++ b/apps/api/src/app/account/account.service.ts @@ -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 { + 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; diff --git a/apps/api/src/app/account/interfaces/cash-details.interface.ts b/apps/api/src/app/account/interfaces/cash-details.interface.ts new file mode 100644 index 000000000..146ee6b29 --- /dev/null +++ b/apps/api/src/app/account/interfaces/cash-details.interface.ts @@ -0,0 +1,6 @@ +import { Account } from '@prisma/client'; + +export interface CashDetails { + accounts: Account[]; + balance: number; +} diff --git a/apps/api/src/app/experimental/experimental.module.ts b/apps/api/src/app/experimental/experimental.module.ts index 3ab67ffb0..7394c70eb 100644 --- a/apps/api/src/app/experimental/experimental.module.ts +++ b/apps/api/src/app/experimental/experimental.module.ts @@ -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, diff --git a/apps/api/src/app/experimental/experimental.service.ts b/apps/api/src/app/experimental/experimental.service.ts index b0d41b867..eda248f52 100644 --- a/apps/api/src/app/experimental/experimental.service.ts +++ b/apps/api/src/app/experimental/experimental.service.ts @@ -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 diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 540cc6f8d..71fcadd12 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -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, diff --git a/apps/api/src/models/portfolio.spec.ts b/apps/api/src/models/portfolio.spec.ts index 9a2f2cfe2..f8fcea69d 100644 --- a/apps/api/src/models/portfolio.spec.ts +++ b/apps/api/src/models/portfolio.spec.ts @@ -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 () => { diff --git a/apps/api/src/models/portfolio.ts b/apps/api/src/models/portfolio.ts index 9540dceb5..38ea79e41 100644 --- a/apps/api/src/models/portfolio.ts +++ b/apps/api/src/models/portfolio.ts @@ -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 */ diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts index b909c7e78..845302d05 100644 --- a/apps/api/src/services/interfaces/interfaces.ts +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -10,6 +10,7 @@ export const MarketState = { }; export const Type = { + Cash: 'Cash', Cryptocurrency: 'Cryptocurrency', ETF: 'ETF', Stock: 'Stock', diff --git a/apps/client/src/app/components/positions-table/positions-table.component.html b/apps/client/src/app/components/positions-table/positions-table.component.html index 1bc05e881..c6cee5f61 100644 --- a/apps/client/src/app/components/positions-table/positions-table.component.html +++ b/apps/client/src/app/components/positions-table/positions-table.component.html @@ -82,7 +82,13 @@ diff --git a/apps/client/src/app/components/positions-table/positions-table.component.scss b/apps/client/src/app/components/positions-table/positions-table.component.scss index 04e37f81d..72df638e1 100644 --- a/apps/client/src/app/components/positions-table/positions-table.component.scss +++ b/apps/client/src/app/components/positions-table/positions-table.component.scss @@ -19,7 +19,9 @@ } .mat-row { - cursor: pointer; + &.cursor-pointer { + cursor: pointer; + } } } } diff --git a/apps/client/src/app/components/positions-table/positions-table.component.ts b/apps/client/src/app/components/positions-table/positions-table.component.ts index 22694cef8..5ad58f4f2 100644 --- a/apps/client/src/app/components/positions-table/positions-table.component.ts +++ b/apps/client/src/app/components/positions-table/positions-table.component.ts @@ -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 = new MatTableDataSource(); public displayedColumns = []; + public ignoreTypes = [Type.Cash]; public isLoading = true; public pageSize = 7; public routeQueryParams: Subscription; diff --git a/apps/client/src/app/components/positions/positions.component.ts b/apps/client/src/app/components/positions/positions.component.ts index da31ba614..cb308aef5 100644 --- a/apps/client/src/app/components/positions/positions.component.ts +++ b/apps/client/src/app/components/positions/positions.component.ts @@ -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' diff --git a/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts b/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts index 10bd7832d..a8ab3e98e 100644 --- a/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts +++ b/apps/client/src/app/pages/tools/analysis/analysis-page.component.ts @@ -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 }; diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts index f7aa52da0..c767b08cf 100644 --- a/libs/common/src/lib/config.ts +++ b/libs/common/src/lib/config.ts @@ -15,6 +15,7 @@ export const currencyPairs: Partial[] = [ ]; export const ghostfolioScraperApiSymbolPrefix = '_GF_'; +export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`; export const locale = 'de-CH';