Browse Source

Merge pull request #7 from dandevaud/feature/Some_performance_optimizations

Feature/some performance optimizations
pull/5027/head
dandevaud 2 years ago
committed by GitHub
parent
commit
10578ee1c7
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 1
      apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts
  2. 75
      apps/api/src/app/portfolio/portfolio-calculator.ts
  3. 100
      apps/api/src/app/portfolio/portfolio.controller.ts
  4. 250
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 3
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts
  6. 16
      apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html
  7. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  8. 8
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  9. 2
      apps/client/src/app/services/import-activities.service.ts
  10. 9
      libs/ui/src/lib/activities-table/activities-table.component.html
  11. 4
      libs/ui/src/lib/activities-table/activities-table.component.scss
  12. 1
      prisma/schema.prisma

1
apps/api/src/app/portfolio/interfaces/portfolio-position-detail.interface.ts

@ -10,6 +10,7 @@ export interface PortfolioPositionDetail {
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
stakeRewards: number;
feeInBaseCurrency: number;
firstBuyDate: string;
grossPerformance: number;

75
apps/api/src/app/portfolio/portfolio-calculator.ts

@ -3,6 +3,7 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
HistoricalDataItem,
ResponseError,
TimelinePosition
} from '@ghostfolio/common/interfaces';
@ -92,7 +93,7 @@ export class PortfolioCalculator {
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
if (order.type === 'BUY' || order.type === 'STAKE') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
@ -277,46 +278,29 @@ export class PortfolioCalculator {
};
}
for (const currentDate of dates) {
const dateString = format(currentDate, DATE_FORMAT);
return dates.map((date) => {
const dateString = format(date, DATE_FORMAT);
let totalCurrentValue = new Big(0);
let totalInvestmentValue = new Big(0);
let maxTotalInvestmentValue = new Big(0);
let totalNetPerformanceValue = new Big(0);
for (const symbol of Object.keys(valuesBySymbol)) {
const symbolValues = valuesBySymbol[symbol];
const currentValue =
symbolValues.currentValues?.[dateString] ?? new Big(0);
const investmentValue =
symbolValues.investmentValues?.[dateString] ?? new Big(0);
const maxInvestmentValue =
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0);
const netPerformanceValue =
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0);
valuesByDate[dateString] = {
totalCurrentValue: (
valuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
).add(currentValue),
totalInvestmentValue: (
valuesByDate[dateString]?.totalInvestmentValue ?? new Big(0)
).add(investmentValue),
maxTotalInvestmentValue: (
valuesByDate[dateString]?.maxTotalInvestmentValue ?? new Big(0)
).add(maxInvestmentValue),
totalNetPerformanceValue: (
valuesByDate[dateString]?.totalNetPerformanceValue ?? new Big(0)
).add(netPerformanceValue)
};
totalCurrentValue = totalCurrentValue.plus(
symbolValues.currentValues?.[dateString] ?? new Big(0)
);
totalInvestmentValue = totalInvestmentValue.plus(
symbolValues.investmentValues?.[dateString] ?? new Big(0)
);
maxTotalInvestmentValue = maxTotalInvestmentValue.plus(
symbolValues.maxInvestmentValues?.[dateString] ?? new Big(0)
);
totalNetPerformanceValue = totalNetPerformanceValue.plus(
symbolValues.netPerformanceValues?.[dateString] ?? new Big(0)
);
}
}
return Object.entries(valuesByDate).map(([date, values]) => {
const {
maxTotalInvestmentValue,
totalCurrentValue,
totalInvestmentValue,
totalNetPerformanceValue
} = values;
const netPerformanceInPercentage = maxTotalInvestmentValue.eq(0)
? 0
: totalNetPerformanceValue
@ -325,7 +309,7 @@ export class PortfolioCalculator {
.toNumber();
return {
date,
date: dateString,
netPerformanceInPercentage,
netPerformance: totalNetPerformanceValue.toNumber(),
totalInvestment: totalInvestmentValue.toNumber(),
@ -931,6 +915,7 @@ export class PortfolioCalculator {
switch (type) {
case 'BUY':
case 'STAKE':
factor = 1;
break;
case 'SELL':
@ -1087,6 +1072,20 @@ export class PortfolioCalculator {
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
} else {
let orderIndex = orders.findIndex(
(o) => o.date === format(day, DATE_FORMAT) && o.type === 'STAKE'
);
if (orderIndex >= 0) {
let order = orders[orderIndex];
orders.splice(orderIndex, 1);
orders.push({
...order,
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
});
}
}
lastUnitPrice = last(orders).unitPrice;
@ -1156,7 +1155,7 @@ export class PortfolioCalculator {
}
const transactionInvestment =
order.type === 'BUY'
order.type === 'BUY' || order.type === 'STAKE'
? order.quantity.mul(order.unitPrice).mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment

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

@ -111,21 +111,38 @@ export class PortfolioController {
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => {
return portfolioPosition.investment;
})
.reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings)
.map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.settings.baseCurrency
);
})
.reduce((a, b) => a + b, 0);
let investmentTuple: [number, number] = [0, 0];
for (let holding of Object.entries(holdings)) {
var portfolioPosition = holding[1];
investmentTuple[0] += portfolioPosition.investment;
investmentTuple[1] += this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user.Settings.settings.baseCurrency
);
}
const totalInvestment = investmentTuple[0];
const totalValue = investmentTuple[1];
if (hasDetails === false) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'
]);
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null;
@ -135,6 +152,24 @@ export class PortfolioController {
portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue;
(portfolioPosition.assetClass = hasDetails
? portfolioPosition.assetClass
: undefined),
(portfolioPosition.assetSubClass = hasDetails
? portfolioPosition.assetSubClass
: undefined),
(portfolioPosition.countries = hasDetails
? portfolioPosition.countries
: []),
(portfolioPosition.currency = hasDetails
? portfolioPosition.currency
: undefined),
(portfolioPosition.markets = hasDetails
? portfolioPosition.markets
: undefined),
(portfolioPosition.sectors = hasDetails
? portfolioPosition.sectors
: []);
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -146,41 +181,6 @@ export class PortfolioController {
}
}
if (
hasDetails === false ||
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
portfolioSummary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'dividend',
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalSell'
]);
}
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
holdings[symbol] = {
...portfolioPosition,
assetClass: hasDetails ? portfolioPosition.assetClass : undefined,
assetSubClass: hasDetails ? portfolioPosition.assetSubClass : undefined,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
return {
accounts,
filteredValueInBaseCurrency,

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

@ -524,11 +524,9 @@ export class PortfolioService {
}
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
for (const item of currentPositions.positions) {
portfolioItemsNow[item.symbol] = item;
if (item.quantity.lte(0)) {
// Ignore positions without any quantity
continue;
@ -544,21 +542,7 @@ export class PortfolioService {
otherMarkets: 0
};
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
this.calculateMarketsAllocation(symbolProfile, markets);
holdings[item.symbol] = {
markets,
@ -589,6 +573,68 @@ export class PortfolioService {
};
}
await this.handleCashPosition(
filters,
isFilteredByAccount,
cashDetails,
userCurrency,
filteredValueInBaseCurrency,
holdings
);
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
filters,
orders,
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts
});
filteredValueInBaseCurrency = await this.handleEmergencyFunds(
filters,
cashDetails,
userCurrency,
filteredValueInBaseCurrency,
emergencyFund,
orders,
accounts,
holdings
);
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
});
return {
accounts,
holdings,
platforms,
summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
};
}
private async handleCashPosition(
filters: Filter[],
isFilteredByAccount: boolean,
cashDetails: CashDetails,
userCurrency: string,
filteredValueInBaseCurrency: Big,
holdings: { [symbol: string]: PortfolioPosition }
) {
const isFilteredByCash = filters?.some((filter) => {
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
});
@ -604,16 +650,26 @@ export class PortfolioService {
holdings[symbol] = cashPositions[symbol];
}
}
}
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
filters,
orders,
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts
});
private async handleEmergencyFunds(
filters: Filter[],
cashDetails: CashDetails,
userCurrency: string,
filteredValueInBaseCurrency: Big,
emergencyFund: Big,
orders: Activity[],
accounts: {
[id: string]: {
balance: number;
currency: string;
name: string;
valueInBaseCurrency: number;
valueInPercentage?: number;
};
},
holdings: { [symbol: string]: PortfolioPosition }
) {
if (
filters?.length === 1 &&
filters[0].id === EMERGENCY_FUND_TAG_ID &&
@ -648,30 +704,32 @@ export class PortfolioService {
value: emergencyFundInCash
};
}
return filteredValueInBaseCurrency;
}
const summary = await this.getSummary({
impersonationId,
userCurrency,
userId,
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
})
});
return {
accounts,
holdings,
platforms,
summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
};
private calculateMarketsAllocation(
symbolProfile: EnhancedSymbolProfile,
markets: {
developedMarkets: number;
emergingMarkets: number;
otherMarkets: number;
}
) {
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
}
public async getPosition(
@ -704,6 +762,7 @@ export class PortfolioService {
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
stakeRewards: undefined,
feeInBaseCurrency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
@ -732,7 +791,11 @@ export class PortfolioService {
.filter((order) => {
tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL';
return (
order.type === 'BUY' ||
order.type === 'SELL' ||
order.type === 'STAKE'
);
})
.map((order) => ({
currency: order.SymbolProfile.currency,
@ -788,6 +851,16 @@ export class PortfolioService {
})
);
const stakeRewards = getSum(
orders
.filter(({ type }) => {
return type === 'STAKE';
})
.map(({ quantity }) => {
return new Big(quantity);
})
);
// Convert investment, gross and net performance to currency of user
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
@ -882,6 +955,7 @@ export class PortfolioService {
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
stakeRewards: stakeRewards.toNumber(),
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee.toNumber(),
SymbolProfile.currency,
@ -945,6 +1019,7 @@ export class PortfolioService {
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,
stakeRewards: 0,
feeInBaseCurrency: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
@ -1405,7 +1480,7 @@ export class PortfolioService {
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) {
if (order.type === 'BUY') {
if (order.type === 'BUY' || order.type === 'STAKE') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
@ -1592,38 +1667,63 @@ export class PortfolioService {
userId
});
const activities = await this.orderService.getOrders({
const ordersRaw = await this.orderService.getOrders({
userCurrency,
userId
});
const excludedActivities = (
await this.orderService.getOrders({
userCurrency,
userId,
withExcludedAccounts: true
})
).filter(({ Account: account }) => {
return account?.isExcluded ?? false;
userId,
withExcludedAccounts: true
});
const activities: Activity[] = [];
const excludedActivities: Activity[] = [];
let dividend = 0;
let fees = 0;
let items = 0;
let liabilities = 0;
let totalBuy = 0;
let totalSell = 0;
for (let order of ordersRaw) {
if (order.Account?.isExcluded ?? false) {
excludedActivities.push(order);
} else {
activities.push(order);
fees += this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
);
let amount = this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.SymbolProfile.currency,
userCurrency
);
switch (order.type) {
case 'DIVIDEND':
dividend += amount;
break;
case 'ITEM':
items += amount;
break;
case 'SELL':
totalSell += amount;
break;
case 'BUY':
totalBuy += amount;
break;
case 'LIABILITY':
liabilities += amount;
}
}
}
const dividend = this.getDividend({
activities,
userCurrency
}).toNumber();
const emergencyFund = new Big(
Math.max(
emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
)
);
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
const firstOrderDate = activities[0]?.date;
const cash = new Big(balanceInBaseCurrency)
.minus(emergencyFund)
@ -1716,7 +1816,7 @@ export class PortfolioService {
userCurrency,
userId,
withExcludedAccounts,
types: ['BUY', 'SELL']
types: ['BUY', 'SELL', 'STAKE']
});
if (orders.length <= 0) {

3
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.component.ts

@ -40,6 +40,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
};
public dataProviderInfo: DataProviderInfo;
public dividendInBaseCurrency: number;
public stakeRewards: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;
public grossPerformance: number;
@ -84,6 +85,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
stakeRewards,
feeInBaseCurrency,
firstBuyDate,
grossPerformance,
@ -107,6 +109,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.stakeRewards = stakeRewards;
this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;

16
apps/client/src/app/components/position/position-detail-dialog/position-detail-dialog.html

@ -126,7 +126,10 @@
>Investment</gf-value
>
</div>
<div class="col-6 mb-3">
<div
*ngIf="dividendInBaseCurrency > 0 || !stakeRewards"
class="col-6 mb-3"
>
<gf-value
i18n
size="medium"
@ -137,6 +140,17 @@
>Dividend</gf-value
>
</div>
<div *ngIf="stakeRewards > 0" class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[precision]="quantityPrecision"
[value]="stakeRewards"
>Stake Rewards
</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
i18n

6
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -223,6 +223,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0;
} else if (this.activityForm.controls['type'].value === 'STAKE') {
this.total =
this.activityForm.controls['quantity'].value *
this.currentMarketPrice ?? 0;
} else {
this.total =
this.activityForm.controls['quantity'].value *
@ -237,7 +241,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (this.activityForm.controls['searchSymbol'].invalid) {
this.data.activity.SymbolProfile = null;
} else if (
['BUY', 'DIVIDEND', 'SELL'].includes(
['BUY', 'DIVIDEND', 'SELL', 'STAKE'].includes(
this.activityForm.controls['type'].value
)
) {

8
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -25,6 +25,9 @@
<mat-option class="line-height-1" value="DIVIDEND">
<span><b>{{ typesTranslationMap['DIVIDEND'] }}</b></span>
</mat-option>
<mat-option class="line-height-1" value="STAKE">
<span><b>{{ typesTranslationMap['STAKE'] }}</b></span>
</mat-option>
<mat-option class="line-height-1" value="LIABILITY">
<span><b>{{ typesTranslationMap['LIABILITY'] }}</b></span>
<br />
@ -132,7 +135,10 @@
<input formControlName="quantity" matInput type="number" />
</mat-form-field>
</div>
<div class="align-items-start d-flex mb-3">
<div
*ngIf="activityForm.controls['type']?.value !== 'STAKE'"
class="align-items-start d-flex mb-3"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label
><ng-container [ngSwitch]="activityForm.controls['type']?.value">

2
apps/client/src/app/services/import-activities.service.ts

@ -346,6 +346,8 @@ export class ImportActivitiesService {
return Type.LIABILITY;
case 'sell':
return Type.SELL;
case 'stake':
return Type.STAKE;
default:
break;
}

9
libs/ui/src/lib/activities-table/activities-table.component.html

@ -163,11 +163,16 @@
dividend: element.type === 'DIVIDEND',
item: element.type === 'ITEM',
liability: element.type === 'LIABILITY',
sell: element.type === 'SELL'
sell: element.type === 'SELL',
stake: element.type === 'STAKE'
}"
>
<ion-icon
*ngIf="element.type === 'BUY' || element.type === 'DIVIDEND'"
*ngIf="
element.type === 'BUY' ||
element.type === 'DIVIDEND' ||
element.type === 'STAKE'
"
name="arrow-up-circle-outline"
></ion-icon>
<ion-icon

4
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -33,6 +33,10 @@
color: var(--blue);
}
&.stake {
color: var(--blue);
}
&.item {
color: var(--purple);
}

1
prisma/schema.prisma

@ -240,6 +240,7 @@ enum Type {
ITEM
LIABILITY
SELL
STAKE
}
enum ViewMode {

Loading…
Cancel
Save