Optimize details endpoint (#3123)

* Make summary optional

* Introduce dedicated holdings endpoint

* Update changelog
pull/3124/head
Thomas Kaul 3 months ago committed by GitHub
parent 6d2a897366
commit eb75be8535
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
- Removed the environment variable `WEB_AUTH_RP_ID`

@ -20,6 +20,7 @@ import {
import {
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
@ -95,21 +96,15 @@ export class PortfolioController {
filterByTags
});
const {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasErrors,
holdings,
platforms,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id
});
const { accounts, hasErrors, holdings, platforms, summary } =
await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id,
withLiabilities: true,
withSummary: true
});
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true;
@ -164,19 +159,21 @@ export class PortfolioController {
'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue',
'dividendInBaseCurrency',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'filteredValueInBaseCurrency',
'fireWealth',
'interest',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalInvestment',
'totalSell'
'totalSell',
'totalValueInBaseCurrency'
]);
}
@ -203,12 +200,9 @@ export class PortfolioController {
return {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError,
holdings,
platforms,
totalValueInBaseCurrency,
summary: portfolioSummary
};
}
@ -279,6 +273,33 @@ export class PortfolioController {
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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments(
@ -502,7 +523,6 @@ export class PortfolioController {
}
const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId,
userId: user.id

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

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

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

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

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

@ -18,7 +18,7 @@
[value]="
isLoading
? undefined
: portfolioDetails?.filteredValueInPercentage
: portfolioDetails?.summary?.filteredValueInPercentage
"
/>
</mat-card-header>
@ -26,10 +26,11 @@
<mat-progress-bar
mode="determinate"
[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>

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

@ -27,6 +27,7 @@ import {
OAuthResponse,
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioPerformanceResponse,
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({
filters,
range,

@ -1,6 +1,6 @@
import * as currencies from '@dinero.js/currencies';
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 {
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) {
return `${dataSource}-${symbol}`;
}

@ -40,6 +40,7 @@ import type { BenchmarkResponse } from './responses/benchmark-response.interface
import type { ResponseError } from './responses/errors.interface';
import type { ImportResponse } from './responses/import-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 { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface';
@ -81,6 +82,7 @@ export {
PortfolioChart,
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioItem,
PortfolioOverview,

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

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

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