Browse Source

Optimize details endpoint (#3123)

* Make summary optional

* Introduce dedicated holdings endpoint

* Update changelog
pull/3124/head
Thomas Kaul 11 months ago
committed by GitHub
parent
commit
eb75be8535
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      CHANGELOG.md
  2. 54
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 49
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 2
      apps/api/src/interceptors/redact-values-in-response.interceptor.ts
  5. 8
      apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts
  6. 2
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  7. 1
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  8. 9
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  9. 30
      apps/client/src/app/pages/portfolio/holdings/holdings-page.component.ts
  10. 41
      apps/client/src/app/services/data.service.ts
  11. 6
      libs/common/src/lib/helper.ts
  12. 2
      libs/common/src/lib/interfaces/index.ts
  13. 5
      libs/common/src/lib/interfaces/portfolio-details.interface.ts
  14. 4
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  15. 5
      libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts

2
CHANGELOG.md

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Optimized the calculation of the accounts table
- Optimized the calculation of the portfolio holdings
- Integrated dividend into the transaction point concept in the portfolio service - Integrated dividend into the transaction point concept in the portfolio service
- Removed the environment variable `WEB_AUTH_RP_ID` - Removed the environment variable `WEB_AUTH_RP_ID`

54
apps/api/src/app/portfolio/portfolio.controller.ts

@ -20,6 +20,7 @@ import {
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
@ -95,20 +96,14 @@ export class PortfolioController {
filterByTags filterByTags
}); });
const { const { accounts, hasErrors, holdings, platforms, summary } =
accounts, await this.portfolioService.getDetails({
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasErrors,
holdings,
platforms,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails({
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
userId: this.request.user.id userId: this.request.user.id,
withLiabilities: true,
withSummary: true
}); });
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -164,19 +159,21 @@ export class PortfolioController {
'currentGrossPerformanceWithCurrencyEffect', 'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance', 'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect', 'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue', 'currentValue',
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'emergencyFund', 'emergencyFund',
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency',
'fireWealth', 'fireWealth',
'interest', 'interest',
'items', 'items',
'liabilities', 'liabilities',
'netWorth',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalSell' 'totalSell',
'totalValueInBaseCurrency'
]); ]);
} }
@ -203,12 +200,9 @@ export class PortfolioController {
return { return {
accounts, accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError, hasError,
holdings, holdings,
platforms, platforms,
totalValueInBaseCurrency,
summary: portfolioSummary summary: portfolioSummary
}; };
} }
@ -279,6 +273,33 @@ export class PortfolioController {
return { dividends }; return { dividends };
} }
@Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('query') filterBySearchQuery?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterBySearchQuery,
filterByTags
});
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
}
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments( public async getInvestments(
@ -502,7 +523,6 @@ export class PortfolioController {
} }
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id userId: user.id

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

@ -24,7 +24,12 @@ import {
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAllActivityTypes,
getSum,
parseDate
} from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -141,7 +146,8 @@ export class PortfolioService {
filters, filters,
withExcludedAccounts, withExcludedAccounts,
impersonationId: userId, impersonationId: userId,
userId: this.request.user.id userId: this.request.user.id,
withLiabilities: true
}) })
]); ]);
@ -332,13 +338,17 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false,
withLiabilities = false,
withSummary = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withLiabilities?: boolean;
withSummary?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -352,7 +362,12 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
withExcludedAccounts withExcludedAccounts,
types: withLiabilities
? undefined
: getAllActivityTypes().filter((activityType) => {
return activityType !== 'LIABILITY';
})
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -625,7 +640,11 @@ export class PortfolioService {
}; };
} }
const summary = await this.getSummary({ let summary: PortfolioSummary;
if (withSummary) {
summary = await this.getSummary({
filteredValueInBaseCurrency,
holdings, holdings,
impersonationId, impersonationId,
userCurrency, userCurrency,
@ -636,18 +655,14 @@ export class PortfolioService {
holdings holdings
}) })
}); });
}
return { return {
accounts, accounts,
holdings, holdings,
platforms, platforms,
summary, summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), hasErrors: currentPositions.hasErrors
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
}; };
} }
@ -1705,6 +1720,7 @@ export class PortfolioService {
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
filteredValueInBaseCurrency,
holdings, holdings,
impersonationId, impersonationId,
userCurrency, userCurrency,
@ -1712,6 +1728,7 @@ export class PortfolioService {
}: { }: {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number; emergencyFundPositionsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big;
holdings: PortfolioDetails['holdings']; holdings: PortfolioDetails['holdings'];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
@ -1893,7 +1910,6 @@ export class PortfolioService {
interest, interest,
items, items,
liabilities, liabilities,
netWorth,
totalBuy, totalBuy,
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
@ -1905,12 +1921,17 @@ export class PortfolioService {
.toNumber(), .toNumber(),
total: emergencyFund.toNumber() total: emergencyFund.toNumber()
}, },
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(performanceInformation.performance.currentValue) fireWealth: new Big(performanceInformation.performance.currentValue)
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(), .toNumber(),
ordersCount: activities.filter(({ type }) => { ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL'; return type === 'BUY' || type === 'SELL';
}).length }).length,
totalValueInBaseCurrency: netWorth
}; };
} }
@ -1943,7 +1964,7 @@ export class PortfolioService {
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
types = ['BUY', 'DIVIDEND', 'ITEM', 'LIABILITY', 'SELL'], types = getAllActivityTypes(),
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {

2
apps/api/src/interceptors/redact-values-in-response.interceptor.ts

@ -49,7 +49,6 @@ export class RedactValuesInResponseInterceptor<T>
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'fee', 'fee',
'feeInBaseCurrency', 'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'investment', 'investment',
@ -58,7 +57,6 @@ export class RedactValuesInResponseInterceptor<T>
'quantity', 'quantity',
'symbolMapping', 'symbolMapping',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice', 'unitPrice',
'value', 'value',
'valueInBaseCurrency' 'valueInBaseCurrency'

8
apps/client/src/app/components/account-detail-dialog/account-detail-dialog.component.ts

@ -115,7 +115,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
); );
this.dataService this.dataService
.fetchPortfolioDetails({ .fetchPortfolioHoldings({
filters: [ filters: [
{ {
type: 'ACCOUNT', type: 'ACCOUNT',
@ -125,11 +125,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = []; this.holdings = holdings;
for (const [symbol, holding] of Object.entries(holdings)) {
this.holdings.push(holding);
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

2
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -282,7 +282,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth" [value]="isLoading ? undefined : summary?.totalValueInBaseCurrency"
/> />
</div> </div>
</div> </div>

1
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -281,7 +281,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.platforms = {}; this.platforms = {};
this.portfolioDetails = { this.portfolioDetails = {
accounts: {}, accounts: {},
filteredValueInPercentage: 0,
holdings: {}, holdings: {},
platforms: {}, platforms: {},
summary: undefined summary: undefined

9
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -18,7 +18,7 @@
[value]=" [value]="
isLoading isLoading
? undefined ? undefined
: portfolioDetails?.filteredValueInPercentage : portfolioDetails?.summary?.filteredValueInPercentage
" "
/> />
</mat-card-header> </mat-card-header>
@ -26,10 +26,11 @@
<mat-progress-bar <mat-progress-bar
mode="determinate" mode="determinate"
[title]=" [title]="
(portfolioDetails?.filteredValueInPercentage * 100).toFixed(2) + (
'%' portfolioDetails?.summary?.filteredValueInPercentage * 100
).toFixed(2) + '%'
" "
[value]="portfolioDetails?.filteredValueInPercentage * 100" [value]="portfolioDetails?.summary?.filteredValueInPercentage * 100"
/> />
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

30
apps/client/src/app/pages/portfolio/holdings/holdings-page.component.ts

@ -3,11 +3,7 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
PortfolioDetails,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
@ -28,8 +24,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[]; public holdings: PortfolioPosition[];
public isLoading = false;
public portfolioDetails: PortfolioDetails;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -83,12 +77,10 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
this.holdings = undefined; this.holdings = undefined;
this.fetchPortfolioDetails() this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((portfolioDetails) => { .subscribe(({ holdings }) => {
this.portfolioDetails = portfolioDetails; this.holdings = holdings;
this.initialize();
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -103,22 +95,12 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private fetchPortfolioDetails() { private fetchHoldings() {
return this.dataService.fetchPortfolioDetails({ return this.dataService.fetchPortfolioHoldings({
filters: this.userService.getFilters() filters: this.userService.getFilters()
}); });
} }
private initialize() {
this.holdings = [];
for (const [symbol, holding] of Object.entries(
this.portfolioDetails.holdings
)) {
this.holdings.push(holding);
}
}
private openPositionDialog({ private openPositionDialog({
dataSource, dataSource,
symbol symbol

41
apps/client/src/app/services/data.service.ts

@ -27,6 +27,7 @@ import {
OAuthResponse, OAuthResponse,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
@ -434,6 +435,46 @@ export class DataService {
); );
} }
public fetchPortfolioHoldings({
filters
}: {
filters?: Filter[];
} = {}) {
return this.http
.get<PortfolioHoldingsResponse>('/api/v1/portfolio/holdings', {
params: this.buildFiltersAsQueryParams({ filters })
})
.pipe(
map((response) => {
if (response.holdings) {
for (const symbol of Object.keys(response.holdings)) {
response.holdings[symbol].assetClassLabel = translate(
response.holdings[symbol].assetClass
);
response.holdings[symbol].assetSubClassLabel = translate(
response.holdings[symbol].assetSubClass
);
response.holdings[symbol].dateOfFirstActivity = response.holdings[
symbol
].dateOfFirstActivity
? parseISO(response.holdings[symbol].dateOfFirstActivity)
: undefined;
response.holdings[symbol].value = isNumber(
response.holdings[symbol].value
)
? response.holdings[symbol].value
: response.holdings[symbol].valueInPercentage;
}
}
return response;
})
);
}
public fetchPortfolioPerformance({ public fetchPortfolioPerformance({
filters, filters,
range, range,

6
libs/common/src/lib/helper.ts

@ -1,6 +1,6 @@
import * as currencies from '@dinero.js/currencies'; import * as currencies from '@dinero.js/currencies';
import { NumberParser } from '@internationalized/number'; import { NumberParser } from '@internationalized/number';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData, Type as ActivityType } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
getDate, getDate,
@ -138,6 +138,10 @@ export function extractNumberFromString({
} }
} }
export function getAllActivityTypes(): ActivityType[] {
return ['BUY', 'DIVIDEND', 'FEE', 'ITEM', 'LIABILITY', 'SELL'];
}
export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) { export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) {
return `${dataSource}-${symbol}`; return `${dataSource}-${symbol}`;
} }

2
libs/common/src/lib/interfaces/index.ts

@ -40,6 +40,7 @@ import type { BenchmarkResponse } from './responses/benchmark-response.interface
import type { ResponseError } from './responses/errors.interface'; import type { ResponseError } from './responses/errors.interface';
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface'; import type { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface'; import type { Statistics } from './statistics.interface';
@ -81,6 +82,7 @@ export {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioItem, PortfolioItem,
PortfolioOverview, PortfolioOverview,

5
libs/common/src/lib/interfaces/portfolio-details.interface.ts

@ -13,8 +13,6 @@ export interface PortfolioDetails {
valueInPercentage?: number; valueInPercentage?: number;
}; };
}; };
filteredValueInBaseCurrency?: number;
filteredValueInPercentage: number;
holdings: { [symbol: string]: PortfolioPosition }; holdings: { [symbol: string]: PortfolioPosition };
platforms: { platforms: {
[id: string]: { [id: string]: {
@ -25,6 +23,5 @@ export interface PortfolioDetails {
valueInPercentage?: number; valueInPercentage?: number;
}; };
}; };
summary: PortfolioSummary; summary?: PortfolioSummary;
totalValueInBaseCurrency?: number;
} }

4
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -13,13 +13,15 @@ export interface PortfolioSummary extends PortfolioPerformance {
}; };
excludedAccountsAndActivities: number; excludedAccountsAndActivities: number;
fees: number; fees: number;
filteredValueInBaseCurrency?: number;
filteredValueInPercentage?: number;
fireWealth: number; fireWealth: number;
firstOrderDate: Date; firstOrderDate: Date;
interest: number; interest: number;
items: number; items: number;
liabilities: number; liabilities: number;
netWorth: number;
ordersCount: number; ordersCount: number;
totalBuy: number; totalBuy: number;
totalSell: number; totalSell: number;
totalValueInBaseCurrency?: number;
} }

5
libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts

@ -0,0 +1,5 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
export interface PortfolioHoldingsResponse {
holdings: PortfolioPosition[];
}
Loading…
Cancel
Save