diff --git a/apps/api/src/app/core/portfolio-calculator.spec.ts b/apps/api/src/app/core/portfolio-calculator.spec.ts index e2fd3f2a6..3ca9e08e4 100644 --- a/apps/api/src/app/core/portfolio-calculator.spec.ts +++ b/apps/api/src/app/core/portfolio-calculator.spec.ts @@ -637,6 +637,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('657.62'), grossPerformance: new Big('-61.84'), grossPerformancePercentage: new Big('-0.08595335390431712673'), + totalInvestment: new Big('719.46'), positions: [ { averagePrice: new Big('719.46'), @@ -675,6 +676,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('657.62'), grossPerformance: new Big('-61.84'), grossPerformancePercentage: new Big('-0.08595335390431712673'), + totalInvestment: new Big('719.46'), positions: [ { averagePrice: new Big('719.46'), @@ -713,6 +715,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('657.62'), grossPerformance: new Big('-9.04'), grossPerformancePercentage: new Big('-0.01356013560135601356'), + totalInvestment: new Big('719.46'), positions: [ { averagePrice: new Big('719.46'), @@ -751,6 +754,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('4871.5'), grossPerformance: new Big('240.4'), grossPerformancePercentage: new Big('0.08839407904876477102'), + totalInvestment: new Big('4460.95'), positions: [ { averagePrice: new Big('178.438'), @@ -831,6 +835,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('3897.2'), grossPerformance: new Big('303.2'), grossPerformancePercentage: new Big('0.27537838148272398344'), + totalInvestment: new Big('2923.7'), positions: [ { averagePrice: new Big('146.185'), @@ -904,6 +909,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('1192327.999656600298238721'), grossPerformance: new Big('92327.999656600898394721'), grossPerformancePercentage: new Big('0.09788498099999947809'), + totalInvestment: new Big('1100000'), positions: [ { averagePrice: new Big('1.01287018290924923237'), // 1'100'000 / 1'086'022.689344542 @@ -992,6 +998,7 @@ describe('PortfolioCalculator', () => { currentValue: new Big('517'), grossPerformance: new Big('17'), // 517 - 500 grossPerformancePercentage: new Big('0.034'), // ((200 * 0.025) + (300 * 0.04)) / (200 + 300) = 3.4% + totalInvestment: new Big('500'), hasErrors: false, positions: [ { diff --git a/apps/api/src/app/core/portfolio-calculator.ts b/apps/api/src/app/core/portfolio-calculator.ts index 375819beb..438f86396 100644 --- a/apps/api/src/app/core/portfolio-calculator.ts +++ b/apps/api/src/app/core/portfolio-calculator.ts @@ -117,6 +117,7 @@ export class PortfolioCalculator { grossPerformance: Big; grossPerformancePercentage: Big; currentValue: Big; + totalInvestment: Big; }> { if (!this.transactionPoints?.length) { return { @@ -124,7 +125,8 @@ export class PortfolioCalculator { positions: [], grossPerformance: new Big(0), grossPerformancePercentage: new Big(0), - currentValue: new Big(0) + currentValue: new Big(0), + totalInvestment: new Big(0) }; } @@ -377,6 +379,7 @@ export class PortfolioCalculator { ) { let hasErrors = false; let currentValue = new Big(0); + let totalInvestment = new Big(0); let grossPerformance = new Big(0); let grossPerformancePercentage = new Big(0); let completeInitialValue = new Big(0); @@ -384,6 +387,7 @@ export class PortfolioCalculator { currentValue = currentValue.add( new Big(currentPosition.marketPrice).mul(currentPosition.quantity) ); + totalInvestment = totalInvestment.add(currentPosition.investment); if (currentPosition.grossPerformance) { grossPerformance = grossPerformance.plus( currentPosition.grossPerformance @@ -411,6 +415,7 @@ export class PortfolioCalculator { } return { currentValue, + totalInvestment, grossPerformance, grossPerformancePercentage: grossPerformancePercentage.div(completeInitialValue), diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts index 729b5213c..bbef3b446 100644 --- a/apps/api/src/app/portfolio/portfolio.controller.ts +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -149,12 +149,11 @@ export class PortfolioController { this.request.user.id ); - const portfolio = await this.portfolioService.createPortfolio( - impersonationUserId || this.request.user.id - ); - try { - details = await portfolio.getDetails(range); + details = await this.portfolioService.getDetails( + impersonationUserId, + range + ); } catch (error) { console.error(error); diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts index 50bcbd2da..b3bb2509e 100644 --- a/apps/api/src/app/portfolio/portfolio.module.ts +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -20,6 +20,7 @@ import { Module } from '@nestjs/common'; import { PortfolioController } from './portfolio.controller'; import { PortfolioService } from './portfolio.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; @Module({ imports: [RedisCacheModule], @@ -35,12 +36,13 @@ import { PortfolioService } from './portfolio.service'; ExchangeRateDataService, GhostfolioScraperApiService, ImpersonationService, + MarketDataService, OrderService, PortfolioService, PrismaService, RakutenRapidApiService, RulesService, - MarketDataService, + SymbolProfileService, UserService, YahooFinanceService ] diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 280d313a6..d8a5c8870 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -11,17 +11,22 @@ 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 } from '@ghostfolio/api/services/interfaces/interfaces'; -import { Type } from '@ghostfolio/api/services/interfaces/interfaces'; +import { IOrder, 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, - Position + PortfolioPosition, + Position, + TimelinePosition } from '@ghostfolio/common/interfaces'; -import { DateRange, RequestWithUser } from '@ghostfolio/common/types'; +import { + DateRange, + OrderWithAccount, + RequestWithUser +} from '@ghostfolio/common/types'; import { Inject, Injectable } from '@nestjs/common'; import { REQUEST } from '@nestjs/core'; import { DataSource } from '@prisma/client'; @@ -52,6 +57,10 @@ import { HistoricalDataItem, PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; +import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface'; @Injectable() export class PortfolioService { @@ -65,7 +74,8 @@ export class PortfolioService { private readonly redisCacheService: RedisCacheService, @Inject(REQUEST) private readonly request: RequestWithUser, private readonly rulesService: RulesService, - private readonly userService: UserService + private readonly userService: UserService, + private readonly symbolProfileService: SymbolProfileService ) {} public async createPortfolio(aUserId: string): Promise { @@ -158,7 +168,7 @@ export class PortfolioService { this.request.user.Settings.currency ); - const transactionPoints = await this.getTransactionPoints(userId); + const { transactionPoints } = await this.getTransactionPoints(userId); portfolioCalculator.setTransactionPoints(transactionPoints); if (transactionPoints.length === 0) { return []; @@ -221,19 +231,98 @@ export class PortfolioService { }; } + public async getDetails( + aImpersonationId: string, + aDateRange: DateRange = 'max' + ): Promise<{ [symbol: string]: PortfolioPosition }> { + const userId = await this.getUserId(aImpersonationId); + + const userCurrency = this.request.user.Settings.currency; + const portfolioCalculator = new PortfolioCalculator( + this.currentRateService, + userCurrency + ); + + const { transactionPoints, orders } = await this.getTransactionPoints( + userId + ); + + if (transactionPoints?.length <= 0) { + return {}; + } + + portfolioCalculator.setTransactionPoints(transactionPoints); + + const portfolioStart = parseDate(transactionPoints[0].date); + const startDate = this.getStartDate(aDateRange, portfolioStart); + const currentPositions = await portfolioCalculator.getCurrentPositions( + startDate + ); + + if (currentPositions.hasErrors) { + throw new Error('Missing information'); + } + + const result: { [symbol: string]: PortfolioPosition } = {}; + const totalValue = currentPositions.currentValue; + + const symbols = currentPositions.positions.map( + (position) => position.symbol + ); + + const [dataProviderResponses, symbolProfiles] = await Promise.all([ + this.dataProviderService.get(symbols), + this.symbolProfileService.getSymbolProfiles(symbols) + ]); + + const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; + for (const symbolProfile of symbolProfiles) { + symbolProfileMap[symbolProfile.symbol] = symbolProfile; + } + + const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; + for (const position of currentPositions.positions) { + portfolioItemsNow[position.symbol] = position; + } + const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency); + + for (const item of currentPositions.positions) { + const value = item.quantity.mul(item.marketPrice); + const symbolProfile = symbolProfileMap[item.symbol]; + const dataProviderResponse = dataProviderResponses[item.symbol]; + result[item.symbol] = { + accounts, + allocationCurrent: value.div(totalValue).toNumber(), + allocationInvestment: item.investment + .div(currentPositions.totalInvestment) + .toNumber(), + countries: symbolProfile.countries, + currency: item.currency, + exchange: dataProviderResponse.exchange, + grossPerformance: item.grossPerformance.toNumber(), + grossPerformancePercent: item.grossPerformancePercentage.toNumber(), + investment: item.investment.toNumber(), + marketPrice: item.marketPrice, + marketState: dataProviderResponse.marketState, + name: item.name, + quantity: item.quantity.toNumber(), + sectors: symbolProfile.sectors, + symbol: item.symbol, + transactionCount: item.transactionCount, + type: dataProviderResponse.type, + value: value.toNumber() + }; + } + + return result; + } + public async getPosition( aImpersonationId: string, aSymbol: string ): Promise { - const impersonationUserId = - await this.impersonationService.validateImpersonationId( - aImpersonationId, - this.request.user.id - ); - - const portfolio = await this.createPortfolio( - impersonationUserId || this.request.user.id - ); + const userId = await this.getUserId(aImpersonationId); + const portfolio = await this.createPortfolio(userId); const position = portfolio.getPositions(new Date())[aSymbol]; @@ -396,20 +485,14 @@ export class PortfolioService { aImpersonationId: string, aDateRange: DateRange = 'max' ): Promise<{ hasErrors: boolean; positions: Position[] }> { - const impersonationUserId = - await this.impersonationService.validateImpersonationId( - aImpersonationId, - this.request.user.id - ); - - const userId = impersonationUserId || this.request.user.id; + const userId = await this.getUserId(aImpersonationId); const portfolioCalculator = new PortfolioCalculator( this.currentRateService, this.request.user.Settings.currency ); - const transactionPoints = await this.getTransactionPoints(userId); + const { transactionPoints } = await this.getTransactionPoints(userId); if (transactionPoints?.length <= 0) { return { @@ -461,7 +544,7 @@ export class PortfolioService { this.request.user.Settings.currency ); - const transactionPoints = await this.getTransactionPoints(userId); + const { transactionPoints } = await this.getTransactionPoints(userId); if (transactionPoints?.length <= 0) { return { @@ -521,11 +604,14 @@ export class PortfolioService { return portfolioStart; } - private async getTransactionPoints(userId: string) { + private async getTransactionPoints(userId: string): Promise<{ + transactionPoints: TransactionPoint[]; + orders: OrderWithAccount[]; + }> { const orders = await this.getOrders(userId); if (orders.length <= 0) { - return []; + return { transactionPoints: [], orders: [] }; } const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ @@ -543,7 +629,10 @@ export class PortfolioService { this.request.user.Settings.currency ); portfolioCalculator.computeTransactionPoints(portfolioOrders); - return portfolioCalculator.getTransactionPoints(); + return { + transactionPoints: portfolioCalculator.getTransactionPoints(), + orders + }; } private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) { @@ -593,6 +682,44 @@ export class PortfolioService { } } + private getAccounts( + orders: OrderWithAccount[], + portfolioItemsNow: { [p: string]: TimelinePosition }, + userCurrency + ) { + const accounts: PortfolioPosition['accounts'] = {}; + for (const order of orders) { + let currentValueOfSymbol = this.exchangeRateDataService.toCurrency( + order.quantity * portfolioItemsNow[order.symbol].marketPrice, + order.currency, + userCurrency + ); + let originalValueOfSymbol = this.exchangeRateDataService.toCurrency( + order.quantity * order.unitPrice, + order.currency, + userCurrency + ); + + if (order.type === 'SELL') { + currentValueOfSymbol *= -1; + originalValueOfSymbol *= -1; + } + + if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) { + accounts[order.Account?.name || UNKNOWN_KEY].current += + currentValueOfSymbol; + accounts[order.Account?.name || UNKNOWN_KEY].original += + originalValueOfSymbol; + } else { + accounts[order.Account?.name || UNKNOWN_KEY] = { + current: currentValueOfSymbol, + original: originalValueOfSymbol + }; + } + } + return accounts; + } + private getOrders(aUserId: string) { return this.orderService.orders({ include: { @@ -605,4 +732,14 @@ export class PortfolioService { where: { userId: aUserId } }); } + + private async getUserId(aImpersonationId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId( + aImpersonationId, + this.request.user.id + ); + + return impersonationUserId || this.request.user.id; + } } diff --git a/apps/api/src/services/interfaces/symbol-profile.interface.ts b/apps/api/src/services/interfaces/symbol-profile.interface.ts new file mode 100644 index 000000000..f6f83c3c1 --- /dev/null +++ b/apps/api/src/services/interfaces/symbol-profile.interface.ts @@ -0,0 +1,15 @@ +import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; +import { Country } from '@ghostfolio/common/interfaces/country.interface'; +import { Currency, DataSource } from '@prisma/client'; + +export interface EnhancedSymbolProfile { + createdAt: Date; + currency: Currency | null; + dataSource: DataSource; + id: string; + name: string | null; + updatedAt: Date; + symbol: string; + countries: Country[]; + sectors: Sector[]; +} diff --git a/apps/api/src/services/symbol-profile.service.ts b/apps/api/src/services/symbol-profile.service.ts new file mode 100644 index 000000000..4af066d73 --- /dev/null +++ b/apps/api/src/services/symbol-profile.service.ts @@ -0,0 +1,64 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma.service'; +import { Injectable } from '@nestjs/common'; +import { Prisma, SymbolProfile } from '@prisma/client'; +import { continents, countries } from 'countries-list'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { Country } from '@ghostfolio/common/interfaces/country.interface'; +import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; +import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; + +@Injectable() +export class SymbolProfileService { + constructor(private prisma: PrismaService) {} + + public async getSymbolProfiles( + symbols: string[] + ): Promise { + return this.prisma.symbolProfile + .findMany({ + where: { + symbol: { + in: symbols + } + } + }) + .then((symbolProfiles) => this.getSymbols(symbolProfiles)); + } + + private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] { + return symbolProfiles.map((symbolProfile) => ({ + ...symbolProfile, + countries: this.getCountries(symbolProfile), + sectors: this.getSectors(symbolProfile) + })); + } + + private getCountries(symbolProfile: SymbolProfile): Country[] { + return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map( + (country) => { + const { code, weight } = country as Prisma.JsonObject; + + return { + code: code as string, + continent: + continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, + name: countries[code as string]?.name ?? UNKNOWN_KEY, + weight: weight as number + }; + } + ); + } + + private getSectors(symbolProfile: SymbolProfile): Sector[] { + return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map( + (sector) => { + const { name, weight } = sector as Prisma.JsonObject; + + return { + name: (name as string) ?? UNKNOWN_KEY, + weight: weight as number + }; + } + ); + } +}