From bcc8cb1e5b7276c1f8af07d4abd8fea16406effa Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 4 Oct 2024 21:44:07 +0200 Subject: [PATCH] Feature/modernize rules implementation (#3869) * Modernize rules implementation * Include markets in details --- .../src/app/portfolio/portfolio.controller.ts | 2 - .../src/app/portfolio/portfolio.service.ts | 172 +++++++++++------- apps/api/src/models/rule.ts | 19 +- .../base-currency-current-investment.ts | 19 +- .../current-investment.ts | 17 +- .../rule-settings-dialog.component.ts | 2 - .../interfaces/portfolio-details.interface.ts | 7 + 7 files changed, 143 insertions(+), 95 deletions(-) diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 9f5635cf5..7c11e4767 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -1,4 +1,3 @@ -import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; @@ -61,7 +60,6 @@ import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; @Controller('portfolio') export class PortfolioController { public constructor( - private readonly accessService: AccessService, private readonly apiService: ApiService, private readonly configurationService: ConfigurationService, private readonly impersonationService: ImpersonationService, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index fc06545a5..6879b2030 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -45,6 +45,7 @@ import type { AccountWithValue, DateRange, GroupBy, + Market, RequestWithUser, UserWithSettings } from '@ghostfolio/common/types'; @@ -581,6 +582,17 @@ export class PortfolioService { }; } + let markets: { + [key in Market]: { + name: string; + value: number; + }; + }; + + if (withMarkets) { + markets = this.getAggregatedMarkets(holdings); + } + let summary: PortfolioSummary; if (withSummary) { @@ -602,6 +614,7 @@ export class PortfolioService { accounts, hasErrors, holdings, + markets, platforms, summary }; @@ -1148,74 +1161,49 @@ export class PortfolioService { public async getReport(impersonationId: string): Promise { const userId = await this.getUserId(impersonationId, this.request.user.id); - const user = await this.userService.user({ id: userId }); - const userCurrency = this.getUserCurrency(user); - - const { activities } = - await this.orderService.getOrdersForPortfolioCalculator({ - userCurrency, - userId - }); + const userSettings = this.request.user.Settings.settings; - const portfolioCalculator = this.calculatorFactory.createCalculator({ - activities, + const { accounts, holdings, summary } = await this.getDetails({ + impersonationId, userId, - calculationType: PerformanceCalculationType.TWR, - currency: this.request.user.Settings.settings.baseCurrency - }); - - let { totalFeesWithCurrencyEffect, positions, totalInvestment } = - await portfolioCalculator.getSnapshot(); - - positions = positions.filter((item) => !item.quantity.eq(0)); - - const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; - - for (const position of positions) { - portfolioItemsNow[position.symbol] = position; - } - - const { accounts } = await this.getValueOfAccountsAndPlatforms({ - activities, - portfolioItemsNow, - userCurrency, - userId + withMarkets: true, + withSummary: true }); - const userSettings = this.request.user.Settings.settings; - return { rules: { - accountClusterRisk: isEmpty(activities) - ? undefined - : await this.rulesService.evaluate( - [ - new AccountClusterRiskCurrentInvestment( - this.exchangeRateDataService, - accounts - ), - new AccountClusterRiskSingleAccount( - this.exchangeRateDataService, - accounts - ) - ], - userSettings - ), - currencyClusterRisk: isEmpty(activities) - ? undefined - : await this.rulesService.evaluate( - [ - new CurrencyClusterRiskBaseCurrencyCurrentInvestment( - this.exchangeRateDataService, - positions - ), - new CurrencyClusterRiskCurrentInvestment( - this.exchangeRateDataService, - positions - ) - ], - userSettings - ), + accountClusterRisk: + summary.ordersCount > 0 + ? await this.rulesService.evaluate( + [ + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + accounts + ) + ], + userSettings + ) + : undefined, + currencyClusterRisk: + summary.ordersCount > 0 + ? await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + Object.values(holdings) + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + Object.values(holdings) + ) + ], + userSettings + ) + : undefined, emergencyFund: await this.rulesService.evaluate( [ new EmergencyFundSetup( @@ -1229,8 +1217,8 @@ export class PortfolioService { [ new FeeRatioInitialInvestment( this.exchangeRateDataService, - totalInvestment.toNumber(), - totalFeesWithCurrencyEffect.toNumber() + summary.committedFunds, + summary.fees ) ], userSettings @@ -1257,6 +1245,62 @@ export class PortfolioService { await this.orderService.assignTags({ dataSource, symbol, tags, userId }); } + private getAggregatedMarkets(holdings: { + [symbol: string]: PortfolioPosition; + }): { + [key in Market]: { name: string; value: number }; + } { + const markets = { + [UNKNOWN_KEY]: { + name: UNKNOWN_KEY, + value: 0 + }, + developedMarkets: { + name: 'developedMarkets', + value: 0 + }, + emergingMarkets: { + name: 'emergingMarkets', + value: 0 + }, + otherMarkets: { + name: 'otherMarkets', + value: 0 + } + }; + + for (const [symbol, position] of Object.entries(holdings)) { + const value = position.valueInBaseCurrency; + + if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.countries.length > 0) { + markets.developedMarkets.value += + position.markets.developedMarkets * value; + markets.emergingMarkets.value += + position.markets.emergingMarkets * value; + markets.otherMarkets.value += position.markets.otherMarkets * value; + } else { + markets[UNKNOWN_KEY].value += value; + } + } + } + + const marketsTotal = + markets.developedMarkets.value + + markets.emergingMarkets.value + + markets.otherMarkets.value + + markets[UNKNOWN_KEY].value; + + markets.developedMarkets.value = + markets.developedMarkets.value / marketsTotal; + markets.emergingMarkets.value = + markets.emergingMarkets.value / marketsTotal; + markets.otherMarkets.value = markets.otherMarkets.value / marketsTotal; + markets[UNKNOWN_KEY].value = markets[UNKNOWN_KEY].value / marketsTotal; + + return markets; + } + private async getCashPositions({ cashDetails, userCurrency, diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 8397f3e46..a1e0d9bee 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -1,8 +1,9 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { groupBy } from '@ghostfolio/common/helper'; -import { UserSettings } from '@ghostfolio/common/interfaces'; -import { TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; + +import { Big } from 'big.js'; import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { RuleInterface } from './interfaces/rule.interface'; @@ -33,24 +34,26 @@ export abstract class Rule implements RuleInterface { return this.name; } - public groupCurrentPositionsByAttribute( - positions: TimelinePosition[], - attribute: keyof TimelinePosition, + public groupCurrentHoldingsByAttribute( + holdings: PortfolioPosition[], + attribute: keyof PortfolioPosition, baseCurrency: string ) { - return Array.from(groupBy(attribute, positions).entries()).map( + return Array.from(groupBy(attribute, holdings).entries()).map( ([attributeValue, objs]) => ({ groupKey: attributeValue, investment: objs.reduce( (previousValue, currentValue) => - previousValue + currentValue.investment.toNumber(), + previousValue + currentValue.investment, 0 ), value: objs.reduce( (previousValue, currentValue) => previousValue + this.exchangeRateDataService.toCurrency( - currentValue.quantity.mul(currentValue.marketPrice).toNumber(), + new Big(currentValue.quantity) + .mul(currentValue.marketPrice) + .toNumber(), currentValue.currency, baseCurrency ), diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts index e3050efcc..39ee8b88d 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -1,35 +1,34 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { UserSettings } from '@ghostfolio/common/interfaces'; -import { TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { - private positions: TimelinePosition[]; + private holdings: PortfolioPosition[]; public constructor( protected exchangeRateDataService: ExchangeRateDataService, - positions: TimelinePosition[] + holdings: PortfolioPosition[] ) { super(exchangeRateDataService, { key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name, name: 'Investment: Base Currency' }); - this.positions = positions; + this.holdings = holdings; } public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( - this.positions, + const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute( + this.holdings, 'currency', ruleSettings.baseCurrency ); - let maxItem = positionsGroupedByCurrency[0]; + let maxItem = holdingsGroupedByCurrency[0]; let totalValue = 0; - positionsGroupedByCurrency.forEach((groupItem) => { + holdingsGroupedByCurrency.forEach((groupItem) => { // Calculate total value totalValue += groupItem.value; @@ -39,7 +38,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { + const baseCurrencyItem = holdingsGroupedByCurrency.find((item) => { return item.groupKey === ruleSettings.baseCurrency; }); diff --git a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts index fadf47ba5..bdb36c78a 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts @@ -1,35 +1,34 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { Rule } from '@ghostfolio/api/models/rule'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; -import { UserSettings } from '@ghostfolio/common/interfaces'; -import { TimelinePosition } from '@ghostfolio/common/models'; +import { PortfolioPosition, UserSettings } from '@ghostfolio/common/interfaces'; export class CurrencyClusterRiskCurrentInvestment extends Rule { - private positions: TimelinePosition[]; + private holdings: PortfolioPosition[]; public constructor( protected exchangeRateDataService: ExchangeRateDataService, - positions: TimelinePosition[] + holdings: PortfolioPosition[] ) { super(exchangeRateDataService, { key: CurrencyClusterRiskCurrentInvestment.name, name: 'Investment' }); - this.positions = positions; + this.holdings = holdings; } public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( - this.positions, + const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute( + this.holdings, 'currency', ruleSettings.baseCurrency ); - let maxItem = positionsGroupedByCurrency[0]; + let maxItem = holdingsGroupedByCurrency[0]; let totalValue = 0; - positionsGroupedByCurrency.forEach((groupItem) => { + holdingsGroupedByCurrency.forEach((groupItem) => { // Calculate total value totalValue += groupItem.value; diff --git a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts index 4fb68e780..265d3c941 100644 --- a/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts +++ b/apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts @@ -35,8 +35,6 @@ export class GfRuleSettingsDialogComponent { @Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams, public dialogRef: MatDialogRef ) { - console.log(this.data.rule); - this.settings = this.data.rule.settings; } } diff --git a/libs/common/src/lib/interfaces/portfolio-details.interface.ts b/libs/common/src/lib/interfaces/portfolio-details.interface.ts index 611ed8056..3c2833071 100644 --- a/libs/common/src/lib/interfaces/portfolio-details.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-details.interface.ts @@ -2,6 +2,7 @@ import { PortfolioPosition, PortfolioSummary } from '@ghostfolio/common/interfaces'; +import { Market } from '@ghostfolio/common/types'; export interface PortfolioDetails { accounts: { @@ -14,6 +15,12 @@ export interface PortfolioDetails { }; }; holdings: { [symbol: string]: PortfolioPosition }; + markets?: { + [key in Market]: { + name: string; + value: number; + }; + }; platforms: { [id: string]: { balance: number;