|
|
@ -12,6 +12,7 @@ import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/ap |
|
|
|
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; |
|
|
|
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; |
|
|
|
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; |
|
|
|
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service'; |
|
|
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; |
|
|
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|
|
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; |
|
|
@ -67,14 +68,16 @@ import { |
|
|
|
isBefore, |
|
|
|
isSameMonth, |
|
|
|
isSameYear, |
|
|
|
isValid, |
|
|
|
max, |
|
|
|
min, |
|
|
|
parseISO, |
|
|
|
set, |
|
|
|
setDayOfYear, |
|
|
|
subDays, |
|
|
|
subYears |
|
|
|
} from 'date-fns'; |
|
|
|
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash'; |
|
|
|
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash'; |
|
|
|
|
|
|
|
import { |
|
|
|
HistoricalDataContainer, |
|
|
@ -91,6 +94,7 @@ const europeMarkets = require('../../assets/countries/europe-markets.json'); |
|
|
|
@Injectable() |
|
|
|
export class PortfolioService { |
|
|
|
public constructor( |
|
|
|
private readonly accountBalanceService: AccountBalanceService, |
|
|
|
private readonly accountService: AccountService, |
|
|
|
private readonly currentRateService: CurrentRateService, |
|
|
|
private readonly dataProviderService: DataProviderService, |
|
|
@ -114,8 +118,12 @@ export class PortfolioService { |
|
|
|
}): Promise<AccountWithValue[]> { |
|
|
|
const where: Prisma.AccountWhereInput = { userId: userId }; |
|
|
|
|
|
|
|
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') { |
|
|
|
where.id = filters[0].id; |
|
|
|
const accountFilter = filters?.find(({ type }) => { |
|
|
|
return type === 'ACCOUNT'; |
|
|
|
}); |
|
|
|
|
|
|
|
if (accountFilter) { |
|
|
|
where.id = accountFilter.id; |
|
|
|
} |
|
|
|
|
|
|
|
const [accounts, details] = await Promise.all([ |
|
|
@ -267,6 +275,13 @@ export class PortfolioService { |
|
|
|
includeDrafts: true |
|
|
|
}); |
|
|
|
|
|
|
|
if (transactionPoints.length === 0) { |
|
|
|
return { |
|
|
|
investments: [], |
|
|
|
streaks: { currentStreak: 0, longestStreak: 0 } |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const portfolioCalculator = new PortfolioCalculator({ |
|
|
|
currency: this.request.user.Settings.settings.baseCurrency, |
|
|
|
currentRateService: this.currentRateService, |
|
|
@ -274,12 +289,6 @@ export class PortfolioService { |
|
|
|
}); |
|
|
|
|
|
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
|
if (transactionPoints.length === 0) { |
|
|
|
return { |
|
|
|
investments: [], |
|
|
|
streaks: { currentStreak: 0, longestStreak: 0 } |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
let investments: InvestmentItem[]; |
|
|
|
|
|
|
@ -367,67 +376,6 @@ export class PortfolioService { |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
public async getChart({ |
|
|
|
dateRange = 'max', |
|
|
|
filters, |
|
|
|
impersonationId, |
|
|
|
userCurrency, |
|
|
|
userId, |
|
|
|
withExcludedAccounts = false |
|
|
|
}: { |
|
|
|
dateRange?: DateRange; |
|
|
|
filters?: Filter[]; |
|
|
|
impersonationId: string; |
|
|
|
userCurrency: string; |
|
|
|
userId: string; |
|
|
|
withExcludedAccounts?: boolean; |
|
|
|
}): Promise<HistoricalDataContainer> { |
|
|
|
userId = await this.getUserId(impersonationId, userId); |
|
|
|
|
|
|
|
const { portfolioOrders, transactionPoints } = |
|
|
|
await this.getTransactionPoints({ |
|
|
|
filters, |
|
|
|
userId, |
|
|
|
withExcludedAccounts |
|
|
|
}); |
|
|
|
|
|
|
|
const portfolioCalculator = new PortfolioCalculator({ |
|
|
|
currency: userCurrency, |
|
|
|
currentRateService: this.currentRateService, |
|
|
|
orders: portfolioOrders |
|
|
|
}); |
|
|
|
|
|
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
|
if (transactionPoints.length === 0) { |
|
|
|
return { |
|
|
|
isAllTimeHigh: false, |
|
|
|
isAllTimeLow: false, |
|
|
|
items: [] |
|
|
|
}; |
|
|
|
} |
|
|
|
const endDate = new Date(); |
|
|
|
|
|
|
|
const portfolioStart = parseDate(transactionPoints[0].date); |
|
|
|
const startDate = this.getStartDate(dateRange, portfolioStart); |
|
|
|
|
|
|
|
const daysInMarket = differenceInDays(new Date(), startDate); |
|
|
|
const step = Math.round( |
|
|
|
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS) |
|
|
|
); |
|
|
|
|
|
|
|
const items = await portfolioCalculator.getChartData( |
|
|
|
startDate, |
|
|
|
endDate, |
|
|
|
step |
|
|
|
); |
|
|
|
|
|
|
|
return { |
|
|
|
items, |
|
|
|
isAllTimeHigh: false, |
|
|
|
isAllTimeLow: false |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
public async getDetails({ |
|
|
|
dateRange = 'max', |
|
|
|
filters, |
|
|
@ -1028,12 +976,6 @@ export class PortfolioService { |
|
|
|
userId |
|
|
|
}); |
|
|
|
|
|
|
|
const portfolioCalculator = new PortfolioCalculator({ |
|
|
|
currency: this.request.user.Settings.settings.baseCurrency, |
|
|
|
currentRateService: this.currentRateService, |
|
|
|
orders: portfolioOrders |
|
|
|
}); |
|
|
|
|
|
|
|
if (transactionPoints?.length <= 0) { |
|
|
|
return { |
|
|
|
hasErrors: false, |
|
|
@ -1041,6 +983,12 @@ export class PortfolioService { |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
const portfolioCalculator = new PortfolioCalculator({ |
|
|
|
currency: this.request.user.Settings.settings.baseCurrency, |
|
|
|
currentRateService: this.currentRateService, |
|
|
|
orders: portfolioOrders |
|
|
|
}); |
|
|
|
|
|
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
|
|
|
|
|
const portfolioStart = parseDate(transactionPoints[0].date); |
|
|
@ -1126,6 +1074,31 @@ export class PortfolioService { |
|
|
|
const user = await this.userService.user({ id: userId }); |
|
|
|
const userCurrency = this.getUserCurrency(user); |
|
|
|
|
|
|
|
const accountBalances = await this.accountBalanceService.getAccountBalances( |
|
|
|
{ filters, user } |
|
|
|
); |
|
|
|
|
|
|
|
let accountBalanceItems: HistoricalDataItem[] = Object.values( |
|
|
|
// Reduce the array to a map with unique dates as keys
|
|
|
|
accountBalances.balances.reduce( |
|
|
|
( |
|
|
|
map: { [date: string]: HistoricalDataItem }, |
|
|
|
{ date, valueInBaseCurrency } |
|
|
|
) => { |
|
|
|
const formattedDate = format(date, DATE_FORMAT); |
|
|
|
|
|
|
|
// Store the item in the map, overwriting if the date already exists
|
|
|
|
map[formattedDate] = { |
|
|
|
date: formattedDate, |
|
|
|
value: valueInBaseCurrency |
|
|
|
}; |
|
|
|
|
|
|
|
return map; |
|
|
|
}, |
|
|
|
{} |
|
|
|
) |
|
|
|
); |
|
|
|
|
|
|
|
const { portfolioOrders, transactionPoints } = |
|
|
|
await this.getTransactionPoints({ |
|
|
|
filters, |
|
|
@ -1139,7 +1112,7 @@ export class PortfolioService { |
|
|
|
orders: portfolioOrders |
|
|
|
}); |
|
|
|
|
|
|
|
if (transactionPoints?.length <= 0) { |
|
|
|
if (accountBalanceItems?.length <= 0 && transactionPoints?.length <= 0) { |
|
|
|
return { |
|
|
|
chart: [], |
|
|
|
firstOrderDate: undefined, |
|
|
@ -1149,6 +1122,7 @@ export class PortfolioService { |
|
|
|
currentGrossPerformancePercent: 0, |
|
|
|
currentNetPerformance: 0, |
|
|
|
currentNetPerformancePercent: 0, |
|
|
|
currentNetWorth: 0, |
|
|
|
currentValue: 0, |
|
|
|
totalInvestment: 0 |
|
|
|
} |
|
|
@ -1157,7 +1131,15 @@ export class PortfolioService { |
|
|
|
|
|
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
|
|
|
|
|
const portfolioStart = parseDate(transactionPoints[0].date); |
|
|
|
const portfolioStart = min( |
|
|
|
[ |
|
|
|
parseDate(accountBalanceItems[0]?.date), |
|
|
|
parseDate(transactionPoints[0]?.date) |
|
|
|
].filter((date) => { |
|
|
|
return isValid(date); |
|
|
|
}) |
|
|
|
); |
|
|
|
|
|
|
|
const startDate = this.getStartDate(dateRange, portfolioStart); |
|
|
|
const { |
|
|
|
currentValue, |
|
|
@ -1175,17 +1157,17 @@ export class PortfolioService { |
|
|
|
let currentNetPerformance = netPerformance; |
|
|
|
let currentNetPerformancePercent = netPerformancePercentage; |
|
|
|
|
|
|
|
const historicalDataContainer = await this.getChart({ |
|
|
|
const { items } = await this.getChart({ |
|
|
|
dateRange, |
|
|
|
filters, |
|
|
|
impersonationId, |
|
|
|
portfolioOrders, |
|
|
|
transactionPoints, |
|
|
|
userCurrency, |
|
|
|
userId, |
|
|
|
withExcludedAccounts |
|
|
|
userId |
|
|
|
}); |
|
|
|
|
|
|
|
const itemOfToday = historicalDataContainer.items.find((item) => { |
|
|
|
return item.date === format(new Date(), DATE_FORMAT); |
|
|
|
const itemOfToday = items.find(({ date }) => { |
|
|
|
return date === format(new Date(), DATE_FORMAT); |
|
|
|
}); |
|
|
|
|
|
|
|
if (itemOfToday) { |
|
|
@ -1195,34 +1177,42 @@ export class PortfolioService { |
|
|
|
).div(100); |
|
|
|
} |
|
|
|
|
|
|
|
accountBalanceItems = accountBalanceItems.filter(({ date }) => { |
|
|
|
return !isBefore(parseDate(date), startDate); |
|
|
|
}); |
|
|
|
|
|
|
|
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => { |
|
|
|
return date === format(new Date(), DATE_FORMAT); |
|
|
|
}); |
|
|
|
|
|
|
|
if (!accountBalanceItemOfToday) { |
|
|
|
accountBalanceItems.push({ |
|
|
|
date: format(new Date(), DATE_FORMAT), |
|
|
|
value: last(accountBalanceItems)?.value ?? 0 |
|
|
|
}); |
|
|
|
} |
|
|
|
|
|
|
|
const mergedHistoricalDataItems = this.mergeHistoricalDataItems( |
|
|
|
accountBalanceItems, |
|
|
|
items |
|
|
|
); |
|
|
|
|
|
|
|
const currentHistoricalDataItem = last(mergedHistoricalDataItems); |
|
|
|
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0; |
|
|
|
|
|
|
|
return { |
|
|
|
errors, |
|
|
|
hasErrors, |
|
|
|
chart: historicalDataContainer.items.map( |
|
|
|
({ |
|
|
|
date, |
|
|
|
netPerformance: netPerformanceOfItem, |
|
|
|
netPerformanceInPercentage, |
|
|
|
totalInvestment: totalInvestmentOfItem, |
|
|
|
value |
|
|
|
}) => { |
|
|
|
return { |
|
|
|
date, |
|
|
|
netPerformanceInPercentage, |
|
|
|
value, |
|
|
|
netPerformance: netPerformanceOfItem, |
|
|
|
totalInvestment: totalInvestmentOfItem |
|
|
|
}; |
|
|
|
} |
|
|
|
), |
|
|
|
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date), |
|
|
|
chart: mergedHistoricalDataItems, |
|
|
|
firstOrderDate: parseDate(items[0]?.date), |
|
|
|
performance: { |
|
|
|
currentValue: currentValue.toNumber(), |
|
|
|
currentNetWorth, |
|
|
|
currentGrossPerformance: currentGrossPerformance.toNumber(), |
|
|
|
currentGrossPerformancePercent: |
|
|
|
currentGrossPerformancePercent.toNumber(), |
|
|
|
currentNetPerformance: currentNetPerformance.toNumber(), |
|
|
|
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(), |
|
|
|
currentValue: currentValue.toNumber(), |
|
|
|
totalInvestment: totalInvestment.toNumber() |
|
|
|
} |
|
|
|
}; |
|
|
@ -1376,6 +1366,62 @@ export class PortfolioService { |
|
|
|
return cashPositions; |
|
|
|
} |
|
|
|
|
|
|
|
private async getChart({ |
|
|
|
dateRange = 'max', |
|
|
|
impersonationId, |
|
|
|
portfolioOrders, |
|
|
|
transactionPoints, |
|
|
|
userCurrency, |
|
|
|
userId |
|
|
|
}: { |
|
|
|
dateRange?: DateRange; |
|
|
|
impersonationId: string; |
|
|
|
portfolioOrders: PortfolioOrder[]; |
|
|
|
transactionPoints: TransactionPoint[]; |
|
|
|
userCurrency: string; |
|
|
|
userId: string; |
|
|
|
}): Promise<HistoricalDataContainer> { |
|
|
|
if (transactionPoints.length === 0) { |
|
|
|
return { |
|
|
|
isAllTimeHigh: false, |
|
|
|
isAllTimeLow: false, |
|
|
|
items: [] |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
userId = await this.getUserId(impersonationId, userId); |
|
|
|
|
|
|
|
const portfolioCalculator = new PortfolioCalculator({ |
|
|
|
currency: userCurrency, |
|
|
|
currentRateService: this.currentRateService, |
|
|
|
orders: portfolioOrders |
|
|
|
}); |
|
|
|
|
|
|
|
portfolioCalculator.setTransactionPoints(transactionPoints); |
|
|
|
|
|
|
|
const endDate = new Date(); |
|
|
|
|
|
|
|
const portfolioStart = parseDate(transactionPoints[0].date); |
|
|
|
const startDate = this.getStartDate(dateRange, portfolioStart); |
|
|
|
|
|
|
|
const daysInMarket = differenceInDays(new Date(), startDate); |
|
|
|
const step = Math.round( |
|
|
|
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS) |
|
|
|
); |
|
|
|
|
|
|
|
const items = await portfolioCalculator.getChartData( |
|
|
|
startDate, |
|
|
|
endDate, |
|
|
|
step |
|
|
|
); |
|
|
|
|
|
|
|
return { |
|
|
|
items, |
|
|
|
isAllTimeHigh: false, |
|
|
|
isAllTimeLow: false |
|
|
|
}; |
|
|
|
} |
|
|
|
|
|
|
|
private getDividendsByGroup({ |
|
|
|
dividends, |
|
|
|
groupBy |
|
|
@ -1999,4 +2045,44 @@ export class PortfolioService { |
|
|
|
|
|
|
|
return { accounts, platforms }; |
|
|
|
} |
|
|
|
|
|
|
|
private mergeHistoricalDataItems( |
|
|
|
accountBalanceItems: HistoricalDataItem[], |
|
|
|
performanceChartItems: HistoricalDataItem[] |
|
|
|
): HistoricalDataItem[] { |
|
|
|
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {}; |
|
|
|
let latestAccountBalance = 0; |
|
|
|
|
|
|
|
for (const item of accountBalanceItems.concat(performanceChartItems)) { |
|
|
|
const isAccountBalanceItem = accountBalanceItems.includes(item); |
|
|
|
|
|
|
|
const totalAccountBalance = isAccountBalanceItem |
|
|
|
? item.value |
|
|
|
: latestAccountBalance; |
|
|
|
|
|
|
|
if (isAccountBalanceItem && performanceChartItems.length > 0) { |
|
|
|
latestAccountBalance = item.value; |
|
|
|
} else { |
|
|
|
historicalDataItemsMap[item.date] = { |
|
|
|
...item, |
|
|
|
totalAccountBalance, |
|
|
|
netWorth: |
|
|
|
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance |
|
|
|
}; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
// Convert to an array and sort by date in ascending order
|
|
|
|
const historicalDataItems = Object.keys(historicalDataItemsMap).map( |
|
|
|
(date) => { |
|
|
|
return historicalDataItemsMap[date]; |
|
|
|
} |
|
|
|
); |
|
|
|
|
|
|
|
historicalDataItems.sort( |
|
|
|
(a, b) => new Date(a.date).getTime() - new Date(b.date).getTime() |
|
|
|
); |
|
|
|
|
|
|
|
return historicalDataItems; |
|
|
|
} |
|
|
|
} |
|
|
|