Browse Source

Feature/respect cash balance in analysis (#203)

* Respect cash balance in in analysis

* Update changelog
pull/205/head
Thomas 4 years ago
committed by GitHub
parent
commit
f22991b090
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      CHANGELOG.md
  2. 40
      apps/api/src/app/account/account.service.ts
  3. 6
      apps/api/src/app/account/interfaces/cash-details.interface.ts
  4. 5
      apps/api/src/app/experimental/experimental.module.ts
  5. 3
      apps/api/src/app/experimental/experimental.service.ts
  6. 10
      apps/api/src/app/portfolio/portfolio.service.ts
  7. 58
      apps/api/src/models/portfolio.spec.ts
  8. 63
      apps/api/src/models/portfolio.ts
  9. 1
      apps/api/src/services/interfaces/interfaces.ts
  10. 8
      apps/client/src/app/components/positions-table/positions-table.component.html
  11. 4
      apps/client/src/app/components/positions-table/positions-table.component.scss
  12. 2
      apps/client/src/app/components/positions-table/positions-table.component.ts
  13. 11
      apps/client/src/app/components/positions/positions.component.ts
  14. 3
      apps/client/src/app/pages/tools/analysis/analysis-page.component.ts
  15. 1
      libs/common/src/lib/config.ts

4
CHANGELOG.md

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

40
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 { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service'; import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable() @Injectable()
export class AccountService { 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( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string 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( public async updateAccount(
params: { params: {
where: Prisma.AccountWhereUniqueInput; where: Prisma.AccountWhereUniqueInput;

6
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;
}

5
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 { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.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'; import { ExperimentalService } from './experimental.service';
@Module({ @Module({
imports: [], imports: [RedisCacheModule],
controllers: [ExperimentalController], controllers: [ExperimentalController],
providers: [ providers: [
AccountService,
AlphaVantageService, AlphaVantageService,
ConfigurationService, ConfigurationService,
DataProviderService, DataProviderService,

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

10
apps/api/src/app/portfolio/portfolio.service.ts

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

58
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 { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper'; import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import { import {
@ -16,6 +17,16 @@ import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service'; import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio'; 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', () => { jest.mock('../services/data-provider.service', () => {
return { return {
DataProviderService: jest.fn().mockImplementation(() => { 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'; const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => { describe('Portfolio', () => {
let accountService: AccountService;
let dataProviderService: DataProviderService; let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio; let portfolio: Portfolio;
let rulesService: RulesService; let rulesService: RulesService;
beforeAll(async () => { beforeAll(async () => {
accountService = new AccountService(null, null, null);
dataProviderService = new DataProviderService( dataProviderService = new DataProviderService(
null, null,
null, null,
@ -101,6 +114,7 @@ describe('Portfolio', () => {
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();
portfolio = new Portfolio( portfolio = new Portfolio(
accountService,
dataProviderService, dataProviderService,
exchangeRateDataService, exchangeRateDataService,
rulesService rulesService
@ -147,12 +161,52 @@ describe('Portfolio', () => {
it('should return empty details', async () => { it('should return empty details', async () => {
const details = await portfolio.getDetails('1d'); 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 () => { it('should return empty details', async () => {
const details = await portfolio.getDetails('max'); 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 () => { it('should return zero performance for 1d', async () => {

63
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 { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import { import {
PortfolioItem, PortfolioItem,
@ -11,7 +13,7 @@ import {
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types'; 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 { continents, countries } from 'countries-list';
import { import {
add, add,
@ -34,7 +36,7 @@ import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service'; import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.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 { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface'; import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order'; import { Order } from './order';
@ -54,6 +56,7 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings; private user: UserWithSettings;
public constructor( public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService, private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService, private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService private rulesService: RulesService
@ -232,10 +235,14 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date()); 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 portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date()); const symbols = this.getSymbols(new Date());
const value = this.getValue(); const value = this.getValue() + cashDetails.balance;
const details: { [symbol: string]: PortfolioPosition } = {}; const details: { [symbol: string]: PortfolioPosition } = {};
@ -372,6 +379,12 @@ export class Portfolio implements PortfolioInterface {
}; };
}); });
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details; return details;
} }
@ -644,6 +657,46 @@ export class Portfolio implements PortfolioInterface {
return this; 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 * TODO: Refactor
*/ */

1
apps/api/src/services/interfaces/interfaces.ts

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

8
apps/client/src/app/components/positions-table/positions-table.component.html

@ -82,7 +82,13 @@
<tr <tr
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
mat-row 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> ></tr>
</table> </table>

4
apps/client/src/app/components/positions-table/positions-table.component.scss

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

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

11
apps/client/src/app/components/positions/positions.component.ts

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

3
apps/client/src/app/pages/tools/analysis/analysis-page.component.ts

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

1
libs/common/src/lib/config.ts

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

Loading…
Cancel
Save