Feature/respect cash balance in analysis (#203)
* Respect cash balance in in analysis * Update changelog
This commit is contained in:
parent
1135a5b335
commit
f22991b090
@ -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…
x
Reference in New Issue
Block a user