Optimize details endpoint (#3123)
* Make summary optional * Introduce dedicated holdings endpoint * Update changelog
This commit is contained in:
parent
6d2a897366
commit
eb75be8535
@ -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,20 +96,14 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
const { accounts, hasErrors, holdings, platforms, summary } =
|
||||
await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
userId: this.request.user.id,
|
||||
withLiabilities: true,
|
||||
withSummary: true
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
@ -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,7 +640,11 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
const summary = await this.getSummary({
|
||||
let summary: PortfolioSummary;
|
||||
|
||||
if (withSummary) {
|
||||
summary = await this.getSummary({
|
||||
filteredValueInBaseCurrency,
|
||||
holdings,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
@ -636,18 +655,14 @@ export class PortfolioService {
|
||||
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…
x
Reference in New Issue
Block a user