diff --git a/apps/api/src/app/core/interfaces/current-positions.interface.ts b/apps/api/src/app/core/interfaces/current-positions.interface.ts new file mode 100644 index 000000000..90fcf9b01 --- /dev/null +++ b/apps/api/src/app/core/interfaces/current-positions.interface.ts @@ -0,0 +1,11 @@ +import { TimelinePosition } from '@ghostfolio/common/interfaces'; +import Big from 'big.js'; + +export interface CurrentPositions { + hasErrors: boolean; + positions: TimelinePosition[]; + grossPerformance: Big; + grossPerformancePercentage: Big; + currentValue: Big; + totalInvestment: Big; +} diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts index de62ec597..b81231cb6 100644 --- a/apps/api/src/app/core/portfolio-calculator.ts +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -26,6 +26,7 @@ import { subDays } from 'date-fns'; import { flatten } from 'lodash'; +import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface'; export class PortfolioCalculator { private transactionPoints: TransactionPoint[]; @@ -111,14 +112,7 @@ export class PortfolioCalculator { this.transactionPoints = transactionPoints; } - public async getCurrentPositions(start: Date): Promise<{ - hasErrors: boolean; - positions: TimelinePosition[]; - grossPerformance: Big; - grossPerformancePercentage: Big; - currentValue: Big; - totalInvestment: Big; - }> { + public async getCurrentPositions(start: Date): Promise { if (!this.transactionPoints?.length) { return { hasErrors: false, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 8cc4464d8..4bddb0d19 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -4,18 +4,14 @@ import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-or import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface'; import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator'; import { OrderService } from '@ghostfolio/api/app/order/order.service'; -import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; -import { UserService } from '@ghostfolio/api/app/user/user.service'; import { OrderType } from '@ghostfolio/api/models/order-type'; -import { Portfolio } from '@ghostfolio/api/models/portfolio'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; -import { IOrder, Type } from '@ghostfolio/api/services/interfaces/interfaces'; +import { Type } from '@ghostfolio/api/services/interfaces/interfaces'; import { RulesService } from '@ghostfolio/api/services/rules.service'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { - PortfolioItem, PortfolioOverview, PortfolioPerformance, PortfolioPosition, @@ -30,11 +26,10 @@ import { } from '@ghostfolio/common/types'; import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; -import { DataSource, Currency, Type as TypeOfOrder } from '@prisma/client'; +import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client'; import Big from 'big.js'; import { add, - addMonths, endOfToday, format, getDate, @@ -42,7 +37,6 @@ import { getYear, isAfter, isBefore, - isSameDay, max, parse, parseISO, @@ -54,7 +48,6 @@ import { subYears } from 'date-fns'; import { isEmpty } from 'lodash'; -import * as roundTo from 'round-to'; import { HistoricalDataItem, @@ -83,69 +76,11 @@ export class PortfolioService { private readonly exchangeRateDataService: ExchangeRateDataService, private readonly impersonationService: ImpersonationService, private readonly orderService: OrderService, - private readonly redisCacheService: RedisCacheService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly rulesService: RulesService, - private readonly userService: UserService, private readonly symbolProfileService: SymbolProfileService ) {} - public async createPortfolio(aUserId: string): Promise { - let portfolio: Portfolio; - const stringifiedPortfolio = await this.redisCacheService.get( - `${aUserId}.portfolio` - ); - - const user = await this.userService.user({ id: aUserId }); - - if (stringifiedPortfolio) { - // Get portfolio from redis - const { - orders, - portfolioItems - }: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = - JSON.parse(stringifiedPortfolio); - - portfolio = new Portfolio( - this.accountService, - this.dataProviderService, - this.exchangeRateDataService, - this.rulesService - ).createFromData({ orders, portfolioItems, user }); - } else { - // Get portfolio from database - const orders = await this.getOrders(aUserId); - - portfolio = new Portfolio( - this.accountService, - this.dataProviderService, - this.exchangeRateDataService, - this.rulesService - ); - portfolio.setUser(user); - await portfolio.setOrders(orders); - - // Cache data for the next time... - const portfolioData = { - orders: portfolio.getOrders(), - portfolioItems: portfolio.getPortfolioItems() - }; - - await this.redisCacheService.set( - `${aUserId}.portfolio`, - JSON.stringify(portfolioData) - ); - } - - // Enrich portfolio with current data - await portfolio.addCurrentPortfolioItems(); - - // Enrich portfolio with future data - await portfolio.addFuturePortfolioItems(); - - return portfolio; - } - public async getInvestments( aImpersonationId: string ): Promise { @@ -603,35 +538,49 @@ export class PortfolioService { public async getReport(impersonationId: string): Promise { const userId = await this.getUserId(impersonationId); - const portfolio = await this.createPortfolio(userId); + const baseCurrency = this.request.user.Settings.currency; - const details = await portfolio.getDetails(); - const { orders } = await this.getTransactionPoints(userId); + const { transactionPoints, orders } = await this.getTransactionPoints( + userId + ); - if (isEmpty(details)) { + if (isEmpty(orders)) { return { rules: {} }; } - const fees = this.getFees(orders); + const portfolioCalculator = new PortfolioCalculator( + this.currentRateService, + this.request.user.Settings.currency + ); + portfolioCalculator.setTransactionPoints(transactionPoints); - const baseCurrency = this.request.user.Settings.currency; + const portfolioStart = parseDate(transactionPoints[0].date); + const currentPositions = await portfolioCalculator.getCurrentPositions( + portfolioStart + ); + + const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; + for (const position of currentPositions.positions) { + portfolioItemsNow[position.symbol] = position; + } + const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency); return { rules: { accountClusterRisk: await this.rulesService.evaluate( [ new AccountClusterRiskInitialInvestment( this.exchangeRateDataService, - details + accounts ), new AccountClusterRiskCurrentInvestment( this.exchangeRateDataService, - details + accounts ), new AccountClusterRiskSingleAccount( this.exchangeRateDataService, - details + accounts ) ], { baseCurrency } @@ -640,19 +589,19 @@ export class PortfolioService { [ new CurrencyClusterRiskBaseCurrencyInitialInvestment( this.exchangeRateDataService, - details + currentPositions ), new CurrencyClusterRiskBaseCurrencyCurrentInvestment( this.exchangeRateDataService, - details + currentPositions ), new CurrencyClusterRiskInitialInvestment( this.exchangeRateDataService, - details + currentPositions ), new CurrencyClusterRiskCurrentInvestment( this.exchangeRateDataService, - details + currentPositions ) ], { baseCurrency } @@ -661,8 +610,8 @@ export class PortfolioService { [ new FeeRatioInitialInvestment( this.exchangeRateDataService, - details, - fees + currentPositions.totalInvestment.toNumber(), + this.getFees(orders) ) ], { baseCurrency } diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts index 48d9d0711..628931068 100644 --- a/apps/api/src/models/rule.ts +++ b/apps/api/src/models/rule.ts @@ -1,5 +1,8 @@ import { groupBy } from '@ghostfolio/common/helper'; -import { PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { + PortfolioPosition, + TimelinePosition +} from '@ghostfolio/common/interfaces'; import { Currency } from '@prisma/client'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; @@ -30,30 +33,30 @@ export abstract class Rule implements RuleInterface { return this.name; } - public groupPositionsByAttribute( - aPositions: { [symbol: string]: PortfolioPosition }, - aAttribute: keyof PortfolioPosition, - aBaseCurrency: Currency + public groupCurrentPositionsByAttribute( + positions: TimelinePosition[], + attribute: keyof TimelinePosition, + baseCurrency: Currency ) { - return Array.from( - groupBy(aAttribute, Object.values(aPositions)).entries() - ).map(([attributeValue, objs]) => ({ - groupKey: attributeValue, - investment: objs.reduce( - (previousValue, currentValue) => - previousValue + currentValue.investment, - 0 - ), - value: objs.reduce( - (previousValue, currentValue) => - previousValue + - this.exchangeRateDataService.toCurrency( - currentValue.quantity * currentValue.marketPrice, - currentValue.currency, - aBaseCurrency - ), - 0 - ) - })); + return Array.from(groupBy(attribute, positions).entries()).map( + ([attributeValue, objs]) => ({ + groupKey: attributeValue, + investment: objs.reduce( + (previousValue, currentValue) => + previousValue + currentValue.investment.toNumber(), + 0 + ), + value: objs.reduce( + (previousValue, currentValue) => + previousValue + + this.exchangeRateDataService.toCurrency( + currentValue.quantity.mul(currentValue.marketPrice).toNumber(), + currentValue.currency, + baseCurrency + ), + 0 + ) + }) + ); } } diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts index d76130614..ff101678e 100644 --- a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -8,7 +8,9 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.in export class AccountClusterRiskCurrentInvestment extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private accounts: { + [account: string]: { current: number; original: number }; + } ) { super(exchangeRateDataService, { name: 'Current Investment' @@ -22,18 +24,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule { }; } = {}; - Object.values(this.positions).forEach((position) => { - for (const [account, { current }] of Object.entries(position.accounts)) { - if (accounts[account]?.investment) { - accounts[account].investment += current; - } else { - accounts[account] = { - investment: current, - name: account - }; - } - } - }); + for (const account of Object.keys(this.accounts)) { + accounts[account] = { + name: account, + investment: this.accounts[account].current + }; + } let maxItem; let totalInvestment = 0; diff --git a/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts b/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts index 51ca6bb3e..cd5d4b393 100644 --- a/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts +++ b/apps/api/src/models/rules/account-cluster-risk/initial-investment.ts @@ -8,7 +8,9 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.in export class AccountClusterRiskInitialInvestment extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private accounts: { + [account: string]: { current: number; original: number }; + } ) { super(exchangeRateDataService, { name: 'Initial Investment' @@ -22,18 +24,12 @@ export class AccountClusterRiskInitialInvestment extends Rule { }; } = {}; - Object.values(this.positions).forEach((position) => { - for (const [account, { original }] of Object.entries(position.accounts)) { - if (platforms[account]?.investment) { - platforms[account].investment += original; - } else { - platforms[account] = { - investment: original, - name: account - }; - } - } - }); + for (const account of Object.keys(this.accounts)) { + platforms[account] = { + name: account, + investment: this.accounts[account].original + }; + } let maxItem; let totalInvestment = 0; diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts index ba7e2bb73..eb2d29667 100644 --- a/apps/api/src/models/rules/account-cluster-risk/single-account.ts +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -1,4 +1,3 @@ -import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { Rule } from '../../rule'; @@ -8,7 +7,9 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.in export class AccountClusterRiskSingleAccount extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private accounts: { + [account: string]: { current: number; original: number }; + } ) { super(exchangeRateDataService, { name: 'Single Account' @@ -16,15 +17,7 @@ export class AccountClusterRiskSingleAccount extends Rule { } public evaluate() { - const accounts: string[] = []; - - Object.values(this.positions).forEach((position) => { - for (const [account] of Object.entries(position.accounts)) { - if (!accounts.includes(account)) { - accounts.push(account); - } - } - }); + const accounts: string[] = Object.keys(this.accounts); if (accounts.length === 1) { return { 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 66c2e6ebc..39d5a08d5 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 @@ -5,11 +5,12 @@ import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { Rule } from '../../rule'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface'; export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private currentPositions: CurrentPositions ) { super(exchangeRateDataService, { name: 'Current Investment: Base Currency' @@ -17,8 +18,8 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private currentPositions: CurrentPositions ) { super(exchangeRateDataService, { name: 'Initial Investment: Base Currency' @@ -17,8 +17,8 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule { public constructor( public exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private currentPositions: CurrentPositions ) { super(exchangeRateDataService, { name: 'Current Investment' @@ -17,8 +17,8 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule { } public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupPositionsByAttribute( - this.positions, + const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( + this.currentPositions.positions, 'currency', ruleSettings.baseCurrency ); diff --git a/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts index 2e38b351e..02d3f8c81 100644 --- a/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts +++ b/apps/api/src/models/rules/currency-cluster-risk/initial-investment.ts @@ -1,15 +1,15 @@ import { Currency } from '@prisma/client'; -import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { Rule } from '../../rule'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; +import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface'; export class CurrencyClusterRiskInitialInvestment extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition } + private currentPositions: CurrentPositions ) { super(exchangeRateDataService, { name: 'Initial Investment' @@ -17,8 +17,8 @@ export class CurrencyClusterRiskInitialInvestment extends Rule { } public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupPositionsByAttribute( - this.positions, + const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute( + this.currentPositions.positions, 'currency', ruleSettings.baseCurrency ); diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts index 8faff972b..bd28c13ec 100644 --- a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -1,5 +1,4 @@ import { Currency } from '@prisma/client'; -import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { Rule } from '../../rule'; @@ -9,7 +8,7 @@ import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.in export class FeeRatioInitialInvestment extends Rule { public constructor( protected exchangeRateDataService: ExchangeRateDataService, - private positions: { [symbol: string]: PortfolioPosition }, + private totalInvestment: number, private fees: number ) { super(exchangeRateDataService, { @@ -18,20 +17,7 @@ export class FeeRatioInitialInvestment extends Rule { } public evaluate(ruleSettings: Settings) { - const positionsGroupedByCurrency = this.groupPositionsByAttribute( - this.positions, - 'currency', - ruleSettings.baseCurrency - ); - - let totalInvestment = 0; - - positionsGroupedByCurrency.forEach((groupItem) => { - // Calculate total investment - totalInvestment += groupItem.investment; - }); - - const feeRatio = this.fees / totalInvestment; + const feeRatio = this.fees / this.totalInvestment; if (feeRatio > ruleSettings.threshold) { return {