|
@ -11,17 +11,22 @@ import { Portfolio } from '@ghostfolio/api/models/portfolio'; |
|
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; |
|
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; |
|
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; |
|
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; |
|
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; |
|
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; |
|
|
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; |
|
|
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 { RulesService } from '@ghostfolio/api/services/rules.service'; |
|
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; |
|
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; |
|
|
import { |
|
|
import { |
|
|
PortfolioItem, |
|
|
PortfolioItem, |
|
|
PortfolioOverview, |
|
|
PortfolioOverview, |
|
|
PortfolioPerformance, |
|
|
PortfolioPerformance, |
|
|
Position |
|
|
PortfolioPosition, |
|
|
|
|
|
Position, |
|
|
|
|
|
TimelinePosition |
|
|
} from '@ghostfolio/common/interfaces'; |
|
|
} 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 { Inject, Injectable } from '@nestjs/common'; |
|
|
import { REQUEST } from '@nestjs/core'; |
|
|
import { REQUEST } from '@nestjs/core'; |
|
|
import { DataSource } from '@prisma/client'; |
|
|
import { DataSource } from '@prisma/client'; |
|
@ -52,6 +57,10 @@ import { |
|
|
HistoricalDataItem, |
|
|
HistoricalDataItem, |
|
|
PortfolioPositionDetail |
|
|
PortfolioPositionDetail |
|
|
} from './interfaces/portfolio-position-detail.interface'; |
|
|
} 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() |
|
|
@Injectable() |
|
|
export class PortfolioService { |
|
|
export class PortfolioService { |
|
@ -65,7 +74,8 @@ export class PortfolioService { |
|
|
private readonly redisCacheService: RedisCacheService, |
|
|
private readonly redisCacheService: RedisCacheService, |
|
|
@Inject(REQUEST) private readonly request: RequestWithUser, |
|
|
@Inject(REQUEST) private readonly request: RequestWithUser, |
|
|
private readonly rulesService: RulesService, |
|
|
private readonly rulesService: RulesService, |
|
|
private readonly userService: UserService |
|
|
private readonly userService: UserService, |
|
|
|
|
|
private readonly symbolProfileService: SymbolProfileService |
|
|
) {} |
|
|
) {} |
|
|
|
|
|
|
|
|
public async createPortfolio(aUserId: string): Promise<Portfolio> { |
|
|
public async createPortfolio(aUserId: string): Promise<Portfolio> { |
|
@ -158,7 +168,7 @@ export class PortfolioService { |
|
|
this.request.user.Settings.currency |
|
|
this.request.user.Settings.currency |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const transactionPoints = await this.getTransactionPoints(userId); |
|
|
const { transactionPoints } = await this.getTransactionPoints(userId); |
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
if (transactionPoints.length === 0) { |
|
|
if (transactionPoints.length === 0) { |
|
|
return []; |
|
|
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( |
|
|
public async getPosition( |
|
|
aImpersonationId: string, |
|
|
aImpersonationId: string, |
|
|
aSymbol: string |
|
|
aSymbol: string |
|
|
): Promise<PortfolioPositionDetail> { |
|
|
): Promise<PortfolioPositionDetail> { |
|
|
const impersonationUserId = |
|
|
const userId = await this.getUserId(aImpersonationId); |
|
|
await this.impersonationService.validateImpersonationId( |
|
|
const portfolio = await this.createPortfolio(userId); |
|
|
aImpersonationId, |
|
|
|
|
|
this.request.user.id |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const portfolio = await this.createPortfolio( |
|
|
|
|
|
impersonationUserId || this.request.user.id |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const position = portfolio.getPositions(new Date())[aSymbol]; |
|
|
const position = portfolio.getPositions(new Date())[aSymbol]; |
|
|
|
|
|
|
|
@ -396,20 +485,14 @@ export class PortfolioService { |
|
|
aImpersonationId: string, |
|
|
aImpersonationId: string, |
|
|
aDateRange: DateRange = 'max' |
|
|
aDateRange: DateRange = 'max' |
|
|
): Promise<{ hasErrors: boolean; positions: Position[] }> { |
|
|
): Promise<{ hasErrors: boolean; positions: Position[] }> { |
|
|
const impersonationUserId = |
|
|
const userId = await this.getUserId(aImpersonationId); |
|
|
await this.impersonationService.validateImpersonationId( |
|
|
|
|
|
aImpersonationId, |
|
|
|
|
|
this.request.user.id |
|
|
|
|
|
); |
|
|
|
|
|
|
|
|
|
|
|
const userId = impersonationUserId || this.request.user.id; |
|
|
|
|
|
|
|
|
|
|
|
const portfolioCalculator = new PortfolioCalculator( |
|
|
const portfolioCalculator = new PortfolioCalculator( |
|
|
this.currentRateService, |
|
|
this.currentRateService, |
|
|
this.request.user.Settings.currency |
|
|
this.request.user.Settings.currency |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const transactionPoints = await this.getTransactionPoints(userId); |
|
|
const { transactionPoints } = await this.getTransactionPoints(userId); |
|
|
|
|
|
|
|
|
if (transactionPoints?.length <= 0) { |
|
|
if (transactionPoints?.length <= 0) { |
|
|
return { |
|
|
return { |
|
@ -461,7 +544,7 @@ export class PortfolioService { |
|
|
this.request.user.Settings.currency |
|
|
this.request.user.Settings.currency |
|
|
); |
|
|
); |
|
|
|
|
|
|
|
|
const transactionPoints = await this.getTransactionPoints(userId); |
|
|
const { transactionPoints } = await this.getTransactionPoints(userId); |
|
|
|
|
|
|
|
|
if (transactionPoints?.length <= 0) { |
|
|
if (transactionPoints?.length <= 0) { |
|
|
return { |
|
|
return { |
|
@ -521,11 +604,14 @@ export class PortfolioService { |
|
|
return portfolioStart; |
|
|
return portfolioStart; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private async getTransactionPoints(userId: string) { |
|
|
private async getTransactionPoints(userId: string): Promise<{ |
|
|
|
|
|
transactionPoints: TransactionPoint[]; |
|
|
|
|
|
orders: OrderWithAccount[]; |
|
|
|
|
|
}> { |
|
|
const orders = await this.getOrders(userId); |
|
|
const orders = await this.getOrders(userId); |
|
|
|
|
|
|
|
|
if (orders.length <= 0) { |
|
|
if (orders.length <= 0) { |
|
|
return []; |
|
|
return { transactionPoints: [], orders: [] }; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ |
|
|
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ |
|
@ -543,7 +629,10 @@ export class PortfolioService { |
|
|
this.request.user.Settings.currency |
|
|
this.request.user.Settings.currency |
|
|
); |
|
|
); |
|
|
portfolioCalculator.computeTransactionPoints(portfolioOrders); |
|
|
portfolioCalculator.computeTransactionPoints(portfolioOrders); |
|
|
return portfolioCalculator.getTransactionPoints(); |
|
|
return { |
|
|
|
|
|
transactionPoints: portfolioCalculator.getTransactionPoints(), |
|
|
|
|
|
orders |
|
|
|
|
|
}; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) { |
|
|
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) { |
|
|
private getOrders(aUserId: string) { |
|
|
return this.orderService.orders({ |
|
|
return this.orderService.orders({ |
|
|
include: { |
|
|
include: { |
|
@ -605,4 +732,14 @@ export class PortfolioService { |
|
|
where: { userId: aUserId } |
|
|
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; |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|